@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
219 lines (199 loc) • 7.5 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 { LinearAccessor } from "../../accessor/linear.js";
import { HttpLinearTransport } from "../../core/linear/client.js";
import { LinearConfig } from "../../core/linear/config.js";
import { read as linearRead } from "../../core/linear/read.js";
import { readdir as linearReaddir } from "../../core/linear/readdir.js";
import { filesContaining as linearFilesContaining } from "../../core/linear/search.js";
import { stat as linearStat } from "../../core/linear/stat.js";
import { VFSName } from "../../types.js";
import { BaseVFS } from "../base.js";
export const VFS_MD = `\`\`\`text
{prefix}
teams/
<team-key>__<team-name>__<team-id>/
team.json
members/
<display-name>__<user-id>.json
issues/
<issue-key>__<issue-id>/
issue.json
comments.jsonl
projects/
<name>__<project-id>.json
cycles/
<name>__<cycle-id>.json
documents/
<title>__<document-id>.json
\`\`\`
- \`ls\` directories first to discover exact names.
- Every entity-named segment is \`<sanitized-human-readable>__<id>\`. The
human-readable part is sanitized, so don't construct it; \`ls\` the
parent directory instead.
- The id is the last \`__\`-separated segment:
\`\`\`text
{prefix}/teams/STR__Strukto-ai__<team-id>/ -> <team-id>
{prefix}/teams/.../issues/STR-42__<issue-id>/ -> <issue-id>
{prefix}/teams/.../members/Alice__<user-id>.json -> <user-id>
\`\`\`
All \`cat\` output is normalized by Mirage (snake_case keys), not the raw
Linear GraphQL response. The shapes:
\`team.json\`:
\`\`\`jsonc
{
"team_id": "...", "team_key": "STR", "team_name": "Strukto",
"name": "...", "description": "...", "timezone": "...",
"updated_at": "...",
"states": [
{ "state_id": "...", "state_name": "Todo", "type": "unstarted" },
... // one element per workflow state
]
}
\`\`\`
\`issue.json\`:
\`\`\`jsonc
{
"issue_id": "...", "issue_key": "STR-42",
"title": "...", "description": "...",
"team_id": "...", "team_key": "...", "team_name": "...",
"project_id": "...", "project_name": "...",
"cycle_id": "...", "cycle_name": "...", "cycle_number": 7,
"state_id": "...", "state_name": "In Progress",
"assignee_id": "...", "assignee_email": "...", "assignee_name": "...",
"creator_id": "...", "creator_email": "...", "creator_name": "...",
"priority": 2, // 0=none 1=urgent 2=high 3=medium 4=low
"label_ids": ["..."],
"label_names": ["bug", "frontend"],
"created_at": "...", "updated_at": "...", "url": "..."
}
\`\`\`
\`comments.jsonl\`, one normalized comment per line:
\`\`\`jsonc
{ "comment_id": "...", "issue_id": "...", "issue_key": "STR-42",
"user_id": "...", "user_email": "...", "user_name": "...",
"body": "...", "created_at": "...", "updated_at": "...",
"url": "..." }
\`\`\`
A project (\`projects/<name>__<project-id>.json\`):
\`\`\`jsonc
{
"project_id": "...", "team_id": "...",
"name": "...", "description": "...", "state": "started",
"lead_id": "...", "updated_at": "...", "url": "...",
"issue_count": 12,
"issues": [
{ "issue_id": "...", "issue_key": "STR-42", "title": "...",
"state_id": "...", "state_name": "In Progress", "url": "..." }
]
}
\`\`\`
A cycle (\`cycles/<name>__<cycle-id>.json\`):
\`\`\`jsonc
{
"cycle_id": "...", "team_id": "...",
"name": "...", "number": 7,
"starts_at": "...", "ends_at": "...",
"updated_at": "...", "url": "..."
}
\`\`\`
A member (\`members/<display-name>__<user-id>.json\`):
\`\`\`jsonc
{
"user_id": "...", "name": "...", "display_name": "...",
"email": "...", "is_active": true, "is_admin": false,
"updated_at": "...", "url": "..."
}
\`\`\`
A document (\`documents/<title>__<document-id>.json\`):
\`\`\`jsonc
{
"document_id": "...", "title": "...", "content": "...",
"project_id": "...", "project_name": "...",
"creator_id": "...", "creator_name": "...", "creator_email": "...",
"created_at": "...", "updated_at": "...", "url": "..."
}
\`\`\`
Useful \`jq\` paths:
\`\`\`jq
.issue_key # issue.json
.state_name # issue.json
.assignee_email # issue.json
.label_names[] # issue.json
.issues[] | select(.state_name == "In Progress") # a project
.states[].state_name # team.json
[inputs] | length # comments.jsonl: count
[inputs | select(.user_email == "x")] | length # comments.jsonl
\`\`\`
To act on issues (list, get, create, update, assign, transition,
comments, projects, cycles, labels, users, documents, search), use the
\`linear\` CLI if installed (\`linear --help\`).`;
export class LinearVFS extends BaseVFS {
handed = {
readdir: ['entry'],
read: ['parent'],
stat: ['entry'],
};
name = VFSName.LINEAR;
cachesReads = true;
// Every file is sized at its parent's readdir from the listing payload
// (comments.jsonl via one bounded comments call), so stat always reports
// the rendered byte length and fskit mounts serve exact reads.
sizesAlwaysKnown = true;
vfsMd = VFS_MD;
config;
accessor;
// Linear's search answers for issues; comments and the team's other files
// are always read.
searchable = [
'teams/*/issues/*/issue.json',
'teams/*/members/*',
'teams/*/projects/*',
'teams/*/cycles/*',
'teams/*/documents/*',
];
constructor(config) {
super();
this.config = config;
const transportOpts = {
apiKey: config.apiKey,
};
if (config.baseUrl !== undefined)
transportOpts.baseUrl = config.baseUrl;
if (config.timeout !== undefined)
transportOpts.timeout = config.timeout;
const accessorOpts = {
capped: () => this.walkLimit >= 0,
};
if (config.teamIds !== undefined)
accessorOpts.teamIds = config.teamIds;
this.accessor = new LinearAccessor(new HttpLinearTransport(transportOpts), accessorOpts);
}
filesContaining(path, text, opts) {
return linearFilesContaining(this.accessor, text, [path], opts.wholeWord && this.walkLimit >= 0);
}
readdir(path, handed) {
return linearReaddir(this.accessor, path, handed?.entry, null, handed?.limit);
}
async read(path, handed) {
return linearRead(this.accessor, path, null, handed?.parent);
}
stat(path, handed) {
return linearStat(this.accessor, path, handed?.entry);
}
getState() {
return Promise.resolve(this.configState(LinearConfig, this.config));
}
}
//# sourceMappingURL=linear.js.map