trajectories 0.0.0 → 0.1.1

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.
Files changed (204) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +201 -0
  3. package/README.md +274 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +8542 -0
  6. package/dist/convert.d.ts +31 -0
  7. package/dist/convert.js +431 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +8402 -0
  10. package/dist/log-baseline.d.ts +26 -0
  11. package/dist/log-baseline.js +104 -0
  12. package/dist/log-content.d.ts +16 -0
  13. package/dist/log-content.js +80 -0
  14. package/dist/log-io.d.ts +10 -0
  15. package/dist/log-io.js +121 -0
  16. package/dist/log-read.d.ts +24 -0
  17. package/dist/log-read.js +117 -0
  18. package/dist/log-reconcile.d.ts +5 -0
  19. package/dist/log-reconcile.js +38 -0
  20. package/dist/log-records.d.ts +35 -0
  21. package/dist/log-records.js +85 -0
  22. package/dist/log-recovery.d.ts +18 -0
  23. package/dist/log-recovery.js +123 -0
  24. package/dist/log-stage.d.ts +14 -0
  25. package/dist/log-stage.js +138 -0
  26. package/dist/log-state.d.ts +89 -0
  27. package/dist/log-state.js +92 -0
  28. package/dist/log-switch.d.ts +3 -0
  29. package/dist/log-switch.js +98 -0
  30. package/dist/log-types.d.ts +129 -0
  31. package/dist/log-types.js +8 -0
  32. package/dist/log.d.ts +8 -0
  33. package/dist/log.js +62 -0
  34. package/dist/migrate.d.ts +29 -0
  35. package/dist/migrate.js +173 -0
  36. package/dist/normalization-runtime.d.ts +1 -0
  37. package/dist/normalization-runtime.js +3150 -0
  38. package/dist/portable.d.ts +9 -0
  39. package/dist/portable.js +26 -0
  40. package/dist/readers/canonical.d.ts +13 -0
  41. package/dist/readers/canonical.js +54 -0
  42. package/dist/readers/cline.d.ts +10 -0
  43. package/dist/readers/cline.js +107 -0
  44. package/dist/readers/cursor.d.ts +20 -0
  45. package/dist/readers/cursor.js +374 -0
  46. package/dist/readers/deepagents.d.ts +41 -0
  47. package/dist/readers/deepagents.js +163 -0
  48. package/dist/readers/fx.d.ts +11 -0
  49. package/dist/readers/fx.js +573 -0
  50. package/dist/readers/github-copilot.d.ts +8 -0
  51. package/dist/readers/github-copilot.js +216 -0
  52. package/dist/readers/grok-build.d.ts +12 -0
  53. package/dist/readers/grok-build.js +391 -0
  54. package/dist/readers/guards.d.ts +4 -0
  55. package/dist/readers/guards.js +121 -0
  56. package/dist/readers/pi-compaction.d.ts +7 -0
  57. package/dist/readers/pi-compaction.js +112 -0
  58. package/dist/readers/pi-context.d.ts +13 -0
  59. package/dist/readers/pi-context.js +64 -0
  60. package/dist/store.d.ts +25 -0
  61. package/dist/store.js +218 -0
  62. package/dist/types.d.ts +91 -0
  63. package/dist/types.js +1 -0
  64. package/dist/writers/claude-code.d.ts +5 -0
  65. package/dist/writers/claude-code.js +51 -0
  66. package/dist/writers/cline.d.ts +3 -0
  67. package/dist/writers/cline.js +77 -0
  68. package/dist/writers/codex.d.ts +3 -0
  69. package/dist/writers/codex.js +32 -0
  70. package/dist/writers/cursor.d.ts +3 -0
  71. package/dist/writers/cursor.js +197 -0
  72. package/dist/writers/deepagents.d.ts +3 -0
  73. package/dist/writers/deepagents.js +116 -0
  74. package/dist/writers/entropy.d.ts +5 -0
  75. package/dist/writers/entropy.js +33 -0
  76. package/dist/writers/fx.d.ts +3 -0
  77. package/dist/writers/fx.js +135 -0
  78. package/dist/writers/github-copilot.d.ts +3 -0
  79. package/dist/writers/github-copilot.js +71 -0
  80. package/dist/writers/grok-build.d.ts +3 -0
  81. package/dist/writers/grok-build.js +43 -0
  82. package/dist/writers/helpers.d.ts +17 -0
  83. package/dist/writers/helpers.js +75 -0
  84. package/dist/writers/omp.d.ts +3 -0
  85. package/dist/writers/omp.js +7 -0
  86. package/dist/writers/opencode.d.ts +3 -0
  87. package/dist/writers/opencode.js +65 -0
  88. package/dist/writers/pi.d.ts +3 -0
  89. package/dist/writers/pi.js +45 -0
  90. package/docs/cline-native-format.md +43 -0
  91. package/docs/copilot-native-format.md +78 -0
  92. package/docs/cursor-native-format.md +103 -0
  93. package/docs/deepagents-native-format.md +98 -0
  94. package/docs/evidence-api.md +170 -0
  95. package/docs/fx-native-format.md +87 -0
  96. package/docs/harness-parity-bootstrap.md +78 -0
  97. package/docs/harness-support-parity.md +107 -0
  98. package/docs/inspirations.md +103 -0
  99. package/docs/ledger-migration.md +141 -0
  100. package/docs/pi-compaction-context.md +75 -0
  101. package/docs/pi-provider-context.md +76 -0
  102. package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
  103. package/docs/research/ledger/README.md +96 -0
  104. package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
  105. package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
  106. package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
  107. package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
  108. package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
  109. package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
  110. package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
  111. package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
  112. package/docs/research/ledger/docs/inspirations.md +320 -0
  113. package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
  114. package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
  115. package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
  116. package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
  117. package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
  118. package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
  119. package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
  120. package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
  121. package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
  122. package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
  123. package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
  124. package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
  125. package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
  126. package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
  127. package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
  128. package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
  129. package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
  130. package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
  131. package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
  132. package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
  133. package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
  134. package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
  135. package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
  136. package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
  137. package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
  138. package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
  139. package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
  140. package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
  141. package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
  142. package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
  143. package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
  144. package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
  145. package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
  146. package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
  147. package/docs/research/ledger/manifest.json +540 -0
  148. package/legacy/NOTICE.md +50 -0
  149. package/legacy/bin/ledger-core.ts +7 -0
  150. package/legacy/bin/ledger.ts +7 -0
  151. package/legacy/bin/traj-bridge.ts +7 -0
  152. package/legacy/scripts/import-claude-history.ts +130 -0
  153. package/legacy/scripts/native-history-smoke.ts +52 -0
  154. package/legacy/scripts/observe-daemon.ts +570 -0
  155. package/legacy/scripts/retitle-sessions.md +19 -0
  156. package/legacy/scripts/verify-runtime-rollout.ts +151 -0
  157. package/legacy/src/canonical.ts +108 -0
  158. package/legacy/src/cli.ts +1293 -0
  159. package/legacy/src/db.ts +2254 -0
  160. package/legacy/src/diagnostics.ts +87 -0
  161. package/legacy/src/discovery.ts +56 -0
  162. package/legacy/src/embeddings.ts +382 -0
  163. package/legacy/src/filter.ts +602 -0
  164. package/legacy/src/gemini_embeddings.ts +103 -0
  165. package/legacy/src/identity.ts +9 -0
  166. package/legacy/src/index.ts +10 -0
  167. package/legacy/src/ingest.ts +791 -0
  168. package/legacy/src/managed.ts +178 -0
  169. package/legacy/src/native/common.ts +68 -0
  170. package/legacy/src/native/cursor.ts +647 -0
  171. package/legacy/src/native/grok.ts +540 -0
  172. package/legacy/src/native/kimi.ts +706 -0
  173. package/legacy/src/native/opencode.ts +470 -0
  174. package/legacy/src/native/sqlite-ro.ts +281 -0
  175. package/legacy/src/native.ts +97 -0
  176. package/legacy/src/normalizer.ts +457 -0
  177. package/legacy/src/parentage.ts +562 -0
  178. package/legacy/src/pressure.ts +162 -0
  179. package/legacy/src/private-storage.ts +74 -0
  180. package/legacy/src/reader.ts +1255 -0
  181. package/legacy/src/scanner.ts +591 -0
  182. package/legacy/src/schema-gate.ts +263 -0
  183. package/legacy/src/schema.ts +829 -0
  184. package/legacy/src/semantic-top-k.ts +71 -0
  185. package/legacy/src/session-hash.ts +49 -0
  186. package/legacy/src/store-status.ts +218 -0
  187. package/legacy/src/sync-preparation.ts +45 -0
  188. package/legacy/src/sync-truth.ts +843 -0
  189. package/legacy/src/sync.ts +1219 -0
  190. package/legacy/src/types.ts +436 -0
  191. package/legacy/src/vocab.txt +30522 -0
  192. package/legacy/src/watch.ts +673 -0
  193. package/legacy/src/writer-lease.ts +310 -0
  194. package/ops/README.md +120 -0
  195. package/ops/install-cli.sh +63 -0
  196. package/ops/install-release.sh +49 -0
  197. package/ops/linux/install-systemd-user.sh +103 -0
  198. package/ops/macos/install-launchd.sh +72 -0
  199. package/ops/sync-settings.sh +37 -0
  200. package/package.json +19 -5
  201. package/skills/trajectories/SKILL.md +138 -0
  202. package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
  203. package/skills/trajectories/scripts/trajectories_search.sh +26 -0
  204. package/index.js +0 -1
