UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

191 lines • 8.31 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 { isFsError } from "../../errors/fs.js"; import { mountedPath } from "../../utils/key_prefix.js"; import { mayHold, recordQueries } from "../../utils/record_search.js"; import { compareCodePoints } from "../../utils/sort.js"; import { rowAt, rowsOf } from "../hierarchy/probe.js"; import { ROOT } from "../hierarchy/scope.js"; import { LinearApiError, searchIssues } from "./client.js"; import { readdir } from "./readdir.js"; import { detectScope } from "./scope.js"; import { rstripSlash } from "../../utils/slash.js"; // The most issues one search answers; a full page may have left some out. export const SEARCH_LIMIT = 250; // The words an issue's JSON holds whatever its text says: its letter-only key // names and the JSON literals. Every other field Linear does not search is // checked against the listing (`unsearchedText`). export const RECORD_KEYS = new Set([ 'title', 'description', 'priority', 'url', 'true', 'false', 'null', ]); // A team's directories whose files' rows keep their text (`keptText`). export const KEPT_DIRS = ['members', 'projects', 'cycles', 'documents']; function child(directory, name) { return mountedPath(directory, `${rstripSlash(directory.mountPath)}/${name}`); } // A directory's children, each with its row and the directory's. async function listed(accessor, directory, entry, parent) { const rows = rowsOf(await readdir(accessor, directory, entry, parent)); return rows.map((row) => [child(directory, row.name), row, entry]); } // The `issues` directories and the kept-text directories under `scope`, each // with its row and its team's; null for a scope inside an issue. async function walked(accessor, scope) { const match = detectScope(scope); let teams; if (match.kind === ROOT || match.kind === 'teams') { teams = await listed(accessor, mountedPath(scope, '/teams'), null, null); } else if (match.kind === 'team' || match.kind === 'issues' || KEPT_DIRS.includes(match.kind)) { const [entry, parent] = await rowAt(readdir, accessor, scope); if (entry === null) return null; if (match.kind === 'issues') return [[[scope, entry, parent]], []]; if (match.kind !== 'team') return [[], [[scope, entry, parent]]]; teams = [[scope, entry, parent]]; } else { return null; } const issueDirs = []; const keptDirs = []; for (const [team, row] of teams) { for (const found of await listed(accessor, team, row, null)) { if (found[1].name === 'issues') issueDirs.push(found); else if (KEPT_DIRS.includes(found[1].name)) keptDirs.push(found); } } return [issueDirs, keptDirs]; } // Each searched text's words, and the issues Linear names for each word; null // when the text cannot be searched or a search fails or answers a full page. async function named(accessor, text) { const queries = recordQueries(text, RECORD_KEYS, true); if (queries === null) return null; const asked = queries.map((query) => query.toLowerCase().split(/\s+/).filter(Boolean)); const found = new Map(); try { for (const word of [...new Set(asked.flat())].sort(compareCodePoints)) { const nodes = await searchIssues(accessor.transport, word, SEARCH_LIMIT); if (nodes.length >= SEARCH_LIMIT) return null; found.set(word, new Set(nodes.map((node) => String(node.id)))); } } catch (err) { // fetch rejects with a TypeError when the connection fails and a // DOMException when its timeout aborts it; a body that is not JSON // rejects with a SyntaxError const failed = isFsError(err) || err instanceof LinearApiError || err instanceof TypeError || err instanceof DOMException || err instanceof SyntaxError; if (!failed) throw err; console.debug(`linear search failed (${String(err)}); reading every issue`); return null; } return [asked, found]; } async function found(accessor, needle, under, askLinear) { const issueDirs = []; const keptDirs = []; for (const scope of under) { const dirs = await walked(accessor, scope); if (dirs === null) return null; for (const item of dirs[0]) issueDirs.push(item); for (const item of dirs[1]) keptDirs.push(item); } const issues = []; for (const [directory, row, above] of issueDirs) { for (const item of await listed(accessor, directory, row, above)) issues.push(item); } const answer = askLinear && issues.length > 0 ? await named(accessor, needle) : null; const held = []; if (answer !== null) { for (const [path, row] of issues) { const kept = row.extra.unsearched; if (typeof kept !== 'string' || mayHold(...answer, String(row.extra.issue_id), kept)) { held.push(path); } } } const out = (held.length > 0 ? held : issues.map(([path]) => path)).map((path) => child(path, 'issue.json')); for (const [directory, row, above] of keptDirs) { for (const [path, file] of await listed(accessor, directory, row, above)) { const kept = file.extra.text; if (typeof kept !== 'string' || kept.includes(needle)) out.push(path); } } return out; } /** * The files under `under` that may hold `text`: the `issue.json` files * Linear's search names or whose other fields hold it, and the member, * project, cycle and document files whose text holds it. * * Linear searches an issue's title and description by word, so with * `askLinear` each word is asked on its own, and an issue is read when each * word is either named there or held by the text the listing kept * (`unsearchedText`): the rest of its JSON (team, project, cycle, state, * people, labels, dates, link), and the words of those two the search may * split otherwise. The text goes through `recordQueries` for its key names and * escapes. Every issue is read without `askLinear`, when the text cannot be * searched, a search fails or answers a full page, or nothing is found, since * Linear indexes a write some time after it lands. A member, project, cycle or * document file is read when the text its row kept (`keptText`) holds `text` * in any case, or when it was too large to keep. The walk lists each directory * from Linear, so the kept text is checked against the records as they are * now. null for a text that is not ASCII or a scope inside an issue. Mirrors * Python's `files_containing`. */ export async function filesContaining(accessor, text, under, askLinear) { if (/[\u0080-\uffff]/.test(text)) return null; try { return await found(accessor, text.toLowerCase(), under, askLinear); } catch (err) { // fetch rejects with a TypeError when the connection fails and a // DOMException when its timeout aborts it; a body that is not JSON // rejects with a SyntaxError const failed = isFsError(err) || err instanceof LinearApiError || err instanceof TypeError || err instanceof DOMException || err instanceof SyntaxError; if (!failed) throw err; console.debug(`linear listing failed (${String(err)}); reading every file`); return null; } } //# sourceMappingURL=search.js.map