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,1293 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { SessionNotFoundError, StoreNotInitializedError, TrajectoryDb } from "./db.js";
3
+ import { aggregateIngestDiagnostics } from "./diagnostics.js";
4
+ import {
5
+ DEFAULT_MODEL_NAME,
6
+ pullModel,
7
+ setEmbeddingProvider,
8
+ } from "./embeddings.js";
9
+ import {
10
+ GeminiEmbeddingProvider,
11
+ resolveGeminiKey,
12
+ } from "./gemini_embeddings.js";
13
+ import { ledgerId } from "./identity.js";
14
+ import { DEFAULT_INGEST_RECORD_BYTES, ingestDiscoveredSession, nativeIngestCatchResult } from "./ingest.js";
15
+ import { normalizeDiscoveredSession } from "./normalizer.js";
16
+ import { FTS_SYNTAX_FALLBACK_REASON, openReadOnlyStore, ReaderError, TrajectoryReader } from "./reader.js";
17
+ import { SCHEMA_VERSION } from "./schema.js";
18
+ import { scanSessions } from "./scanner.js";
19
+ import { readStoreStatus, StoreStatusError } from "./store-status.js";
20
+ import { executeSyncMaintenanceStdio, executeSyncWorkerStdio, executeSyncPreparationStdio, SyncPreparationError, runSync } from "./sync.js";
21
+ import type { BuildSummary, DiscoveredSession, QueryResult, ScanOutcome, TombstoneRecord } from "./types.js";
22
+ import { LedgerWatcher } from "./watch.js";
23
+ import {
24
+ acquireWriterLease,
25
+ DEFAULT_WRITER_LEASE_WAIT_MS,
26
+ describeLeaseHolder,
27
+ isMemoryStorePath,
28
+ WriterLeaseBusyError,
29
+ } from "./writer-lease.js";
30
+
31
+ const VERSION: string = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
32
+
33
+ /** The provider a `--embedding` value names; an absent flag asks for local, a bare `--embedding` asks for Gemini. */
34
+ function requestedEmbedding(value: string | boolean | undefined): string {
35
+ return value === true ? "gemini" : typeof value === "string" ? value : "local";
36
+ }
37
+
38
+ /**
39
+ * The one place that decides which `--embedding` values exist. The flag parser
40
+ * runs it for every verb — a verb that never embeds still has to reject a typo
41
+ * rather than accept a provider it will ignore — and the wiring below runs it
42
+ * again before selecting a provider, so neither site can drift.
43
+ */
44
+ function assertKnownEmbedding(value: string | boolean | undefined): void {
45
+ const requested = requestedEmbedding(value);
46
+ if (requested !== "local" && requested !== "gemini" && requested !== "none") {
47
+ throw new Error(`Unknown embedding provider: ${requested} (use local|gemini|none)`);
48
+ }
49
+ }
50
+
51
+ function applyEmbeddingProvider(
52
+ flags: Record<string, string | boolean>,
53
+ isRobot: boolean,
54
+ ): void {
55
+ assertKnownEmbedding(flags.embedding);
56
+ // Local embedding is the privacy-safe default. Cloud embedding is enabled
57
+ // only when the caller explicitly asks for Gemini.
58
+ if (requestedEmbedding(flags.embedding) !== "gemini") return;
59
+ if (resolveGeminiKey()) {
60
+ setEmbeddingProvider(new GeminiEmbeddingProvider());
61
+ return;
62
+ }
63
+ const warn = "No Gemini API key found; falling back to local MiniLM embeddings.";
64
+ if (isRobot) console.error(warn);
65
+ else console.warn(warn);
66
+ }
67
+
68
+ /** Options that distinguish the canonical `ledger` executable from legacy entrypoints. */
69
+ export interface CliOptions {
70
+ /** Name used in help, version, and error strings (default: "traj-bridge"). */
71
+ programName?: string;
72
+ /** Help-text suffix describing the default database resolution. */
73
+ dbHelpNote?: string;
74
+ }
75
+
76
+ export async function runCli(argv: string[], options: CliOptions = {}): Promise<void> {
77
+ const isRobot = argv.includes("--robot");
78
+ const programName = options.programName ?? "traj-bridge";
79
+ const dbHelpNote = options.dbHelpNote ?? " (default: ledger.sqlite)";
80
+
81
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
82
+ if (isRobot && argv.length === 0) {
83
+ emitRobotError(
84
+ "MISSING_COMMAND",
85
+ "Please specify a command (search, recent, sync, status, scan, build, watch, models).",
86
+ );
87
+ }
88
+ printHelp(programName, dbHelpNote);
89
+ return;
90
+ }
91
+
92
+ if (argv.includes("--version") || argv.includes("-v")) {
93
+ if (programName === "trajectories") {
94
+ if (isRobot) {
95
+ console.log(JSON.stringify({ version: VERSION, name: "trajectories" }));
96
+ } else {
97
+ console.log(`Trajectories v${VERSION}`);
98
+ }
99
+ return;
100
+ }
101
+ if (isRobot) {
102
+ console.log(JSON.stringify({ version: VERSION, name: "traj-bridge" }));
103
+ } else {
104
+ console.log(`traj-bridge v${VERSION}`);
105
+ }
106
+ return;
107
+ }
108
+
109
+ const firstArg = argv[0];
110
+
111
+ try {
112
+ if (firstArg === "scan") {
113
+ await handleScan(argv.slice(1), isRobot);
114
+ } else if (firstArg === "sync") {
115
+ await handleSync(argv.slice(1), isRobot);
116
+ } else if (firstArg === "build") {
117
+ await handleBuild(argv.slice(1), isRobot);
118
+ } else if (firstArg === "search" || firstArg === "query") {
119
+ await handleQuery(argv.slice(1), isRobot, programName);
120
+ } else if (firstArg === "recent" || firstArg === "last") {
121
+ await handleLast(argv.slice(1), isRobot);
122
+ } else if (firstArg === "status") {
123
+ await handleStatus(argv.slice(1), isRobot);
124
+ } else if (firstArg === "delete") {
125
+ await handleDelete(argv.slice(1), isRobot);
126
+ } else if (firstArg === "watch") {
127
+ await handleWatch(argv.slice(1), isRobot);
128
+ } else if (firstArg === "models") {
129
+ await handleModels(argv.slice(1), isRobot);
130
+ } else if (firstArg === "__sync-prepare") {
131
+ await executeSyncPreparationStdio();
132
+ } else if (firstArg === "__sync-worker") {
133
+ // Private verb: this process IS the spawned worker child, so it writes on
134
+ // its parent's lease authority rather than trusting the transport marker.
135
+ await executeSyncWorkerStdio({ workerChild: true });
136
+ } else if (firstArg === "__sync-maintenance") {
137
+ await executeSyncMaintenanceStdio({ workerChild: true });
138
+ } else {
139
+ if (isRobot) {
140
+ emitRobotError("UNKNOWN_COMMAND", `Unknown command: ${firstArg}`);
141
+ }
142
+ console.error(`Unknown command: ${firstArg}\n`);
143
+ printHelp(programName, dbHelpNote);
144
+ process.exit(1);
145
+ }
146
+ } catch (err: unknown) {
147
+ if (err instanceof ReaderError) {
148
+ // A read verb's typed refusal: the store is missing, unreadable, or too
149
+ // new for this build. It never creates, upgrades, or repairs anything.
150
+ if (isRobot) {
151
+ emitRobotError(err.code, err.message);
152
+ }
153
+ console.error(`Error: ${err.message}`);
154
+ process.exit(1);
155
+ }
156
+ if (err instanceof SyncPreparationError) {
157
+ if (isRobot) emitRobotError(err.code, err.message);
158
+ throw err;
159
+ }
160
+ if (err instanceof WriterLeaseBusyError) {
161
+ // Another writer owns the store: a typed, actionable refusal instead of
162
+ // silently blocking on SQLite's busy_timeout.
163
+ if (isRobot) {
164
+ emitRobotError(err.code, err.message);
165
+ }
166
+ console.error(`Error: ${err.message}`);
167
+ process.exit(1);
168
+ }
169
+ if (err instanceof SessionNotFoundError) {
170
+ // `delete`'s typed refusal: the store does not hold the session the user
171
+ // named, so there is nothing to record. A tombstone for a session that was
172
+ // never ingested would be an exclusion rule, which this store does not
173
+ // have (db.ts).
174
+ if (isRobot) {
175
+ emitRobotError(err.code, err.message);
176
+ }
177
+ console.error(`Error: ${err.message}`);
178
+ process.exit(1);
179
+ }
180
+ if (isRobot) {
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ const code = message.includes("schema version") ? "SCHEMA_MISMATCH" : "RUNTIME_ERROR";
183
+ emitRobotError(code, message);
184
+ }
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ function emitRobotError(code: string, message: string, exitCode = 1): never {
190
+ console.log(JSON.stringify({ error: { code, message } }));
191
+ process.exit(exitCode);
192
+ }
193
+
194
+ function printHelp(programName: string, dbHelpNote: string): void {
195
+ console.log(`Trajectories CLI v${VERSION} — invoked as ${programName}
196
+
197
+ Canonical daily commands:
198
+ search <query> Search normalized sessions and records using SQLite FTS5
199
+ recent List most recently updated sessions
200
+ sync Run one bounded lexical ingestion pass and exit
201
+ status Report store path, schema version, and counts (read-only)
202
+
203
+ Advanced commands:
204
+ scan Scan and list available sessions across local coding-agent stores
205
+ build Normalize all local sessions and store in hash-deduped SQLite DB
206
+ watch Watch session stores for new/modified sessions and auto-import
207
+ models pull Download the local embedding model for semantic/hybrid search
208
+ delete <session> Delete one session: stop serving it and refuse to re-import it
209
+
210
+ Usage:
211
+ ${programName} search <query> [options]
212
+ ${programName} recent [options]
213
+ ${programName} sync [options]
214
+ ${programName} status [options]
215
+ ${programName} scan [options]
216
+ ${programName} build [options]
217
+ ${programName} watch [options]
218
+ ${programName} models pull [options]
219
+ ${programName} delete <session-id> --reason <text> [--hard] [options]
220
+ ${programName} delete --list [options]
221
+
222
+ Aliases: query = search; last = recent. The canonical executable is \`trajectories\`;
223
+ traj-bridge and ledger-core are compatibility entrypoints for the same CLI.
224
+
225
+ Options:
226
+ --db <path> Path to SQLite database${dbHelpNote}
227
+ --sources <list> Comma-separated list of sources (e.g. codex,pi,claude-code,hermes)
228
+ --source <name> Filter search/recent results to a single source adapter
229
+ --embedding <provider> Embedding provider: local (default), gemini, or none (build)
230
+ --debounce <ms> Debounce interval in milliseconds for batch processing (default: 2000)
231
+ --limit <num> Limit number of sessions per source (default: 1000)
232
+ --embed Generate embeddings during watch (off by default)
233
+ --reconcile-ms <ms> Watch catch-up interval (default: 60000; 0 disables)
234
+ --import-budget <num> Max sessions to normalize per source per watch tick (default: 25)
235
+ --max-record-bytes <n> Max JSONL record bytes during sync/watch (default: 67108864)
236
+ --session-budget <n> Max distinct queued sessions per sync run (default: 25)
237
+ --source-byte-budget <n> Max source bytes read per sync run (default: 67108864)
238
+ --wall-ms <n> Sync wall-time budget in milliseconds (default: 120000)
239
+ --retry-budget <n> Failed worker allowance per sync run (default: 3)
240
+ --attempts-per-session <n> Max chunks per session per sync run (default: 8)
241
+ --read-bytes-per-second <n> Source read-rate ceiling (default: 8388608)
242
+ --worker-ms <n> Per-worker deadline in milliseconds (default: 30000)
243
+ --workers <n> Max concurrent session workers (default: 2)
244
+ --fresh-session-budget <n> Reserved recent sessions per sync run (default: 5)
245
+ --fresh-window-ms <n> Recent-session window in milliseconds (default: 3600000)
246
+ --sync-chunk-bytes <n> Cold-rebuild chunk size (default: 1048576)
247
+ --pressure <mode> Adaptive Linux pressure control: auto|off (default: auto)
248
+ --maintenance-ms <n> Disposable maintenance deadline; 0 disables (default: 5000)
249
+ --fts-merge-pages <n> FTS pages merged per maintenance pass; 0 disables (default: 64)
250
+ --json Output results as JSON
251
+ --robot Machine-readable JSONL on stdout; human status on stderr
252
+ --reason <text> Why the session is deleted; what the store's audit row records (delete)
253
+ --hard Remove the records as well, not only serving and admission (delete)
254
+ --list List the deletions this store records instead of deleting (delete)
255
+ --output-version <n> Search/query robot format: 1 (legacy default) or 2 (metadata)
256
+ --semantic Vector semantic search (search)
257
+ --hybrid Hybrid FTS5 + semantic search via RRF (search)
258
+ --verbose Show detailed progress during normalization
259
+ -h, --help Show help
260
+ -v, --version Show version
261
+ `);
262
+ }
263
+
264
+ const BOOLEAN_FLAGS = new Set(["robot", "json", "verbose", "semantic", "hybrid", "embed", "hard", "list"]);
265
+
266
+ function parseFlags(args: string[]): {
267
+ positionals: string[];
268
+ flags: Record<string, string | boolean>;
269
+ } {
270
+ const positionals: string[] = [];
271
+ const flags: Record<string, string | boolean> = {};
272
+
273
+ for (let i = 0; i < args.length; i++) {
274
+ const arg = args[i];
275
+ if (arg.startsWith("--")) {
276
+ const eq = arg.indexOf("=");
277
+ const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
278
+ if (eq !== -1) {
279
+ // --flag=value form (e.g. --db=PATH); an empty value stays a string.
280
+ flags[key] = arg.slice(eq + 1);
281
+ } else if (BOOLEAN_FLAGS.has(key)) {
282
+ flags[key] = true;
283
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
284
+ flags[key] = args[i + 1];
285
+ i++;
286
+ } else {
287
+ flags[key] = true;
288
+ }
289
+ } else if (arg.startsWith("-")) {
290
+ const key = arg.slice(1);
291
+ flags[key] = true;
292
+ } else {
293
+ positionals.push(arg);
294
+ }
295
+ }
296
+
297
+ // Every verb that parses flags gets the same answer for a nonsense provider,
298
+ // whether or not it embeds: silently ignoring a typo is how `--embedding
299
+ // gemini` on a lexical verb, or `--embedding bogus` on any verb, becomes a
300
+ // search that only looks semantic. Cheap, and before any verb body runs.
301
+ assertKnownEmbedding(flags.embedding);
302
+
303
+ return { positionals, flags };
304
+ }
305
+
306
+ /**
307
+ * One operator-facing line per source that could not be listed. Human scan and
308
+ * verbose build share it so the same failure reads the same in both verbs.
309
+ */
310
+ function formatScanOutcome(outcome: ScanOutcome): string {
311
+ const root = outcome.root ? ` (root ${outcome.root})` : "";
312
+ return `note: source ${outcome.source}: ${outcome.status}${root}${outcome.detail ? ` - ${outcome.detail}` : ""}`;
313
+ }
314
+
315
+ async function handleScan(args: string[], isRobot: boolean): Promise<void> {
316
+ const { flags } = parseFlags(args);
317
+ const sources = typeof flags.sources === "string" ? flags.sources.split(",") : undefined;
318
+ const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
319
+
320
+ if (isRobot) {
321
+ console.error("Scanning local agent session stores...");
322
+ } else {
323
+ console.log("Scanning local agent session stores...");
324
+ }
325
+ const { sessions: discovered, outcomes } = await scanSessions({ sources, limit });
326
+
327
+ const bySource: Record<string, number> = {};
328
+ for (const s of discovered) {
329
+ bySource[s.source] = (bySource[s.source] ?? 0) + 1;
330
+ }
331
+
332
+ if (isRobot) {
333
+ if (flags.json) {
334
+ console.log(JSON.stringify({ total: discovered.length, bySource, sessions: discovered, outcomes }));
335
+ } else {
336
+ // The outcomes line leads, like search_meta: a consumer that filters
337
+ // sessions still learns which sources were covered.
338
+ console.log(JSON.stringify({ type: "scan_outcomes", outcomes }));
339
+ for (const s of discovered) {
340
+ console.log(
341
+ JSON.stringify({
342
+ id: s.id,
343
+ source: s.source,
344
+ path: s.path,
345
+ updatedAt: s.updatedAt,
346
+ sizeBytes: s.sizeBytes,
347
+ }),
348
+ );
349
+ }
350
+ }
351
+ return;
352
+ }
353
+
354
+ // A source that is simply not installed is the normal case, not news: only
355
+ // anomalies reach stderr. Robots still get every row, including missing_root.
356
+ for (const outcome of outcomes) {
357
+ if (outcome.status === "listable" || outcome.status === "missing_root") continue;
358
+ console.error(formatScanOutcome(outcome));
359
+ }
360
+
361
+ if (flags.json) {
362
+ console.log(JSON.stringify({ total: discovered.length, bySource, sessions: discovered, outcomes }, null, 2));
363
+ return;
364
+ }
365
+
366
+ console.log(`\nDiscovered ${discovered.length} sessions across ${Object.keys(bySource).length} sources:\n`);
367
+ for (const [source, count] of Object.entries(bySource).sort((a, b) => b[1] - a[1])) {
368
+ console.log(` - ${source.padEnd(16)}: ${count} sessions`);
369
+ }
370
+
371
+ console.log(`\nTotal: ${discovered.length} sessions`);
372
+ }
373
+
374
+ async function handleBuild(args: string[], isRobot: boolean): Promise<void> {
375
+ const { flags } = parseFlags(args);
376
+ // "none" builds lexical-only: no provider wiring at all, so a quota-limited
377
+ // host needs neither the Gemini key nor the local ONNX download (issue #4).
378
+ // Any other value wires the global provider as before; buildStore only
379
+ // consults the mode for the none shortcut.
380
+ const embedMode = requestedEmbedding(flags.embedding) === "none" ? "none" : "provider";
381
+ if (embedMode !== "none") applyEmbeddingProvider(flags, isRobot);
382
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
383
+ // The lease attaches before any store handle exists: a refused build must
384
+ // leave the database exactly as it found it.
385
+ const lease = isMemoryStorePath(dbPath)
386
+ ? null
387
+ : acquireWriterLease(dbPath, { waitMs: DEFAULT_WRITER_LEASE_WAIT_MS, holder: "build" });
388
+ try {
389
+ await buildStore(dbPath, isRobot, {
390
+ sources: typeof flags.sources === "string" ? flags.sources.split(",") : undefined,
391
+ limit: typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined,
392
+ verbose: Boolean(flags.verbose),
393
+ embedMode,
394
+ });
395
+ } finally {
396
+ lease?.close();
397
+ }
398
+ }
399
+
400
+ interface BuildOptions {
401
+ sources?: string[];
402
+ limit?: number;
403
+ verbose: boolean;
404
+ /** `none` builds lexical-only; `provider` defers to the wired global provider. */
405
+ embedMode: "provider" | "none";
406
+ }
407
+
408
+ async function buildStore(dbPath: string, isRobot: boolean, options: BuildOptions): Promise<void> {
409
+ const { sources, limit, verbose, embedMode } = options;
410
+
411
+ const logProse = isRobot ? console.error : console.log;
412
+
413
+ logProse(`Building SQLite store at ${dbPath}...`);
414
+ const { sessions: discovered, outcomes } = await scanSessions({ sources, limit });
415
+ logProse(`Discovered ${discovered.length} candidate sessions to import.`);
416
+ if (verbose) {
417
+ for (const outcome of outcomes) {
418
+ if (outcome.status === "listable" || outcome.status === "missing_root") continue;
419
+ if (isRobot) console.log(JSON.stringify({ type: "scan_outcome", ...outcome }));
420
+ else console.log(` ${formatScanOutcome(outcome)}`);
421
+ }
422
+ }
423
+
424
+ const db = new TrajectoryDb(dbPath);
425
+ try {
426
+ // The build holds the lease for this whole verb, so a pending index
427
+ // re-derivation (derive-rule counter, C2) is this verb's one-shot too — the
428
+ // same work `sync` runs, taken before the import loop so the rest of the build
429
+ // reads the index it just derived. Without it an upgraded store would keep
430
+ // answering from an index derived under other rules until some other pass ran.
431
+ const deriveStarted = performance.now();
432
+ const deriveMs = db.reindexFts() ? performance.now() - deriveStarted : 0;
433
+
434
+ // The build holds the lease for this whole verb, so the scan verdicts land
435
+ // durably here too — the same single choke point sync uses, under the store
436
+ // handle the pass already migrated.
437
+ db.recordSourceCoverage(outcomes);
438
+
439
+ const summary: BuildSummary = {
440
+ scanned: discovered.length,
441
+ imported: 0,
442
+ deduped: 0,
443
+ failed: 0,
444
+ unembedded: 0,
445
+ embedding_skipped: 0,
446
+ embedding_failed: 0,
447
+ tombstoned: 0,
448
+ totalRecords: 0,
449
+ sources: {},
450
+ };
451
+
452
+ const CHUNK_SIZE = 16;
453
+ // One place that counts and reports a refused listing, so the two axes
454
+ // (refused before the read, refused by the store's own guard inside ingest)
455
+ // cannot be counted differently or reported in different words.
456
+ const countTombstoned = (item: DiscoveredSession, tombstone: TombstoneRecord): void => {
457
+ summary.tombstoned++;
458
+ summary.sources[item.source].tombstoned++;
459
+ if (!verbose) return;
460
+ if (isRobot) {
461
+ console.log(JSON.stringify({
462
+ type: "session", action: "tombstoned", source: item.source, id: item.id,
463
+ mode: tombstone.mode, deleted_at: tombstone.deleted_at,
464
+ }));
465
+ } else {
466
+ console.log(` [TOMBSTONED] ${item.source}/${item.id} (${tombstone.mode}; deleted ${tombstone.deleted_at})`);
467
+ }
468
+ };
469
+ for (let i = 0; i < discovered.length; i += CHUNK_SIZE) {
470
+ const chunk = discovered.slice(i, i + CHUNK_SIZE);
471
+ const normalizedChunk = await Promise.all(
472
+ chunk.map(async (item) => {
473
+ // The deletion's listing axis, first because it is the cheap one and the
474
+ // one that saves the most work: a listing whose native id names a deleted
475
+ // session is refused before its file is opened. The committed id is
476
+ // checked in the loop below, after normalization, for the sources that
477
+ // group several files under one session id.
478
+ if (db.tombstoneForListing(item)) return { item, norm: null };
479
+ // Native stores must be read at commit time, not retained across earlier embeddings.
480
+ const norm = item.native ? null : await normalizeDiscoveredSession(item);
481
+ return { item, norm };
482
+ }),
483
+ );
484
+
485
+ for (const { item, norm } of normalizedChunk) {
486
+ if (!summary.sources[item.source]) {
487
+ summary.sources[item.source] = { scanned: 0, imported: 0, deduped: 0, failed: 0, tombstoned: 0 };
488
+ }
489
+ summary.sources[item.source].scanned++;
490
+
491
+ // The deletion's two axes, in one verdict: the listing's native id (the
492
+ // same probe that kept the file from being read above, and the one that
493
+ // covers a re-scan or a copy at another path) and, for a source that
494
+ // groups files under a group id, the committed session id.
495
+ const tombstone = db.tombstoneForListing(item) ?? (norm ? db.getTombstone(norm.session.id) : null);
496
+ if (tombstone) {
497
+ countTombstoned(item, tombstone);
498
+ continue;
499
+ }
500
+
501
+ let session = norm?.session;
502
+ let records = norm?.records;
503
+ let updated = false;
504
+ let deduped = false;
505
+ if (item.native) {
506
+ const existed = db.getSession(ledgerId(item.source, item.id)) !== null;
507
+ const result = await ingestDiscoveredSession(db, item, {
508
+ maxRecordBytes: DEFAULT_INGEST_RECORD_BYTES,
509
+ }).catch((error: unknown) => nativeIngestCatchResult(error, item.id));
510
+ const cachedFailure = result.kind === "unchanged" &&
511
+ db.getIngestCursor(item.source, item.id, item.path)?.status === "failed";
512
+ const warnings = cachedFailure
513
+ ? ["Native ingestion previously failed; the unchanged source remains skipped."]
514
+ : result.warnings ?? [];
515
+ for (const warning of warnings) {
516
+ console.error("[ledger] ingest warning:", JSON.stringify({ source: item.source, sourcePath: item.path, warning }));
517
+ }
518
+ if (result.kind === "tombstoned") {
519
+ // The store's own guard refused the commit: a source grouped this
520
+ // listing under a session id the pass could not name before the read,
521
+ // or the session was deleted while the pass held it. The refusal names
522
+ // the session, so the audit row is reachable from here.
523
+ const refused = db.tombstoneForListing(item) ?? (result.sessionId ? db.getTombstone(result.sessionId) : null);
524
+ if (refused) countTombstoned(item, refused);
525
+ continue;
526
+ }
527
+ if (result.kind !== "skipped" && !cachedFailure && result.sessionId) {
528
+ session = db.getSession(result.sessionId) ?? undefined;
529
+ records = session ? db.getSessionRecords(session.id) : undefined;
530
+ updated = existed;
531
+ deduped = result.kind === "deduped" || result.kind === "unchanged";
532
+ }
533
+ } else if (norm) {
534
+ ({ updated, deduped } = db.saveSession(
535
+ norm.session, norm.records, aggregateIngestDiagnostics(norm.diagnostics),
536
+ ));
537
+ }
538
+
539
+ if (!session || !records) {
540
+ summary.failed++;
541
+ summary.sources[item.source].failed++;
542
+ if (item.native && verbose) {
543
+ if (isRobot) console.log(JSON.stringify({ type: "session", action: "skip", source: item.source, id: item.id }));
544
+ else console.log(` [SKIP] ${item.source}/${item.id}`);
545
+ }
546
+ continue;
547
+ }
548
+
549
+ if (embedMode === "none") {
550
+ summary.embedding_skipped++;
551
+ } else if (!deduped || !db.hasSessionEmbeddings(session.id)) {
552
+ try {
553
+ await db.embedSessionRecords(session.id, records);
554
+ } catch (err) {
555
+ // Embeddings are enrichment. Persisted canonical records remain valid
556
+ // and later build/watch runs can retry the missing vectors.
557
+ summary.embedding_failed++;
558
+ if (verbose) {
559
+ if (isRobot) {
560
+ console.log(JSON.stringify({ type: "session", action: "unembedded", source: item.source, id: session.id }));
561
+ } else {
562
+ console.log(` [UNEMBED] ${item.source}/${session.id}`);
563
+ }
564
+ }
565
+ // Stderr is the human-progress channel in robot mode too: stdout
566
+ // stays data-only, but the outage must not be silent.
567
+ console.error(`[ledger] embedding failed for ${item.source}/${session.id}:`, err);
568
+ }
569
+ }
570
+ // Work skipped/failing is not proof that previously stored vectors vanished.
571
+ if (!db.hasStoredSessionEmbeddings(session.id)) summary.unembedded++;
572
+ if (deduped) {
573
+ summary.deduped++;
574
+ summary.sources[item.source].deduped++;
575
+ if (verbose) {
576
+ if (isRobot) {
577
+ console.log(JSON.stringify({ type: "session", action: "dedup", source: item.source, id: item.id }));
578
+ } else {
579
+ console.log(` [DEDUP] ${item.source}/${item.id}`);
580
+ }
581
+ }
582
+ } else {
583
+ summary.imported++;
584
+ summary.totalRecords += records.length;
585
+ summary.sources[item.source].imported++;
586
+ if (verbose) {
587
+ if (isRobot) {
588
+ console.log(
589
+ JSON.stringify({
590
+ type: "session",
591
+ action: updated ? "update" : "import",
592
+ source: item.source,
593
+ id: item.id,
594
+ records: records.length,
595
+ }),
596
+ );
597
+ } else {
598
+ console.log(` [${updated ? "UPDATE" : "IMPORT"}] ${item.source}/${item.id} (${records.length} records)`);
599
+ }
600
+ }
601
+ }
602
+ }
603
+ }
604
+
605
+ if (isRobot) {
606
+ console.log(
607
+ JSON.stringify({
608
+ type: "summary",
609
+ scanned: summary.scanned,
610
+ imported: summary.imported,
611
+ deduped: summary.deduped,
612
+ failed: summary.failed,
613
+ unembedded: summary.unembedded,
614
+ embedding_skipped: summary.embedding_skipped,
615
+ embedding_failed: summary.embedding_failed,
616
+ tombstoned: summary.tombstoned,
617
+ totalRecords: summary.totalRecords,
618
+ deriveMs,
619
+ sources: summary.sources,
620
+ }),
621
+ );
622
+ } else {
623
+ console.log("\nBuild complete:");
624
+ console.log(` Sessions in DB : ${db.getSessionCount()}`);
625
+ console.log(` Records in DB : ${db.getRecordCount()}`);
626
+ console.log(` Imported : ${summary.imported}`);
627
+ console.log(` Deduped : ${summary.deduped}`);
628
+ console.log(` Skipped/Empty : ${summary.failed}`);
629
+ if (summary.unembedded > 0) {
630
+ console.log(` Unembedded : ${summary.unembedded} (vectors absent; later builds retry)`);
631
+ }
632
+ if (summary.embedding_skipped) console.log(` Embedding skipped: ${summary.embedding_skipped}`);
633
+ if (summary.embedding_failed) console.log(` Embedding failed : ${summary.embedding_failed} (coverage may be partial)`);
634
+ if (summary.tombstoned > 0) {
635
+ console.log(` Tombstoned : ${summary.tombstoned} (deleted by the user; not re-imported, see \`delete --list\`)`);
636
+ }
637
+ if (deriveMs > 0) {
638
+ console.log(
639
+ ` Re-derived : ${Math.round(deriveMs)}ms (records_fts and titles_fts rebuilt from this store; no source was re-read)`,
640
+ );
641
+ }
642
+ console.log("\nBreakdown by source:");
643
+ for (const [source, stats] of Object.entries(summary.sources)) {
644
+ console.log(
645
+ ` - ${source.padEnd(14)}: ${stats.imported} imported, ${stats.deduped} deduped, ${stats.failed} skipped` +
646
+ `${stats.tombstoned ? `, ${stats.tombstoned} tombstoned` : ""} (from ${stats.scanned} scanned)`,
647
+ );
648
+ }
649
+ }
650
+ } finally { db.close(); }
651
+ }
652
+
653
+ function numericFlag(flags: Record<string, string | boolean>, name: string): number | undefined {
654
+ const value = flags[name];
655
+ return value === undefined ? undefined : typeof value === "string" ? Number(value) : NaN;
656
+ }
657
+
658
+ async function handleSync(args: string[], isRobot: boolean): Promise<void> {
659
+ const { flags } = parseFlags(args);
660
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
661
+ const sources = typeof flags.sources === "string" ? flags.sources.split(",").filter(Boolean) : undefined;
662
+ const pressureMode = flags.pressure === undefined ? undefined
663
+ : flags.pressure === "auto" || flags.pressure === "off" ? flags.pressure
664
+ : (() => { throw new Error("--pressure must be auto or off"); })();
665
+ const summary = await runSync({
666
+ dbPath,
667
+ sources,
668
+ scanLimit: numericFlag(flags, "limit"),
669
+ maxRecordBytes: numericFlag(flags, "max-record-bytes"),
670
+ budgets: {
671
+ sessions: numericFlag(flags, "session-budget"),
672
+ sourceBytes: numericFlag(flags, "source-byte-budget"),
673
+ wallMs: numericFlag(flags, "wall-ms"),
674
+ retries: numericFlag(flags, "retry-budget"),
675
+ attemptsPerSession: numericFlag(flags, "attempts-per-session"),
676
+ readBytesPerSecond: numericFlag(flags, "read-bytes-per-second"),
677
+ workerMs: numericFlag(flags, "worker-ms"),
678
+ workers: numericFlag(flags, "workers"),
679
+ freshSessions: numericFlag(flags, "fresh-session-budget"),
680
+ freshWindowMs: numericFlag(flags, "fresh-window-ms"),
681
+ chunkBytes: numericFlag(flags, "sync-chunk-bytes"),
682
+ maintenanceMs: numericFlag(flags, "maintenance-ms"),
683
+ ftsMergePages: numericFlag(flags, "fts-merge-pages"),
684
+ },
685
+ pressureMode,
686
+ });
687
+ if (isRobot || flags.json) {
688
+ console.log(JSON.stringify(summary, null, flags.json === true && !isRobot ? 2 : undefined));
689
+ return;
690
+ }
691
+ if (summary.skipped) {
692
+ console.log(
693
+ `Trajectories sync skipped: another writer holds the store (${describeLeaseHolder(summary.skipped.holder)}).`,
694
+ );
695
+ return;
696
+ }
697
+ console.log(`Trajectories sync ${summary.outcome}: ${summary.completed} completed, ${summary.pending.total} pending.`);
698
+ if (summary.outcome === "wall_budget_exceeded") {
699
+ console.log("Wall budget reached: the pass stopped there, and pending sessions stay queued for the next run.");
700
+ }
701
+ console.log(`Workers: ${summary.worker_processes} processes / ${summary.worker_attempts} attempts; source read: ${summary.work.source_bytes_read} bytes; elapsed: ${Math.round(summary.elapsed_ms)}ms.`);
702
+ // A pass that re-derived the index spent a one-shot inside it; saying so is
703
+ // what separates a 20-second upgrade from an unexplained stall.
704
+ if (summary.timing.derive_ms > 0) {
705
+ console.log(`Index re-derivation: ${Math.round(summary.timing.derive_ms)}ms (records_fts and titles_fts rebuilt from this store; no source was re-read).`);
706
+ }
707
+ if (summary.budgets.exhausted.length) console.log(`Budget boundary: ${summary.budgets.exhausted.join(", ")}.`);
708
+ }
709
+
710
+ async function handleQuery(args: string[], isRobot: boolean, programName: string): Promise<void> {
711
+ const { positionals, flags } = parseFlags(args);
712
+ const outputVersion = flags["output-version"] ?? "1";
713
+ const outputVersionError = flags["output-version"] !== undefined && !isRobot
714
+ ? "--output-version requires --robot."
715
+ : outputVersion !== "1" && outputVersion !== "2"
716
+ ? "--output-version requires a version: 1 or 2."
717
+ : null;
718
+ if (outputVersionError) {
719
+ if (isRobot) emitRobotError("INVALID_ARGUMENT", outputVersionError);
720
+ console.error(`Error: INVALID_ARGUMENT: ${outputVersionError}`);
721
+ process.exit(1);
722
+ }
723
+ applyEmbeddingProvider(flags, isRobot);
724
+ const queryText = positionals.join(" ").trim();
725
+
726
+ if (!queryText) {
727
+ if (isRobot) {
728
+ emitRobotError("MISSING_QUERY", "Please provide a search query.");
729
+ }
730
+ console.error("Error: Please provide a search query.");
731
+ console.error(`Example: ${programName} search "artifacts hosting subdomain"`);
732
+ process.exit(1);
733
+ }
734
+
735
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
736
+
737
+ const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : 20;
738
+ const sourceFilter = typeof flags.source === "string" ? flags.source : undefined;
739
+
740
+ const isSemantic = flags.semantic === true;
741
+ const isHybrid = flags.hybrid === true;
742
+
743
+ const db = new TrajectoryReader(dbPath);
744
+ let result: QueryResult;
745
+ const parentTitles = new Map<string, string | null>();
746
+ let coverageSignal: ReturnType<TrajectoryReader["getSourceCoverageSummary"]>;
747
+ let discoverySignal: ReturnType<TrajectoryReader["getDiscoverySummary"]>;
748
+ try {
749
+ if (isSemantic) {
750
+ result = await db.querySemantic(queryText, { limit, source: sourceFilter });
751
+ } else if (isHybrid) {
752
+ result = await db.queryHybrid(queryText, { limit, source: sourceFilter });
753
+ } else {
754
+ result = db.query(queryText, { limit, source: sourceFilter });
755
+ }
756
+
757
+ for (const hit of result.hits) {
758
+ const parentId = hit.parent_session_id;
759
+ if (!parentId || parentTitles.has(parentId)) continue;
760
+ parentTitles.set(parentId, db.getSession(parentId)?.title ?? null);
761
+ }
762
+ // The durable coverage signal rides the v2 envelope (issue #37); read it
763
+ // while the reader handle is open — the connection is short-lived by design.
764
+ coverageSignal = db.getSourceCoverageSummary();
765
+ discoverySignal = db.getDiscoverySummary();
766
+ } finally { db.close(); }
767
+
768
+ if ((isRobot && outputVersion === "1") || (!isRobot && !flags.json)) {
769
+ if (result.meta.widened?.applied) {
770
+ console.error(`note: 0 phrase matches; widened to prefix terms (${result.meta.widened.widened_hits} hits)`);
771
+ } else if (isRobot && result.meta.widened?.skip_reason) {
772
+ console.error(`note: prefix widening not applied (${result.meta.widened.skip_reason})`);
773
+ }
774
+ if (result.meta.fallback_reason === FTS_SYNTAX_FALLBACK_REASON) {
775
+ console.error(`note: fts syntax fallback used (${result.meta.fallback_reason})`);
776
+ } else if (result.meta.fallback_reason) {
777
+ // A different reason means a different thing happened -- an unavailable
778
+ // embedding provider, not an unparsable FTS expression.
779
+ console.error(`note: answered lexically (${result.meta.fallback_reason})`);
780
+ }
781
+ }
782
+
783
+ if (isRobot) {
784
+ const robotHits = result.hits.map((hit) => ({
785
+ session_id: hit.session_id,
786
+ source: hit.source,
787
+ title: hit.title,
788
+ cwd: hit.cwd,
789
+ timestamp: hit.started_at,
790
+ score: typeof hit.rank === "number" ? hit.rank : 0,
791
+ snippet: hit.snippet.slice(0, 240),
792
+ parent_session_id: hit.parent_session_id ?? null,
793
+ parent_title: hit.parent_session_id
794
+ ? parentTitles.get(hit.parent_session_id) ?? null
795
+ : null,
796
+ agent_nickname: hit.agent_nickname ?? null,
797
+ thread_role: hit.thread_role ?? null,
798
+ ...(outputVersion === "2" ? { match_type: hit.match_type } : {}),
799
+ }));
800
+
801
+ // V1 preserves the pre-PR20 hit stream/array, including empty results.
802
+ // V2 adds the envelope and per-hit provenance for agents that opt in.
803
+ const searchMeta = outputVersion === "2" ? {
804
+ type: "search_meta",
805
+ ...result.meta,
806
+ total_hits: result.totalHits,
807
+ sessions_count: result.sessionsCount,
808
+ ...(coverageSignal ? { coverage: coverageSignal } : {}),
809
+ ...(discoverySignal ? { discovery: discoverySignal } : {}),
810
+ } : null;
811
+
812
+ if (flags.json) {
813
+ console.log(JSON.stringify(searchMeta ? { search_meta: searchMeta, hits: robotHits } : robotHits));
814
+ } else {
815
+ if (searchMeta) console.log(JSON.stringify(searchMeta));
816
+ for (const hit of robotHits) {
817
+ console.log(JSON.stringify(hit));
818
+ }
819
+ }
820
+ return;
821
+ }
822
+
823
+ if (flags.json) {
824
+ console.log(JSON.stringify(result, null, 2));
825
+ return;
826
+ }
827
+
828
+ console.log(`Found ${result.totalHits} matching records across ${result.sessionsCount} sessions for: "${queryText}"\n`);
829
+
830
+ if (result.hits.length === 0) {
831
+ console.log("No matching records found.");
832
+ return;
833
+ }
834
+
835
+ const widenedSuffix = result.meta.widened?.applied ? " [widened]" : "";
836
+ for (let i = 0; i < result.hits.length; i++) {
837
+ const hit = result.hits[i];
838
+ const header = `[${i + 1}] [${hit.source}] Session: ${hit.session_id} (${hit.started_at ?? "unknown date"})${widenedSuffix}`;
839
+ console.log(header);
840
+ if (hit.title) {
841
+ console.log(` Title: ${hit.title}`);
842
+ }
843
+ if (hit.cwd) {
844
+ console.log(` Cwd: ${hit.cwd}`);
845
+ }
846
+ if (hit.parent_session_id) {
847
+ const nick = hit.agent_nickname ? ` (${hit.agent_nickname})` : "";
848
+ console.log(` Parent:${nick} ${hit.parent_session_id}`);
849
+ }
850
+ console.log(` Role: ${hit.record_type}`);
851
+ console.log(` Match: ${hit.snippet}`);
852
+ console.log();
853
+ }
854
+ }
855
+
856
+ async function handleLast(args: string[], isRobot: boolean): Promise<void> {
857
+ const { flags } = parseFlags(args);
858
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
859
+
860
+ const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : 20;
861
+ const sourceFilter = typeof flags.source === "string" ? flags.source : undefined;
862
+
863
+ const db = new TrajectoryReader(dbPath);
864
+ const sessions = db.getLastSessions({ limit, source: sourceFilter });
865
+ const formattedSessions = sessions.map((s) => {
866
+ const parent = s.parent_session_id ? db.getSession(s.parent_session_id) : null;
867
+ return {
868
+ session_id: s.id,
869
+ source: s.source,
870
+ title: s.title,
871
+ cwd: s.cwd,
872
+ started_at: s.started_at,
873
+ ended_at: s.ended_at,
874
+ updated_at: s.updated_at,
875
+ timestamp: s.updated_at ?? s.started_at,
876
+ record_count: s.record_count,
877
+ model: s.model,
878
+ parent_session_id: s.parent_session_id ?? null,
879
+ parent_title: parent?.title ?? null,
880
+ parent_tool_use_id: s.parent_tool_use_id ?? null,
881
+ agent_nickname: s.agent_nickname ?? null,
882
+ thread_role: s.thread_role ?? null,
883
+ };
884
+ });
885
+ db.close();
886
+
887
+ if (isRobot) {
888
+ if (flags.json) {
889
+ console.log(JSON.stringify(formattedSessions));
890
+ } else {
891
+ for (const s of formattedSessions) {
892
+ console.log(JSON.stringify(s));
893
+ }
894
+ }
895
+ return;
896
+ }
897
+
898
+ if (flags.json) {
899
+ console.log(JSON.stringify(formattedSessions, null, 2));
900
+ return;
901
+ }
902
+
903
+ console.log(`Recent sessions (limit ${limit}${sourceFilter ? `, source: ${sourceFilter}` : ""}):\n`);
904
+ if (formattedSessions.length === 0) {
905
+ console.log("No sessions found.");
906
+ return;
907
+ }
908
+
909
+ for (let i = 0; i < formattedSessions.length; i++) {
910
+ const s = formattedSessions[i];
911
+ console.log(`[${i + 1}] [${s.source}] Session: ${s.session_id} (${s.updated_at || s.started_at || "unknown date"})`);
912
+ if (s.title) {
913
+ console.log(` Title: ${s.title.replace(/\n/g, " ").slice(0, 100)}`);
914
+ }
915
+ if (s.cwd) {
916
+ console.log(` Cwd: ${s.cwd}`);
917
+ }
918
+ if (s.parent_session_id) {
919
+ const label = s.agent_nickname ? `${s.agent_nickname} → ` : "";
920
+ const parentLabel = s.parent_title
921
+ ? `${s.parent_title.replace(/\n/g, " ").slice(0, 80)} (${s.parent_session_id})`
922
+ : s.parent_session_id;
923
+ console.log(` Parent: ${label}${parentLabel}`);
924
+ }
925
+ console.log(` Records: ${s.record_count}`);
926
+ console.log();
927
+ }
928
+ }
929
+
930
+ /**
931
+ * `trajectories delete <session-id>` — the store's one deletion verb, and the only
932
+ * caller of the store's tombstone API.
933
+ *
934
+ * The verb names a session, records why, and either closes the session to
935
+ * serving and admission (the default) or removes its records as well (`--hard`).
936
+ * It takes the writer lease like every other verb that writes: a deletion
937
+ * racing a sync pass would otherwise interleave with the pass that is
938
+ * re-admitting the same session, and the lease is this repository's answer to
939
+ * that (writer-lease.ts). `--list` reports the deletions the store records,
940
+ * which is the audit readout — the reason a deletion stores is worth nothing if
941
+ * nothing can read it back.
942
+ *
943
+ * The policy half of deletion is deliberately absent: who may delete, how long
944
+ * a record may be kept, and whether a deletion must be explained are open
945
+ * questions (docs/research/2026-09-16-c7-tombstones.md). What the verb fixes is
946
+ * what the store *does* once asked — and that only a user asking can make it do
947
+ * it.
948
+ */
949
+ async function handleDelete(args: string[], isRobot: boolean): Promise<void> {
950
+ const { positionals, flags } = parseFlags(args);
951
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
952
+ const listAudit = flags.list === true;
953
+ const sessionId = positionals[0];
954
+ const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
955
+
956
+ const usageError =
957
+ listAudit && sessionId !== undefined
958
+ ? "--list takes no session id."
959
+ : listAudit && flags.hard === true
960
+ ? "--list deletes nothing, so --hard has no meaning with it."
961
+ : !listAudit && !sessionId
962
+ ? "Please provide a session id to delete."
963
+ : !listAudit && !reason
964
+ ? "Please say why: --reason <text>. The reason is what the stored deletion records."
965
+ : null;
966
+ if (usageError) {
967
+ if (isRobot) emitRobotError("INVALID_ARGUMENT", usageError);
968
+ console.error(`Error: INVALID_ARGUMENT: ${usageError}`);
969
+ process.exit(1);
970
+ }
971
+
972
+ // A deletion needs a store to act on, and this verb must not create or adopt
973
+ // one: a writer open initializes an empty or foreign file — that is what
974
+ // `sync` and `build` are for — and conjuring a database to delete from is not a
975
+ // deletion. Two guards, because they answer different questions: the read gate
976
+ // classifies the store and refuses it with the read verbs' own codes and
977
+ // wording, and the writer open below is told not to initialize one, which is
978
+ // the invariant even if the file changes between the two statements.
979
+ if (!isMemoryStorePath(dbPath)) openReadOnlyStore(dbPath).close();
980
+
981
+ // The lease attaches before any store handle exists, as `build`'s does: a
982
+ // refused deletion must leave the database exactly as it found it.
983
+ const lease = isMemoryStorePath(dbPath)
984
+ ? null
985
+ : acquireWriterLease(dbPath, { waitMs: DEFAULT_WRITER_LEASE_WAIT_MS, holder: "delete" });
986
+ try {
987
+ let db: TrajectoryDb;
988
+ try {
989
+ db = new TrajectoryDb(dbPath, { requireExisting: true });
990
+ } catch (error) {
991
+ // The store vanished (or was replaced by something that is not one)
992
+ // between the classification above and this open. The refusal is the
993
+ // classification's own wording, and nothing has been created.
994
+ if (error instanceof StoreNotInitializedError) {
995
+ throw new ReaderError(
996
+ "STORE_NOT_INITIALIZED",
997
+ `${error.message} There is nothing to delete; run \`trajectories sync\` or \`trajectories build\` first.`,
998
+ );
999
+ }
1000
+ throw error;
1001
+ }
1002
+ try {
1003
+ if (listAudit) {
1004
+ const tombstones = db.listTombstones();
1005
+ if (isRobot || flags.json) {
1006
+ const rows = tombstones.map((row) => ({ type: "tombstone", ...row }));
1007
+ if (flags.json === true && !isRobot) {
1008
+ console.log(JSON.stringify(rows, null, 2));
1009
+ } else {
1010
+ // Robot mode is JSONL: one row per line, and an empty stream when the
1011
+ // store records no deletion — which is the honest answer, not an error.
1012
+ for (const row of rows) console.log(JSON.stringify(row));
1013
+ }
1014
+ return;
1015
+ }
1016
+ if (tombstones.length === 0) {
1017
+ console.log("No deletions recorded in this store.");
1018
+ return;
1019
+ }
1020
+ console.log(`${tombstones.length} deletion${tombstones.length === 1 ? "" : "s"} recorded:\n`);
1021
+ for (const row of tombstones) {
1022
+ console.log(` ${row.session_id} [${row.mode}] ${row.deleted_at}`);
1023
+ console.log(` ${row.reason}`);
1024
+ }
1025
+ return;
1026
+ }
1027
+
1028
+ // A refusal (no such session) propagates to the CLI's typed-error handler,
1029
+ // so the lease and the store handle are released before the process exits.
1030
+ const outcome = db.tombstoneSession(sessionId, { reason, hard: flags.hard === true });
1031
+ const { tombstone, created, escalated, recordsRemoved } = outcome;
1032
+ if (isRobot || flags.json) {
1033
+ console.log(
1034
+ JSON.stringify(
1035
+ {
1036
+ type: "tombstone",
1037
+ session_id: tombstone.session_id,
1038
+ source: tombstone.source,
1039
+ native_id: tombstone.native_id,
1040
+ source_path: tombstone.source_path,
1041
+ mode: tombstone.mode,
1042
+ reason: tombstone.reason,
1043
+ record_count: tombstone.record_count,
1044
+ records_removed: recordsRemoved,
1045
+ deleted_at: tombstone.deleted_at,
1046
+ created,
1047
+ escalated,
1048
+ },
1049
+ null,
1050
+ flags.json === true && !isRobot ? 2 : undefined,
1051
+ ),
1052
+ );
1053
+ return;
1054
+ }
1055
+
1056
+ const headline = !created && !escalated
1057
+ ? `Already deleted: ${tombstone.session_id} (${tombstone.mode})`
1058
+ : tombstone.mode === "hard"
1059
+ ? `Deleted ${tombstone.session_id} (hard: ${recordsRemoved} records removed from this store)`
1060
+ : `Deleted ${tombstone.session_id} (${tombstone.record_count} records stay in the store, withheld from search and reads)`;
1061
+ console.log(headline);
1062
+ console.log(` reason: ${tombstone.reason}`);
1063
+ console.log(` deleted_at: ${tombstone.deleted_at}`);
1064
+ if (!created && !escalated) {
1065
+ console.log(" Nothing changed; use --hard to remove the records as well.");
1066
+ } else if (escalated) {
1067
+ console.log(" The same deletion, carried through to the records: the reason and timestamp above are the ones first given.");
1068
+ } else if (tombstone.mode === "hard") {
1069
+ console.log(" The tombstone is all that is left of it; no later scan can re-import this session.");
1070
+ } else {
1071
+ console.log(" The records stay as evidence and no scan can re-import this session; --hard removes them.");
1072
+ }
1073
+ } finally {
1074
+ db.close();
1075
+ }
1076
+ } finally {
1077
+ lease?.close();
1078
+ }
1079
+ }
1080
+
1081
+ async function handleStatus(args: string[], isRobot: boolean): Promise<void> {
1082
+ const { flags } = parseFlags(args);
1083
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
1084
+
1085
+ let status;
1086
+ try {
1087
+ status = readStoreStatus(dbPath);
1088
+ } catch (err: unknown) {
1089
+ if (err instanceof StoreStatusError) {
1090
+ if (isRobot) {
1091
+ emitRobotError(err.code, err.message);
1092
+ }
1093
+ console.error(`Error: ${err.message}`);
1094
+ process.exit(1);
1095
+ }
1096
+ throw err;
1097
+ }
1098
+
1099
+ const payload = {
1100
+ path: status.path,
1101
+ initialized: status.initialized,
1102
+ schema_version: status.schemaVersion,
1103
+ supported_schema_version: SCHEMA_VERSION,
1104
+ sessions: status.sessions,
1105
+ records: status.records,
1106
+ echo_filtered: status.echoFiltered,
1107
+ tombstones: status.tombstones,
1108
+ tombstoned_records: status.tombstonedRecords,
1109
+ diagnostics: status.diagnostics,
1110
+ coverage: status.coverage,
1111
+ ...(status.discovery ? { discovery: status.discovery } : {}),
1112
+ };
1113
+
1114
+ if (isRobot || flags.json) {
1115
+ console.log(JSON.stringify(payload, null, flags.json === true && !isRobot ? 2 : undefined));
1116
+ return;
1117
+ }
1118
+
1119
+ console.log(`Trajectories store: ${status.path}`);
1120
+ if (!status.initialized) {
1121
+ console.log("Initialized: no (no store found at this path)");
1122
+ return;
1123
+ }
1124
+ console.log(`Initialized: yes`);
1125
+ console.log(`Schema version: ${status.schemaVersion} (supported: ${SCHEMA_VERSION})`);
1126
+ console.log(`Sessions: ${status.sessions}`);
1127
+ console.log(`Records: ${status.records}`);
1128
+ console.log(
1129
+ status.echoFiltered === null
1130
+ ? "Echo filtered: unknown (not derived under this build's rules yet; the next writer pass derives it)"
1131
+ : `Echo filtered: ${status.echoFiltered} records (agent lookups of this store; never searchable)`,
1132
+ );
1133
+ // Holdings are reported as they are, tombstones included (the counts above
1134
+ // name what the store keeps); this line says how much of that is withheld.
1135
+ console.log(
1136
+ status.tombstones
1137
+ ? `Tombstones: ${status.tombstones} session${status.tombstones === 1 ? "" : "s"} deleted by the user; ` +
1138
+ `${status.tombstonedRecords} of the records above are held back from search and reads`
1139
+ : "Tombstones: none (no session has been deleted by the user)",
1140
+ );
1141
+ const diagnostics = status.diagnostics;
1142
+ if (!diagnostics) {
1143
+ console.log("Diagnostics: unknown (store predates diagnostics schema)");
1144
+ } else if (!diagnostics.byCode.length) {
1145
+ console.log("Diagnostics: none");
1146
+ } else {
1147
+ console.log(
1148
+ `Diagnostics: ${diagnostics.totalOccurrences} occurrences in ${diagnostics.totalSessions} sessions`,
1149
+ );
1150
+ for (const { code, sessions, occurrences } of diagnostics.byCode.slice(0, 5)) {
1151
+ console.log(` ${code}: ${occurrences} occurrences in ${sessions} sessions`);
1152
+ }
1153
+ if (diagnostics.byCode.length > 5) {
1154
+ console.log(` ... ${diagnostics.byCode.length - 5} more codes (see --robot)`);
1155
+ }
1156
+ }
1157
+ if (status.discovery) {
1158
+ const { pending_sources, unknown_sources, exhausted_sources } = status.discovery;
1159
+ console.log(`Discovery: ${exhausted_sources} exhausted, ${pending_sources} pending, ${unknown_sources} unknown (observed sweeps, not archive completeness)`);
1160
+ }
1161
+ const coverage = status.coverage;
1162
+ if (!coverage) {
1163
+ console.log("Coverage: unknown (store predates source coverage schema)");
1164
+ } else if (!coverage.length) {
1165
+ console.log("Coverage: none (no pass has scanned this store)");
1166
+ } else {
1167
+ const listable = coverage.filter((row) => row.status === "listable").length;
1168
+ const missing = coverage.filter((row) => row.status === "missing_root").length;
1169
+ console.log(
1170
+ `Coverage: ${listable}/${coverage.length} sources listable${missing ? `, ${missing} missing` : ""}`,
1171
+ );
1172
+ // An uninstalled source is not news, the same rule the scan notes use — it
1173
+ // is counted above and stays quiet here. Everything else that could not be
1174
+ // listed is an anomaly and reads exactly as the scan would have said it.
1175
+ for (const row of coverage) {
1176
+ if (row.status === "listable" || row.status === "missing_root") continue;
1177
+ console.log(` ${formatScanOutcome(row)}`);
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ async function handleWatch(args: string[], isRobot: boolean): Promise<void> {
1183
+ const { flags } = parseFlags(args);
1184
+ const embed = Boolean(flags.embed);
1185
+ if (embed) applyEmbeddingProvider(flags, isRobot);
1186
+ const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
1187
+ const sources = typeof flags.sources === "string" ? flags.sources.split(",") : undefined;
1188
+ const debounceMs = typeof flags.debounce === "string" ? parseInt(flags.debounce, 10) : 2000;
1189
+ const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
1190
+ const reconcileMs =
1191
+ typeof flags["reconcile-ms"] === "string"
1192
+ ? parseInt(flags["reconcile-ms"], 10)
1193
+ : 60000;
1194
+ const importBudget =
1195
+ typeof flags["import-budget"] === "string"
1196
+ ? parseInt(flags["import-budget"], 10)
1197
+ : 25;
1198
+ const maxRecordBytes = flags["max-record-bytes"] === undefined
1199
+ ? undefined
1200
+ : typeof flags["max-record-bytes"] === "string"
1201
+ ? Number(flags["max-record-bytes"])
1202
+ : NaN;
1203
+ const verbose = Boolean(flags.verbose);
1204
+
1205
+ const logProse = isRobot ? console.error : console.log;
1206
+ logProse("Watching local agent session stores...");
1207
+ logProse(` Database : ${dbPath}`);
1208
+ logProse(` Sources : ${sources ? sources.join(", ") : "all detected"}`);
1209
+ logProse(` Debounce : ${debounceMs}ms`);
1210
+ logProse(` Embed : ${embed ? "on" : "off (lexical)"}`);
1211
+ logProse(` Reconcile: ${reconcileMs}ms`);
1212
+ logProse(` Budget : ${importBudget} sessions/source/tick`);
1213
+
1214
+ const watcher = new LedgerWatcher({
1215
+ dbPath,
1216
+ sources,
1217
+ debounceMs,
1218
+ limit,
1219
+ verbose,
1220
+ embed,
1221
+ reconcileMs,
1222
+ importBudget,
1223
+ maxRecordBytes,
1224
+ onBatch: (summary) => {
1225
+ if (isRobot) {
1226
+ console.log(
1227
+ JSON.stringify({
1228
+ type: "watch_status",
1229
+ source: summary.source,
1230
+ imported: summary.imported,
1231
+ appended: summary.appended,
1232
+ deduped: summary.deduped,
1233
+ skipped: summary.skipped,
1234
+ scanned: summary.scanned,
1235
+ }),
1236
+ );
1237
+ } else {
1238
+ console.log(
1239
+ `[watch] ${summary.source}: ${summary.imported} imported, ${summary.appended} appended, ${summary.deduped} deduped, ${summary.skipped} skipped (from ${summary.scanned} scanned)`,
1240
+ );
1241
+ }
1242
+ },
1243
+ });
1244
+
1245
+ const { promise: exitPromise, resolve: exitResolve } = Promise.withResolvers<void>();
1246
+ const shutdown = () => {
1247
+ void watcher.stop().then(exitResolve, () => {
1248
+ console.error("[ledger] watcher shutdown failed.");
1249
+ process.exitCode = 1;
1250
+ exitResolve();
1251
+ });
1252
+ };
1253
+
1254
+ process.on("SIGINT", shutdown);
1255
+ process.on("SIGTERM", shutdown);
1256
+
1257
+ try {
1258
+ await watcher.start();
1259
+ await exitPromise;
1260
+ } finally {
1261
+ await watcher.stop();
1262
+ process.off("SIGINT", shutdown);
1263
+ process.off("SIGTERM", shutdown);
1264
+ }
1265
+ }
1266
+
1267
+ async function handleModels(args: string[], isRobot: boolean): Promise<void> {
1268
+ const { positionals } = parseFlags(args);
1269
+ const subCmd = positionals[0];
1270
+ if (subCmd === "pull") {
1271
+ const { modelPath, vocabPath } = await pullModel({ verbose: !isRobot });
1272
+ if (isRobot) {
1273
+ console.log(
1274
+ JSON.stringify({
1275
+ type: "models_pull",
1276
+ model: DEFAULT_MODEL_NAME,
1277
+ modelPath,
1278
+ vocabPath,
1279
+ status: "ok",
1280
+ }),
1281
+ );
1282
+ } else {
1283
+ console.log(`Model ${DEFAULT_MODEL_NAME} pulled successfully to ${modelPath}`);
1284
+ }
1285
+ return;
1286
+ }
1287
+
1288
+ if (isRobot) {
1289
+ emitRobotError("UNKNOWN_COMMAND", `Unknown models subcommand: ${subCmd}`);
1290
+ }
1291
+ console.error(`Unknown models subcommand: ${subCmd}\nUsage: trajectories models pull`);
1292
+ process.exit(1);
1293
+ }