@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,156 @@
|
|
|
1
|
+
import { fork, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { HistoryIndexError } from "./types.ts";
|
|
5
|
+
import { requirePiPackageRoot } from "../runs/shared/pi-spawn.ts";
|
|
6
|
+
import type { ForegroundResumeRun, HistoryEntry, HistoryEntryInput, HistoryIndexStatus, HistoryOwner, HistoryPage, HistoryPageInput, HistoryRunOptions, HistoryRunPage, HistoryResult, HistorySearchInput, HistorySearchPage, OwnedRun, Request, Response } from "./types.ts";
|
|
7
|
+
export * from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** A disposable browse index. Authoritative owner/control/receipt checks remain outside this engine. */
|
|
10
|
+
export class SubagentHistoryIndex {
|
|
11
|
+
private process?: ChildProcess;
|
|
12
|
+
private children = new Map<ChildProcess, Promise<void>>();
|
|
13
|
+
private owner?: HistoryOwner;
|
|
14
|
+
private ready?: Promise<void>;
|
|
15
|
+
private unavailable?: Error;
|
|
16
|
+
private closed = false;
|
|
17
|
+
private closing?: Promise<void>;
|
|
18
|
+
private sequence = 0;
|
|
19
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; cleanup: () => void }>();
|
|
20
|
+
private listeners = new Set<() => void>();
|
|
21
|
+
private agentDir: string;
|
|
22
|
+
constructor(agentDir: string) { this.agentDir = path.resolve(agentDir); }
|
|
23
|
+
get failure(): Error | undefined { return this.unavailable; }
|
|
24
|
+
|
|
25
|
+
private spawn(): void {
|
|
26
|
+
const child = fork(fileURLToPath(new URL(`worker${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url)), [this.agentDir], {
|
|
27
|
+
execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "advanced",
|
|
28
|
+
env: { ...process.env, PI_PACKAGE_DIR: requirePiPackageRoot(), PI_CODING_AGENT_DIR: this.agentDir },
|
|
29
|
+
});
|
|
30
|
+
this.unavailable = undefined;
|
|
31
|
+
this.process = child;
|
|
32
|
+
this.children.set(child, new Promise<void>((resolve) => {
|
|
33
|
+
const exited = () => {
|
|
34
|
+
child.removeListener("exit", exited); child.removeListener("close", exited);
|
|
35
|
+
this.children.delete(child); resolve();
|
|
36
|
+
};
|
|
37
|
+
// Disconnected IPC children can exit without Node emitting close. Exit already
|
|
38
|
+
// releases their database and watch handles; failed spawns report close instead.
|
|
39
|
+
child.once("exit", exited); child.once("close", exited);
|
|
40
|
+
}));
|
|
41
|
+
child.on("message", (message: Response) => {
|
|
42
|
+
if (child !== this.process) return;
|
|
43
|
+
if ("changed" in message) {
|
|
44
|
+
for (const listener of this.listeners) { try { listener(); } catch { /* Consumer failures must not strand IPC requests. */ } }
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const pending = this.pending.get(message.id);
|
|
48
|
+
if (!pending) return;
|
|
49
|
+
this.pending.delete(message.id); pending.cleanup();
|
|
50
|
+
if (message.error) pending.reject(new HistoryIndexError(message.error.code, message.error.message));
|
|
51
|
+
else pending.resolve(message.value);
|
|
52
|
+
});
|
|
53
|
+
child.on("error", () => this.stop(new HistoryIndexError("UNAVAILABLE", "History process could not start."), child));
|
|
54
|
+
child.on("exit", () => this.stop(new HistoryIndexError("UNAVAILABLE", "History process exited; retry to reopen the disposable index."), child));
|
|
55
|
+
}
|
|
56
|
+
private stop(error: Error, child = this.process, kill = true): void {
|
|
57
|
+
if (!child || child !== this.process) return;
|
|
58
|
+
this.unavailable = error;
|
|
59
|
+
this.process = undefined; this.ready = undefined;
|
|
60
|
+
for (const pending of this.pending.values()) { pending.cleanup(); pending.reject(error); }
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
if (kill && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
63
|
+
}
|
|
64
|
+
private send<T>(method: string, input?: any, signal?: AbortSignal, timeout = 3000): Promise<T> {
|
|
65
|
+
if (this.closed) return Promise.reject(new HistoryIndexError("CLOSED", "History index is closed."));
|
|
66
|
+
if (signal?.aborted) return Promise.reject(new HistoryIndexError("CANCELLED", "History request cancelled."));
|
|
67
|
+
const child = this.process;
|
|
68
|
+
if (!child?.connected) return Promise.reject(new HistoryIndexError("UNAVAILABLE", "History process is unavailable."));
|
|
69
|
+
return new Promise<T>((resolve, reject) => {
|
|
70
|
+
const id = ++this.sequence;
|
|
71
|
+
const cancel = () => this.stop(new HistoryIndexError("CANCELLED", "History request cancelled; the process was stopped and can be reopened."), child);
|
|
72
|
+
const timer = setTimeout(() => this.stop(new HistoryIndexError("DEADLINE", "History request exceeded its process deadline; retry or narrow the query."), child), timeout);
|
|
73
|
+
timer.unref();
|
|
74
|
+
this.pending.set(id, { resolve, reject, cleanup: () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); } });
|
|
75
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
76
|
+
child.send({ id, method, input } satisfies Request, (error) => { if (error) this.stop(new HistoryIndexError("UNAVAILABLE", "History IPC failed."), child); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
private async ensure(): Promise<void> {
|
|
80
|
+
if (this.closed) throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
81
|
+
if (this.unavailable) throw this.unavailable;
|
|
82
|
+
if (!this.owner) throw new HistoryIndexError("NO_OWNER", "Set genuine restored ownership before querying history.");
|
|
83
|
+
if (!this.process) {
|
|
84
|
+
await Promise.all(this.children.values());
|
|
85
|
+
if (this.closed) throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
86
|
+
if (!this.process) { this.spawn(); this.ready = this.send<void>("setOwner", this.owner, undefined, 15_000); }
|
|
87
|
+
}
|
|
88
|
+
await this.ready;
|
|
89
|
+
}
|
|
90
|
+
/** Arms an ordinary deadline only in the continuation that saw the current admission and worker settle, never behind a replacement admission. */
|
|
91
|
+
private async admitted<T>(method: string, input?: unknown, signal?: AbortSignal, timeout?: number): Promise<T> {
|
|
92
|
+
await this.ensure();
|
|
93
|
+
const ready = this.ready, child = this.process;
|
|
94
|
+
await ready;
|
|
95
|
+
// setOwner() or a restart may have replaced readiness during either await; wait for the replacement.
|
|
96
|
+
if (!child || ready !== this.ready || child !== this.process) return this.admitted<T>(method, input, signal, timeout);
|
|
97
|
+
return this.send<T>(method, input, signal, timeout);
|
|
98
|
+
}
|
|
99
|
+
async setOwner(input: HistoryOwner): Promise<void> {
|
|
100
|
+
if (this.closed) throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
101
|
+
if (!this.process) await Promise.all(this.children.values());
|
|
102
|
+
if (this.closed) throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
103
|
+
// Snapshot inputs so later caller mutation cannot silently change admission or replay.
|
|
104
|
+
const snapshot = structuredClone(input);
|
|
105
|
+
if (!snapshot.ownerSessionId || snapshot.runs.some((run) => run.ownerSessionId !== snapshot.ownerSessionId)) throw new HistoryIndexError("OWNERSHIP", "Every run must belong to the current owner session.");
|
|
106
|
+
if (new Set(snapshot.runs.map((run) => run.runId)).size !== snapshot.runs.length) throw new HistoryIndexError("INVALID", "Duplicate owned run IDs.");
|
|
107
|
+
this.owner = snapshot;
|
|
108
|
+
if (!this.process) this.spawn();
|
|
109
|
+
this.ready = this.send<void>("setOwner", snapshot, undefined, 15_000);
|
|
110
|
+
await this.ready;
|
|
111
|
+
}
|
|
112
|
+
async updateRun(run: OwnedRun, foreground?: ForegroundResumeRun): Promise<void> {
|
|
113
|
+
await this.ensure();
|
|
114
|
+
if (run.ownerSessionId !== this.owner!.ownerSessionId || foreground && foreground.runId !== run.runId) throw new HistoryIndexError("OWNERSHIP", "Run does not belong to the current owner.");
|
|
115
|
+
const copy = structuredClone(run), fg = foreground && structuredClone(foreground);
|
|
116
|
+
await this.admitted<void>("updateRun", { run: copy, foreground: fg });
|
|
117
|
+
this.owner!.runs = [...this.owner!.runs.filter((entry) => entry.runId !== run.runId), copy];
|
|
118
|
+
if (fg) this.owner!.foregroundRuns = [...(this.owner!.foregroundRuns ?? []).filter((entry) => entry.runId !== run.runId), fg];
|
|
119
|
+
}
|
|
120
|
+
private async query<T>(method: string, input: { signal?: AbortSignal }): Promise<T> {
|
|
121
|
+
const { signal, ...wire } = input;
|
|
122
|
+
return this.admitted<T>(method, wire, signal);
|
|
123
|
+
}
|
|
124
|
+
/** Tool discovery waits for run metadata only, not transcript backfill. */
|
|
125
|
+
needsControls(): Promise<boolean> { return this.admitted("needsControls", undefined, undefined, 15_000); }
|
|
126
|
+
listRuns(options: HistoryRunOptions = {}): Promise<HistoryRunPage> { return this.query("listRuns", options); }
|
|
127
|
+
historyPage(input: HistoryPageInput): Promise<HistoryPage> { return this.query("historyPage", input); }
|
|
128
|
+
search(input: HistorySearchInput): Promise<HistorySearchPage> { return this.query("search", input); }
|
|
129
|
+
status(): Promise<HistoryIndexStatus> { return this.query("status", {}); }
|
|
130
|
+
/** Validated bounded preview, including paired tools outside the current page. */
|
|
131
|
+
entry(input: HistoryEntryInput): Promise<HistoryEntry | null> { return this.query("entry", input); }
|
|
132
|
+
/** Explicit selected detail only; records over 16 MiB fail with RECORD_TOO_LARGE. */
|
|
133
|
+
record(input: HistoryEntryInput): Promise<Record<string, any> | null> { return this.query("record", input); }
|
|
134
|
+
/** Selected canonical output, never a transcript scan or a truncated-preview match. */
|
|
135
|
+
result(input: Pick<HistoryEntryInput, "runId" | "index" | "signal">): Promise<HistoryResult | null> { return this.query("result", input); }
|
|
136
|
+
async refresh(runId?: string, options: { signal?: AbortSignal } = {}): Promise<void> {
|
|
137
|
+
if (!this.closed) this.unavailable = undefined;
|
|
138
|
+
return this.admitted("refresh", { runId }, options.signal, 120_000);
|
|
139
|
+
}
|
|
140
|
+
onChanged(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
141
|
+
/** Hard cancellation affects this instance's outstanding work, not native sessions or other indexers. */
|
|
142
|
+
cancel(): void { this.stop(new HistoryIndexError("CANCELLED", "History process cancelled; explicitly refresh or retry to reopen it.")); }
|
|
143
|
+
close(): Promise<void> {
|
|
144
|
+
if (this.closing) return this.closing;
|
|
145
|
+
const child = this.process;
|
|
146
|
+
this.closed = true; this.listeners.clear();
|
|
147
|
+
this.unavailable ??= new HistoryIndexError("CLOSED", "History index closed.");
|
|
148
|
+
const timers = [...this.children.keys()].map((owned) => { const timer = setTimeout(() => owned.kill("SIGKILL"), 1000); timer.unref(); return timer; });
|
|
149
|
+
this.closing = Promise.all(this.children.values()).then(() => { for (const timer of timers) clearTimeout(timer); });
|
|
150
|
+
if (child) {
|
|
151
|
+
this.stop(new HistoryIndexError("CLOSED", "History index closed."), child, false);
|
|
152
|
+
if (child.connected) child.disconnect(); else child.kill("SIGKILL");
|
|
153
|
+
}
|
|
154
|
+
return this.closing;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { createHash, type Hash } from "node:crypto";
|
|
3
|
+
import { JournalFrames, type Projection } from "../shared/journal-reader.ts";
|
|
4
|
+
import { HistoryIndexError } from "./types.ts";
|
|
5
|
+
import { exactTextDigest, HistoryStore, safeText, type SourceRow } from "./store.ts";
|
|
6
|
+
import { stripTerminalSequences } from "../shared/native-tui.ts";
|
|
7
|
+
import { stripAcceptanceReport } from "../runs/shared/acceptance-reports.ts";
|
|
8
|
+
|
|
9
|
+
export function identity(stat: fs.BigIntStats): string { return `${stat.dev}:${stat.ino}`; }
|
|
10
|
+
export function stamp(stat: fs.BigIntStats): string { return `${identity(stat)}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; }
|
|
11
|
+
const metadata = new Set(["type", "id", "parentId", "timestamp", "version", "cwd", "provider", "model", "modelId", "thinkingLevel", "customType", "display", "summary", "content"]);
|
|
12
|
+
const messageFields = new Set(["role", "content", "output", "summary", "toolCallId", "toolName", "isError", "timestamp", "model", "provider", "stopReason", "errorMessage", "customType", "display", "command", "exitCode", "cancelled"]);
|
|
13
|
+
export const previewLimits = { depth: 64, nodes: 16_384, arrayLength: 4096, keyLength: 512 };
|
|
14
|
+
export const previewProjection: Projection = (keys, root) => {
|
|
15
|
+
if (!keys.length) return true;
|
|
16
|
+
if (keys[0] === "message") {
|
|
17
|
+
if (keys.length === 1) return true;
|
|
18
|
+
if (!messageFields.has(String(keys[1]))) return false;
|
|
19
|
+
if (keys[1] === "content") {
|
|
20
|
+
if (keys.length <= 3) return 512;
|
|
21
|
+
if (keys[3] === "arguments") {
|
|
22
|
+
if (keys.length === 4) return true;
|
|
23
|
+
if (keys.length === 5) return Object.keys(root?.message?.content?.[Number(keys[2])]?.arguments ?? {}).length < 32 ? 512 : false;
|
|
24
|
+
return keys.length === 6 && keys[5] === 0 ? 512 : false;
|
|
25
|
+
}
|
|
26
|
+
return keys.length === 4 && ["type", "text", "thinking", "id", "name", "mimeType"].includes(String(keys[3])) ? 512 : false;
|
|
27
|
+
}
|
|
28
|
+
return keys.length === 2 ? 512 : false;
|
|
29
|
+
}
|
|
30
|
+
if (keys[0] === "details") return keys.length === 1 || keys.length === 2 && ["bodyText", "message"].includes(String(keys[1])) || keys.length === 3 && keys[1] === "message" && keys[2] === "id" ? 512 : false;
|
|
31
|
+
if (keys[0] === "content" && keys.length > 1) return keys.length <= 2 || keys.length === 3 && ["type", "text", "mimeType"].includes(String(keys[2])) ? 512 : false;
|
|
32
|
+
return keys.length === 1 && metadata.has(String(keys[0])) ? 512 : false;
|
|
33
|
+
};
|
|
34
|
+
function candidate(keys: readonly (string | number)[]): boolean {
|
|
35
|
+
return ["summary", "content"].includes(String(keys[0])) && keys.length === 1
|
|
36
|
+
|| keys.length === 2 && keys[0] === "details" && keys[1] === "bodyText"
|
|
37
|
+
|| keys[0] === "message" && keys.length === 2 && ["content", "output", "summary"].includes(String(keys[1]))
|
|
38
|
+
|| keys[0] === "message" && keys[1] === "content" && keys.length === 4 && keys[3] === "text"
|
|
39
|
+
|| keys[0] === "content" && keys.length === 3 && keys[2] === "text";
|
|
40
|
+
}
|
|
41
|
+
function allowed(value: any, field: string): boolean {
|
|
42
|
+
const keys = JSON.parse(field) as (string | number)[];
|
|
43
|
+
if (value.type === "custom_message") {
|
|
44
|
+
if (value.display === false) return false;
|
|
45
|
+
if (keys[0] === "details") return value.customType === "subagent-human-message";
|
|
46
|
+
if (value.customType === "subagent-human-message" && typeof value.details?.bodyText === "string") return false;
|
|
47
|
+
return keys[0] === "content" && (keys.length === 1 || value.content?.[Number(keys[1])]?.type === "text");
|
|
48
|
+
}
|
|
49
|
+
if (["compaction", "branch_summary"].includes(value.type)) return keys.length === 1 && keys[0] === "summary";
|
|
50
|
+
if (value.type !== "message" || keys[0] !== "message") return false;
|
|
51
|
+
const message = value.message;
|
|
52
|
+
if (!message) return false;
|
|
53
|
+
if (["compactionSummary", "branchSummary"].includes(message.role)) return keys[1] === "summary";
|
|
54
|
+
if (message.role === "bashExecution") return keys[1] === "output";
|
|
55
|
+
if (!["user", "assistant", "toolResult", "custom"].includes(message.role) || message.role === "custom" && message.display === false) return false;
|
|
56
|
+
return keys[1] === "content" && (keys.length === 2 || message.content?.[Number(keys[2])]?.type === "text");
|
|
57
|
+
}
|
|
58
|
+
export function compactEntry(value: any): Record<string, any> {
|
|
59
|
+
let remaining = 4096;
|
|
60
|
+
const clean = (input: any, key?: string): any => {
|
|
61
|
+
if (typeof input === "string") { const text = safeText(input, ["id", "parentId", "type", "role", "toolCallId", "name", "toolName", "timestamp"].includes(key ?? "") ? 512 : Math.min(512, remaining)); remaining -= Math.min(remaining, text.length); return text; }
|
|
62
|
+
if (Array.isArray(input)) return input.slice(0, 32).map((item) => clean(item));
|
|
63
|
+
if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([name, item]) => [name, clean(item, name)]));
|
|
64
|
+
return input;
|
|
65
|
+
};
|
|
66
|
+
return clean(value);
|
|
67
|
+
}
|
|
68
|
+
/** Exact matches never use previews. Oversized selected text has no fingerprint. */
|
|
69
|
+
class AnswerTexts {
|
|
70
|
+
private fields = new Map<number, string>();
|
|
71
|
+
private length = 0;
|
|
72
|
+
private unavailable = false;
|
|
73
|
+
write(keys: readonly (string | number)[], text: string): void {
|
|
74
|
+
if (this.unavailable || keys.length !== 4 || keys[0] !== "message" || keys[1] !== "content" || !["text", "thinking"].includes(String(keys[3]))) return;
|
|
75
|
+
this.length += text.length;
|
|
76
|
+
if (this.length > 16 * 1024 * 1024 || Number(keys[2]) >= 4096) { this.unavailable = true; this.fields.clear(); return; }
|
|
77
|
+
const index = Number(keys[2]);
|
|
78
|
+
this.fields.set(index, (this.fields.get(index) ?? "") + text);
|
|
79
|
+
}
|
|
80
|
+
fingerprints(value: any, id: string): Array<[string, string]> {
|
|
81
|
+
if (this.unavailable || value.type !== "message" || value.message?.role !== "assistant") return [];
|
|
82
|
+
const content = Array.isArray(value.message.content) ? value.message.content : [];
|
|
83
|
+
const first = content.findIndex((part: any, index: number) => ["text", "thinking"].includes(part.type) && Boolean(this.fields.get(index)));
|
|
84
|
+
if (first < 0 || !content.some((part: any, index: number) => part.type === "text" && Boolean(this.fields.get(index)))) return [];
|
|
85
|
+
const item = `${id}:${first}`, parts = content.map((part: any, index: number) => ["text", "thinking"].includes(part.type) ? stripTerminalSequences(this.fields.get(index) ?? "") : part.type === "image" ? `[Image: ${part.mimeType ?? "image"}]` : "").filter(Boolean);
|
|
86
|
+
const texts = [parts.join("\n"), ...content.flatMap((part: any, index: number) => part.type === "text" ? [stripTerminalSequences(this.fields.get(index) ?? "")] : [])];
|
|
87
|
+
return [...new Set(texts.map((text) => stripAcceptanceReport(text).trim()).filter(Boolean))].map((text) => [exactTextDigest(text), item]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function visibleId(value: any, id: string): string | null {
|
|
91
|
+
if (["custom_message", "compaction", "branch_summary"].includes(value.type)) return id;
|
|
92
|
+
if (value.type !== "message") return null;
|
|
93
|
+
if (["user", "toolResult", "bashExecution"].includes(value.message?.role)) return id;
|
|
94
|
+
if (value.message?.role !== "assistant") return null;
|
|
95
|
+
const content = Array.isArray(value.message.content) ? value.message.content : [];
|
|
96
|
+
const last = content.findLastIndex((part: any) => part.type === "toolCall" || part.type === "text" && part.text || part.type === "thinking" && part.thinking);
|
|
97
|
+
return value.message.errorMessage ? `${id}:error` : last < 0 ? null : `${id}:${last}`;
|
|
98
|
+
}
|
|
99
|
+
interface TextToken { text: string; start: number; end: number }
|
|
100
|
+
interface TextBuffer { word: string; wordStart: number; received: number; tokens: TextToken[]; length: number; terminal: TerminalText }
|
|
101
|
+
/** Streaming ECMA-48 removal, retaining original UTF-16 positions without buffering control payloads. */
|
|
102
|
+
class TerminalText {
|
|
103
|
+
private state: "text" | "escape" | "intermediate" | "csi" | "string" | "stringEscape" = "text";
|
|
104
|
+
write(text: string, visible: (text: string, start: number) => void): void {
|
|
105
|
+
let position = 0;
|
|
106
|
+
while (position < text.length) {
|
|
107
|
+
if (this.state === "text") {
|
|
108
|
+
const next = text.slice(position).search(/[\x1b\x90\x98\x9b\x9d-\x9f]/);
|
|
109
|
+
if (next < 0) { visible(text.slice(position), position); return; }
|
|
110
|
+
visible(text.slice(position, position + next), position); position += next;
|
|
111
|
+
const control = text[position++];
|
|
112
|
+
this.state = control === "\x1b" ? "escape" : control === "\x9b" ? "csi" : "string";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const char = text[position++];
|
|
116
|
+
if (this.state === "escape") this.state = char === "[" ? "csi" : "]PX^_".includes(char) ? "string" : char >= " " && char <= "/" ? "intermediate" : "text";
|
|
117
|
+
else if (this.state === "intermediate") { if (char >= "0" && char <= "~") this.state = "text"; }
|
|
118
|
+
else if (this.state === "csi") { if (char >= "@" && char <= "~") this.state = "text"; }
|
|
119
|
+
else if (char === "\x07" || char === "\x9c" || this.state === "stringEscape" && char === "\\") this.state = "text";
|
|
120
|
+
else this.state = char === "\x1b" ? "stringEscape" : "string";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Token windows overlap 12 tokens, including phrases across arbitrarily long whitespace. */
|
|
125
|
+
class TextChunks {
|
|
126
|
+
private fields = new Map<string, TextBuffer>();
|
|
127
|
+
private staged: Array<{ field: string; start: number; end: number; text: string }> = [];
|
|
128
|
+
private emit(field: string, buffer: TextBuffer, final = false): void {
|
|
129
|
+
if (!buffer.tokens.length) return;
|
|
130
|
+
this.staged.push({ field, start: buffer.tokens[0].start, end: buffer.tokens.at(-1)!.end, text: buffer.tokens.map((token) => token.text).join(" ") });
|
|
131
|
+
buffer.tokens = final ? [] : buffer.tokens.slice(-12);
|
|
132
|
+
buffer.length = buffer.tokens.reduce((length, token) => length + token.text.length + 1, 0);
|
|
133
|
+
}
|
|
134
|
+
private token(field: string, buffer: TextBuffer, end: number): void {
|
|
135
|
+
if (!buffer.word) return;
|
|
136
|
+
buffer.tokens.push({ text: buffer.word, start: buffer.wordStart, end });
|
|
137
|
+
buffer.length += buffer.word.length + 1; buffer.word = "";
|
|
138
|
+
if (buffer.tokens.length >= 256 || buffer.length >= 8192 && buffer.tokens.length > 12) this.emit(field, buffer);
|
|
139
|
+
}
|
|
140
|
+
write(keys: readonly (string | number)[], text: string): void {
|
|
141
|
+
if (!candidate(keys)) return;
|
|
142
|
+
const field = JSON.stringify(keys);
|
|
143
|
+
let buffer = this.fields.get(field);
|
|
144
|
+
if (!buffer) {
|
|
145
|
+
if (this.fields.size >= 4096) throw new HistoryIndexError("RECORD_COMPLEXITY", "Record exceeds the 4096 visible text-field indexing budget.");
|
|
146
|
+
this.fields.set(field, buffer = { word: "", wordStart: 0, received: 0, tokens: [], length: 0, terminal: new TerminalText() });
|
|
147
|
+
}
|
|
148
|
+
buffer.terminal.write(text, (visible, offset) => {
|
|
149
|
+
for (const part of visible.matchAll(/[\p{L}\p{N}\p{M}]+|[^\p{L}\p{N}\p{M}]+/gu)) {
|
|
150
|
+
const start = buffer.received + offset + part.index!;
|
|
151
|
+
if (/^[\p{L}\p{N}\p{M}]/u.test(part[0])) {
|
|
152
|
+
if (!buffer.word) buffer.wordStart = start;
|
|
153
|
+
buffer.word += part[0];
|
|
154
|
+
if (buffer.word.length > 65_536) throw new HistoryIndexError("RECORD_COMPLEXITY", "Record contains a lexical token exceeding the 64 KiB indexing budget.");
|
|
155
|
+
} else this.token(field, buffer, start);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
buffer.received += text.length;
|
|
159
|
+
}
|
|
160
|
+
flush(store: HistoryStore, finish = false): void {
|
|
161
|
+
if (finish) {
|
|
162
|
+
for (const [field, buffer] of this.fields) { this.token(field, buffer, buffer.received); this.emit(field, buffer, true); }
|
|
163
|
+
this.fields.clear();
|
|
164
|
+
}
|
|
165
|
+
if (this.staged.length) {
|
|
166
|
+
const insert = store.db.prepare("INSERT INTO pending_text VALUES (?,?,?,?)");
|
|
167
|
+
for (const item of this.staged) insert.run(item.field, item.start, item.end, item.text);
|
|
168
|
+
this.staged = [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One physical source, incremental byte framing and short row/cursor publication transactions. */
|
|
174
|
+
export class SourceIngest {
|
|
175
|
+
private fd: number;
|
|
176
|
+
private initial: fs.BigIntStats;
|
|
177
|
+
private source: SourceRow;
|
|
178
|
+
private recordDigest: Hash = createHash("sha256");
|
|
179
|
+
/** prefix_digest is "start:sha256" of the last indexed line; earlier releases hashed the whole prefix. */
|
|
180
|
+
private legacy = false;
|
|
181
|
+
private expected: string | null = null;
|
|
182
|
+
private verified: Hash = createHash("sha256");
|
|
183
|
+
private line: Hash = createHash("sha256");
|
|
184
|
+
private lineStart = 0;
|
|
185
|
+
private verifyAt = 0;
|
|
186
|
+
private position: number;
|
|
187
|
+
private frames: JournalFrames;
|
|
188
|
+
private text = new TextChunks();
|
|
189
|
+
private answerTexts = new AnswerTexts();
|
|
190
|
+
private buffer = Buffer.alloc(0);
|
|
191
|
+
private record?: { value: any; start: number; end: number; digest: string };
|
|
192
|
+
private stagedEntry?: number;
|
|
193
|
+
private textCursor = 0;
|
|
194
|
+
private malformed = false;
|
|
195
|
+
private complexity = false;
|
|
196
|
+
private phase: "verify" | "read" | "text" | "done";
|
|
197
|
+
private closed = false;
|
|
198
|
+
private headerSeen: boolean;
|
|
199
|
+
private store: HistoryStore;
|
|
200
|
+
readonly id: string;
|
|
201
|
+
constructor(store: HistoryStore, source: SourceRow, force = false) {
|
|
202
|
+
this.store = store; this.source = source; this.id = source.id;
|
|
203
|
+
store.beginIngest();
|
|
204
|
+
let opened: number | undefined;
|
|
205
|
+
try {
|
|
206
|
+
this.source = source = store.source(source.id)!;
|
|
207
|
+
if (!source) throw new HistoryIndexError("SOURCE_CHANGED", "Source was removed before ingestion.");
|
|
208
|
+
store.operations.sourceChecks++; store.operations.sourceOpens++;
|
|
209
|
+
this.fd = opened = fs.openSync(source.path, "r"); this.initial = fs.fstatSync(this.fd, { bigint: true });
|
|
210
|
+
if (!this.initial.isFile() || this.initial.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Journal is not a supported regular file.");
|
|
211
|
+
if (source.identity && (source.identity !== identity(this.initial) || Number(this.initial.size) < source.cursor)) { store.resetSource(source.id); this.source = source = store.source(source.id)!; }
|
|
212
|
+
this.position = source.cursor;
|
|
213
|
+
this.headerSeen = source.cursor > 0;
|
|
214
|
+
store.clearUnpublished(source.id);
|
|
215
|
+
store.run("DELETE FROM pending_text");
|
|
216
|
+
// Appends re-hash only the last indexed line, so live sources cost their new bytes, not their size.
|
|
217
|
+
const anchor = /^(\d+):([0-9a-f]{64})$/.exec(source.prefix_digest ?? "");
|
|
218
|
+
this.legacy = !anchor; this.expected = anchor ? anchor[2] : source.prefix_digest;
|
|
219
|
+
this.verifyAt = this.lineStart = anchor ? Number(anchor[1]) : 0;
|
|
220
|
+
this.phase = !force && source.stamp === stamp(this.initial) ? "done" : source.cursor ? "verify" : "read";
|
|
221
|
+
if (this.phase !== "done") store.run("UPDATE sources SET state='indexing',error=NULL WHERE id=?", source.id);
|
|
222
|
+
this.frames = new JournalFrames(previewProjection, (record) => {
|
|
223
|
+
this.record = { ...record, digest: this.recordDigest.copy().digest("hex") };
|
|
224
|
+
}, "inspect", source.cursor, (_start, _end, error) => { this.malformed = true; this.complexity = error instanceof RangeError || error instanceof HistoryIndexError && error.code === "RECORD_COMPLEXITY"; }, (keys, chunk) => { this.text.write(keys, chunk); this.answerTexts.write(keys, chunk); }, undefined, true, previewLimits);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (opened !== undefined) fs.closeSync(opened);
|
|
227
|
+
store.endIngest();
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
private published(end: number, work?: () => void): void {
|
|
232
|
+
this.store.transaction(() => {
|
|
233
|
+
const current = this.store.source(this.id)!;
|
|
234
|
+
if (!current || current.generation !== this.source.generation || current.cursor !== this.source.cursor) throw new HistoryIndexError("SOURCE_CHANGED", "Another writer advanced this source; reconcile before replaying.");
|
|
235
|
+
work?.();
|
|
236
|
+
this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, `${this.source.cursor}:${this.recordDigest.copy().digest("hex")}`, identity(this.initial), this.id);
|
|
237
|
+
this.store.bump();
|
|
238
|
+
});
|
|
239
|
+
this.source.cursor = end;
|
|
240
|
+
this.recordDigest = createHash("sha256"); this.text = new TextChunks(); this.answerTexts = new AnswerTexts();
|
|
241
|
+
this.store.run("DELETE FROM pending_text"); this.record = undefined; this.malformed = this.complexity = false;
|
|
242
|
+
}
|
|
243
|
+
private finishRecord(end: number): void {
|
|
244
|
+
this.text.flush(this.store, true);
|
|
245
|
+
if (this.malformed) {
|
|
246
|
+
if (!this.headerSeen) throw new Error("Malformed native session header.");
|
|
247
|
+
this.published(end, () => this.store.run("UPDATE sources SET malformed=malformed+1,complexity=complexity+? WHERE id=?", this.complexity ? 1 : 0, this.id)); return;
|
|
248
|
+
}
|
|
249
|
+
const record = this.record;
|
|
250
|
+
if (!record) { this.published(end); return; }
|
|
251
|
+
const value = record.value;
|
|
252
|
+
if (!this.headerSeen) {
|
|
253
|
+
if (value.type !== "session") throw new Error("Linked source does not have a native session header.");
|
|
254
|
+
this.published(end, () => this.store.run("UPDATE sources SET session_id=?,header_digest=?,format_version=? WHERE id=?", typeof value.id === "string" ? value.id : null, record.digest, typeof value.version === "number" ? value.version : null, this.id));
|
|
255
|
+
this.source.format_version = typeof value.version === "number" ? value.version : null;
|
|
256
|
+
this.headerSeen = true; return;
|
|
257
|
+
}
|
|
258
|
+
if (value.type === "session") throw new Error("Unexpected session header inside a native source.");
|
|
259
|
+
const nativeId = typeof value.id === "string" ? value.id : null;
|
|
260
|
+
const id = nativeId ?? `legacy-${record.start}`;
|
|
261
|
+
if (this.store.get("SELECT rowid FROM entries WHERE source_id=? AND generation=? AND id=?", this.id, this.source.generation, id)) {
|
|
262
|
+
this.published(end, () => this.store.run("UPDATE sources SET malformed=malformed+1 WHERE id=?", this.id)); return;
|
|
263
|
+
}
|
|
264
|
+
const timestamp = typeof value.timestamp === "number" ? value.timestamp : Date.parse(value.timestamp);
|
|
265
|
+
const parent = this.source.format_version === 1 ? this.store.get("SELECT id FROM entries WHERE source_id=? AND generation=? AND published=1 ORDER BY start DESC LIMIT 1", this.id, this.source.generation)?.id ?? null : typeof value.parentId === "string" ? value.parentId : null;
|
|
266
|
+
const preview = compactEntry(value); preview.id = id; preview.parentId = parent;
|
|
267
|
+
const model = value.type === "model_change" && typeof value.modelId === "string" ? `${value.provider}/${value.modelId}` : value.type === "message" && value.message?.role === "assistant" && typeof value.message.model === "string" ? `${value.message.provider}/${value.message.model}` : null;
|
|
268
|
+
this.stagedEntry = Number(this.store.run("INSERT INTO entries(source_id,generation,id,native_id,parent_id,start,end,digest,timestamp,type,preview,role,assistant_text,visible_id,human_id,configuration_model,configuration_thinking) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", this.id, this.source.generation, id, nativeId, parent, record.start, end, record.digest, Number.isFinite(timestamp) ? timestamp : null, typeof value.type === "string" ? value.type : "unknown", JSON.stringify(preview), typeof value.message?.role === "string" ? value.message.role : null,
|
|
269
|
+
value.message?.role === "assistant" && Array.isArray(value.message.content) && value.message.content.some((part: any) => part.type === "text" && part.text) ? 1 : 0,
|
|
270
|
+
visibleId(value, id), value.type === "custom_message" && value.customType === "subagent-human-message" && typeof value.details?.message?.id === "string" ? value.details.message.id : null, model, value.type === "thinking_level_change" && typeof value.thinkingLevel === "string" ? value.thinkingLevel : null).lastInsertRowid);
|
|
271
|
+
this.textCursor = 0; this.phase = "text";
|
|
272
|
+
}
|
|
273
|
+
private indexText(): void {
|
|
274
|
+
const chunks = this.store.all("SELECT rowid,* FROM pending_text WHERE rowid>? ORDER BY rowid LIMIT 32", this.textCursor);
|
|
275
|
+
if (chunks.length) {
|
|
276
|
+
this.store.transaction(() => {
|
|
277
|
+
for (const chunk of chunks) {
|
|
278
|
+
if (!allowed(this.record!.value, chunk.field)) continue;
|
|
279
|
+
const text = safeText(chunk.text, 1_048_576);
|
|
280
|
+
const id = this.store.run("INSERT INTO documents(entry_rowid,field,text_start,text_end,preview) VALUES (?,?,?,?,?)", this.stagedEntry!, chunk.field, chunk.position, chunk.end_position, safeText(text, 512)).lastInsertRowid;
|
|
281
|
+
this.store.run("INSERT INTO corpus(rowid,text) VALUES (?,?)", id, text);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
this.textCursor = chunks.at(-1).rowid; return;
|
|
285
|
+
}
|
|
286
|
+
const value = this.record!.value, rowid = this.stagedEntry!;
|
|
287
|
+
const answers = this.answerTexts.fingerprints(value, typeof value.id === "string" ? value.id : `legacy-${this.record!.start}`);
|
|
288
|
+
this.published(this.record!.end, () => {
|
|
289
|
+
if (this.store.run("UPDATE entries SET published=1 WHERE rowid=? AND source_id=? AND generation=? AND published=0", rowid, this.id, this.source.generation).changes !== 1) throw new HistoryIndexError("SOURCE_CHANGED", "Staged entry disappeared before publication; reconcile before advancing the source.");
|
|
290
|
+
for (const [digest, item] of answers) this.store.run("INSERT OR IGNORE INTO answers VALUES (?,?,?)", rowid, digest, item);
|
|
291
|
+
for (const part of Array.isArray(value.message?.content) ? value.message.content : []) if (part.type === "toolCall" && typeof part.id === "string") this.store.run("INSERT INTO tools VALUES (?,?,?,?,?)", rowid, this.id, this.source.generation, part.id, "call");
|
|
292
|
+
if (value.message?.role === "toolResult" && typeof value.message.toolCallId === "string") this.store.run("INSERT INTO tools VALUES (?,?,?,?,?)", rowid, this.id, this.source.generation, value.message.toolCallId, "result");
|
|
293
|
+
});
|
|
294
|
+
this.stagedEntry = undefined; this.phase = "read";
|
|
295
|
+
}
|
|
296
|
+
/** Each pump reads <=64 KiB or indexes <=32 chunks, then returns to the IPC event loop. */
|
|
297
|
+
step(): boolean {
|
|
298
|
+
if (this.phase === "done") { this.close(); return true; }
|
|
299
|
+
if (this.phase === "text") { this.indexText(); return false; }
|
|
300
|
+
if (this.phase === "verify") {
|
|
301
|
+
const bytes = Buffer.allocUnsafe(Math.min(64 * 1024, this.source.cursor - this.verifyAt));
|
|
302
|
+
const count = fs.readSync(this.fd, bytes, 0, bytes.length, this.verifyAt);
|
|
303
|
+
if (count !== bytes.length) throw new Error("Journal truncated during prefix verification.");
|
|
304
|
+
this.store.operations.sourceBytesRead += count;
|
|
305
|
+
this.verified.update(bytes);
|
|
306
|
+
if (this.legacy) {
|
|
307
|
+
// A whole-prefix check also finds the last indexed line, which anchors every later check.
|
|
308
|
+
const before = this.source.cursor - 2 - this.verifyAt, lf = before < 0 ? -1 : bytes.lastIndexOf(10, before);
|
|
309
|
+
if (lf < 0) this.line.update(bytes);
|
|
310
|
+
else { this.lineStart = this.verifyAt + lf + 1; this.line = createHash("sha256").update(bytes.subarray(lf + 1)); }
|
|
311
|
+
}
|
|
312
|
+
this.verifyAt += count;
|
|
313
|
+
if (this.verifyAt === this.source.cursor) {
|
|
314
|
+
if (this.verified.digest("hex") !== this.expected) {
|
|
315
|
+
this.store.resetSource(this.id); throw new HistoryIndexError("SOURCE_CHANGED", "Journal prefix changed; a new physical generation was scheduled.");
|
|
316
|
+
}
|
|
317
|
+
if (this.legacy) this.store.run("UPDATE sources SET prefix_digest=? WHERE id=?", `${this.lineStart}:${this.line.digest("hex")}`, this.id);
|
|
318
|
+
this.phase = "read";
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (!this.buffer.length) {
|
|
323
|
+
const remaining = Number(this.initial.size) - this.position;
|
|
324
|
+
if (remaining <= 0) return this.finish();
|
|
325
|
+
const bytes = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
|
|
326
|
+
const count = fs.readSync(this.fd, bytes, 0, bytes.length, this.position);
|
|
327
|
+
if (count !== bytes.length) throw new Error("Journal truncated during ingestion.");
|
|
328
|
+
this.store.operations.sourceBytesRead += count;
|
|
329
|
+
this.position += count; this.buffer = bytes;
|
|
330
|
+
}
|
|
331
|
+
const newline = this.buffer.indexOf(10);
|
|
332
|
+
const bytes = newline < 0 ? this.buffer : this.buffer.subarray(0, newline + 1);
|
|
333
|
+
this.recordDigest.update(bytes); this.frames.write(bytes); this.text.flush(this.store);
|
|
334
|
+
this.buffer = this.buffer.subarray(bytes.length);
|
|
335
|
+
if (newline >= 0) this.finishRecord(this.frames.offset);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
private finish(): boolean {
|
|
339
|
+
this.frames.finish();
|
|
340
|
+
const now = fs.statSync(this.source.path, { bigint: true });
|
|
341
|
+
if (identity(now) !== identity(this.initial) || now.size < this.initial.size || now.size === this.initial.size && stamp(now) !== stamp(this.initial)) {
|
|
342
|
+
this.store.resetSource(this.id); throw new HistoryIndexError("SOURCE_CHANGED", "Journal changed during ingestion; retrying its new generation.");
|
|
343
|
+
}
|
|
344
|
+
const { malformed, complexity } = this.store.source(this.id)!;
|
|
345
|
+
this.store.run("UPDATE sources SET identity=?,stamp=?,checked_at=?,state=?,error=? WHERE id=?", identity(this.initial), stamp(this.initial), Date.now(), malformed ? "degraded" : this.source.cursor < Number(this.initial.size) ? "partial" : "current", malformed ? `${malformed} malformed, duplicate or over-budget LF-published records were skipped.${complexity ? ` ${complexity} exceeded bounded history structure/text budgets.` : ""}` : null, this.id);
|
|
346
|
+
this.phase = "done"; this.close(); return true;
|
|
347
|
+
}
|
|
348
|
+
close(): void { if (!this.closed) { fs.closeSync(this.fd); this.store.endIngest(); this.closed = true; } }
|
|
349
|
+
}
|