UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

137 lines (122 loc) • 4.25 kB
import type { ReadonlyJSONArray, ReadonlyJSONValue, ReadonlyJSONObject, } from "../../utils"; import { assertSafePathSegment } from "./changeTree"; import type { GorpStreamOperation } from "./types"; type PathFrame = | { kind: "array"; state: ReadonlyJSONArray; key: number } | { kind: "object"; state: ReadonlyJSONObject; key: string }; export class GorpStreamAccumulator { private _state: ReadonlyJSONValue; private readonly _strict: boolean; private readonly _logged = new Set<string>(); private _warnedClamp = false; constructor( initialValue: ReadonlyJSONValue = null, options: { strict?: boolean } = {}, ) { this._state = initialValue; this._strict = options.strict ?? true; } get state() { return this._state; } private logOnce(key: string, log: () => void) { if (this._logged.has(key) || this._logged.size >= 20) return; this._logged.add(key); log(); } append(ops: readonly GorpStreamOperation[]) { this._state = ops.reduce((state, op) => { if (this._strict) return this.apply(state, op); try { return this.apply(state, op); } catch (error) { this.logOnce(`skip:${String(error)}`, () => console.error( `Skipped unappliable gorp operation: ${String(error)}`, op, ), ); return state; } }, this._state); } private apply(state: ReadonlyJSONValue, op: GorpStreamOperation) { const type = op.type; switch (type) { case "set": return this.updatePath(state, op.path, () => op.value); case "append-text": return this.updatePath(state, op.path, (current) => { if (typeof current !== "string") throw new Error(`Expected string at path [${op.path.join(", ")}]`); return current + op.value; }); default: { const _exhaustiveCheck: never = type; throw new Error(`Invalid operation type: ${_exhaustiveCheck}`); } } } private updatePath( state: ReadonlyJSONValue | undefined, path: readonly string[], updater: (current: ReadonlyJSONValue | undefined) => ReadonlyJSONValue, ): ReadonlyJSONValue { if (path.length === 0) return updater(state); const frames: PathFrame[] = []; let current = state; for (let depth = 0; depth < path.length; depth++) { current ??= {}; if (typeof current !== "object") { throw new Error(`Invalid path: [${path.slice(depth).join(", ")}]`); } const key = path[depth]!; assertSafePathSegment(key); if (Array.isArray(current)) { let index = Number(key); // The wire can deliver numeric segments (op.path is only type-checked, // not runtime-validated), so canonicality is compared via String(key). if (!Number.isInteger(index) || String(index) !== String(key)) { throw new Error( `Expected array index at [${path.slice(depth).join(", ")}]`, ); } if (index < 0) throw new Error(`Insert array index out of bounds`); if (index > current.length) { if (this._strict) throw new Error(`Insert array index out of bounds`); if (!this._warnedClamp) { this._warnedClamp = true; console.warn( `Clamped out-of-bounds gorp array index ${index} to ${current.length}`, ); } index = current.length; } frames.push({ kind: "array", state: current, key: index }); current = Object.hasOwn(current, index) ? current[index] : undefined; } else { const object = current as ReadonlyJSONObject; frames.push({ kind: "object", state: object, key }); current = Object.hasOwn(object, key) ? object[key] : undefined; } } let updated = updater(current); for (let index = frames.length - 1; index >= 0; index--) { const frame = frames[index]!; if (frame.kind === "array") { const next = [...frame.state]; next[frame.key] = updated; updated = next; } else { const next = { ...frame.state }; next[frame.key] = updated; updated = next; } } return updated; } }