UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

246 lines • 10.4 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { Delta, FileChangeKind } from "../../types.js"; import { mountPrefixOf } from "../../utils/key_prefix.js"; import { sortedJson } from "../../utils/sort.js"; import { changesBetween, changesUnder, lostAt } from "../../events/delta.js"; import { extractAttachments, extractHeader } from "./messages.js"; import { attachDirName, dateFromInternal, MSG_SUFFIX, msgFilename } from "./readdir.js"; /** * The paths a message is under `labelIds`: its file in each label's day * directory, and its attachment directory beside it when it has attachments, * named as the listings name them. Mirrors Python's `message_paths`. */ export function messagePaths(prefix, raw, labels, labelIds) { const mid = raw.id ?? ''; const subject = extractHeader(raw.payload?.headers, 'Subject') || 'No Subject'; const day = dateFromInternal(raw.internalDate ?? '0'); const attached = extractAttachments(raw.payload).length > 0; const file = msgFilename(subject, mid); const paths = []; for (const labelId of labelIds) { const label = labels.get(labelId); if (label === undefined) continue; const name = label.type === 'system' ? labelId : (label.name ?? labelId); paths.push(`${prefix}/${name}/${day}/${file}`); if (attached) paths.push(`${prefix}/${name}/${day}/${attachDirName(subject, mid)}`); } return paths; } /** * Each label's folder name, by label id, as the root listing names it: a * system label by its id, any other by its name. Mirrors Python's * `label_folders`. */ export function labelFolders(labels) { return new Map(labels.map((label) => [ label.id, label.type === 'system' ? label.id : (label.name ?? label.id), ])); } /** * Record each label folder that left or was renamed, then each that appeared or * took a new name, and move the known paths below a renamed one (or forget them * below one that left), so a later change to one of its messages starts where * the message now is. Every path moves once, by the folder it was in: one label * can take the name another gave up in the same pull. Mirrors Python's * `_folders_moved`. */ function foldersMoved(changes, known, prefix, before, after) { const moves = new Map(); for (const [labelId, name] of before) { const now = after.get(labelId); if (now !== name) moves.set(`${prefix}/${name}`, now === undefined ? null : `${prefix}/${now}`); } for (const old of moves.keys()) changes.push([FileChangeKind.DELETE, old]); for (const [labelId, name] of after) { if (before.get(labelId) !== name) changes.push([FileChangeKind.CREATE, `${prefix}/${name}`]); } for (const [mid, paths] of known) known.set(mid, moved(paths, moves)); } /** * `paths` with each one in a moved folder put in its new one, or dropped when * its folder left. Mirrors Python's `_moved`. */ function moved(paths, moves) { const out = []; for (const path of paths) { const cut = path.lastIndexOf('/', path.lastIndexOf('/') - 1); const folder = path.slice(0, cut); const next = moves.get(folder); if (next === undefined) out.push(path); else if (next !== null) out.push(next + path.slice(cut)); } return out; } /** * Checkpointed delta over Gmail's history. The checkpoint holds the history id * the next pull starts after, each label's folder name and, by message id, the * paths of each message a pull has seen. History records no label created, * renamed or deleted, so each pull lists the labels and compares the folders: * one that appeared, left or moved is reported whatever its messages did. A record names a message by id only, so each one added or * relabeled is read to name its paths: one under each label it holds, in the * day it arrived. Its labels before the record follow from the label changes * the records carry, so a relabel moves exactly the paths it should, and a file * it keeps is updated, since its labels are part of its content. A message * deleted for good cannot be read any more: one a pull has seen leaves the * paths it was, and one it has not reports the event root as unknown, as a * history id Gmail no longer keeps does. Mirrors Python's GmailDeltaHook. */ export class GmailDeltaHook { accessor; constructor(accessor) { this.accessor = accessor; } async pull(root, checkpoint) { const gmail = this.accessor.gmail; const state = decode(checkpoint); if (state === null) return new Delta({ changes: [], checkpoint: await baseline(gmail) }); const [start, folders, known] = state; const observed = new Date(); let records; let latest; try { ; [records, latest] = await gmail.listHistory(start); } catch (err) { if (err.status !== 404) throw err; console.debug(`gmail history ${start} is gone: ${String(err)}`); return new Delta({ changes: [lostAt(root, observed)], checkpoint: await baseline(gmail) }); } const prefix = mountPrefixOf(root.virtual, root.vfsPath); const listed = await gmail.listLabels(); const labels = new Map(listed.map((label) => [label.id, label])); const nowFolders = labelFolders(listed); const changes = []; foldersMoved(changes, known, prefix, folders, nowFolders); const added = new Set(); const first = new Map(); const gone = []; const seenOf = (mid) => { const seen = first.get(mid) ?? new Map(); first.set(mid, seen); return seen; }; for (const record of records) { for (const item of (record.messagesAdded ?? [])) { const mid = item.message?.id ?? ''; added.add(mid); seenOf(mid); } for (const [key, removes] of [ ['labelsAdded', false], ['labelsRemoved', true], ]) { for (const item of (record[key] ?? [])) { const seen = seenOf(item.message?.id ?? ''); for (const labelId of item.labelIds ?? []) { if (!seen.has(labelId)) seen.set(labelId, removes); } } } for (const item of (record.messagesDeleted ?? [])) { gone.push(item.message?.id ?? ''); } } for (const [mid, seen] of first) { if (gone.includes(mid)) continue; let raw; try { raw = await gmail.getMessageRaw(mid); } catch (err) { if (err.status !== 404) throw err; console.debug(`gmail message ${mid} is gone: ${String(err)}`); gone.push(mid); continue; } const now = raw.labelIds ?? []; const after = messagePaths(prefix, raw, labels, now); let before = known.get(mid); if (before === undefined) { const held = [ ...now.filter((label) => !seen.has(label)), ...[...seen].filter(([, removes]) => removes).map(([label]) => label), ]; before = added.has(mid) ? [] : messagePaths(prefix, raw, labels, held); } for (const item of changesBetween(before, after, (v) => v.endsWith(MSG_SUFFIX))) changes.push(item); known.set(mid, after); } let unknown = false; for (const mid of new Set(gone)) { const was = known.get(mid); known.delete(mid); if (was !== undefined) for (const item of changesBetween(was, [], () => false)) changes.push(item); else if (!added.has(mid)) unknown = true; } const events = changesUnder(root, changes, observed); if (unknown) events.push(lostAt(root, observed)); return new Delta({ changes: events, checkpoint: encode(latest, nowFolders, known) }); } } /** * A checkpoint at the mailbox's present: its history id and its label folders, * with no message seen yet. Mirrors Python's `_baseline`. */ async function baseline(gmail) { const history = await gmail.currentHistoryId(); return encode(history, labelFolders(await gmail.listLabels()), new Map()); } function encode(historyId, folders, known) { const labels = Object.fromEntries(folders); return sortedJson({ gmail: { history: historyId, labels, messages: Object.fromEntries(known) } }); } /** * The history id, the label folders and the known paths a checkpoint holds, * null for a baseline. Mirrors Python's `_decode`. */ function decode(checkpoint) { if (checkpoint === null) return null; const data = JSON.parse(checkpoint); const state = data.gmail; if (state === undefined || typeof state.history !== 'string') return null; return [ state.history, new Map(Object.entries(state.labels ?? {}).map(([id, name]) => [id, String(name)])), new Map(Object.entries(state.messages ?? {}).map(([mid, paths]) => [mid, paths.map(String)])), ]; } /** Build the Gmail delta hook (`GmailDeltaHook`). Mirrors Python's `build_delta_hook`. */ export function buildDeltaHook(accessor) { return new GmailDeltaHook(accessor); } //# sourceMappingURL=events.js.map