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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +201 -0
- package/README.md +274 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +8542 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8402 -0
- package/dist/log-baseline.d.ts +26 -0
- package/dist/log-baseline.js +104 -0
- package/dist/log-content.d.ts +16 -0
- package/dist/log-content.js +80 -0
- package/dist/log-io.d.ts +10 -0
- package/dist/log-io.js +121 -0
- package/dist/log-read.d.ts +24 -0
- package/dist/log-read.js +117 -0
- package/dist/log-reconcile.d.ts +5 -0
- package/dist/log-reconcile.js +38 -0
- package/dist/log-records.d.ts +35 -0
- package/dist/log-records.js +85 -0
- package/dist/log-recovery.d.ts +18 -0
- package/dist/log-recovery.js +123 -0
- package/dist/log-stage.d.ts +14 -0
- package/dist/log-stage.js +138 -0
- package/dist/log-state.d.ts +89 -0
- package/dist/log-state.js +92 -0
- package/dist/log-switch.d.ts +3 -0
- package/dist/log-switch.js +98 -0
- package/dist/log-types.d.ts +129 -0
- package/dist/log-types.js +8 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +62 -0
- package/dist/migrate.d.ts +29 -0
- package/dist/migrate.js +173 -0
- package/dist/normalization-runtime.d.ts +1 -0
- package/dist/normalization-runtime.js +3150 -0
- package/dist/portable.d.ts +9 -0
- package/dist/portable.js +26 -0
- package/dist/readers/canonical.d.ts +13 -0
- package/dist/readers/canonical.js +54 -0
- package/dist/readers/cline.d.ts +10 -0
- package/dist/readers/cline.js +107 -0
- package/dist/readers/cursor.d.ts +20 -0
- package/dist/readers/cursor.js +374 -0
- package/dist/readers/deepagents.d.ts +41 -0
- package/dist/readers/deepagents.js +163 -0
- package/dist/readers/fx.d.ts +11 -0
- package/dist/readers/fx.js +573 -0
- package/dist/readers/github-copilot.d.ts +8 -0
- package/dist/readers/github-copilot.js +216 -0
- package/dist/readers/grok-build.d.ts +12 -0
- package/dist/readers/grok-build.js +391 -0
- package/dist/readers/guards.d.ts +4 -0
- package/dist/readers/guards.js +121 -0
- package/dist/readers/pi-compaction.d.ts +7 -0
- package/dist/readers/pi-compaction.js +112 -0
- package/dist/readers/pi-context.d.ts +13 -0
- package/dist/readers/pi-context.js +64 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +218 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/writers/claude-code.d.ts +5 -0
- package/dist/writers/claude-code.js +51 -0
- package/dist/writers/cline.d.ts +3 -0
- package/dist/writers/cline.js +77 -0
- package/dist/writers/codex.d.ts +3 -0
- package/dist/writers/codex.js +32 -0
- package/dist/writers/cursor.d.ts +3 -0
- package/dist/writers/cursor.js +197 -0
- package/dist/writers/deepagents.d.ts +3 -0
- package/dist/writers/deepagents.js +116 -0
- package/dist/writers/entropy.d.ts +5 -0
- package/dist/writers/entropy.js +33 -0
- package/dist/writers/fx.d.ts +3 -0
- package/dist/writers/fx.js +135 -0
- package/dist/writers/github-copilot.d.ts +3 -0
- package/dist/writers/github-copilot.js +71 -0
- package/dist/writers/grok-build.d.ts +3 -0
- package/dist/writers/grok-build.js +43 -0
- package/dist/writers/helpers.d.ts +17 -0
- package/dist/writers/helpers.js +75 -0
- package/dist/writers/omp.d.ts +3 -0
- package/dist/writers/omp.js +7 -0
- package/dist/writers/opencode.d.ts +3 -0
- package/dist/writers/opencode.js +65 -0
- package/dist/writers/pi.d.ts +3 -0
- package/dist/writers/pi.js +45 -0
- package/docs/cline-native-format.md +43 -0
- package/docs/copilot-native-format.md +78 -0
- package/docs/cursor-native-format.md +103 -0
- package/docs/deepagents-native-format.md +98 -0
- package/docs/evidence-api.md +170 -0
- package/docs/fx-native-format.md +87 -0
- package/docs/harness-parity-bootstrap.md +78 -0
- package/docs/harness-support-parity.md +107 -0
- package/docs/inspirations.md +103 -0
- package/docs/ledger-migration.md +141 -0
- package/docs/pi-compaction-context.md +75 -0
- package/docs/pi-provider-context.md +76 -0
- package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
- package/docs/research/ledger/README.md +96 -0
- package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
- package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
- package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
- package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
- package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
- package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
- package/docs/research/ledger/docs/inspirations.md +320 -0
- package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
- package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
- package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
- package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
- package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
- package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
- package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
- package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
- package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
- package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
- package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
- package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
- package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
- package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
- package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
- package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
- package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
- package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
- package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
- package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
- package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
- package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
- package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
- package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
- package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
- package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
- package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
- package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
- package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
- package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
- package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
- package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
- package/docs/research/ledger/manifest.json +540 -0
- package/legacy/NOTICE.md +50 -0
- package/legacy/bin/ledger-core.ts +7 -0
- package/legacy/bin/ledger.ts +7 -0
- package/legacy/bin/traj-bridge.ts +7 -0
- package/legacy/scripts/import-claude-history.ts +130 -0
- package/legacy/scripts/native-history-smoke.ts +52 -0
- package/legacy/scripts/observe-daemon.ts +570 -0
- package/legacy/scripts/retitle-sessions.md +19 -0
- package/legacy/scripts/verify-runtime-rollout.ts +151 -0
- package/legacy/src/canonical.ts +108 -0
- package/legacy/src/cli.ts +1293 -0
- package/legacy/src/db.ts +2254 -0
- package/legacy/src/diagnostics.ts +87 -0
- package/legacy/src/discovery.ts +56 -0
- package/legacy/src/embeddings.ts +382 -0
- package/legacy/src/filter.ts +602 -0
- package/legacy/src/gemini_embeddings.ts +103 -0
- package/legacy/src/identity.ts +9 -0
- package/legacy/src/index.ts +10 -0
- package/legacy/src/ingest.ts +791 -0
- package/legacy/src/managed.ts +178 -0
- package/legacy/src/native/common.ts +68 -0
- package/legacy/src/native/cursor.ts +647 -0
- package/legacy/src/native/grok.ts +540 -0
- package/legacy/src/native/kimi.ts +706 -0
- package/legacy/src/native/opencode.ts +470 -0
- package/legacy/src/native/sqlite-ro.ts +281 -0
- package/legacy/src/native.ts +97 -0
- package/legacy/src/normalizer.ts +457 -0
- package/legacy/src/parentage.ts +562 -0
- package/legacy/src/pressure.ts +162 -0
- package/legacy/src/private-storage.ts +74 -0
- package/legacy/src/reader.ts +1255 -0
- package/legacy/src/scanner.ts +591 -0
- package/legacy/src/schema-gate.ts +263 -0
- package/legacy/src/schema.ts +829 -0
- package/legacy/src/semantic-top-k.ts +71 -0
- package/legacy/src/session-hash.ts +49 -0
- package/legacy/src/store-status.ts +218 -0
- package/legacy/src/sync-preparation.ts +45 -0
- package/legacy/src/sync-truth.ts +843 -0
- package/legacy/src/sync.ts +1219 -0
- package/legacy/src/types.ts +436 -0
- package/legacy/src/vocab.txt +30522 -0
- package/legacy/src/watch.ts +673 -0
- package/legacy/src/writer-lease.ts +310 -0
- package/ops/README.md +120 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-release.sh +49 -0
- package/ops/linux/install-systemd-user.sh +103 -0
- package/ops/macos/install-launchd.sh +72 -0
- package/ops/sync-settings.sh +37 -0
- package/package.json +19 -5
- package/skills/trajectories/SKILL.md +138 -0
- package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
- package/skills/trajectories/scripts/trajectories_search.sh +26 -0
- 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
|
+
}
|