@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
246 lines • 10.4 kB
JavaScript
// ========= 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