@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
260 lines • 10.7 kB
JavaScript
import { FileStat, FileType } from "../types.js";
import { enoent } from "../errors/fs.js";
import { abortable, sleep } from "../utils/abort.js";
import { jsonlBytes } from "./render/json.js";
import { concat } from "../utils/bytes.js";
import { compareCodePoints } from "../utils/sort.js";
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
export function parseTime(value) {
const date = value.slice(0, 10);
const stamp = Date.parse(value);
if (Number(value.slice(0, 4)) === 0 ||
!TIMESTAMP.test(value) ||
!Number.isFinite(stamp) ||
new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date ||
Number(value.slice(11, 13)) > 23) {
throw new Error('expected RFC3339 timestamp with timezone and at most millisecond precision');
}
return stamp / 1000;
}
export class TimeRange {
start;
end;
constructor(start, end) {
this.start = start == null ? null : parseTime(start);
this.end = end == null ? null : parseTime(end);
if (this.start !== null && this.end !== null && this.start >= this.end)
throw new Error('start_time must be earlier than end_time');
}
get bounded() {
return this.start !== null || this.end !== null;
}
clip(start, end) {
return [Math.max(start, this.start ?? start), Math.min(end, this.end ?? end)];
}
dayBounds(day) {
const start = Date.parse(`${day}T00:00:00Z`) / 1000;
return this.clip(start, start + 86400);
}
requireDay(day, path) {
const [start, end] = this.dayBounds(day);
if (start >= end)
throw enoent(path);
}
listingDays(first, last, span = null) {
let lo = Date.parse(`${first}T00:00:00Z`) / 1000;
let hi = Date.parse(`${last}T00:00:00Z`) / 1000;
if (this.start !== null)
lo = Math.max(lo, Math.floor(this.start / 86400) * 86400);
if (this.end !== null)
hi = Math.min(hi, Math.floor(this.end / 86400) * 86400);
if (span !== null) {
lo = Math.max(lo, Date.parse(`${span[0]}T00:00:00Z`) / 1000);
hi = Math.min(hi, Date.parse(`${span[1]}T00:00:00Z`) / 1000 - 86400);
}
const days = [];
for (let day = lo; day <= hi; day += 86400) {
const [start, end] = this.clip(day, day + 86400);
if (start < end)
days.push(new Date(day * 1000).toISOString().slice(0, 10));
}
return days;
}
vfsMd() {
const start = this.start === null ? 'unbounded' : new Date(this.start * 1000).toISOString();
const end = this.end === null ? 'unbounded' : new Date(this.end * 1000).toISOString();
return `\n\nTime scope: \`start_time=${start}\` (inclusive), \`end_time=${end}\` (exclusive). Explicit paths and globs cannot escape this scope.`;
}
}
export function guardDay(accessor, match, virtual) {
accessor.timeRange.requireDay(match.slots.day ?? '', virtual);
return Promise.resolve();
}
/**
* The channel a day's chat.jsonl reads, proven by the listing.
*
* The typed `name__id` dirname is only trusted once a listing proves it, so a
* fabricated channel id is ENOENT rather than a raw API error. The file's row
* carries its channel, and so does its day's, which a day the window does not
* list still has: a sealed day lists nothing but the file still reads through
* the channel, reproducing the API's own answer for the fetch. Mirrors
* Python's `day_channel_id`.
*/
export function dayChannelId(path, entry, parent) {
for (const row of [entry, parent]) {
const channel = row?.extra.channel_id;
if (typeof channel === 'string' && channel !== '')
return channel;
}
throw enoent(path);
}
/**
* Stat a day directory, which resolves beyond the listed window.
*
* The parent listing synthesizes a bounded window of recent days, but the API
* answers a range query for any date, so a well-formed day under a channel or
* label that exists is a directory whether or not the window lists it,
* carrying its parent's ids as a listed day does. A bogus parent chain is
* ENOENT. `guard` refuses a day outside the mount's scope before any lookup,
* `guardDay` on a scoped mount. Mirrors Python's `day_stat`.
*/
export function dayStat(guard) {
return async (accessor, match, path, entry, parent) => {
if (guard !== undefined)
await guard(accessor, match, path.virtual);
if (entry !== null)
return entry;
if (parent === null)
throw enoent(path);
return new FileStat({
name: match.slots.day ?? '',
type: FileType.DIRECTORY,
extra: { ...parent.extra },
});
};
}
/**
* A chat day's last `n` messages, then each one posted to it after them, as
* its chat.jsonl lines.
*
* `messages` is the day as a read lists it, so its last `n` come first. Then
* every `interval` seconds `after` is asked for the messages posted since the
* last one seen (the whole day while none is), until a poll that starts once
* the day is over: nothing can land in it after that, and the stream waits to
* be closed, as `tail -f` waits on a file that stops growing. Each ask starts
* where the last answer ended, so none is lost or printed twice. An iterator
* rather than a generator: a generator queues `return` behind a pull waiting
* out its interval, so an idle `tail -f` could never close it. Closing also
* ends a pull waiting on a stalled ask, whose late answer is dropped, as
* Python's task cancel ends it there. Mirrors Python's `followed_day`.
*/
export function followedDay(messages, n, end, interval, after) {
let last = messages[messages.length - 1] ?? null;
let pending = n > 0 && messages.length > 0 ? jsonlBytes(messages.slice(-n)) : null;
let over = false;
const stop = new AbortController();
const done = { value: undefined, done: true };
return {
async next() {
if (pending !== null) {
const value = pending;
pending = null;
return { value, done: false };
}
try {
while (!over) {
await sleep(interval * 1000, stop.signal);
over = Date.now() / 1000 >= end;
const added = await abortable(after(last), stop.signal);
if (added.length > 0) {
last = added[added.length - 1] ?? last;
return { value: jsonlBytes(added), done: false };
}
}
await sleep(Number.POSITIVE_INFINITY, stop.signal);
}
catch (err) {
if (stop.signal.aborted)
return done;
throw err;
}
return done;
},
return() {
stop.abort();
pending = null;
return Promise.resolve(done);
},
[Symbol.asyncIterator]() {
return this;
},
};
}
/**
* Several chat days' chat.jsonl files, one walk per run of a channel's
* consecutive days. Each file is proven as its own read proves it (a day in
* scope, a channel the listing holds); one that is not, or is no chat file, is
* left out for its own read to answer, as is a day past `limit` bytes. Each
* day's lines are the run's messages posted within it, oldest first, as its own
* read lists them. Mirrors Python's `read_days`.
*/
export async function readDays(scope, paths, handed, detect, pages, order, within, limit) {
const days = new Map();
for (const [i, path] of paths.entries()) {
const match = detect(path);
if (match.kind !== 'messages')
continue;
const day = match.slots.day ?? '';
let channelId;
try {
scope.requireDay(day, path.virtual);
channelId = dayChannelId(path, handed[i]?.entry ?? null, handed[i]?.parent ?? null);
}
catch (err) {
if (err.code !== 'ENOENT')
throw err;
console.debug(`readDays leaves out ${path.virtual}: ${String(err)}`);
continue;
}
const named = days.get(channelId) ?? new Map();
named.set(day, path.virtual);
days.set(channelId, named);
}
const read = (channelId, run, named) => readRun(channelId, run, named, scope, pages, order, within, limit);
const answers = new Map();
for (const [channelId, named] of days) {
let run = [];
for (const day of [...named.keys()].sort(compareCodePoints)) {
const last = run[run.length - 1];
if (last !== undefined &&
Date.parse(`${day}T00:00:00Z`) - Date.parse(`${last}T00:00:00Z`) !== 86_400_000) {
for (const [virtual, body] of await read(channelId, run, named))
answers.set(virtual, body);
run = [];
}
run.push(day);
}
for (const [virtual, body] of await read(channelId, run, named))
answers.set(virtual, body);
}
return answers;
}
/**
* One run of a channel's consecutive days, walked once. The walk stops, and the
* run is left out, once its messages pass `limit` bytes a day: no more than
* that could be kept, and each day's own read answers it. Mirrors Python's
* `_read_run`.
*/
async function readRun(channelId, run, named, scope, pages, order, within, limit) {
const answers = new Map();
const [first] = run;
const last = run[run.length - 1];
if (first === undefined || last === undefined)
return answers;
const budget = limit * run.length;
const lines = [];
let size = 0;
for await (const page of pages(channelId, first, last)) {
for (const message of page) {
const line = jsonlBytes([message]);
lines.push([message, line]);
size += line.byteLength;
}
if (size > budget) {
console.debug(`readDays leaves out ${channelId} from ${first}: past ${String(budget)} bytes`);
return answers;
}
}
lines.sort(([a], [b]) => {
const [ka, kb] = [order(a), order(b)];
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
for (const day of run) {
const [start, end] = scope.dayBounds(day);
const body = concat(lines.filter(([m]) => within(m, start, end)).map(([, line]) => line));
if (body.byteLength <= limit)
answers.set(named.get(day) ?? '', body);
}
return answers;
}
//# sourceMappingURL=time_range.js.map