@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
139 lines • 5.9 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 { isEnoent } from "../../errors/fs.js";
import { recordQueries } from "../../utils/record_search.js";
import { listRecords } from "./client.js";
import { AirtableApiError } from "./errors.js";
import { recordsJsonl } from "./normalize.js";
import { schemaTable } from "./readdir.js";
import { detectScope } from "./scope.js";
// A line's keys besides its field names, and the JSON literals.
export const LINE_KEYS = new Set([
'record_id',
'created_time',
'fields',
'true',
'false',
'null',
]);
// Cell types a line spells as the text a formula reads of them: a string, or
// strings in a list a formula joins with ", ".
export const TEXT_TYPES = new Set([
'singleLineText',
'multilineText',
'richText',
'email',
'url',
'phoneNumber',
'singleSelect',
'multipleSelects',
]);
// Cell types a line spells with digits, `- . : + e T Z` or `true` only, where
// no text of three letters lies outside a key.
export const WORDLESS_TYPES = new Set([
'number',
'percent',
'currency',
'duration',
'rating',
'autoNumber',
'count',
'checkbox',
'date',
'dateTime',
'createdTime',
'lastModifiedTime',
]);
// Cell types whose JSON holds no space outside the text a formula reads of
// them: a link's record ids, an AI text's state and error type around its
// value, an attachment's ids, urls and type around its file name. A text with
// a space is found in the formula's text or nowhere.
export const SPACELESS_TYPES = new Set(['multipleRecordLinks']);
export const SPACED_TEXT_TYPES = new Set(['aiText', 'multipleAttachments']);
/**
* The lines of a table's records.jsonl or a view's file that may hold `text`,
* in the order a plain read lists them.
*
* Airtable lists only the records a formula is true for, so the formula asks
* whether the record id (`RECORD_ID()`) or a cell, as the text a formula reads
* and lowercased, holds `text`; grep matches each line itself. Only a table
* whose every field spells its cell as that text (a text, a select, an email,
* a url, a phone number) or with no word in it (a number, a date, a checkbox)
* is asked; a link, an AI text or an attachment holds no space outside the
* text a formula reads of it, so a table with one is asked for a text with a
* space. A collaborator, a lookup or a formula renders ids or errors a formula
* does not read. `text` goes through `recordQueries` for the line's keys,
* field names among them, and its escapes. null for any other file, a path
* the listing does not hold, a table with another field type or a field name
* a formula cannot quote, a text a key holds, a failed listing, or more than
* `maxReadRecords` matches. Mirrors Python's `lines_containing`.
*/
export async function linesContaining(accessor, path, text, entry = null) {
const match = detectScope(path);
if ((match.kind !== 'records' && match.kind !== 'view') || entry === null)
return null;
let table;
try {
table = await schemaTable(accessor, match);
}
catch (err) {
if (isEnoent(err))
return null;
throw err;
}
const fields = (Array.isArray(table.fields) ? table.fields : []).filter((field) => typeof field === 'object' && field !== null);
const names = fields.map((field) => (typeof field.name === 'string' ? field.name : ''));
const keys = new Set([...LINE_KEYS, ...names.map((name) => name.toLowerCase())]);
const queries = recordQueries(text, keys, false);
if (queries === null)
return null;
const asked = queries.map((query) => query.toLowerCase());
if (asked.some((query) => [...keys].some((key) => key.includes(query))))
return null;
const spaced = asked.every((query) => query.includes(' '));
const cells = ['LOWER(RECORD_ID())'];
for (const [i, field] of fields.entries()) {
const name = names[i] ?? '';
const kind = typeof field.type === 'string' ? field.type : '';
if (name.includes('{') || name.includes('}'))
return null;
if (TEXT_TYPES.has(kind) || (spaced && SPACED_TEXT_TYPES.has(kind))) {
cells.push(`LOWER({${name}} & "")`);
}
else if (!(WORDLESS_TYPES.has(kind) || (spaced && SPACELESS_TYPES.has(kind)))) {
return null;
}
}
const formula = `OR(${asked.flatMap((query) => cells.map((cell) => `SEARCH("${query}", ${cell})`)).join(', ')})`;
const cap = accessor.maxReadRecords;
let records;
try {
records = await listRecords(accessor, match.slots.base_id ?? '', match.slots.table_id ?? '', {
...(match.slots.view_id !== undefined ? { view: match.slots.view_id } : {}),
formula,
maxRecords: cap + 1,
});
}
catch (err) {
// fetch rejects with a TypeError when the connection fails
if (!(err instanceof AirtableApiError || err instanceof TypeError))
throw err;
console.debug(`airtable search failed (${String(err)}); reading the file`);
return null;
}
if (records.length > cap)
return null;
return recordsJsonl(records);
}
//# sourceMappingURL=search.js.map