@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
188 lines (175 loc) • 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 { GDocsAccessor } from "../../accessor/gdocs.js";
import { MIME } from "../../core/gdocs/constants.js";
import { read as gdocsRead } from "../../core/gdocs/read.js";
import { readdir as gdocsReaddir } from "../../core/gdocs/readdir.js";
import { rename as gdocsRename } from "../../core/gdocs/rename.js";
import { stat as gdocsStat } from "../../core/gdocs/stat.js";
import { unlink as gdocsUnlink } from "../../core/gdocs/unlink.js";
import { TokenManager } from "../../core/google/client.js";
import { appDeltaHook } from "../../core/google/events.js";
import { filesContaining as googleFilesContaining } from "../../core/google/search.js";
import { VFSName } from "../../types.js";
import { DOCS_WORDS } from "../../utils/generated/google_vocabulary.js";
import { sliceWindow } from "../../utils/ranges.js";
import { BaseVFS } from "../base.js";
import { GDocsConfig } from "./config.js";
import { makeFilename } from "./doc_entry.js";
export const VFS_MD = `\`\`\`text
{prefix}
owned/ # docs you created
<YYYY-MM-DD>_<title>__<doc-id>.gdoc.json
shared/ # docs others shared with you
<YYYY-MM-DD>_<title>__<doc-id>.gdoc.json
\`\`\`
- \`<YYYY-MM-DD>\` is the file's \`modifiedTime\`, so a date glob such as
\`2026-05-*\` narrows by it.
- \`<title>\` is sanitized: spaces and any character outside
\`[A-Za-z0-9_.-]\` become \`_\`, at most 100 characters.
- \`<doc-id>\` is the Google Docs document id.
- \`shared/\` holds docs shared with you by others; ones you own
and shared with others are still in \`owned/\`.
A \`.gdoc.json\` holds the Google Docs API \`documents.get\` response, read
with \`includeTabsContent=true\` so every tab is present:
\`\`\`jsonc
{
"documentId": "...",
"title": "...",
"tabs": [ // one entry per top-level tab
{
"tabProperties": {
"tabId": "t.0", // names a tab for gws docs write
"title": "Tab 1",
"index": 0
},
"documentTab": {
"body": {
"content": [
{ // one element per block
"paragraph": {
"elements": [
{ "textRun": { "content": "the actual text\\n",
"textStyle": {...} } }
],
"paragraphStyle": {...}
}
},
{ "table": {...} },
{ "sectionBreak": {...} }
]
},
"documentStyle": {...},
"namedStyles": {...}
},
"childTabs": [ { ...same shape, nested to any depth... } ]
// a nested tab also carries
// parentTabId and nestingLevel;
// a root tab carries neither
}
],
"revisionId": "..."
}
\`\`\`
There is no top-level .body. A tab-aware response leaves the singleton
fields (\`.body\`, \`.documentStyle\`, \`.namedStyles\`) empty and hangs every
tab's content off \`.tabs[].documentTab\` instead. Tabs also nest, so text
can sit at any depth under \`.childTabs\`; the recursive recipes below read
a one-tab and a fifty-tab document alike.
Useful \`jq\` paths:
\`\`\`jq
.title
[.. | .tabProperties? // empty | .title] # every tab name
[.. | .textRun? // empty | .content] | add # all text, any depth
[.. | .tabProperties? // empty] | length # tab count
[.tabs[0].documentTab.body.content[]
| .paragraph?.elements[]?.textRun.content] | add # first tab only
.revisionId
\`\`\`
The first block of a body is always a \`sectionBreak\`, which carries no
\`.paragraph\`, so a path through \`.paragraph\` needs the \`?\` or it fails
with \`Cannot iterate over null\`.
Delete, on a \`write\` path:
\`\`\`sh
rm {prefix}/owned/<file>.gdoc.json # deletes it from Drive for good
rm -f <path> # ignores a missing file
\`\`\`
Only \`.gdoc.json\` files can be removed; \`owned/\` and \`shared/\` cannot.
Rename to a new title, on a \`write\` path:
\`\`\`sh
mv {prefix}/owned/<date>_<old>__<id>.gdoc.json \\
{prefix}/owned/<date>_<new>__<id>.gdoc.json
\`\`\`
Keep the folder, the date and the id, and spell the title the way the
listing does (spaces become \`_\`); any other name is refused.`;
export class GDocsVFS extends BaseVFS {
name = VFSName.GDOCS;
cachesReads = true;
indexTtl = 86_400;
// Reads stamp listing metadata; a fresh stat checks Drive by file ID.
readRevalidatable = true;
vfsMd = VFS_MD;
wholeWordSearch = true;
config;
accessor;
constructor(config) {
super();
this.config = config;
const tm = new TokenManager(config);
this.accessor = new GDocsAccessor({ tokenManager: tm });
}
handed = {
unlink: ['entry'],
rename: ['entry'],
};
renderers = { '.gdoc.json': 'readDoc' };
/**
* 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 googleFilesContaining(this.accessor, gdocsReaddir, MIME, DOCS_WORDS, text, [path]);
}
readdir(path) {
return gdocsReaddir(this.accessor, path);
}
/** Render the file as the JSON its `.gdoc.json` name holds. */
async readDoc(path, _handed, offset = 0, size = null) {
const data = await gdocsRead(this.accessor, path);
return offset === 0 && size === null ? data : sliceWindow(data, offset, size);
}
stat(path) {
return gdocsStat(this.accessor, path);
}
unlink(path, handed) {
return gdocsUnlink(this.accessor, path, handed?.entry);
}
rename(src, dst, handed) {
return gdocsRename(this.accessor, src, dst, handed?.entry ?? null);
}
deltaHook() {
return appDeltaHook(this.accessor, MIME, makeFilename);
}
getState() {
return Promise.resolve(this.configState(GDocsConfig, this.config));
}
}
//# sourceMappingURL=gdocs.js.map