@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
264 lines • 11.7 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 { 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