@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
158 lines (154 loc) • 6.75 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 { GCalAccessor } from "../../accessor/gcal.js";
import { bucketName, bucketStart } from "../../core/gcal/day.js";
import { buildDeltaHook } from "../../core/gcal/events.js";
import { read as gcalRead, readMany as gcalReadMany } from "../../core/gcal/read.js";
import { readdir as gcalReaddir } from "../../core/gcal/readdir.js";
import { filesContaining as gcalFilesContaining } from "../../core/gcal/search.js";
import { stat as gcalStat } from "../../core/gcal/stat.js";
import { unlink as gcalUnlink } from "../../core/gcal/unlink.js";
import { TokenManager } from "../../core/google/client.js";
import { VFSName } from "../../types.js";
import { BaseVFS } from "../base.js";
import { GCalConfig } from "./config.js";
export const VFS_MD = `\`\`\`text
{prefix}
primary/
calendar.json
{bucket}/
<eventId>__{day}0900-1030_Team_Standup.gcal.json
Engineering__team@group.calendar.google.com/
{bucket}/
...
\`\`\`
Reading the tree:
- \`ls {prefix}/\` lists the calendars. The primary one is always spelled
\`primary\`; every other directory ends in \`__<calendarId>\`.
- \`ls {prefix}/primary/\` lists buckets with events from all available
history through today +90 days unless \`end_time\` is configured.
\`start_time\`/\`end_time\` restrict this mount, including direct paths,
date globs and event reads. A date glob such as
\`ls {prefix}/primary/2027-03-*\` selects its own listing range within
that scope, beyond the default future horizon.
{layout}
- \`cat\` an event for the unmodified Calendar API payload, including
attendees, description, location and the original start and end with
offsets.
- \`calendar.json\` carries the calendar's \`accessRole\` and, in
\`bucketTimeZone\`, the zone the buckets are cut in. That zone is
mount-wide, so every calendar's \`{bucket}/\` covers the same hours.
- On a calendar shared as free/busy only, Google returns no titles at
all, so events render as \`busy\`.
Acting on it:
- On a \`write\` path, \`rm\` an event file to delete that event for every
attendee.
- Everything else goes through the \`gws calendar\` CLI, which takes the
ids the tree renders.
- A written event must carry an explicit UTC offset or \`timeZone\`; an
ambiguous local time is not interpreted for you.`;
export const DAY_VFS_MD = `- A bucket is one local day, named by its date. A well-formed date
inside the configured scope is a directory even when it holds no
events. Dates outside the scope are absent.
- A file name is \`<eventId>__<HHMM-HHMM>_<Title>.gcal.json\`. The times
are clamped to that day, so an event running through it reads
\`0000-2400\`. A multi-day event appears under every day it covers.`;
export const BUCKET_VFS_MD = `- A bucket is {days} local days, named by its first and last day on a
fixed grid where 7-day buckets run Monday to Sunday. A bucket on the
grid inside the configured scope is a directory even when it holds no
events; a bare date, a span off the grid and a bucket outside the
scope are absent. A glob matches the name, so \`2027-03-*\` finds the
buckets that open in March.
- A file name is \`<eventId>__<YYYY-MM-DD>_<HHMM-HHMM>_<Title>.gcal.json\`,
dated with its day. The times are clamped to that day, so an event
running through it reads \`0000-2400\`. A multi-day event appears once
for each day of the bucket it covers.`;
const EXAMPLE_DAY = '2026-08-11';
/** The VFS.md text for a mount's tree, its examples named on that grid. */
function treeVfsMd(size) {
const layout = size === 1 ? DAY_VFS_MD : BUCKET_VFS_MD;
const bucket = bucketName(bucketStart(EXAMPLE_DAY, size), size);
const day = size === 1 ? '' : `${EXAMPLE_DAY}_`;
return VFS_MD.replaceAll('{layout}', layout)
.replaceAll('{days}', String(size))
.replaceAll('{bucket}', bucket)
.replaceAll('{day}', day);
}
export class GCalVFS extends BaseVFS {
handed = {
stat: ['entry'],
unlink: ['entry'],
};
name = VFSName.GCAL;
cachesReads = true;
// Shorter than the other Google mounts: a calendar is edited by other
// people and a day-long index would keep serving a schedule that has
// already moved.
indexTtl = 300;
vfsMd;
wholeWordSearch = true;
// Calendar's search answers for events; calendar.json is always read.
searchable = ['*/*/*.gcal.json'];
config;
accessor;
// A config built by hand skips the schema, so its default is applied here.
constructor(options) {
super();
const config = { ...options, bucketDays: options.bucketDays ?? 1 };
this.config = config;
const tm = new TokenManager(config);
this.accessor = new GCalAccessor({
tokenManager: tm,
config,
capped: () => this.walkLimit >= 0,
});
this.vfsMd = treeVfsMd(config.bucketDays) + this.accessor.timeRange.vfsMd();
}
readdir(path) {
return gcalReaddir(this.accessor, path);
}
async read(path) {
return gcalRead(this.accessor, path);
}
readMany(paths, limit) {
return gcalReadMany(this.accessor, paths, limit);
}
deltaHook() {
return buildDeltaHook(this.accessor);
}
/**
* Whether this VFS answers `name`; with no walk limit it reads every
* file rather than ask the service's search.
*/
supports(name) {
if (name === 'files_containing' && this.walkLimit < 0)
return false;
return super.supports(name);
}
filesContaining(path, text, opts) {
if (!opts.wholeWord || this.walkLimit < 0)
return Promise.resolve(null);
return gcalFilesContaining(this.accessor, text, [path]);
}
stat(path, handed) {
return gcalStat(this.accessor, path, handed?.entry);
}
unlink(path, handed) {
return gcalUnlink(this.accessor, path, handed?.entry);
}
getState() {
return Promise.resolve(this.configState(GCalConfig, this.config));
}
}
//# sourceMappingURL=gcal.js.map