@@ -0,0 +1,602 @@
1
+ /**
2
+ * The self-referential echo filter: the records of an agent *asking Ledger
3
+ * itself* are excluded from the derived search index, so a store cannot answer
4
+ * a query with the transcript of the previous identical query.
5
+ *
6
+ * The failure this names is concrete and was observed daily: agents that use
7
+ * Ledger are themselves ingested by Ledger, so every `ledger search` a session
8
+ * ran is a record that mentions the query words — and comes back as a hit for
9
+ * them. Wake ships the same exclusion for its own surface (`wake_*` MCP tools,
10
+ * `wake-cli`/`wake-mcp` shells; `crates/wake-core/src/adapters/mod.rs#L425-L447`,
11
+ * `#L469-L535`) and its stance is the one taken here:
12
+ *
13
+ * - Only the *lookup* is excluded. Dialogue records are never touched, and a
14
+ * `git commit -m "…ledger…"`, a `cd` into a checkout, a `grep` for the word,
15
+ * a `Read` of a source file, or a `bun test` in this repo stay searchable —
16
+ * this repository's own development sessions must survive the filter intact.
17
+ * - Matching is structural, never a substring. A tool name must *end* with a
18
+ * Ledger tool name at a word boundary (client prefixes vary:
19
+ * `mcp__ledger__ledger_search`, `mcp_ledger_search`, bare `ledger_search`;
20
+ * `awake_search` is not one), and a shell command must *name a Ledger binary
21
+ * in command position*: the first word, or the word after a separator
22
+ * (`&&`, `||`, `;`, `|`, `&`, newline), a command substitution, or a prefix
23
+ * word (`sudo`, `env`, `time`, …). `grep ledger`, `cat ledger.ts`,
24
+ * `echo ledger`, and a `/Volumes/dev/workspace/ledger` path in an argument
25
+ * match neither rule.
26
+ * - Quoting is lexical, not textual: a separator inside quotes is text
27
+ * (`rg -n "a|ledger|b"` is one command running `rg`), and a heredoc body is
28
+ * data, not commands (`cat > notes.md <<'EOF'` writing a line that begins
29
+ * `ledger search …` is a document, not a lookup).
30
+ * - A tool *result* is excluded only when its own call is: the pairing is the
31
+ * record's `tool_call_id` against an excluded call in the same session, never
32
+ * a guess from the result text.
33
+ *
34
+ * The rule is one pure function over the three canonical fields that carry the
35
+ * decision — `record_type`, `tool_name`, `tool_arguments_json` — so ingest, the
36
+ * index re-derivation, the parity gate, and the status verb all classify a
37
+ * record the same way, and a record is a lookup by its canonical content, not
38
+ * by a flag someone could forget to maintain.
39
+ *
40
+ * What is *not* here, deliberately: the thin subclass of shell shapes this rule
41
+ * cannot see (a JSON shape with no `command`/`cmd`/`input` field, a program
42
+ * invoked from inside a script or a `subprocess.run` list). Wrapped scripts —
43
+ * `bash -c "…"`, `bash -lc '…'`, argv arrays — ARE segmented; see
44
+ * `commandSegments`. The residual shapes cost a noisy hit, never a hidden
45
+ * record: the rule errs toward indexing.
46
+ */
47
+ import type { Database } from "bun:sqlite";
48
+
49
+ /**
50
+ * The tools a Ledger MCP server exposes. A client namespaces them under its own
51
+ * prefix, so the name is matched as a word-boundary *suffix* — Wake's rule
52
+ * (`ends_with_word`, `adapters/mod.rs#L517-L535`) — and not as a substring.
53
+ */
54
+ const LOOKUP_TOOLS: readonly string[] = [
55
+ "ledger_search",
56
+ "ledger_recent",
57
+ "ledger_status",
58
+ "ledger_sync",
59
+ "ledger_scan",
60
+ "ledger_build",
61
+ "ledger_watch",
62
+ "ledger_models",
63
+ ];
64
+
65
+ /**
66
+ * Claude Code's MCP namespacing (`mcp__<server>__<tool>`). Every tool a Ledger
67
+ * MCP server exposes is a lookup whatever it is called, so a name carrying the
68
+ * server segment is excluded without consulting {@link LOOKUP_TOOLS} — the
69
+ * server's own namespace is the stronger statement.
70
+ */
71
+ const MCP_SERVER_SEGMENT = "mcp__ledger__";
72
+
73
+ /**
74
+ * The binaries that *are* the lookup, by basename (`ledger`, `ledger-core`,
75
+ * `traj-bridge` and the `.ts`/`.js`/`.exe` forms `bun` runs and Windows
76
+ * installs). A path is accepted (`/Users/…/.local/bin/ledger`,
77
+ * `./bin/ledger.ts`) because a tokenizer cannot know PATH.
78
+ */
79
+ const BINARY_BASENAMES: Record<string, true> = {
80
+ trajectories: true,
81
+ "trajectories.ts": true,
82
+ "trajectories.js": true,
83
+ "trajectories.exe": true,
84
+ ledger: true,
85
+ "ledger.ts": true,
86
+ "ledger.js": true,
87
+ "ledger.exe": true,
88
+ "ledger-core": true,
89
+ "ledger-core.ts": true,
90
+ "ledger-core.js": true,
91
+ "ledger-core.exe": true,
92
+ "traj-bridge": true,
93
+ "traj-bridge.ts": true,
94
+ "traj-bridge.js": true,
95
+ "traj-bridge.exe": true,
96
+ };
97
+
98
+ /** Runners whose next word is the program: `bun run ./bin/ledger.ts`. */
99
+ const RUNNERS: Record<string, true> = { bun: true, "bun.exe": true, bunx: true, "bunx.exe": true };
100
+
101
+ /** Shell tools whose arguments may hide the command inside an input string. */
102
+ const SHELL_TOOL_NAMES: Record<string, true> = {
103
+ bash: true,
104
+ exec: true,
105
+ exec_command: true,
106
+ shell: true,
107
+ shell_command: true,
108
+ terminal: true,
109
+ };
110
+
111
+ /** A path-shaped token, so `pwd)/ledger` and `$X/ledger` are not read as paths. */
112
+ const PATH_TOKEN = /^[A-Za-z0-9._~+/@%:\\-]+$/;
113
+
114
+ /** Words that precede a command without being it. */
115
+ const PREFIX_WORDS: Record<string, true> = {
116
+ sudo: true,
117
+ command: true,
118
+ env: true,
119
+ time: true,
120
+ nohup: true,
121
+ nice: true,
122
+ exec: true,
123
+ builtin: true,
124
+ do: true,
125
+ then: true,
126
+ else: true,
127
+ if: true,
128
+ while: true,
129
+ until: true,
130
+ };
131
+
132
+ /** `FOO=bar` assignments may also precede a command. */
133
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
134
+
135
+ /** A heredoc redirection: `<<EOF`, `<<'EOF'`, `<<-"EOF"`. */
136
+ const HEREDOC_START = /<<(-?)(?:(["']?)([A-Za-z0-9_]+)\2)/y;
137
+ /** A shell wrapper flag that makes the next word a script: `-c`, `-lc`, `-ic`. */
138
+ const SHELL_WRAPPER_FLAG = /^-[A-Za-z]*c[A-Za-z]*$/;
139
+ /** A shell executable by basename, for the `bash -lc "…"` shape. */
140
+ const SHELL_BASENAMES: Record<string, true> = {
141
+ bash: true,
142
+ sh: true,
143
+ dash: true,
144
+ zsh: true,
145
+ ksh: true,
146
+ fish: true,
147
+ };
148
+
149
+ /** The basename of a path-shaped token, or null when the token is not one. */
150
+ function basename(token: string): string | null {
151
+ const bare = token.replace(/^["']|["']$/g, "");
152
+ if (!PATH_TOKEN.test(bare)) return null;
153
+ return bare.slice(Math.max(bare.lastIndexOf("/"), bare.lastIndexOf("\\")) + 1);
154
+ }
155
+
156
+ /** Whether the token names a Ledger binary in path or extension form. */
157
+ function binaryBasename(token: string): string | null {
158
+ const base = basename(token);
159
+ return base !== null && BINARY_BASENAMES[base] ? base : null;
160
+ }
161
+
162
+ /**
163
+ * A command string with its heredoc bodies removed. Bodies are data the shell
164
+ * feeds to a program's stdin — a `<<'EOF'` line that begins `ledger search` is
165
+ * what the program printed or wrote, not what ran.
166
+ */
167
+ function withoutHeredocs(command: string): string {
168
+ let out = "";
169
+ let quote: string | null = null;
170
+ for (let i = 0; i < command.length; i++) {
171
+ const char = command[i];
172
+ if (quote !== null) {
173
+ out += char;
174
+ if (char === "\\" && quote === '"') out += command[++i] ?? "";
175
+ else if (char === quote) quote = null;
176
+ continue;
177
+ }
178
+ if (char === "'" || char === '"') {
179
+ quote = char;
180
+ out += char;
181
+ continue;
182
+ }
183
+ if (char === "\\") {
184
+ out += char + (command[++i] ?? "");
185
+ continue;
186
+ }
187
+ if (char === "<" && command[i + 1] === "<") {
188
+ HEREDOC_START.lastIndex = i;
189
+ const start = HEREDOC_START.exec(command);
190
+ if (start !== null) {
191
+ const [, dashes, , delimiter] = start;
192
+ let cursor = command.indexOf("\n", HEREDOC_START.lastIndex);
193
+ let end = command.length;
194
+ while (cursor !== -1) {
195
+ const lineEnd = command.indexOf("\n", cursor + 1);
196
+ const line = command.slice(cursor + 1, lineEnd === -1 ? command.length : lineEnd);
197
+ if ((dashes ? line.replace(/^\t+/, "") : line) === delimiter) {
198
+ end = lineEnd === -1 ? command.length : lineEnd + 1;
199
+ break;
200
+ }
201
+ cursor = lineEnd;
202
+ }
203
+ out += "\n";
204
+ i = end - 1;
205
+ continue;
206
+ }
207
+ }
208
+ out += char;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * A command string split at its command positions: the start, and every word
215
+ * after an unquoted separator (`&&`, `||`, `;`, `|`, `&`, newline) or command
216
+ * substitution (`$(`, backtick). Everything after a separator is judged
217
+ * separately — a later command in a `&&` chain is caught, an argument that
218
+ * merely mentions a binary is not.
219
+ */
220
+ function commandSegments(command: string): string[] {
221
+ const stripped = withoutHeredocs(command);
222
+ const segments: string[] = [];
223
+ let current = "";
224
+ let quote: string | null = null;
225
+ for (let i = 0; i < stripped.length; i++) {
226
+ const char = stripped[i];
227
+ if (quote !== null) {
228
+ current += char;
229
+ if (char === "\\" && quote === '"') current += stripped[++i] ?? "";
230
+ else if (char === quote) quote = null;
231
+ continue;
232
+ }
233
+ if (char === "'" || char === '"') {
234
+ quote = char;
235
+ current += char;
236
+ continue;
237
+ }
238
+ if (char === "\\") {
239
+ current += char + (stripped[++i] ?? "");
240
+ continue;
241
+ }
242
+ if (char === "\n" || char === ";" || char === "&" || char === "|" || char === "`") {
243
+ segments.push(current);
244
+ current = "";
245
+ if ((char === "&" || char === "|") && stripped[i + 1] === char) i++;
246
+ continue;
247
+ }
248
+ if (char === "$" && stripped[i + 1] === "(") {
249
+ segments.push(current);
250
+ current = "";
251
+ i++;
252
+ continue;
253
+ }
254
+ current += char;
255
+ }
256
+ segments.push(current);
257
+ return segments;
258
+ }
259
+
260
+ /** One segment's words, split on unquoted whitespace; quotes stay on the word. */
261
+ function segmentWords(segment: string): string[] {
262
+ const words: string[] = [];
263
+ let current = "";
264
+ let quote: string | null = null;
265
+ for (let i = 0; i < segment.length; i++) {
266
+ const char = segment[i];
267
+ if (quote !== null) {
268
+ current += char;
269
+ if (char === "\\" && quote === '"') current += segment[++i] ?? "";
270
+ else if (char === quote) quote = null;
271
+ continue;
272
+ }
273
+ if (char === "'" || char === '"') {
274
+ quote = char;
275
+ current += char;
276
+ continue;
277
+ }
278
+ if (/\s/.test(char)) {
279
+ if (current !== "") words.push(current);
280
+ current = "";
281
+ continue;
282
+ }
283
+ current += char;
284
+ }
285
+ if (current !== "") words.push(current);
286
+ return words;
287
+ }
288
+
289
+ /**
290
+ * Whether one command segment runs a Ledger binary: skip the words that may
291
+ * precede a command, then require a Ledger binary in command position — or, for
292
+ * a runner (`bun`), the program it is told to run. A shell wrapper's `-c`
293
+ * script is one more command string, judged the same way.
294
+ */
295
+ function segmentNamesLedgerBinary(segment: string): boolean {
296
+ const words = segmentWords(segment);
297
+ let i = 0;
298
+ while (i < words.length && (PREFIX_WORDS[words[i]] || ENV_ASSIGNMENT.test(words[i]))) i++;
299
+ if (i >= words.length) return false;
300
+ const base = basename(words[i]);
301
+ if (base === null) return false;
302
+ if (RUNNERS[base]) {
303
+ i++;
304
+ while (i < words.length && (words[i].startsWith("--") || words[i] === "run" || words[i] === "x")) i++;
305
+ return i < words.length && binaryBasename(words[i]) !== null;
306
+ }
307
+ if (binaryBasename(words[i]) !== null) return true;
308
+ if (!SHELL_BASENAMES[base] || !SHELL_WRAPPER_FLAG.test(words[i + 1] ?? "")) return false;
309
+ // The script is one more command string. It is still wearing the quotes the
310
+ // shell took off (`bash -lc "ledger search x"` reaches `segmentWords` as one
311
+ // word), so they are stripped and the same segmentation judges it — which
312
+ // also respects quotes *inside* the script, so an alternation that merely
313
+ // contains the word (`bash -lc "rg -n 'ard|ledger|review' docs"`) is not a
314
+ // run, exactly as it is not one outside a wrapper.
315
+ const script = words
316
+ .slice(i + 2)
317
+ .join(" ")
318
+ .replace(/^["']|["']$/g, "");
319
+ return commandSegments(script).some(segmentNamesLedgerBinary);
320
+ }
321
+
322
+ /**
323
+ * The command a shell wrapper was given: `["bash", "-lc", "ledger search"]` is
324
+ * `ledger search`; any other argv array is joined as written.
325
+ */
326
+ function joinedArgv(argv: readonly string[]): string {
327
+ const [head, ...rest] = argv;
328
+ if (head !== undefined && rest.length > 0 && SHELL_WRAPPER_FLAG.test(rest[0])) {
329
+ const base = head.replace(/^["']|["']$/g, "").replace(/^.*[/\\]/, "");
330
+ if (SHELL_BASENAMES[base]) return rest.slice(1).join(" ");
331
+ }
332
+ return argv.join(" ");
333
+ }
334
+
335
+ /** The Codex-style `exec` shape: a call list with `cmd:"…"` inside it. */
336
+ const EMBEDDED_CMD = /\bcmd\s*:\s*(["'])/g;
337
+
338
+ function embeddedCommands(input: string): string[] {
339
+ const commands: string[] = [];
340
+ for (const match of input.matchAll(EMBEDDED_CMD)) {
341
+ const quote = match[1];
342
+ const start = match.index + match[0].length;
343
+ const end = input.indexOf(quote, start);
344
+ if (end !== -1) commands.push(input.slice(start, end));
345
+ }
346
+ return commands;
347
+ }
348
+
349
+ /**
350
+ * Every shell command string a tool call's arguments carry: the structured
351
+ * `command`/`cmd` fields (string or argv array), and — for shell tools whose
352
+ * input is a call list — the `cmd:"…"` strings inside that input. The
353
+ * structured fields are read for *any* tool, because a tool that names a
354
+ * command names a command; the input scan is for shell tool names only, so a
355
+ * prompt or message body quoting a `cmd:` line is never read as a run.
356
+ */
357
+ export function commandStrings(
358
+ toolName: string | null,
359
+ toolArgumentsJson: string | null,
360
+ ): string[] {
361
+ if (!toolArgumentsJson) return [];
362
+ let args: unknown;
363
+ try {
364
+ args = JSON.parse(toolArgumentsJson);
365
+ } catch {
366
+ return [];
367
+ }
368
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return [];
369
+ const fields = args as Record<string, unknown>;
370
+ const commands: string[] = [];
371
+ for (const key of ["command", "cmd"]) {
372
+ const value = fields[key];
373
+ if (typeof value === "string") commands.push(value);
374
+ else if (Array.isArray(value) && value.every((part) => typeof part === "string")) {
375
+ commands.push(joinedArgv(value));
376
+ }
377
+ }
378
+ const input = fields["input"];
379
+ if (typeof input === "string" && toolName !== null && SHELL_TOOL_NAMES[toolName.toLowerCase()]) {
380
+ commands.push(...embeddedCommands(input));
381
+ }
382
+ return commands;
383
+ }
384
+
385
+ /** Whether `name` ends with `word` at a word boundary (Wake's `ends_with_word`). */
386
+ function endsWithWord(name: string, word: string): boolean {
387
+ if (!name.endsWith(word)) return false;
388
+ const previous = name[name.length - word.length - 1];
389
+ return previous === undefined || !/[\p{L}\p{N}]/u.test(previous);
390
+ }
391
+
392
+ /**
393
+ * Whether one `assistant-tool-call` record is the agent querying Ledger: a
394
+ * Ledger MCP tool by name, or a shell command that runs a Ledger binary in
395
+ * command position. Any other record type is never a lookup — dialogue, meta,
396
+ * and tool *results* are excluded only through their call.
397
+ */
398
+ export function isEchoCall(
399
+ recordType: string,
400
+ toolName: string | null,
401
+ toolArgumentsJson: string | null,
402
+ ): boolean {
403
+ if (recordType !== "assistant-tool-call") return false;
404
+ const name = toolName?.trim();
405
+ if (name) {
406
+ if (LOOKUP_TOOLS.some((tool) => endsWithWord(name, tool))) return true;
407
+ if (name.includes(MCP_SERVER_SEGMENT)) return true;
408
+ }
409
+ return commandStrings(toolName, toolArgumentsJson).some((command) =>
410
+ commandSegments(command).some(segmentNamesLedgerBinary),
411
+ );
412
+ }
413
+
414
+ /**
415
+ * The SQL superset of the shell half of {@link isEchoCall}: rows whose
416
+ * arguments can possibly name a Ledger binary. The LIKE is deliberately loose —
417
+ * it is the database doing the cheap part of the scan, so the rule only reads
418
+ * the candidate rows — and it must only ever *broaden* the set: every token the
419
+ * rule accepts (`ledger`, `ledger-core`, `traj-bridge`, in any path form)
420
+ * contains `ledger` or `traj-bridge` as written.
421
+ */
422
+ export const ECHO_CANDIDATE_SQL =
423
+ "(tool_name LIKE '%ledger%' OR tool_arguments_json LIKE '%ledger%' OR tool_arguments_json LIKE '%traj-bridge%' OR tool_arguments_json LIKE '%trajectories%')";
424
+
425
+ /** The canonical fields the filter reads from one record. */
426
+ export interface EchoCandidate {
427
+ readonly id: string;
428
+ readonly record_type: string;
429
+ readonly tool_call_id: string | null;
430
+ readonly tool_name: string | null;
431
+ readonly tool_arguments_json: string | null;
432
+ }
433
+
434
+ /** A candidate row read back from `records`, which carries its session too. */
435
+ interface StoredCall extends EchoCandidate {
436
+ readonly session_id: string;
437
+ }
438
+
439
+ /** A tool result the pairing half reads. */
440
+ interface ResultRow {
441
+ id: string;
442
+ session_id: string;
443
+ tool_call_id: string | null;
444
+ }
445
+
446
+ /**
447
+ * A session-qualified call key. Pairing never crosses a session: a result's
448
+ * `tool_call_id` names a call in its own transcript, so the key carries the
449
+ * session and the same client-side id in two sessions cannot pair across them.
450
+ */
451
+ function pairingKey(sessionId: string, toolCallId: string): string {
452
+ return `${sessionId}\u0000${toolCallId}`;
453
+ }
454
+
455
+ /**
456
+ * The excluded id and `tool_call_id` of one call, when the rule names it a
457
+ * lookup. The single place `isEchoCall` meets the `records` shape, so the
458
+ * whole-store and batch forms below cannot classify a call differently — and
459
+ * the record's *own* type decides: a tool result that happens to carry a tool
460
+ * name is not a call, or the batch form would exclude it and the next
461
+ * re-derivation would index it back, the two forms disagreeing about one row.
462
+ */
463
+ function echoCallOf(call: EchoCandidate): { id: string; toolCallId: string | null } | null {
464
+ if (!isEchoCall(call.record_type, call.tool_name, call.tool_arguments_json)) return null;
465
+ return { id: call.id, toolCallId: call.tool_call_id };
466
+ }
467
+
468
+ /**
469
+ * The size of the store's derived echo set, or null when the store has no such
470
+ * table — every store written before the filter, and any store no writer has
471
+ * opened since. Null is the honest answer there: *unknown*, not zero, because
472
+ * the rule has not been derived into the store yet. A writer pass creates the
473
+ * table (CREATE_TABLES_SQL) and the index re-derivation fills it.
474
+ */
475
+ export function echoCount(db: Database): number | null {
476
+ const present = db
477
+ .query<{ name: string }, []>(
478
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'records_echo' LIMIT 1;",
479
+ )
480
+ .get();
481
+ if (!present) return null;
482
+ return db.query<{ count: number }, []>("SELECT COUNT(*) AS count FROM records_echo;").get()!.count;
483
+ }
484
+
485
+ /**
486
+ * The record ids the store excludes: every echo tool call, and every tool
487
+ * result paired with one.
488
+ *
489
+ * One streaming pass over `records` — a call is judged by {@link isEchoCall}
490
+ * where it is read, and a tool result is checked against the echo calls already
491
+ * seen — because this is the form the re-derivation, the parity gate, and the
492
+ * status verb share, and each of them runs it over the whole store: a second
493
+ * scan, or a candidate pass that re-reads every argument blob, is measurable on
494
+ * a large store (one pass over this repo's own 1.1 GiB store reads ~5 s cold,
495
+ * ~0.35 s warm). Results are held until the call set is complete rather than
496
+ * relying on source order, so a store whose adapter wrote a result ahead of its
497
+ * call still derives the same index as one that did not.
498
+ *
499
+ * Pairing never crosses a session: a result's `tool_call_id` names a call in
500
+ * its own transcript, so the key carries the session and the same client-side
501
+ * id in two sessions cannot pair across them.
502
+ */
503
+ export function echoExcludedIds(db: Database): Set<string> {
504
+ const excluded = new Set<string>();
505
+ const echoCallKeys = new Set<string>();
506
+ const results: ResultRow[] = [];
507
+ const rows = db
508
+ .query<StoredCall, []>(
509
+ `SELECT id, session_id, record_type, tool_call_id, tool_name, tool_arguments_json FROM records
510
+ WHERE record_type = 'tool'
511
+ OR (record_type = 'assistant-tool-call' AND ${ECHO_CANDIDATE_SQL})`,
512
+ )
513
+ .iterate();
514
+ for (const row of rows) {
515
+ if (row.record_type === "tool") {
516
+ if (row.tool_call_id !== null) results.push({ id: row.id, session_id: row.session_id, tool_call_id: row.tool_call_id });
517
+ continue;
518
+ }
519
+ const echo = echoCallOf(row);
520
+ if (echo === null) continue;
521
+ excluded.add(echo.id);
522
+ if (echo.toolCallId !== null) echoCallKeys.add(pairingKey(row.session_id, echo.toolCallId));
523
+ }
524
+ if (echoCallKeys.size === 0) return excluded;
525
+ for (const result of results) {
526
+ if (echoCallKeys.has(pairingKey(result.session_id, result.tool_call_id as string))) {
527
+ excluded.add(result.id);
528
+ }
529
+ }
530
+ return excluded;
531
+ }
532
+
533
+ /**
534
+ * One ingest batch's share of the echo filter: the record ids this batch must
535
+ * keep out of the index, and the `tool_call_id`s of the echo calls behind them.
536
+ *
537
+ * The ids are the batch's own rows. The call ids are what the writer needs to
538
+ * repair rows an *earlier* batch committed: an adapter may write a result
539
+ * before its call, and the batch that carried the result had nothing to pair it
540
+ * with — so the call arriving now has to take that result's index row back.
541
+ * The earlier batch is past rewriting; this is the same derivation one batch
542
+ * late, and it lands in the call's own transaction so the index and the derived
543
+ * set cannot disagree about the pair.
544
+ *
545
+ * The batch's own rows are classified directly; a result whose call sits in an
546
+ * earlier batch is resolved through a lookup by `tool_call_id` in the same
547
+ * session, which is what makes a call and its result in different batches
548
+ * derive to the same index state as a re-derivation from scratch.
549
+ */
550
+ export interface BatchEchoes {
551
+ readonly excluded: ReadonlySet<string>;
552
+ readonly callIds: ReadonlySet<string>;
553
+ }
554
+
555
+ export function echoExcludedBatchIds(
556
+ db: Database,
557
+ sessionId: string,
558
+ records: readonly EchoCandidate[],
559
+ ): BatchEchoes {
560
+ const excluded = new Set<string>();
561
+ const keys = new Set<string>();
562
+ const callIds = new Set<string>();
563
+ const pending = new Set<string>();
564
+ for (const record of records) {
565
+ const echo = echoCallOf(record);
566
+ if (echo !== null) {
567
+ excluded.add(echo.id);
568
+ if (echo.toolCallId !== null) {
569
+ keys.add(pairingKey(sessionId, echo.toolCallId));
570
+ callIds.add(echo.toolCallId);
571
+ }
572
+ } else if (record.record_type === "tool" && record.tool_call_id !== null) {
573
+ pending.add(record.tool_call_id);
574
+ }
575
+ }
576
+ for (const toolCallId of pending) {
577
+ if (keys.has(pairingKey(sessionId, toolCallId))) pending.delete(toolCallId);
578
+ }
579
+ if (pending.size > 0) {
580
+ // The call may be a row this batch cannot see: read it back from the session
581
+ // and let the same rule decide, rather than trusting the result's text.
582
+ const calls = db
583
+ .query<StoredCall, (string | number)[]>(
584
+ `SELECT id, session_id, record_type, tool_call_id, tool_name, tool_arguments_json FROM records
585
+ WHERE session_id = ? AND record_type = 'assistant-tool-call'
586
+ AND tool_call_id IN (SELECT value FROM json_each(?))`,
587
+ )
588
+ .all(sessionId, JSON.stringify([...pending]));
589
+ for (const call of calls) {
590
+ const echo = echoCallOf(call);
591
+ if (echo !== null && echo.toolCallId !== null) {
592
+ keys.add(pairingKey(sessionId, echo.toolCallId));
593
+ callIds.add(echo.toolCallId);
594
+ }
595
+ }
596
+ }
597
+ for (const record of records) {
598
+ if (record.record_type !== "tool" || record.tool_call_id === null) continue;
599
+ if (keys.has(pairingKey(sessionId, record.tool_call_id))) excluded.add(record.id);
600
+ }
601
+ return { excluded, callIds };
602
+ }