@fitchmultz/pi-subagents 0.0.0-stage → 0.44.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1654 -0
- package/LICENSE +21 -0
- package/README.md +1533 -2
- package/agents/context-builder.md +97 -0
- package/agents/debugger.md +51 -0
- package/agents/delegate.md +13 -0
- package/agents/fixer.md +37 -0
- package/agents/oracle.md +71 -0
- package/agents/planner.md +72 -0
- package/agents/researcher.md +51 -0
- package/agents/reviewer-claude.md +69 -0
- package/agents/reviewer-gpt.md +68 -0
- package/agents/reviewer-ponytail.md +82 -0
- package/agents/reviewer-security.md +81 -0
- package/agents/reviewer.md +69 -0
- package/agents/scout.md +66 -0
- package/agents/ui-designer.md +51 -0
- package/agents/watcher.md +26 -0
- package/agents/worker.md +46 -0
- package/agents/writer.md +36 -0
- package/dist/agents/agent-management.js +923 -0
- package/dist/agents/agent-scope.js +7 -0
- package/dist/agents/agent-selection.js +20 -0
- package/dist/agents/agent-serializer.js +100 -0
- package/dist/agents/agents.js +752 -0
- package/dist/agents/chain-serializer.js +367 -0
- package/dist/agents/frontmatter.js +21 -0
- package/dist/agents/identity.js +29 -0
- package/dist/agents/skills.js +570 -0
- package/dist/extension/build-info.js +1 -0
- package/dist/extension/compact-tools.js +112 -0
- package/dist/extension/config.js +22 -0
- package/dist/extension/control-notices.js +29 -0
- package/dist/extension/doctor.js +143 -0
- package/dist/extension/fanout-child.js +326 -0
- package/dist/extension/index.js +577 -0
- package/dist/extension/schemas.js +416 -0
- package/dist/extension/tool-input.js +20 -0
- package/dist/extension/tool-result.js +14 -0
- package/dist/history/canonical-result.js +81 -0
- package/dist/history/database.js +72 -0
- package/dist/history/index.js +199 -0
- package/dist/history/ingest.js +468 -0
- package/dist/history/queries.js +350 -0
- package/dist/history/store.js +216 -0
- package/dist/history/types.js +4 -0
- package/dist/history/worker.js +466 -0
- package/dist/intercom/intercom-bridge.js +112 -0
- package/dist/intercom/live-intercom.js +94 -0
- package/dist/intercom/result-intercom.js +373 -0
- package/dist/pi-intercom/agent-dir.js +10 -0
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/pi-intercom/broker/broker.js +426 -0
- package/dist/pi-intercom/broker/client.js +492 -0
- package/dist/pi-intercom/broker/framing.js +65 -0
- package/dist/pi-intercom/broker/paths.js +42 -0
- package/dist/pi-intercom/broker/pid.js +66 -0
- package/dist/pi-intercom/broker/spawn.js +221 -0
- package/dist/pi-intercom/config.js +102 -0
- package/dist/pi-intercom/index.js +2942 -0
- package/dist/pi-intercom/reply-tracker.js +182 -0
- package/dist/pi-intercom/session-targets.js +128 -0
- package/dist/pi-intercom/subagent-live-events.js +132 -0
- package/dist/pi-intercom/topics.js +157 -0
- package/dist/pi-intercom/types.js +163 -0
- package/dist/pi-intercom/ui/compose.js +269 -0
- package/dist/pi-intercom/ui/inline-message.js +80 -0
- package/dist/pi-intercom/ui/session-list.js +124 -0
- package/dist/runs/background/async-control.js +95 -0
- package/dist/runs/background/async-execution.js +676 -0
- package/dist/runs/background/async-job-tracker.js +383 -0
- package/dist/runs/background/async-result-file.js +58 -0
- package/dist/runs/background/async-resume.js +305 -0
- package/dist/runs/background/async-status.js +442 -0
- package/dist/runs/background/completion-dedupe.js +69 -0
- package/dist/runs/background/completion-delivery.js +245 -0
- package/dist/runs/background/notify.js +71 -0
- package/dist/runs/background/parallel-groups.js +55 -0
- package/dist/runs/background/result-watcher.js +426 -0
- package/dist/runs/background/run-id-resolver.js +64 -0
- package/dist/runs/background/run-status.js +382 -0
- package/dist/runs/background/stale-run-reconciler.js +335 -0
- package/dist/runs/background/subagent-runner-launcher.js +55 -0
- package/dist/runs/background/subagent-runner.js +1872 -0
- package/dist/runs/background/top-level-async.js +5 -0
- package/dist/runs/foreground/chain-clarify.js +1152 -0
- package/dist/runs/foreground/clarify-invocation.js +72 -0
- package/dist/runs/foreground/execution-input.js +423 -0
- package/dist/runs/foreground/foreground-control.js +817 -0
- package/dist/runs/foreground/question-control.js +88 -0
- package/dist/runs/foreground/run-async-path.js +205 -0
- package/dist/runs/foreground/subagent-executor.js +442 -0
- package/dist/runs/foreground/subagent-params.js +165 -0
- package/dist/runs/foreground/wait-run.js +141 -0
- package/dist/runs/shared/acceptance-contract.js +253 -0
- package/dist/runs/shared/acceptance-evaluation.js +256 -0
- package/dist/runs/shared/acceptance-finalization.js +280 -0
- package/dist/runs/shared/acceptance-reports.js +147 -0
- package/dist/runs/shared/acceptance.js +4 -0
- package/dist/runs/shared/chain-outputs.js +110 -0
- package/dist/runs/shared/child-attempt.js +693 -0
- package/dist/runs/shared/child-execution-cwd.js +96 -0
- package/dist/runs/shared/child-observations.js +114 -0
- package/dist/runs/shared/claude-code.js +199 -0
- package/dist/runs/shared/completion-guard.js +31 -0
- package/dist/runs/shared/dynamic-fanout.js +274 -0
- package/dist/runs/shared/history-index.js +72 -0
- package/dist/runs/shared/mcp-direct-tool-allowlist.js +334 -0
- package/dist/runs/shared/model-fallback.js +185 -0
- package/dist/runs/shared/mutating-tool-guard.js +170 -0
- package/dist/runs/shared/native-finalization.js +101 -0
- package/dist/runs/shared/native-usage.js +153 -0
- package/dist/runs/shared/nested-events.js +700 -0
- package/dist/runs/shared/nested-path.js +48 -0
- package/dist/runs/shared/nested-render.js +108 -0
- package/dist/runs/shared/parallel-utils.js +61 -0
- package/dist/runs/shared/parent-receipts.js +68 -0
- package/dist/runs/shared/parent-usage.js +121 -0
- package/dist/runs/shared/pi-args.js +310 -0
- package/dist/runs/shared/pi-spawn.js +52 -0
- package/dist/runs/shared/run-history-queries.js +45 -0
- package/dist/runs/shared/run-history.js +110 -0
- package/dist/runs/shared/run-id-ambiguity.js +4 -0
- package/dist/runs/shared/run-records.js +679 -0
- package/dist/runs/shared/session-cwd-preload.js +33 -0
- package/dist/runs/shared/single-output.js +222 -0
- package/dist/runs/shared/streaming-text.js +15 -0
- package/dist/runs/shared/structured-output.js +55 -0
- package/dist/runs/shared/subagent-control.js +179 -0
- package/dist/runs/shared/subagent-prompt-runtime.js +197 -0
- package/dist/runs/shared/subagent-tool-loop-guard.js +66 -0
- package/dist/runs/shared/supervisor-questions.js +350 -0
- package/dist/runs/shared/workflow-graph.js +214 -0
- package/dist/runs/shared/workflow-policy.js +84 -0
- package/dist/runs/shared/worktree.js +525 -0
- package/dist/shared/agent-context-policy.js +126 -0
- package/dist/shared/artifacts.js +96 -0
- package/dist/shared/atomic-json.js +13 -0
- package/dist/shared/cooperative.js +20 -0
- package/dist/shared/execution-cwd.js +14 -0
- package/dist/shared/file-coalescer.js +21 -0
- package/dist/shared/fork-context.js +49 -0
- package/dist/shared/formatters.js +85 -0
- package/dist/shared/journal-reader.js +407 -0
- package/dist/shared/lazy-tools.js +32 -0
- package/dist/shared/model-info.js +68 -0
- package/dist/shared/native-session.js +15 -0
- package/dist/shared/native-tui.js +18 -0
- package/dist/shared/native-typebox.js +20 -0
- package/dist/shared/post-exit-stdio-guard.js +197 -0
- package/dist/shared/prompt-sections.js +8 -0
- package/dist/shared/session-entries.js +52 -0
- package/dist/shared/session-identity.js +14 -0
- package/dist/shared/settings.js +254 -0
- package/dist/shared/status-format.js +111 -0
- package/dist/shared/temp-root.js +114 -0
- package/dist/shared/types.js +207 -0
- package/dist/shared/ui-mode.js +3 -0
- package/dist/shared/utils.js +334 -0
- package/dist/slash/prompt-template-bridge.js +298 -0
- package/dist/slash/slash-bridge.js +116 -0
- package/dist/slash/slash-commands.js +607 -0
- package/dist/slash/slash-live-state.js +246 -0
- package/dist/tui/action-hints.js +75 -0
- package/dist/tui/agent-history.js +197 -0
- package/dist/tui/agent-view.js +1656 -0
- package/dist/tui/render.js +1515 -0
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +581 -0
- package/docs/performance.md +121 -0
- package/package.json +102 -4
- package/prompts/gather-context-and-clarify.md +14 -0
- package/prompts/parallel-cleanup.md +62 -0
- package/prompts/parallel-context-build.md +56 -0
- package/prompts/parallel-handoff-plan.md +62 -0
- package/prompts/parallel-research.md +51 -0
- package/prompts/parallel-review.md +59 -0
- package/prompts/review-loop.md +42 -0
- package/scripts/build.mjs +134 -0
- package/scripts/check-compat.mjs +48 -0
- package/scripts/compat-host.mjs +19 -0
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/linux-smoke.sh +51 -0
- package/scripts/local-install-smoke.mjs +66 -0
- package/scripts/native-package-smoke.mjs +82 -0
- package/scripts/package-smoke.mjs +209 -0
- package/scripts/prepare.mjs +67 -0
- package/scripts/real-pi-smoke.mjs +415 -0
- package/scripts/run-tests.mjs +149 -0
- package/skills/pi-intercom/SKILL.md +113 -0
- package/skills/pi-intercom/evals/evals.json +29 -0
- package/skills/pi-intercom/evals/trigger-evals.json +47 -0
- package/skills/pi-intercom/references/peer-sessions.md +69 -0
- package/skills/pi-subagents/SKILL.md +76 -0
- package/skills/pi-subagents/evals/evals.json +29 -0
- package/skills/pi-subagents/evals/trigger-evals.json +42 -0
- package/skills/pi-subagents/references/full-orchestration-guide.md +863 -0
- package/src/agents/agent-management.ts +813 -0
- package/src/agents/agent-scope.ts +7 -0
- package/src/agents/agent-selection.ts +23 -0
- package/src/agents/agent-serializer.ts +99 -0
- package/src/agents/agents.ts +916 -0
- package/src/agents/chain-serializer.ts +329 -0
- package/src/agents/frontmatter.ts +21 -0
- package/src/agents/identity.ts +30 -0
- package/src/agents/skills.ts +637 -0
- package/src/extension/build-info.ts +2 -0
- package/src/extension/compact-tools.ts +114 -0
- package/src/extension/config.ts +23 -0
- package/src/extension/control-notices.ts +47 -0
- package/src/extension/doctor.ts +171 -0
- package/src/extension/fanout-child.ts +312 -0
- package/src/extension/index.ts +649 -0
- package/src/extension/schemas.ts +444 -0
- package/src/extension/tool-input.ts +19 -0
- package/src/extension/tool-result.ts +16 -0
- package/src/history/canonical-result.ts +62 -0
- package/src/history/database.ts +59 -0
- package/src/history/index.ts +156 -0
- package/src/history/ingest.ts +349 -0
- package/src/history/queries.ts +241 -0
- package/src/history/store.ts +203 -0
- package/src/history/types.ts +166 -0
- package/src/history/worker.ts +321 -0
- package/src/intercom/intercom-bridge.ts +123 -0
- package/src/intercom/live-intercom.ts +104 -0
- package/src/intercom/result-intercom.ts +429 -0
- package/src/pi-intercom/agent-dir.ts +9 -0
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/pi-intercom/broker/broker.ts +463 -0
- package/src/pi-intercom/broker/client.ts +558 -0
- package/src/pi-intercom/broker/framing.ts +77 -0
- package/src/pi-intercom/broker/paths.ts +47 -0
- package/src/pi-intercom/broker/pid.ts +59 -0
- package/src/pi-intercom/broker/spawn.ts +248 -0
- package/src/pi-intercom/config.ts +142 -0
- package/src/pi-intercom/index.ts +2929 -0
- package/src/pi-intercom/reply-tracker.ts +211 -0
- package/src/pi-intercom/session-targets.ts +172 -0
- package/src/pi-intercom/subagent-live-events.ts +133 -0
- package/src/pi-intercom/topics.ts +125 -0
- package/src/pi-intercom/types.ts +271 -0
- package/src/pi-intercom/ui/compose.ts +289 -0
- package/src/pi-intercom/ui/inline-message.ts +92 -0
- package/src/pi-intercom/ui/session-list.ts +139 -0
- package/src/runs/background/async-control.ts +89 -0
- package/src/runs/background/async-execution.ts +850 -0
- package/src/runs/background/async-job-tracker.ts +385 -0
- package/src/runs/background/async-result-file.ts +62 -0
- package/src/runs/background/async-resume.ts +333 -0
- package/src/runs/background/async-status.ts +481 -0
- package/src/runs/background/completion-dedupe.ts +79 -0
- package/src/runs/background/completion-delivery.ts +195 -0
- package/src/runs/background/notify.ts +123 -0
- package/src/runs/background/parallel-groups.ts +53 -0
- package/src/runs/background/result-watcher.ts +423 -0
- package/src/runs/background/run-id-resolver.ts +69 -0
- package/src/runs/background/run-status.ts +384 -0
- package/src/runs/background/stale-run-reconciler.ts +369 -0
- package/src/runs/background/subagent-runner-launcher.ts +56 -0
- package/src/runs/background/subagent-runner.ts +2053 -0
- package/src/runs/background/top-level-async.ts +13 -0
- package/src/runs/foreground/chain-clarify.ts +1340 -0
- package/src/runs/foreground/clarify-invocation.ts +77 -0
- package/src/runs/foreground/execution-input.ts +467 -0
- package/src/runs/foreground/foreground-control.ts +821 -0
- package/src/runs/foreground/question-control.ts +82 -0
- package/src/runs/foreground/run-async-path.ts +243 -0
- package/src/runs/foreground/subagent-executor.ts +501 -0
- package/src/runs/foreground/subagent-params.ts +320 -0
- package/src/runs/foreground/wait-run.ts +124 -0
- package/src/runs/shared/acceptance-contract.ts +257 -0
- package/src/runs/shared/acceptance-evaluation.ts +275 -0
- package/src/runs/shared/acceptance-finalization.ts +353 -0
- package/src/runs/shared/acceptance-reports.ts +152 -0
- package/src/runs/shared/acceptance.ts +33 -0
- package/src/runs/shared/chain-outputs.ts +114 -0
- package/src/runs/shared/child-attempt.ts +623 -0
- package/src/runs/shared/child-execution-cwd.ts +91 -0
- package/src/runs/shared/child-observations.ts +99 -0
- package/src/runs/shared/claude-code.ts +243 -0
- package/src/runs/shared/completion-guard.ts +35 -0
- package/src/runs/shared/dynamic-fanout.ts +295 -0
- package/src/runs/shared/history-index.ts +69 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +376 -0
- package/src/runs/shared/model-fallback.ts +219 -0
- package/src/runs/shared/mutating-tool-guard.ts +194 -0
- package/src/runs/shared/native-finalization.ts +128 -0
- package/src/runs/shared/native-usage.ts +113 -0
- package/src/runs/shared/nested-events.ts +752 -0
- package/src/runs/shared/nested-path.ts +52 -0
- package/src/runs/shared/nested-render.ts +119 -0
- package/src/runs/shared/parallel-utils.ts +153 -0
- package/src/runs/shared/parent-receipts.ts +59 -0
- package/src/runs/shared/parent-usage.ts +121 -0
- package/src/runs/shared/pi-args.ts +350 -0
- package/src/runs/shared/pi-spawn.ts +52 -0
- package/src/runs/shared/run-history-queries.ts +44 -0
- package/src/runs/shared/run-history.ts +100 -0
- package/src/runs/shared/run-id-ambiguity.ts +4 -0
- package/src/runs/shared/run-records.ts +607 -0
- package/src/runs/shared/session-cwd-preload.ts +27 -0
- package/src/runs/shared/single-output.ts +267 -0
- package/src/runs/shared/streaming-text.ts +18 -0
- package/src/runs/shared/structured-output.ts +68 -0
- package/src/runs/shared/subagent-control.ts +219 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +201 -0
- package/src/runs/shared/subagent-tool-loop-guard.ts +89 -0
- package/src/runs/shared/supervisor-questions.ts +415 -0
- package/src/runs/shared/workflow-graph.ts +220 -0
- package/src/runs/shared/workflow-policy.ts +125 -0
- package/src/runs/shared/worktree.ts +635 -0
- package/src/shared/agent-context-policy.ts +184 -0
- package/src/shared/artifacts.ts +95 -0
- package/src/shared/atomic-json.ts +16 -0
- package/src/shared/cooperative.ts +21 -0
- package/src/shared/execution-cwd.ts +14 -0
- package/src/shared/file-coalescer.ts +29 -0
- package/src/shared/fork-context.ts +68 -0
- package/src/shared/formatters.ts +84 -0
- package/src/shared/journal-reader.ts +333 -0
- package/src/shared/lazy-tools.ts +36 -0
- package/src/shared/model-info.ts +81 -0
- package/src/shared/native-session.ts +8 -0
- package/src/shared/native-tui.ts +10 -0
- package/src/shared/native-typebox.ts +14 -0
- package/src/shared/post-exit-stdio-guard.ts +204 -0
- package/src/shared/prompt-sections.ts +10 -0
- package/src/shared/session-entries.ts +49 -0
- package/src/shared/session-identity.ts +18 -0
- package/src/shared/settings.ts +384 -0
- package/src/shared/status-format.ts +123 -0
- package/src/shared/temp-root.ts +107 -0
- package/src/shared/types.ts +1260 -0
- package/src/shared/ui-mode.ts +5 -0
- package/src/shared/utils.ts +342 -0
- package/src/slash/prompt-template-bridge.ts +408 -0
- package/src/slash/slash-bridge.ts +178 -0
- package/src/slash/slash-commands.ts +617 -0
- package/src/slash/slash-live-state.ts +292 -0
- package/src/tui/action-hints.ts +69 -0
- package/src/tui/agent-history.ts +210 -0
- package/src/tui/agent-view.ts +1323 -0
- package/src/tui/render.ts +1550 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { JsonProjection } from "../shared/journal-reader.js";
|
|
4
|
+
import { compactEntry, identity, previewLimits, previewProjection } from "./ingest.js";
|
|
5
|
+
import { HistoryIndexError } from "./types.js";
|
|
6
|
+
import { hash, HistoryStore, integer, pageLimit } from "./store.js";
|
|
7
|
+
export class HistoryQueries {
|
|
8
|
+
store;
|
|
9
|
+
info;
|
|
10
|
+
constructor(store, info) { this.store = store; this.info = info; }
|
|
11
|
+
cursor(query, keys, offset) {
|
|
12
|
+
return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset })).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
parseCursor(value, query) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return;
|
|
17
|
+
let cursor;
|
|
18
|
+
try {
|
|
19
|
+
if (value.length > 4096)
|
|
20
|
+
throw new Error();
|
|
21
|
+
cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
22
|
+
if (!Array.isArray(cursor.keys) || cursor.keys.length > 8 || cursor.keys.some((key) => typeof key !== "number" && typeof key !== "string") || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
|
|
23
|
+
throw new Error();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new HistoryIndexError("INVALID_CURSOR", "Invalid history cursor.");
|
|
27
|
+
}
|
|
28
|
+
if (cursor.generation !== this.store.generation || cursor.owner !== this.store.owner || cursor.query !== query || cursor.version !== this.info().version)
|
|
29
|
+
throw new HistoryIndexError("STALE_CURSOR", "History changed or this cursor belongs to a different query/source; restart paging.");
|
|
30
|
+
return cursor;
|
|
31
|
+
}
|
|
32
|
+
child(runId, index) {
|
|
33
|
+
integer(index, 0);
|
|
34
|
+
const child = this.store.get("SELECT * FROM children WHERE run_id=? AND child_index=?", runId, index);
|
|
35
|
+
if (!child)
|
|
36
|
+
throw new HistoryIndexError("OWNERSHIP", "Run/child is not in the current owner's admitted handles.");
|
|
37
|
+
return { source: child.source_id ? this.store.source(child.source_id) : undefined, agent: child.agent };
|
|
38
|
+
}
|
|
39
|
+
listRuns(options) {
|
|
40
|
+
const limit = pageLimit(options.limit, 20), offset = integer(options.offset, 0), sort = options.sort ?? "attention";
|
|
41
|
+
if (!["attention", "newest", "oldest"].includes(sort))
|
|
42
|
+
throw new HistoryIndexError("INVALID", "Unsupported run sort.");
|
|
43
|
+
if (options.state && !["live", "completed", "paused", "blocked", "failed", "unknown"].includes(options.state))
|
|
44
|
+
throw new HistoryIndexError("INVALID", "Unsupported run state.");
|
|
45
|
+
if ((options.text?.length ?? 0) > 256 || (options.agent?.length ?? 0) > 256 || options.latestTasksOnly !== undefined && typeof options.latestTasksOnly !== "boolean")
|
|
46
|
+
throw new HistoryIndexError("INVALID", "Invalid run filter.");
|
|
47
|
+
const query = hash(JSON.stringify(["runs", sort, options.agent ?? null, options.state ?? null, options.text?.toLowerCase() ?? null, options.latestTasksOnly ?? false]));
|
|
48
|
+
const cursor = this.parseCursor(options.cursor, query);
|
|
49
|
+
if (cursor && options.offset !== undefined)
|
|
50
|
+
throw new HistoryIndexError("INVALID", "Use cursor or offset, not both.");
|
|
51
|
+
const clauses = [], params = [], childClauses = [], childParams = [];
|
|
52
|
+
if (options.agent) {
|
|
53
|
+
childClauses.push("c.agent=?");
|
|
54
|
+
childParams.push(options.agent);
|
|
55
|
+
}
|
|
56
|
+
if (options.text) {
|
|
57
|
+
childClauses.push("instr(c.filter_text,?)>0");
|
|
58
|
+
childParams.push(options.text.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
if (options.latestTasksOnly)
|
|
61
|
+
childClauses.push("NOT EXISTS(SELECT 1 FROM runs successor WHERE successor.predecessor_id=c.run_id AND successor.predecessor_index=c.child_index)");
|
|
62
|
+
const childWhere = childClauses.length ? ` AND ${childClauses.join(" AND ")}` : "";
|
|
63
|
+
if (childWhere) {
|
|
64
|
+
clauses.push(`EXISTS(SELECT 1 FROM children c WHERE c.run_id=r.id${childWhere})`);
|
|
65
|
+
params.push(...childParams);
|
|
66
|
+
}
|
|
67
|
+
if (options.state) {
|
|
68
|
+
clauses.push("r.state=?");
|
|
69
|
+
params.push(options.state);
|
|
70
|
+
}
|
|
71
|
+
const total = Number(this.store.get(`SELECT COUNT(*) AS total FROM runs r ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}`, ...params).total);
|
|
72
|
+
const columns = sort === "attention" ? ["attention", "negative_updated", "id"] : [sort === "newest" ? "negative_updated" : "updated", "id"];
|
|
73
|
+
if (cursor) {
|
|
74
|
+
if (cursor.keys.length !== columns.length)
|
|
75
|
+
throw new HistoryIndexError("INVALID_CURSOR", "Invalid run seek tuple.");
|
|
76
|
+
clauses.push(`(${columns.map((column) => `r.${column}`).join(",")}) > (${columns.map(() => "?").join(",")})`);
|
|
77
|
+
params.push(...cursor.keys);
|
|
78
|
+
}
|
|
79
|
+
const rows = this.store.all(`SELECT * FROM runs r ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY ${columns.map((column) => `r.${column}`).join(",")} LIMIT ? OFFSET ?`, ...params, limit + 1, cursor ? 0 : offset);
|
|
80
|
+
const more = rows.length > limit, page = rows.slice(0, limit), position = cursor?.offset ?? offset;
|
|
81
|
+
const views = page.map((row) => {
|
|
82
|
+
const view = JSON.parse(row.view);
|
|
83
|
+
if (childWhere)
|
|
84
|
+
view.matchedChildIndexes = this.store.all(`SELECT child_index FROM children c WHERE c.run_id=?${childWhere} ORDER BY child_index`, view.runId, ...childParams).map((child) => child.child_index);
|
|
85
|
+
for (const child of view.children) {
|
|
86
|
+
const { source } = this.child(view.runId, child.index);
|
|
87
|
+
if (source)
|
|
88
|
+
child.nativeConfiguration = this.configuration(source, child.state === "live" ? {} : { terminalEntryId: child.result?.terminalEntryId, endedAt: child.result?.terminalEntryId ? undefined : view.updatedAt, leaf: child.result?.terminalLeafId }, false);
|
|
89
|
+
}
|
|
90
|
+
return view;
|
|
91
|
+
});
|
|
92
|
+
return { rows: views, total, offset: position, ...this.info(), ...(more ? { nextOffset: position + page.length, nextCursor: this.cursor(query, columns.map((column) => page.at(-1)[column]), position + page.length) } : {}) };
|
|
93
|
+
}
|
|
94
|
+
boundary(source, input, alias = "") {
|
|
95
|
+
const column = alias ? `${alias}.` : "", clauses = [`${column}source_id=?`, `${column}generation=?`, `${column}published=1`], params = [source.id, source.generation];
|
|
96
|
+
let terminalSequence;
|
|
97
|
+
if (input.terminalEntryId) {
|
|
98
|
+
const terminal = this.store.get("SELECT start FROM entries WHERE source_id=? AND generation=? AND id=? AND published=1", source.id, source.generation, input.terminalEntryId);
|
|
99
|
+
if (!terminal)
|
|
100
|
+
throw new HistoryIndexError("BOUNDARY_UNAVAILABLE", "Saved terminal entry is not available in the current indexed generation.");
|
|
101
|
+
terminalSequence = terminal.start;
|
|
102
|
+
clauses.push(`${column}start<=?`);
|
|
103
|
+
params.push(terminal.start);
|
|
104
|
+
}
|
|
105
|
+
else if (input.endedAt !== undefined) {
|
|
106
|
+
clauses.push(`${column}timestamp<=?`);
|
|
107
|
+
params.push(integer(input.endedAt, 0));
|
|
108
|
+
}
|
|
109
|
+
return { clauses, params, terminalSequence };
|
|
110
|
+
}
|
|
111
|
+
configuration(source, input, strict = true) {
|
|
112
|
+
if (input.leaf === null)
|
|
113
|
+
return {};
|
|
114
|
+
let boundary;
|
|
115
|
+
try {
|
|
116
|
+
boundary = this.boundary(source, input);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!strict && error instanceof HistoryIndexError && error.code === "BOUNDARY_UNAVAILABLE")
|
|
120
|
+
return {};
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
|
|
124
|
+
if (!leaf)
|
|
125
|
+
return {};
|
|
126
|
+
// Fix recursive loop order: look up each child's parent by ID instead of
|
|
127
|
+
// rescanning the eligible transcript at every ancestry step.
|
|
128
|
+
const ancestry = `WITH RECURSIVE eligible AS (SELECT rowid,id,parent_id,start,timestamp,configuration_model,configuration_thinking FROM entries WHERE ${boundary.clauses.join(" AND ")}), branch AS (SELECT * FROM eligible WHERE id=? UNION SELECT parent.* FROM branch child CROSS JOIN eligible parent WHERE parent.id=child.parent_id)`;
|
|
129
|
+
const facts = this.store.get(`${ancestry} SELECT
|
|
130
|
+
(SELECT COUNT(*) FROM branch) AS count,
|
|
131
|
+
EXISTS(SELECT 1 FROM branch child WHERE parent_id IS NULL OR NOT EXISTS(SELECT 1 FROM eligible parent WHERE parent.id=child.parent_id)) AS rooted,
|
|
132
|
+
(SELECT configuration_model FROM branch WHERE configuration_model IS NOT NULL ORDER BY start DESC LIMIT 1) AS model,
|
|
133
|
+
(SELECT timestamp FROM branch WHERE configuration_model IS NOT NULL ORDER BY start DESC LIMIT 1) AS recorded_at,
|
|
134
|
+
(SELECT configuration_thinking FROM branch WHERE configuration_thinking IS NOT NULL ORDER BY start DESC LIMIT 1) AS thinking`, ...boundary.params, leaf);
|
|
135
|
+
if (!facts.count && input.leaf && strict)
|
|
136
|
+
throw new HistoryIndexError("BOUNDARY_UNAVAILABLE", "Selected configuration leaf is not available in the indexed generation.");
|
|
137
|
+
if (facts.count && !facts.rooted) {
|
|
138
|
+
if (!strict)
|
|
139
|
+
return {};
|
|
140
|
+
throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry.");
|
|
141
|
+
}
|
|
142
|
+
return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
|
|
143
|
+
}
|
|
144
|
+
historyPage(input) {
|
|
145
|
+
const { source } = this.child(input.runId, input.index);
|
|
146
|
+
const info = this.info(), limit = pageLimit(input.limit);
|
|
147
|
+
if (input.before !== undefined && input.after !== undefined)
|
|
148
|
+
throw new HistoryIndexError("INVALID", "Use before or after, not both.");
|
|
149
|
+
if (input.messageIds && (!Array.isArray(input.messageIds) || input.messageIds.length > 1000 || input.messageIds.some((id) => typeof id !== "string" || id.length > 512)) || input.readThrough !== undefined && input.readThrough !== null && (typeof input.readThrough !== "string" || input.readThrough.length > 1024))
|
|
150
|
+
throw new HistoryIndexError("INVALID", "Invalid scoped message/read metadata request.");
|
|
151
|
+
const empty = { ...info, entries: [], count: 0, hasMore: false, sourceId: source?.id ?? null, generation: source?.generation ?? null, sessionId: source?.session_id ?? null, sourceState: source?.state ?? "unlinked", configuration: {}, deliveredMessages: [], latestEntryId: null };
|
|
152
|
+
if (!source)
|
|
153
|
+
return { ...empty, unavailable: "This legacy or unstarted child has no linked native conversation." };
|
|
154
|
+
const query = hash(JSON.stringify(["history", input.runId, input.index, source.id, source.generation, input.terminalEntryId ?? null, input.endedAt ?? null, input.leaf ?? null]));
|
|
155
|
+
const cursor = this.parseCursor(input.cursor, query);
|
|
156
|
+
if (cursor && (input.before !== undefined || input.after !== undefined))
|
|
157
|
+
throw new HistoryIndexError("INVALID", "Use cursor or sequence, not both.");
|
|
158
|
+
const { clauses, params, terminalSequence } = this.boundary(source, input);
|
|
159
|
+
const metadataWhere = clauses.join(" AND "), metadataParams = params.slice();
|
|
160
|
+
const latest = this.store.get(`SELECT start,visible_id FROM entries WHERE ${metadataWhere} AND visible_id IS NOT NULL ORDER BY start DESC LIMIT 1`, ...metadataParams);
|
|
161
|
+
const marker = typeof input.readThrough === "string" ? this.store.get(`SELECT start,visible_id FROM entries WHERE ${metadataWhere} AND id=?`, ...metadataParams, input.readThrough.replace(/:(?:\d+|error)$/, "")) : undefined;
|
|
162
|
+
const deliveredMessages = [];
|
|
163
|
+
for (const id of new Set(input.messageIds ?? [])) {
|
|
164
|
+
const human = this.store.get(`SELECT start FROM entries WHERE ${metadataWhere} AND human_id=? ORDER BY start DESC LIMIT 1`, ...metadataParams, id);
|
|
165
|
+
if (human)
|
|
166
|
+
deliveredMessages.push([id, Boolean(this.store.get(`SELECT rowid FROM entries WHERE ${metadataWhere} AND start>? AND assistant_text=1 LIMIT 1`, ...metadataParams, human.start))]);
|
|
167
|
+
}
|
|
168
|
+
const outputDigest = this.store.get("SELECT output_digest FROM children WHERE run_id=? AND child_index=?", input.runId, input.index)?.output_digest;
|
|
169
|
+
const configuration = this.configuration(source, input);
|
|
170
|
+
const count = Number(this.store.get(`SELECT COUNT(*) AS count FROM entries WHERE ${metadataWhere}`, ...metadataParams).count);
|
|
171
|
+
const before = cursor ? integer(cursor.keys[0], 0) : input.before;
|
|
172
|
+
if (before !== undefined) {
|
|
173
|
+
clauses.push("start<?");
|
|
174
|
+
params.push(integer(before, 0));
|
|
175
|
+
}
|
|
176
|
+
if (input.after !== undefined) {
|
|
177
|
+
clauses.push("start>?");
|
|
178
|
+
params.push(integer(input.after, 0));
|
|
179
|
+
}
|
|
180
|
+
const forward = input.after !== undefined;
|
|
181
|
+
const fetched = this.store.all(`SELECT * FROM entries WHERE ${clauses.join(" AND ")} ORDER BY start ${forward ? "ASC" : "DESC"} LIMIT ?`, ...params, limit + 1);
|
|
182
|
+
const more = fetched.length > limit, rows = fetched.slice(0, limit);
|
|
183
|
+
if (!forward)
|
|
184
|
+
rows.reverse();
|
|
185
|
+
const first = rows[0], last = rows.at(-1);
|
|
186
|
+
const finalResultId = outputDigest ? this.finalResultId(input, outputDigest, first && last ? [first.start, last.start] : undefined) : undefined;
|
|
187
|
+
const previous = first && this.store.get(`SELECT start FROM entries WHERE ${metadataWhere} AND start<? LIMIT 1`, ...metadataParams, first.start);
|
|
188
|
+
return { ...empty, configuration, deliveredMessages, latestEntryId: latest?.visible_id ?? null, terminalSequence,
|
|
189
|
+
...(input.readThrough !== undefined ? { unreadAfter: Boolean(latest && (!marker || latest.start > marker.start || latest.start === marker.start && latest.visible_id !== input.readThrough)) } : {}),
|
|
190
|
+
...(finalResultId ? { finalResultId } : {}), entries: rows.map((row) => this.store.entry(row)), count, hasMore: forward ? more : Boolean(previous),
|
|
191
|
+
...(previous && first ? { previousBefore: first.start, previousCursor: this.cursor(query, [first.start], 0) } : {}),
|
|
192
|
+
...(last ? { nextAfter: last.start } : {}), ...(source.error ? { unavailable: source.error } : source.state === "missing" ? { unavailable: "Linked conversation is missing." } : {}) };
|
|
193
|
+
}
|
|
194
|
+
search(input) {
|
|
195
|
+
const expression = searchGrammar(input.query), limit = pageLimit(input.limit, 20), sort = input.sort ?? "relevance";
|
|
196
|
+
if (!["relevance", "newest"].includes(sort))
|
|
197
|
+
throw new HistoryIndexError("INVALID", "Unsupported search sort.");
|
|
198
|
+
if (input.index !== undefined && !input.runId)
|
|
199
|
+
throw new HistoryIndexError("INVALID", "A child search requires runId.");
|
|
200
|
+
if (input.runId && !this.store.get("SELECT id FROM runs WHERE id=?", input.runId))
|
|
201
|
+
throw new HistoryIndexError("OWNERSHIP", "Run is not admitted by the current owner.");
|
|
202
|
+
if (input.index !== undefined)
|
|
203
|
+
this.child(input.runId, input.index);
|
|
204
|
+
if (input.runId)
|
|
205
|
+
for (const child of this.store.all(`SELECT source_id,terminal_entry_id,ended_at FROM children WHERE run_id=?${input.index !== undefined ? " AND child_index=?" : ""}`, input.runId, ...(input.index !== undefined ? [input.index] : []))) {
|
|
206
|
+
const source = child.source_id && this.store.source(child.source_id);
|
|
207
|
+
if (source)
|
|
208
|
+
this.boundary(source, { terminalEntryId: child.terminal_entry_id ?? undefined, endedAt: child.ended_at ?? undefined });
|
|
209
|
+
}
|
|
210
|
+
const query = hash(JSON.stringify(["search", expression, sort, input.runId ?? null, input.index ?? null, input.agent ?? null]));
|
|
211
|
+
const cursor = this.parseCursor(input.cursor, query);
|
|
212
|
+
const clauses = ["e.published=1", "e.generation=s.generation", "(c.terminal_entry_id IS NULL OR e.start<=terminal.start)", "(c.ended_at IS NULL OR e.timestamp<=c.ended_at)"], params = [...expression];
|
|
213
|
+
if (input.runId) {
|
|
214
|
+
clauses.push("c.run_id=?");
|
|
215
|
+
params.push(input.runId);
|
|
216
|
+
}
|
|
217
|
+
if (input.index !== undefined) {
|
|
218
|
+
clauses.push("c.child_index=?");
|
|
219
|
+
params.push(input.index);
|
|
220
|
+
}
|
|
221
|
+
if (input.agent) {
|
|
222
|
+
if (input.agent.length > 256)
|
|
223
|
+
throw new HistoryIndexError("INVALID", "Agent filter is too long.");
|
|
224
|
+
clauses.push("c.agent=?");
|
|
225
|
+
params.push(input.agent);
|
|
226
|
+
}
|
|
227
|
+
// Ordering/filters apply globally before limit. Common terms may still require an FTS
|
|
228
|
+
// scan/sort; the parent enforces a hard deadline by killing this separate process.
|
|
229
|
+
const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
|
|
230
|
+
if (cursor) {
|
|
231
|
+
if (cursor.keys.length !== 4)
|
|
232
|
+
throw new HistoryIndexError("INVALID_CURSOR", "Invalid search seek tuple.");
|
|
233
|
+
clauses.push(`(${score},d.id,c.run_id,c.child_index)>(?,?,?,?)`);
|
|
234
|
+
params.push(...cursor.keys);
|
|
235
|
+
}
|
|
236
|
+
// Match words across all visible fields/windows of one native record. A quoted
|
|
237
|
+
// phrase remains a single FTS expression, so it cannot span unrelated fields.
|
|
238
|
+
const rows = this.store.all(`WITH hits AS MATERIALIZED (
|
|
239
|
+
${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?`).join(" UNION ALL ")}
|
|
240
|
+
), terms AS (
|
|
241
|
+
SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
|
|
242
|
+
), matched AS (
|
|
243
|
+
SELECT entry_rowid,SUM(score) AS score FROM terms GROUP BY entry_rowid HAVING COUNT(*)=${expression.length}
|
|
244
|
+
), excerpts AS (
|
|
245
|
+
SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
|
|
246
|
+
)
|
|
247
|
+
SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
|
|
248
|
+
const more = rows.length > limit, page = rows.slice(0, limit);
|
|
249
|
+
return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
|
|
250
|
+
ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
|
|
251
|
+
...(more ? { nextCursor: this.cursor(query, [page.at(-1).score, page.at(-1).document_id, page.at(-1).run_id, page.at(-1).child_index], (cursor?.offset ?? 0) + page.length) } : {}) };
|
|
252
|
+
}
|
|
253
|
+
finalResultId(input, digest, preferredRange) {
|
|
254
|
+
const { source } = this.child(input.runId, input.index);
|
|
255
|
+
if (!source)
|
|
256
|
+
return;
|
|
257
|
+
const boundary = this.boundary(source, input, "e");
|
|
258
|
+
return this.store.get(`SELECT a.item_id FROM answers a JOIN entries e ON e.rowid=a.entry_rowid WHERE a.digest=? AND ${boundary.clauses.join(" AND ")} ORDER BY ${preferredRange ? "(e.start BETWEEN ? AND ?) DESC," : ""}e.start DESC LIMIT 1`, digest, ...boundary.params, ...(preferredRange ?? []))?.item_id;
|
|
259
|
+
}
|
|
260
|
+
selected(input, full) {
|
|
261
|
+
const { source } = this.child(input.runId, input.index);
|
|
262
|
+
if (!source)
|
|
263
|
+
return null;
|
|
264
|
+
const boundary = this.boundary(source, input), where = boundary.clauses.join(" AND ");
|
|
265
|
+
let row;
|
|
266
|
+
if (input.ref) {
|
|
267
|
+
if (input.ref.sourceId !== source.id || input.ref.generation !== source.generation)
|
|
268
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected source generation changed; refresh history.");
|
|
269
|
+
row = this.store.get(`SELECT * FROM entries WHERE ${where} AND start=? AND end=? AND digest=?`, ...boundary.params, input.ref.start, input.ref.end, input.ref.digest);
|
|
270
|
+
if (!row)
|
|
271
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected byte reference is no longer indexed.");
|
|
272
|
+
}
|
|
273
|
+
else if (input.entryId)
|
|
274
|
+
row = this.store.get(`SELECT * FROM entries WHERE ${where} AND id=?`, ...boundary.params, input.entryId);
|
|
275
|
+
else if (input.toolCallId && ["call", "result"].includes(input.kind ?? "call"))
|
|
276
|
+
row = this.store.get(`SELECT e.* FROM tools t JOIN entries e ON e.rowid=t.entry_rowid WHERE ${this.boundary(source, input, "e").clauses.join(" AND ")} AND t.call_id=? AND t.kind=? ORDER BY e.start DESC LIMIT 1`, ...boundary.params, input.toolCallId, input.kind ?? "call");
|
|
277
|
+
else
|
|
278
|
+
throw new HistoryIndexError("INVALID", "Select an entry ID, byte reference, or tool call/result ID.");
|
|
279
|
+
if (!row)
|
|
280
|
+
return null;
|
|
281
|
+
const body = validateRecord(source, row, full);
|
|
282
|
+
return full ? body : { ...this.store.entry(row), entry: { ...compactEntry(body), id: row.id } };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function searchGrammar(query) {
|
|
286
|
+
if (typeof query !== "string" || !query.trim() || query.length > 1024)
|
|
287
|
+
throw new HistoryIndexError("INVALID_QUERY", "Search requires 1–12 tokens or one quoted phrase.");
|
|
288
|
+
const text = query.trim(), phrase = text.startsWith('"') && text.endsWith('"');
|
|
289
|
+
const words = (phrase ? text.slice(1, -1) : text).trim().split(/\s+/u);
|
|
290
|
+
if (!words.length || words.length > 12 || words.some((word) => !/^[\p{L}\p{N}\p{M}]{1,64}$/u.test(word) || !phrase && ["AND", "OR", "NOT", "NEAR"].includes(word)))
|
|
291
|
+
throw new HistoryIndexError("INVALID_QUERY", "Only lexical tokens or one quoted phrase are supported; operators, punctuation, and prefixes are not allowed.");
|
|
292
|
+
return phrase ? [`"${words.join(" ")}"`] : [...new Set(words.map((word) => `"${word}"`))];
|
|
293
|
+
}
|
|
294
|
+
function validateRecord(source, row, full) {
|
|
295
|
+
let fd;
|
|
296
|
+
try {
|
|
297
|
+
fd = fs.openSync(source.path, "r");
|
|
298
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
299
|
+
if (identity(stat) !== source.identity || Number(stat.size) < row.end)
|
|
300
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected conversation was replaced or truncated.");
|
|
301
|
+
const headerHash = createHash("sha256"), headerBytes = Buffer.allocUnsafe(64 * 1024);
|
|
302
|
+
let headerPosition = 0, headerComplete = false;
|
|
303
|
+
while (!headerComplete && headerPosition < 1024 * 1024) {
|
|
304
|
+
const count = fs.readSync(fd, headerBytes, 0, headerBytes.length, headerPosition);
|
|
305
|
+
if (!count)
|
|
306
|
+
break;
|
|
307
|
+
const newline = headerBytes.subarray(0, count).indexOf(10);
|
|
308
|
+
const length = newline < 0 ? count : newline + 1;
|
|
309
|
+
headerHash.update(headerBytes.subarray(0, length));
|
|
310
|
+
headerPosition += length;
|
|
311
|
+
headerComplete = newline >= 0;
|
|
312
|
+
}
|
|
313
|
+
if (!headerComplete || headerHash.digest("hex") !== source.header_digest)
|
|
314
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected native header changed or exceeds its validation budget.");
|
|
315
|
+
if (full && row.end - row.start > 16 * 1024 * 1024)
|
|
316
|
+
throw new HistoryIndexError("RECORD_TOO_LARGE", "Selected full record exceeds the 16 MiB detail budget; use its validated preview.");
|
|
317
|
+
const digest = createHash("sha256"), parser = new JsonProjection(previewProjection, undefined, undefined, previewLimits), bytes = Buffer.allocUnsafe(64 * 1024), body = [];
|
|
318
|
+
const decoder = new TextDecoder("utf8", { fatal: true });
|
|
319
|
+
for (let position = row.start; position < row.end;) {
|
|
320
|
+
const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, row.end - position), position);
|
|
321
|
+
if (!count)
|
|
322
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected record was truncated during validation.");
|
|
323
|
+
const chunk = bytes.subarray(0, count);
|
|
324
|
+
digest.update(chunk);
|
|
325
|
+
if (full)
|
|
326
|
+
body.push(Buffer.from(chunk));
|
|
327
|
+
else
|
|
328
|
+
parser.write(decoder.decode(chunk, { stream: true }));
|
|
329
|
+
position += count;
|
|
330
|
+
}
|
|
331
|
+
const current = fs.statSync(source.path, { bigint: true }), after = fs.fstatSync(fd, { bigint: true });
|
|
332
|
+
if (identity(current) !== identity(stat) || current.size < BigInt(row.end) || after.ctimeNs !== stat.ctimeNs && after.size <= stat.size || digest.digest("hex") !== row.digest)
|
|
333
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected record changed; refresh history before reading details.");
|
|
334
|
+
if (full)
|
|
335
|
+
return JSON.parse(new TextDecoder("utf8", { fatal: true }).decode(Buffer.concat(body)));
|
|
336
|
+
parser.write(decoder.decode());
|
|
337
|
+
return parser.finish();
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (error.code === "ENOENT")
|
|
341
|
+
throw new HistoryIndexError("SOURCE_CHANGED", "Selected conversation is missing.");
|
|
342
|
+
if (error instanceof RangeError)
|
|
343
|
+
throw new HistoryIndexError("RECORD_COMPLEXITY", "Selected preview exceeds bounded history structure budgets.");
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
if (fd !== undefined)
|
|
348
|
+
fs.closeSync(fd);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { historyDirectory, historyTransaction, openHistoryDatabase } from "./database.js";
|
|
5
|
+
import { stripTerminalSequences } from "../shared/native-tui.js";
|
|
6
|
+
import { HistoryIndexError } from "./types.js";
|
|
7
|
+
import { readSavedOutput } from "./canonical-result.js";
|
|
8
|
+
export const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
9
|
+
export const exactTextDigest = (text) => hash(stripTerminalSequences(text).trim());
|
|
10
|
+
export const safeText = (value, limit = 1024) => typeof value === "string" ? value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").slice(0, limit) : "";
|
|
11
|
+
export function integer(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
|
12
|
+
const result = value === undefined ? fallback : value;
|
|
13
|
+
if (!Number.isSafeInteger(result) || Number(result) < 0 || Number(result) > maximum)
|
|
14
|
+
throw new HistoryIndexError("INVALID", "Expected a bounded non-negative integer.");
|
|
15
|
+
return Number(result);
|
|
16
|
+
}
|
|
17
|
+
export function pageLimit(value, fallback = 100) {
|
|
18
|
+
const limit = integer(value, fallback, 100);
|
|
19
|
+
if (!limit)
|
|
20
|
+
throw new HistoryIndexError("INVALID", "Page limit must be from 1 to 100.");
|
|
21
|
+
return limit;
|
|
22
|
+
}
|
|
23
|
+
export class HistoryStore {
|
|
24
|
+
db;
|
|
25
|
+
generation;
|
|
26
|
+
file;
|
|
27
|
+
owner;
|
|
28
|
+
operations = { sourceChecks: 0, sourceOpens: 0, sourceBytesRead: 0, runProjections: 0, queries: 0 };
|
|
29
|
+
ingestLock;
|
|
30
|
+
outputDigests = new Map();
|
|
31
|
+
constructor(agentDir, owner) {
|
|
32
|
+
this.owner = owner;
|
|
33
|
+
const directory = historyDirectory(agentDir);
|
|
34
|
+
const manifest = path.join(directory, `${hash(owner)}.json`);
|
|
35
|
+
let name;
|
|
36
|
+
try {
|
|
37
|
+
const saved = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
38
|
+
if (typeof saved.file === "string" && /^[a-f0-9-]+\.sqlite$/.test(saved.file))
|
|
39
|
+
name = saved.file;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
let db;
|
|
46
|
+
if (name) {
|
|
47
|
+
try {
|
|
48
|
+
const file = path.join(directory, name);
|
|
49
|
+
if (!fs.existsSync(file) || fs.lstatSync(file).isSymbolicLink())
|
|
50
|
+
throw new HistoryIndexError("CORRUPT", "Invalid index generation.");
|
|
51
|
+
db = openHistoryDatabase(file);
|
|
52
|
+
if (db.prepare("PRAGMA user_version").get()?.user_version !== 5)
|
|
53
|
+
throw new HistoryIndexError("CORRUPT", "Invalid index schema.");
|
|
54
|
+
db.prepare("SELECT value FROM meta WHERE key='generation'").get();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
db?.close();
|
|
58
|
+
db = undefined;
|
|
59
|
+
if (!isCorruption(error))
|
|
60
|
+
throw error;
|
|
61
|
+
name = undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!name) {
|
|
65
|
+
name = `${randomUUID()}.sqlite`;
|
|
66
|
+
db = openHistoryDatabase(path.join(directory, name));
|
|
67
|
+
this.schema(db);
|
|
68
|
+
const temporary = `${manifest}.${process.pid}.${randomUUID()}`;
|
|
69
|
+
fs.writeFileSync(temporary, JSON.stringify({ file: name }), { mode: 0o600 });
|
|
70
|
+
fs.renameSync(temporary, manifest);
|
|
71
|
+
// Never rename/unlink an open SQLite generation or separate its WAL. Old corrupt
|
|
72
|
+
// generations remain private for diagnosis; the manifest selects the new projection.
|
|
73
|
+
}
|
|
74
|
+
this.db = db;
|
|
75
|
+
this.file = path.join(directory, name);
|
|
76
|
+
fs.chmodSync(this.file, 0o600);
|
|
77
|
+
for (const suffix of ["-wal", "-shm"])
|
|
78
|
+
if (fs.existsSync(this.file + suffix))
|
|
79
|
+
fs.chmodSync(this.file + suffix, 0o600);
|
|
80
|
+
this.generation = String(this.get("SELECT value FROM meta WHERE key='generation'").value);
|
|
81
|
+
this.db.exec("CREATE TEMP TABLE pending_text(field TEXT, position INTEGER, end_position INTEGER, text TEXT)");
|
|
82
|
+
}
|
|
83
|
+
schema(db) {
|
|
84
|
+
db.exec(`
|
|
85
|
+
CREATE TABLE meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
86
|
+
INSERT INTO meta VALUES ('generation','${randomUUID()}'),('version','0'),('indexedAt','0');
|
|
87
|
+
CREATE TABLE runs(id TEXT PRIMARY KEY,attention INTEGER NOT NULL,updated INTEGER NOT NULL,negative_updated INTEGER NOT NULL,state TEXT NOT NULL,filter_text TEXT NOT NULL,view TEXT NOT NULL,predecessor_id TEXT,predecessor_index INTEGER);
|
|
88
|
+
CREATE INDEX runs_predecessor ON runs(predecessor_id,predecessor_index);
|
|
89
|
+
CREATE INDEX runs_attention ON runs(attention,negative_updated,id);
|
|
90
|
+
CREATE INDEX runs_newest ON runs(negative_updated,id);
|
|
91
|
+
CREATE INDEX runs_oldest ON runs(updated,id);
|
|
92
|
+
CREATE INDEX runs_state ON runs(state,attention,negative_updated,id);
|
|
93
|
+
CREATE TABLE sources(id TEXT PRIMARY KEY,path TEXT NOT NULL UNIQUE,generation INTEGER NOT NULL DEFAULT 1,identity TEXT,stamp TEXT,session_id TEXT,header_digest TEXT,cursor INTEGER NOT NULL DEFAULT 0,prefix_digest TEXT,state TEXT NOT NULL DEFAULT 'pending',error TEXT,malformed INTEGER NOT NULL DEFAULT 0,complexity INTEGER NOT NULL DEFAULT 0,checked_at INTEGER,format_version INTEGER);
|
|
94
|
+
CREATE TABLE children(run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,child_index INTEGER NOT NULL,agent TEXT NOT NULL,source_id TEXT REFERENCES sources(id),filter_text TEXT NOT NULL,output_digest TEXT,terminal_entry_id TEXT,ended_at INTEGER,PRIMARY KEY(run_id,child_index));
|
|
95
|
+
CREATE INDEX children_source ON children(source_id);
|
|
96
|
+
CREATE INDEX children_agent ON children(agent,run_id);
|
|
97
|
+
CREATE TABLE entries(rowid INTEGER PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id),generation INTEGER NOT NULL,id TEXT NOT NULL,native_id TEXT,parent_id TEXT,start INTEGER NOT NULL,end INTEGER NOT NULL,digest TEXT NOT NULL,timestamp INTEGER,type TEXT NOT NULL,preview TEXT NOT NULL,published INTEGER NOT NULL DEFAULT 0,role TEXT,assistant_text INTEGER NOT NULL DEFAULT 0,visible_id TEXT,human_id TEXT,configuration_model TEXT,configuration_thinking TEXT,UNIQUE(source_id,generation,id),UNIQUE(source_id,generation,start));
|
|
98
|
+
CREATE INDEX entries_page ON entries(source_id,generation,published,start);
|
|
99
|
+
CREATE INDEX entries_time ON entries(source_id,generation,published,timestamp,start);
|
|
100
|
+
CREATE INDEX entries_human ON entries(source_id,generation,human_id,start);
|
|
101
|
+
CREATE TABLE answers(entry_rowid INTEGER NOT NULL REFERENCES entries(rowid) ON DELETE CASCADE,digest TEXT NOT NULL,item_id TEXT NOT NULL,PRIMARY KEY(entry_rowid,digest));
|
|
102
|
+
CREATE INDEX answers_digest ON answers(digest,entry_rowid);
|
|
103
|
+
CREATE TABLE tools(entry_rowid INTEGER NOT NULL REFERENCES entries(rowid) ON DELETE CASCADE,source_id TEXT NOT NULL,generation INTEGER NOT NULL,call_id TEXT NOT NULL,kind TEXT NOT NULL);
|
|
104
|
+
CREATE INDEX tools_lookup ON tools(source_id,generation,call_id,kind);
|
|
105
|
+
CREATE TABLE documents(id INTEGER PRIMARY KEY,entry_rowid INTEGER NOT NULL REFERENCES entries(rowid) ON DELETE CASCADE,field TEXT NOT NULL,text_start INTEGER NOT NULL,text_end INTEGER NOT NULL,preview TEXT NOT NULL);
|
|
106
|
+
CREATE INDEX documents_entry ON documents(entry_rowid);
|
|
107
|
+
CREATE VIRTUAL TABLE corpus USING fts5(text,content='',contentless_delete=1,tokenize='unicode61');
|
|
108
|
+
PRAGMA user_version=5;
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
get(sql, ...params) { return this.db.prepare(sql).get(...params); }
|
|
112
|
+
all(sql, ...params) { return this.db.prepare(sql).all(...params); }
|
|
113
|
+
run(sql, ...params) { return this.db.prepare(sql).run(...params); }
|
|
114
|
+
deleteDocuments(sourceId, unpublishedOnly = false) {
|
|
115
|
+
const where = `entry_rowid IN (SELECT rowid FROM entries WHERE source_id=?${unpublishedOnly ? " AND published=0" : ""})`;
|
|
116
|
+
this.run(`DELETE FROM corpus WHERE rowid IN (SELECT id FROM documents WHERE ${where})`, sourceId);
|
|
117
|
+
this.run(`DELETE FROM documents WHERE ${where}`, sourceId);
|
|
118
|
+
}
|
|
119
|
+
transaction(work) { return historyTransaction(this.db, work); }
|
|
120
|
+
bump() { this.run("UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='version'"); this.run("UPDATE meta SET value=? WHERE key='indexedAt'", String(Date.now())); }
|
|
121
|
+
source(id) { return this.get("SELECT * FROM sources WHERE id=?", id); }
|
|
122
|
+
resetSource(id, state = "pending", error = null) {
|
|
123
|
+
this.transaction(() => {
|
|
124
|
+
this.deleteDocuments(id);
|
|
125
|
+
this.run("DELETE FROM entries WHERE source_id=?", id);
|
|
126
|
+
this.run("UPDATE sources SET generation=generation+1,identity=NULL,stamp=NULL,session_id=NULL,header_digest=NULL,format_version=NULL,cursor=0,prefix_digest=NULL,state=?,error=?,malformed=0,complexity=0,checked_at=? WHERE id=?", state, error, Date.now(), id);
|
|
127
|
+
this.bump();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
clearUnpublished(id) {
|
|
131
|
+
this.transaction(() => {
|
|
132
|
+
this.deleteDocuments(id, true);
|
|
133
|
+
this.run("DELETE FROM entries WHERE source_id=? AND published=0", id);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
outputDigest(result) {
|
|
137
|
+
if (!result)
|
|
138
|
+
return null;
|
|
139
|
+
if (!result.fullOutputPath)
|
|
140
|
+
return typeof result.finalOutput === "string" && result.finalOutput.length !== 8192 && !result.truncation?.truncated ? exactTextDigest(result.finalOutput) : null;
|
|
141
|
+
try {
|
|
142
|
+
const stat = fs.statSync(result.fullOutputPath, { bigint: true });
|
|
143
|
+
if (!stat.isFile() || stat.size > 16n * 1024n * 1024n)
|
|
144
|
+
return null;
|
|
145
|
+
const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
146
|
+
const cached = this.outputDigests.get(result.fullOutputPath);
|
|
147
|
+
if (cached?.stamp === stamp)
|
|
148
|
+
return cached.digest;
|
|
149
|
+
const text = readSavedOutput(result.fullOutputPath);
|
|
150
|
+
const digest = exactTextDigest(text);
|
|
151
|
+
this.outputDigests.set(result.fullOutputPath, { stamp, digest });
|
|
152
|
+
return digest;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
putView(view, full = view) {
|
|
159
|
+
const attention = view.attention.includes("awaiting_input") ? 0 : view.attention.some((reason) => reason !== "unreviewed") ? 1 : view.state === "live" ? 2 : view.attention.length ? 3 : 4;
|
|
160
|
+
const json = JSON.stringify(view), filters = new Map(full.children.map((child) => [child.index, safeText(`${child.agent} ${child.label ?? ""} ${child.task ?? full.task}`, Infinity).toLowerCase()]));
|
|
161
|
+
const filterText = safeText(`${full.task} ${[...filters.values()].join(" ")}`, Infinity).toLowerCase();
|
|
162
|
+
const digests = new Map(full.children.map((child) => [child.index, this.outputDigest(child.result)]));
|
|
163
|
+
const saved = this.get("SELECT view,filter_text FROM runs WHERE id=?", view.runId);
|
|
164
|
+
if (saved?.view === json && saved.filter_text === filterText && this.all("SELECT child_index,output_digest FROM children WHERE run_id=?", view.runId).every((child) => child.output_digest === digests.get(child.child_index)))
|
|
165
|
+
return;
|
|
166
|
+
this.transaction(() => {
|
|
167
|
+
this.run("INSERT INTO runs VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET attention=excluded.attention,updated=excluded.updated,negative_updated=excluded.negative_updated,state=excluded.state,filter_text=excluded.filter_text,view=excluded.view,predecessor_id=excluded.predecessor_id,predecessor_index=excluded.predecessor_index", view.runId, attention, view.updatedAt, -view.updatedAt, view.state, filterText, json, view.predecessorRunId ?? null, view.predecessorIndex ?? 0);
|
|
168
|
+
this.run("DELETE FROM children WHERE run_id=?", view.runId);
|
|
169
|
+
for (const child of view.children) {
|
|
170
|
+
let source = null;
|
|
171
|
+
if (child.sessionFile) {
|
|
172
|
+
const file = path.resolve(child.sessionFile);
|
|
173
|
+
source = hash(file);
|
|
174
|
+
this.run("INSERT INTO sources(id,path) VALUES (?,?) ON CONFLICT(id) DO NOTHING", source, file);
|
|
175
|
+
}
|
|
176
|
+
const terminal = child.state !== "live" && child.state !== "unknown";
|
|
177
|
+
this.run("INSERT INTO children VALUES (?,?,?,?,?,?,?,?)", view.runId, child.index, child.agent, source, filters.get(child.index) ?? "", digests.get(child.index) ?? null, terminal ? child.result?.terminalEntryId ?? null : null, terminal && !child.result?.terminalEntryId ? view.updatedAt : null);
|
|
178
|
+
}
|
|
179
|
+
this.bump();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
info(pending) {
|
|
183
|
+
const sources = this.get("SELECT COUNT(*) AS total,SUM(state IN ('pending','indexing')) AS pending,SUM(state IN ('degraded','missing','error')) AS errors FROM sources WHERE id IN (SELECT source_id FROM children)");
|
|
184
|
+
const indexedAt = Number(this.get("SELECT value FROM meta WHERE key='indexedAt'").value) || null;
|
|
185
|
+
const errors = Number(sources.errors ?? 0);
|
|
186
|
+
const remaining = pending + Number(sources.pending ?? 0);
|
|
187
|
+
const freshness = { authoritative: false, state: remaining ? "catching-up" : errors ? "degraded" : "current", pending: remaining, errors, indexedAt };
|
|
188
|
+
return { version: Number(this.get("SELECT value FROM meta WHERE key='version'").value), indexedAt, freshness };
|
|
189
|
+
}
|
|
190
|
+
entry(row) {
|
|
191
|
+
return { id: row.id, nativeId: row.native_id, parentId: row.parent_id, sequence: row.start, timestamp: row.timestamp, type: row.type,
|
|
192
|
+
ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, entry: JSON.parse(row.preview) };
|
|
193
|
+
}
|
|
194
|
+
/** A separate SQLite transaction serializes incremental staging without blocking browse queries.
|
|
195
|
+
* SQLite releases it on process death; no PID lease, expiry, or stale-file deletion is needed. */
|
|
196
|
+
beginIngest() {
|
|
197
|
+
if (!this.ingestLock) {
|
|
198
|
+
this.ingestLock = openHistoryDatabase(`${this.file}.ingest.sqlite`);
|
|
199
|
+
this.ingestLock.exec("PRAGMA busy_timeout=0");
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
this.ingestLock.exec("BEGIN IMMEDIATE");
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (/locked|busy/i.test(String(error.message)))
|
|
206
|
+
throw new HistoryIndexError("INDEX_BUSY", "Another worker is indexing this generation.");
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
endIngest() { if (this.ingestLock?.isTransaction)
|
|
211
|
+
this.ingestLock.exec("ROLLBACK"); }
|
|
212
|
+
close() { this.ingestLock?.close(); this.db.close(); }
|
|
213
|
+
}
|
|
214
|
+
function isCorruption(error) {
|
|
215
|
+
return error instanceof HistoryIndexError && error.code === "CORRUPT" || /malformed|not a database|no such table|corrupt/i.test(String(error.message));
|
|
216
|
+
}
|