@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
181 lines • 7.92 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 { NAME_MAX_BYTES, byteLength, sanitizeLabel } from "../../utils/sanitize.js";
import { compareCodePoints } from "../../utils/sort.js";
import { ContentType, DirListing, FileStat, FileType } from "../../types.js";
import { makeReaddir } from "../hierarchy/readdir.js";
import { entryStat } from "../hierarchy/stat.js";
import { extractAttachments, extractHeader, messageJsonBytes } from "./messages.js";
import { detectScope } from "./scope.js";
import { globSpan, hasGlobSpan } from "../../utils/glob_walk.js";
import { dateDirToGmailQuery, spanToGmailQuery } from "./date_query.js";
const TITLE_MAX = 80;
export const MSG_SUFFIX = '.gmail.json';
const MAX_MESSAGES = 50;
export const sanitize = (text, maxBytes) => sanitizeLabel(text, {
fallback: 'No_Subject',
maxLen: TITLE_MAX,
...(maxBytes !== undefined ? { maxBytes } : {}),
});
/**
* 80 characters is 240 bytes of CJK, which overflows the 255-byte NAME_MAX
* once the id and `.gmail.json` are added; the filesystem rejects the name
* outright. So the subject takes what the id and the suffix leave.
*/
export function msgFilename(subject, msgId) {
const fixed = 2 + byteLength(msgId) + MSG_SUFFIX.length;
return `${sanitize(subject, NAME_MAX_BYTES - fixed)}__${msgId}${MSG_SUFFIX}`;
}
export function attachDirName(subject, msgId) {
return msgFilename(subject, msgId).slice(0, -MSG_SUFFIX.length);
}
export function dateFromInternal(internalDate) {
if (internalDate === undefined)
return '1970-01-01';
const ts = Number.parseInt(internalDate, 10);
if (!Number.isFinite(ts))
return '1970-01-01';
const d = new Date(ts);
const yyyy = d.getUTCFullYear().toString().padStart(4, '0');
const mm = (d.getUTCMonth() + 1).toString().padStart(2, '0');
const dd = d.getUTCDate().toString().padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
const ATTACHMENT = entryStat('attachment_id');
function attachmentEntries(raw) {
return extractAttachments(raw.payload).map((att) => ATTACHMENT(att.filename, att.attachmentId, { size: att.size }));
}
/**
* One date directory's children, plus its attachment-dir seeds. With
* `keepText` each message's row keeps its rendered text, lowercased, for
* grep (a `walkLimit` of 0 or more).
*/
function dateChildren(raws, keepText) {
const children = [];
const seeds = new Map();
for (const raw of raws) {
const mid = raw.id ?? '';
const headers = raw.payload?.headers ?? [];
const subject = extractHeader(headers, 'Subject') || 'No Subject';
const filename = msgFilename(subject, mid);
// The listing already fetched the full message, so the exact rendered
// .gmail.json length and text are free; sizeEstimate is the source
// message size and stays in extra. The kept text leaves the labels out,
// the one field that changes after the listing, which a search declines
// to rule on instead.
const rendered = messageJsonBytes(raw);
children.push(new FileStat({
name: filename,
type: FileType.FILE,
content: ContentType.JSON,
size: rendered.byteLength,
extra: {
message_id: mid,
...(keepText
? {
text: new TextDecoder()
.decode(messageJsonBytes({ ...raw, labelIds: [] }))
.toLowerCase(),
}
: {}),
...(raw.sizeEstimate != null ? { size_estimate: raw.sizeEstimate } : {}),
},
}));
const attEntries = attachmentEntries(raw);
if (attEntries.length > 0) {
const attDirName = attachDirName(subject, mid);
children.push(new FileStat({ name: attDirName, type: FileType.DIRECTORY, extra: { message_id: mid } }));
seeds.set(attDirName, attEntries);
}
}
return { children, seeds };
}
async function groupByDate(accessor, msgIds) {
const groups = new Map();
for (const m of msgIds) {
const raw = await accessor.gmail.getMessageRaw(m.id);
const dateStr = dateFromInternal(raw.internalDate);
let bucket = groups.get(dateStr);
if (bucket === undefined) {
bucket = [];
groups.set(dateStr, bucket);
}
bucket.push(raw);
}
return groups;
}
async function listRoot(accessor, _match) {
const labels = await accessor.gmail.listLabels();
return labels.map((lb) => new FileStat({
name: lb.type === 'system' ? lb.id : (lb.name ?? lb.id),
type: FileType.DIRECTORY,
extra: { label_id: lb.id },
}));
}
async function listLabel(accessor, match, own) {
// The bare listing is a window, the most recent MAX_MESSAGES, so the day
// dirs it mints are whichever days those fell on. A glob pushes its own
// span into the query instead of filtering that window, which is the only
// way to reach a day older than it.
const span = globSpan(match.pattern);
const labelId = String(own.extra.label_id);
const msgIds = await accessor.gmail.listMessages({
labelId,
query: span === null ? null : spanToGmailQuery(span[0], span[1]),
maxResults: MAX_MESSAGES,
});
const groups = await groupByDate(accessor, msgIds);
const sortedDates = [...groups.keys()].sort(compareCodePoints).reverse();
const entries = [];
const seeds = new Map();
for (const dateStr of sortedDates) {
entries.push(new FileStat({ name: dateStr, type: FileType.DIRECTORY, extra: { label_id: labelId } }));
const { children, seeds: attSeeds } = dateChildren(groups.get(dateStr) ?? [], accessor.capped());
seeds.set(dateStr, children);
for (const [attDir, attEntries] of attSeeds)
seeds.set(`${dateStr}/${attDir}`, attEntries);
}
return new DirListing({ entries, seeds, partial: span !== null, window: true });
}
async function listDay(accessor, match, own) {
// The day's row carries its label: one the label's bounded recent listing
// minted, or one stat makes for any well-formed day under a real label,
// since a date query answers for any day.
const day = match.slots.day ?? '';
const dateQuery = dateDirToGmailQuery(day);
if (dateQuery === null)
return [];
const msgIds = await accessor.gmail.listMessages({
labelId: String(own.extra.label_id),
query: dateQuery,
maxResults: MAX_MESSAGES,
});
const groups = await groupByDate(accessor, msgIds);
const { children, seeds } = dateChildren(groups.get(day) ?? [], accessor.capped());
return new DirListing({ entries: children, seeds, window: true });
}
async function listAttachmentDir(accessor, _match, own) {
const raw = await accessor.gmail.getMessageRaw(String(own.extra.message_id));
return attachmentEntries(raw);
}
export const readdir = makeReaddir(detectScope, {
listers: { root: listRoot },
entryListers: {
label: listLabel,
day: listDay,
attachment_dir: listAttachmentDir,
},
patternKinds: { label: hasGlobSpan },
});
//# sourceMappingURL=readdir.js.map