UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

264 lines • 11.7 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 { parseTime } from "../time_range.js"; import { CALENDAR_FILE, PRIMARY_DIR, eventTitle, makeCalendarDirname, makeEventFilename, } from "../../vfs/gcal/event_entry.js"; import { ContentType, DirListing, FileStat, FileType, } from "../../types.js"; import { enoent } from "../../errors/fs.js"; import { mountPrefixOf } from "../../utils/key_prefix.js"; import { nonPlainWords } from "../../utils/record_search.js"; import { globPrefix, literalSpan } from "../../utils/glob_walk.js"; import { SPAN_SEP, bucketName, bucketStart, clampedHhmm, dayBounds, localDate, daysCovered, eventSpan, parseBucket, shiftDay, windowBounds, } from "./day.js"; import { ROOT } from "../hierarchy/scope.js"; import { detectScope } from "./scope.js"; import { compareCodePoints } from "../../utils/sort.js"; import { compactJsonBytes, compactJsonText } from "../render/json.js"; export const FREE_BUSY_ROLE = 'freeBusyReader'; // The text Calendar's search reads that the listing does not keep. const SEARCHED_FIELDS = ['summary', 'description', 'location']; /** Render the per-calendar metadata file. */ export function calendarPayload(entry, tz) { return compactJsonBytes({ id: entry.id ?? null, summary: entry.summary ?? null, accessRole: entry.accessRole ?? null, primary: entry.primary === true, calendarTimeZone: entry.timeZone ?? null, // The zone the day directories are bucketed in, which is mount-wide // and therefore not always this calendar's own. bucketTimeZone: tz, }); } /** Split a path into `[mount prefix, mount-relative key, virtual key]`. */ export function normalize(path) { const prefix = mountPrefixOf(path.virtual, path.vfsPath); const key = (path.pattern !== null ? path.dir : path).vfsPath; const virtualKey = key !== '' ? `${prefix}/${key}` : prefix !== '' ? prefix : '/'; return [prefix, key, virtualKey]; } /** Map each calendar's directory name to its calendarList entry. */ export async function calendarIndex(accessor) { const rows = await accessor.calendar.listCalendars(accessor.config.minAccessRole); const out = new Map(); for (const row of rows) { const calId = row.id; if (typeof calId !== 'string' || calId === '') continue; const summary = row.summary; const name = makeCalendarDirname(typeof summary === 'string' ? summary : calId, calId, row.primary === true); out.set(name, row); } return out; } /** * The one zone every day directory on this mount is bucketed in. * * Defaults to the primary calendar's zone, matching how the Calendar UI * draws its grid: bucketing each calendar in its own zone would make the * same directory name mean different 24-hour windows on different * calendars, so a cross-calendar free/busy comparison would be wrong. */ export function bucketZone(accessor, calendars) { const pinned = accessor.config.timeZone; if (pinned !== undefined && pinned !== '') return pinned; const primary = calendars.get(PRIMARY_DIR); if (primary !== undefined) { const tz = primary.timeZone; if (typeof tz === 'string' && tz !== '') return tz; } for (const entry of calendars.values()) { const tz = entry.timeZone; if (typeof tz === 'string' && tz !== '') return tz; } return 'UTC'; } /** * The listing window, honouring a date glob when one was typed. * * A bare readdir reports a rolling window around today because a calendar * is unbounded in both directions and the API offers no descending * startTime order. A glob escapes it by pushing its own bounds down, widened * to whole buckets so a bucket the glob reaches into is decided on all of * its days, not only on the ones it named. A glob is read up to its first * span separator: a bucket name is keyed on its first day, so * `2027-03-01--2027-03-07*` bounds the listing as `2027-03-01*` does. */ function daySpan(pattern, today, tz, scope, size) { const span = literalSpan(globPrefix(pattern).split(SPAN_SEP, 1)[0] ?? ''); let lo = scope.start; let hi = scope.end; if (span !== null) { const first = parseTime(dayBounds(bucketStart(span[0], size), tz)[0]); const last = parseTime(dayBounds(bucketStart(shiftDay(span[1], -1), size), tz, size)[1]); lo = Math.max(first, lo ?? first); hi = Math.min(last, hi ?? last); } else hi ??= parseTime(windowBounds(today, tz, size)[1]); return [ lo === null ? null : new Date(lo * 1000).toISOString(), new Date(hi * 1000).toISOString(), lo === null ? '0001-01-01' : localDate(lo * 1000, tz), localDate(hi * 1000 - 1, tz), ]; } /** * The days of a bucket directory inside the configured mount scope. * * A name off this mount's grid is absent, as is a bucket wholly outside the * scope: direct paths are bounded exactly as listings are. The days come * back ascending and consecutive. */ export function scopedBucket(accessor, name, tz, virtual) { const size = accessor.config.bucketDays; const start = parseBucket(name, size); if (start === null) throw enoent(virtual); const days = []; for (let offset = 0; offset < size; offset++) { const day = shiftDay(start, offset); const [lo, hi] = dayBounds(day, tz); const [from, to] = accessor.timeRange.clip(parseTime(lo), parseTime(hi)); if (from < to) days.push(day); } if (days.length === 0) throw enoent(virtual); return days; } /** * What an event's file holds beyond the plain words Calendar's search reads, * lowercased: its JSON with the summary, description and location emptied, * then each of their words that is not plain (a link, an address, markup, an * entity, a hyphenated or non-ASCII word), which the search may split * otherwise than grep -w. Mirrors Python's `unsearched_text`. */ export function unsearchedText(event) { const rest = Object.fromEntries(Object.entries(event).map(([key, value]) => [ key, SEARCHED_FIELDS.includes(key) && typeof value === 'string' ? '' : value, ])); const kept = [compactJsonText(rest)]; for (const field of SEARCHED_FIELDS) { const value = event[field]; if (typeof value === 'string') { for (const item of nonPlainWords(value)) kept.push(item); } } return kept.join(' ').toLowerCase(); } /** * Build the rows of one bucket directory. * * An event gets one row for each of the bucket's days it covers, so a * multi-day bucket lists what its day directories would, flattened, each * name carrying its day when `dated`. With `unsearched` each row keeps the * text Calendar's search does not read (`unsearchedText`), for grep. */ function eventEntries(events, days, tz, freeBusy, dated, unsearched = false) { const rows = []; for (const event of events) { const eventId = event.id; if (typeof eventId !== 'string' || eventId === '') continue; const span = eventSpan(event, tz); if (span === null) continue; const summary = event.summary; const title = eventTitle(typeof summary === 'string' ? summary : null, freeBusy); const updated = event.updated; const size = compactJsonBytes(event).length; const extra = { event_id: eventId }; if (unsearched) extra.unsearched = unsearchedText(event); for (const day of daysCovered(span, tz)) { if (!days.includes(day)) continue; const name = makeEventFilename(eventId, clampedHhmm(span, day, tz), title, dated ? day : null); rows.push(new FileStat({ name, type: FileType.FILE, content: ContentType.JSON, modified: typeof updated === 'string' ? updated : null, size, extra, })); } } return rows; } /** List one level of the calendar tree. */ export async function readdir(accessor, path) { const [, key] = normalize(path); // Bespoke below the classifier: the date-glob push-down filters the // events query itself, and a globbed listing is no listing of the // directory, which the kit readdir has no notion of. const match = detectScope(key); if (match.kind !== ROOT && match.kind !== 'calendar' && match.kind !== 'bucket') { throw enoent(path.virtual); } const calendars = await calendarIndex(accessor); const tz = bucketZone(accessor, calendars); if (match.kind === ROOT) { return [...calendars.keys()] .sort(compareCodePoints) .map((name) => new FileStat({ name, type: FileType.DIRECTORY })); } const entry = calendars.get(match.slots.calendar ?? ''); if (entry === undefined) throw enoent(path.virtual); const calId = entry.id; if (typeof calId !== 'string') throw enoent(path.virtual); const freeBusy = entry.accessRole === FREE_BUSY_ROLE; const size = accessor.config.bucketDays; if (match.kind === 'calendar') { const [timeMin, timeMax, first, last] = daySpan(path.pattern, accessor.today(tz), tz, accessor.timeRange, size); const events = await accessor.calendar.listEvents(calId, timeMin, timeMax, tz, accessor.timeRange); const seen = new Set(); for (const event of events) { const span = eventSpan(event, tz); if (span === null) continue; for (const day of daysCovered(span, tz)) { if (day >= first && day <= last) seen.add(bucketName(bucketStart(day, size), size)); } } const rows = [ new FileStat({ name: CALENDAR_FILE, type: FileType.FILE, content: ContentType.JSON, size: calendarPayload(entry, tz).length, }), ]; for (const name of [...seen].sort(compareCodePoints)) { rows.push(new FileStat({ name, type: FileType.DIRECTORY })); } // The buckets holding events in the listed span: a bucket outside it, // or one with no event, is still a directory its path reaches, so the // listing proves nothing absent. A globbed listing is a filtered view on // top of that. return new DirListing({ entries: rows, partial: path.pattern !== null, window: true }); } const days = scopedBucket(accessor, match.slots.bucket ?? '', tz, path.virtual); const [timeMin, timeMax] = dayBounds(days[0] ?? '', tz, days.length); const events = await accessor.calendar.listEvents(calId, timeMin, timeMax, tz, accessor.timeRange); return eventEntries(events, days, tz, freeBusy, size > 1, accessor.capped()); } //# sourceMappingURL=readdir.js.map