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,414 @@
1
+ # C10: Kimi as a first-class source, 2026-09-16
2
+
3
+ ## Outcome
4
+
5
+ Kimi CLI is now a source this store discovers, reads, normalizes, and covers like
6
+ any other: `packages/core/src/native/kimi.ts` (discovery of
7
+ `~/.kimi/sessions/<workspace-md5>/<session-uuid>/wire.jsonl`, the replay of its
8
+ append-only event protocol into canonical records, the `state.json` sidecar, the
9
+ shared workspace registry, and the `subagents/<agent-id>/` registry), wired
10
+ through `scanner.ts`, `native.ts`, `normalizer.ts`, `parentage.ts`, and
11
+ `watch.ts`. A top-level session is keyed `kimi:<session-uuid>`; a child is keyed
12
+ `kimi:<parent-uuid>:<agent-id>`, so an agent id is never assumed globally unique.
13
+ The source gets its own `source_coverage` row, and
14
+ `packages/core/tests/native-kimi.test.ts` (15 cases) covers the fixture plan.
15
+
16
+ The sentences this cycle exists to make impossible:
17
+
18
+ 1. **"A `ToolCall` row carries the arguments."** It usually carries a *prefix* of
19
+ them: on this host 220 of 309 real calls open a JSON object they never close,
20
+ and the remainder streams into `ToolCallPart` rows that follow, interleaved
21
+ with other calls' results. The call's arguments are the *shortest prefix of
22
+ its own argument bytes that parses as an object* — a rule that resolves 309/309
23
+ real calls (89 already complete, 220 completed from their fragments).
24
+ 2. **"`ToolCallPart` rows are noise."** They are the call's own argument bytes;
25
+ they are counted as lifecycle only because the call row — not they — carries
26
+ the record's identity and timestamp.
27
+ 3. **"A subagent's parent is written on the child."** The path proves the parent
28
+ (`<parent-session>/subagents/<agent>/`), and the *parent's* wire binds the
29
+ spawning call (`SubagentEvent.parent_tool_call_id` → `agent_id`). Nothing on
30
+ the child names either: 5/5 real `meta.json` files have no parent or tool
31
+ field. The scan reads the binding once per parent, while it is already walking
32
+ the store.
33
+ 4. **"A `SubagentEvent` is a second copy of the conversation."** It is: the
34
+ parent's mirror carries exactly the child's own rows (per-type counts equal for
35
+ 5/5 children), so the mirror is state — counted, never projected. Projecting it
36
+ would store the child's transcript twice under two ids.
37
+ 5. **"`context.jsonl` is the transcript."** It is compacted working memory,
38
+ rewritten when the context fills; `wire.jsonl` is append-only and is what this
39
+ reader reads. None of the three source files is ever written.
40
+ 6. **"An interrupted session has no metadata, so it is not a session."**
41
+ `state.json` is optional by construction (an aborted session has none:
42
+ `e5bb8737` on this host), so the title falls back to the first user turn and
43
+ the session's span falls back to its own stream.
44
+ 7. **"What the adapter drops, nobody knows."** Every drop — lifecycle rows, the
45
+ subagent mirrors, repeated result rows, unrecognized kinds, non-text parts,
46
+ invalid or torn lines — becomes a typed, counted diagnostic in
47
+ `ingest_diagnostics`, with the kind names in the bounded sample.
48
+
49
+ ### Evidence, and what kind it is
50
+
51
+ **OBSERVED** (this tree, this cycle):
52
+
53
+ - `bun test` — 654 pass / 0 fail, 4,962 assertions, 45 files (the new file
54
+ contributes 15 cases / 111 assertions; `cli-canonical` and `watch-targets` grew
55
+ their source lists); `bun run typecheck` and `bun run build` clean.
56
+ - The real-corpus pass over the read-only `~/.kimi` store into a scratch store
57
+ under `/tmp` (below): 8 sessions (3 top-level + 5 children), 806 records, 5
58
+ parent edges all tool-call-bound, 1 real failed tool result preserved, FTS
59
+ present with the reassembled arguments searchable, second pass fully deduped.
60
+ - Argument reassembly measured over every real call: 309/309 resolved to a JSON
61
+ object, none left as a fragment, and the resolved calls carry the keys their
62
+ tool name implies (`ReadFile`→`path`, `Shell`→`command`, `Grep`→`pattern`,
63
+ `Agent`→`description`/`prompt`/`subagent_type`).
64
+ - The `TurnBegin` shape split: 29 part-array rows and 5 whole-string rows — the
65
+ whole-string rows are exactly the 5 children.
66
+ - Every other shape fact in the tables below, counted from the 8 real wires
67
+ (2,013 event rows, 0 torn lines) with content never printed. The test suite's
68
+ fixtures are synthetic models of those shapes; no real transcript text is
69
+ copied into this repo.
70
+ - `@letta-ai/trajectory` 0.3.0 was inspected in `packages/core/node_modules`:
71
+ its adapter registry (`atif`, `claude-code`, `codex`, `copilot-cli`, `cursor`,
72
+ `droid`, `gemini-cli`, `hermes`, `letta-code`, `openclaw`, `opencode`, `pi`,
73
+ `deepagents`) has no Kimi entry and is not an extension point.
74
+
75
+ **INFERRED** (not run here):
76
+
77
+ - A workspace whose path is not in `~/.kimi/kimi.json` (a relocated directory, a
78
+ registry edited by hand) leaves that session's cwd unknown and changes nothing
79
+ else; the registry is a shared index, so a torn one costs cwd only and is not
80
+ diagnosed per session. The fallback the scout floated — parsing the
81
+ `<git-context>` block out of a prompt — was not implemented: the registry maps
82
+ every workspace on this host (3/3), and a second, weaker cwd mechanism is not
83
+ worth its ambiguity.
84
+ - `ToolCallPart` attribution is *positional*, not a documented rule. A fragment
85
+ is appended to the call whose window it lands in — after that call's row, before
86
+ the next call's row — and the first prefix that parses wins. Under the protocol
87
+ observed here a call's fragments always precede the next announcement, so the
88
+ rule is exact (309/309 real calls resolved, 0 starved, 0 leftover). A future
89
+ protocol that emitted an *earlier* call's completing fragment after a later
90
+ call's row would give that fragment to the later call: with
91
+ `A:{"pattern":"`, then `B:{"path":"`, then a fragment `hi"}`, B's window
92
+ consumes it and B stores `{"path":"hi"}` — a wrong-but-valid object — while A
93
+ keeps its announced prefix verbatim. Nothing in the wire distinguishes the two
94
+ (fragments carry no call id), so the position *is* the contract; the sequence is
95
+ pinned in the suite so a future change to the rule is visible. The risk is
96
+ future-protocol only.
97
+ - Compaction never truncates the wire: `CompactionBegin`/`CompactionEnd` are
98
+ lifecycle rows beside an append-only stream, so replay preserves history. No
99
+ compaction row exists on this host to observe directly.
100
+ - A resumed or retried call could re-emit a `ToolResult`; no such row exists on
101
+ this host (309 results / 309 distinct call ids), so the one-result-per-call rule
102
+ is defensive and proven only synthetically.
103
+
104
+ **Historical reference, deliberately not reused:** Wake's
105
+ `crates/wake-core/src/adapters/kimi.rs` targets a layout and protocol that no
106
+ longer exist — root `~/.kimi-code`, a `session_index.jsonl`, transcripts at
107
+ `session_<uuid>/agents/main/wire.jsonl`, protocol `"1.3"` with `turn.prompt` /
108
+ `context.append_message` rows. Shipping Kimi CLI (protocol `"1.10"` on this host)
109
+ writes `~/.kimi/sessions/<md5>/<uuid>/wire.jsonl` with `TurnBegin` / `ContentPart`
110
+ / `ToolCall` / `ToolResult`. No line parser, path rule, or type name survived the
111
+ port; Wake is cited here as the reason to distrust a stale adapter, not as a
112
+ source of code.
113
+
114
+ **Not done at all:** no upstream `@letta-ai/trajectory` adapter was written or
115
+ proposed; `~/.kimi` was never modified (read-only, mtimes untouched); the
116
+ production Ledger store was never opened by this cycle; no embedding or semantic
117
+ measurement was taken; `StatusUpdate` token accounting (`token_usage`,
118
+ `context_usage`, `context_tokens`) is not stored, because the store has no field
119
+ for it — it is a counted lifecycle row instead; no cwd flattening was implemented
120
+ (each session keeps the cwd it ran in).
121
+
122
+ **Production first ingest is still blocked, by a known wall.** A scoped
123
+ `ledger sync --sources kimi` pass discovers and enqueues this source but its queue
124
+ drain is not source-scoped (issue #69: `--sources` scopes discovery, not the claim
125
+ order), so a new bank's rows sit behind an existing backlog. That is the failure
126
+ C9 hit in production and recorded; it is unchanged by this cycle, and it is why
127
+ every measurement here was taken on scratch stores under `/tmp` (the C9 pattern),
128
+ never on the production store. Kimi will ingest in production when #69 (or the
129
+ #49 D1/D4 work) threads the source filter into the queue claim.
130
+
131
+ **A second gate is the operator, not the code.** The installer defaults pin the
132
+ source list a deployed service scrapes, and they have never been extended for the
133
+ new banks: `ops/macos/install-launchd.sh:9` and
134
+ `ops/linux/install-systemd-user.sh:48` both still say the same fourteen sources
135
+ (claude-code, codex, cursor, pi, omp, hermes, openclaw, letta-code, opencode,
136
+ droid, deepagents, openhands, gemini-cli, copilot-cli) — no `grok`, no `kimi` —
137
+ while `watch`'s `DEFAULT_SOURCES` and the scan's default set now carry both. A
138
+ host installed through those scripts therefore never sees either bank, even after
139
+ #69 lands, and a `watch` service beside a scheduled-`sync` service diverges in
140
+ what it covers. Whether to extend the installer defaults (and which sources a
141
+ default deployment should read at all) is the owner's call; this cycle did not
142
+ change service behavior, and the omission is recorded here so it is a decision
143
+ rather than an accident.
144
+
145
+ ## The format, as observed
146
+
147
+ Layout (`~/.kimi/sessions`): `<workspace-md5>/<session-uuid>/` holds `wire.jsonl`
148
+ (the transcript), `state.json` (metadata sidecar: `custom_title` and
149
+ `title_generated`, approval settings, todos, archive flags), `context.jsonl`
150
+ (compacted working memory — not a source), and, when the session spawned
151
+ children, `subagents/<agent-id>/` with `wire.jsonl`, `meta.json`, `prompt.txt`,
152
+ and `output`. `~/.kimi/kimi.json` sits beside the sessions root and registers the
153
+ workspace paths whose MD5 names the directories above it; `~/.kimi/device_id`
154
+ is installation identity and Kimi's own index lives nowhere else — there is no
155
+ SQLite in the store at all. No `subagents/` directory exists without a parent.
156
+
157
+ `wire.jsonl` line 1 is the file's protocol stamp (`{"type":"metadata",
158
+ "protocol_version":"1.10"}`, `"1.1"` in children); every later row is
159
+ `{"timestamp": <unix seconds>, "message":{"type": <event>, "payload": <object>}}`,
160
+ appended in place (`"a"` mode), one event per line.
161
+
162
+ Aggregate counts over the 8 wires / 2,013 event rows (content never printed; only
163
+ types, shapes, and counts):
164
+
165
+ | fact | observed |
166
+ | --- | --- |
167
+ | event types | `ToolCall` 309, `ToolResult` 309, `SubagentEvent` 726, `ToolCallPart` 227, `ContentPart` 146 (113 `think`, 33 `text`), `StepBegin` 114, `StatusUpdate` 113, `TurnBegin` 34, `TurnEnd` 34, `StepInterrupted` 1 |
168
+ | torn/invalid lines | 0 |
169
+ | `TurnBegin.user_input` | 29 part arrays (`[{type:"text", text}]`), 5 whole strings — the strings are exactly the 5 children |
170
+ | `ToolCall` arguments | 89 complete JSON, 220 prefixes completed by following fragments, 0 unresolvable; keys match the tool name in 309/309 |
171
+ | `ToolCall.function` | `{name, arguments: string}`, `type: "function"`, `extras: null` |
172
+ | `ToolResult.return_value` | `{is_error, output, message, display, extras}`; 308 `is_error: false` / 1 `true`; 4 rows with an empty `output` and a non-empty `message`; 2 rows with `display` renderings (a diff, an exit-code brief) that are not the model-visible text |
173
+ | `StepBegin.payload` | `{n}` — a step counter, not a boundary of speech |
174
+ | `StatusUpdate.payload` | `context_usage`, `context_tokens`, `max_context_tokens`, `token_usage{input_other, output, input_cache_read, input_cache_creation}`, `message_id`, `plan_mode`, `mcp_status` |
175
+ | `SubagentEvent.payload` | `parent_tool_call_id`, `agent_id`, `subagent_type`, `event:{type, payload}`; the inner stream's per-type counts equal the child's own wire for 5/5 children |
176
+ | child `meta.json` keys | `agent_id`, `subagent_type`, `status`, `description`, `created_at`, `updated_at`, `last_task_id`, `launch_spec{agent_id, subagent_type, model_override, effective_model, created_at}` — **no** parent session or tool field |
177
+ | child extras | `prompt.txt` and `output` in 5/5 children; `output` is a stage/tool log, not a transcript |
178
+ | `state.json` keys | `version`, `approval`, `additional_dirs`, `custom_title`, `title_generated`, `title_generate_attempts`, `plan_mode`, `plan_session_id`, `plan_slug`, `wire_mtime`, `archived`, `archived_at`, `auto_archive_exempt`, `todos`; absent for the interrupted session |
179
+ | `kimi.json` | `work_dirs: [{path, kaos, last_session_id}]`; each path's MD5 equals its sessions subdirectory name (3/3); one registered workspace (flow-v2) has a hash directory with no sessions |
180
+ | sessions | 3 top-level, 5 children; per session: 1144/139/4 rows (top-level) and 111/141/144/156/174 rows (children) |
181
+
182
+ Row grammar → canonical record:
183
+
184
+ | wire event | record |
185
+ | --- | --- |
186
+ | `TurnBegin` (parts or a whole string) | `user` (projected as the lane's user-prompt event) |
187
+ | `ContentPart` (`type: "think"`) | `reasoning` |
188
+ | `ContentPart` (`type: "text"`) | `assistant` |
189
+ | `ToolCall` (`id`, `function.{name, arguments}`) | `assistant-tool-call` (`tool_call_id` = `id`, `tool_arguments_json` = the resolved arguments) |
190
+ | `ToolResult` (`tool_call_id`, `return_value`) | `tool` (`tool_result_ok` = `!is_error`, `tool_result_json` = the result text) |
191
+ | `SubagentEvent` | dropped, counted — the child's own wire holds that transcript |
192
+ | `StepBegin`, `StepInterrupted`, `ToolCallPart`, `StatusUpdate`, `TurnEnd`, `ApprovalRequest`, `ApprovalResponse`, `CompactionBegin`, `CompactionEnd` | dropped, counted (lifecycle) |
193
+ | anything else | dropped, counted |
194
+ | the line-1 `metadata` stamp | neither projected nor counted: it is the file's header, not an event |
195
+
196
+ ## Assembly: the rules, and why each one
197
+
198
+ **Parts, not fragments.** Unlike Grok's token chunks, a `ContentPart` is one whole
199
+ part: 79 adjacent `think` rows on this host each end in a finished sentence and
200
+ begin a new one (`"...structure as mentioned in AGENTS.md."` followed by
201
+ `"The user wants me to use a multi-agent s..."`), and no two `text` rows are ever
202
+ adjacent. Concatenating them the way Grok's run rule does would merge two
203
+ separate reasoning blocks into one record; each part therefore becomes its own
204
+ record, and only the wire's own row boundaries decide where a message ends.
205
+
206
+ **Tool arguments are a stream that usually outruns its row.** A call is announced
207
+ with the arguments known at that moment. `{"path":` followed by
208
+ `ToolCallPart{"arguments_part":" \"air-cloud/webhook.go\"}"}` is one call's
209
+ arguments split across rows, and the fragments are *not* adjacent to the row that
210
+ announced them — other calls' `ToolResult` rows and `StatusUpdate` rows land in
211
+ between. The reader walks a call's own window (from its row to the next call row),
212
+ appends fragments, and takes the **first prefix that parses as a JSON object**:
213
+ the row's own text if it is already complete, otherwise the shortest completion
214
+ its fragments provide. Fragments carry no call id, so that window *is* the
215
+ attribution rule — its one failure mode, and why it is safe against the protocol
216
+ observed here, is recorded below. Measured over 309 real calls: 89 complete at
217
+ announce time, 220 completed from fragments, 0 left unfinished, and the resolved
218
+ arguments are semantically right for every tool (`ReadFile` 200, `Shell` 65,
219
+ `Grep` 36, `Agent` 5, `Glob` 2, `WriteFile` 1). Storing only the row's prefix was
220
+ rejected: it would truncate 220 of 309 real calls' arguments, which are the
221
+ most-queried part of a coding transcript. Guessing past what parses was rejected
222
+ too: a call whose stream never completes keeps the prefix — the library then
223
+ stores it under its own `{"_raw": …}` convention, which is lossless and marks
224
+ itself as unshaped.
225
+
226
+ **Tool results.** `ok = !is_error` (309 real results: 308/1), keyed by
227
+ `tool_call_id`. The result's text is `return_value.output` when non-empty, else
228
+ `return_value.message` — 4 real rows have an empty output with the answer in the
229
+ message (`"No matches found."`), and the `display` renderings (a diff, an
230
+ exit-code brief) are presentation, not text. The first result for a call id is
231
+ that call's outcome and a repeated result row is counted, so one call can never
232
+ carry two results — the same discipline C9 landed for Grok's repeated terminals,
233
+ here proven only synthetically because no real wire repeats a result.
234
+
235
+ **Identity.** Rows have no ids of their own, so each projected row is identified
236
+ by its **line number** (`kimi-line-<n>`): the file is append-only, so a line never
237
+ moves, whereas a byte offset would shift when an earlier run grows. Sequence and
238
+ timestamps come from the row's position and its `timestamp` (unix seconds →
239
+ ISO-8601 milliseconds). The listing's own id is stamped on a `session.start`
240
+ envelope row emitted for **every** session, so a session that projects no
241
+ conversation rows at all still carries its own group.
242
+
243
+ **cwd is a hash lookup, not a guess.** The session directory is the MD5 of the
244
+ workspace path, so the workspace is recovered by hashing the paths in
245
+ `kimi.json`; a workspace that is not registered leaves cwd unknown. This is the
246
+ only mechanism used: the prompt-borne `<git-context>` block (visible in the
247
+ children's whole-string turns) is *not* parsed for it.
248
+
249
+ **Sidecar.** `state.json` supplies the title (`custom_title`, which the CLI writes
250
+ as the display title — truncated with an ellipsis, and not a copy of the first
251
+ prompt). A missing sidecar is not an error; a malformed one is a diagnostic and
252
+ costs the title only. The wire's own bounds are the session's span when the
253
+ sidecar holds no timestamps (it holds none at all — `wire_mtime` is `null`).
254
+
255
+ ## The projection lane, and the decision behind it
256
+
257
+ The pinned normalizer (`@letta-ai/trajectory` 0.3.0) has **no** Kimi adapter, and
258
+ its registry is a frozen object, not an extension point. Ledger's established
259
+ answer is the one it already uses for Cursor, OpenCode, and Grok: project the
260
+ native store into a contract the library *documents* and let the library do
261
+ identity, hashing, bounds, and validation. C9's lane comparison is unchanged and
262
+ applies verbatim — `atif` loses the terminal outcome, `letta-code` cannot separate
263
+ a later result from its call, `cursor` has no timestamps — so Kimi joins **Grok's
264
+ lane**:
265
+
266
+ The chosen contract is the same shape as ACP and Copilot CLI: one event per line,
267
+ an id per event, calls and results as separate events, a boolean outcome on the
268
+ result. The projection is a rename:
269
+
270
+ | Kimi wire | copilot-cli event |
271
+ | --- | --- |
272
+ | `session.start` envelope (listing id, first timestamp, cwd) | `session.start` (`sessionId`, `startTime`, `context.cwd`) |
273
+ | `TurnBegin` | `hook.start` (`hookType: "userPromptSubmitted"`, `input.prompt`) |
274
+ | `ContentPart` `think` / `text` | `assistant.message` (`reasoningText` / `content`) |
275
+ | `ToolCall` | `assistant.message` (`toolRequests[]` = `{toolCallId, name, arguments}`) |
276
+ | `ToolResult` | `tool.execution_complete` (`toolCallId`, `success`, `result.content` / `error.message`) |
277
+
278
+ `KIMI_PROJECTION_SOURCE` in `native/kimi.ts` names the lane; `normalizer.ts` maps
279
+ each unreadable source to its lane in one `PROJECTION_LANES` table and restores
280
+ the records' provenance (`record.source_type = "kimi"`) right after
281
+ normalization. Recorded consequences, so nobody has to rediscover them:
282
+
283
+ - `records.source_type` is `kimi` and `sessions.source` is `kimi`; the store's
284
+ provenance axis is the source, never the lane.
285
+ - The single `meta` record's `record_json` names the *projection lane*
286
+ (`"source":"copilot-cli"`), and both `record_hash` and the meta `content_hash`
287
+ are the library's hashes over it. Ledger does not rewrite library-owned hashes;
288
+ the note is the disclosure.
289
+ - **A projected source's group is authoritative**, and kimi is in that set (the
290
+ C9 P1 fix). The reader's own envelope always agrees with it, so
291
+ `source_group_conflict` cannot fire; a reader change that stopped stamping the
292
+ envelope would still land on `kimi:<uuid>` rather than the library's `default`.
293
+ - Tool results and arguments are subject to the library's default bounds: the
294
+ real-corpus pass stored 184 `tool_result_truncated` occurrences (2,500
295
+ characters, head-tail) over 7 sessions and exactly one
296
+ `tool_arguments_truncated` (the one `WriteFile` call whose reassembled arguments
297
+ passed 20,000 characters). Both are durable diagnostics, not silent losses.
298
+ - An argument string the library cannot parse is stored as `{"_raw": …}` — the
299
+ unfinished-prefix case above, which is a *fidelity* outcome, not an error.
300
+ - The session-level `model` stays null: Kimi's wire carries token accounting but
301
+ no model id, and `meta.json`'s `effective_model` is `null` on this host.
302
+
303
+ ## Parentage: the path proves the parent, the parent's wire proves the call
304
+
305
+ Two pieces of evidence, both read from outside the child:
306
+
307
+ 1. **The path.** A child transcript lives at
308
+ `<parent-session>/subagents/<agent-id>/wire.jsonl`, so the enclosing session
309
+ directory is the parent — the same `parentIdFromSubagentsPath` shape Claude
310
+ Code and Cursor use. A session is never its own parent. The child's listing id
311
+ is `<parent-uuid>:<agent-id>`: agent ids are 9 characters (`"a"` + 8 hex) and
312
+ unique only within a parent, so a bare agent id would collide across sessions.
313
+ 2. **The parent's wire.** The parent records `SubagentEvent` rows binding
314
+ `parent_tool_call_id` → `agent_id`; the first binding for a registered child
315
+ wins, and only children that exist as directories are bound. The scan reads
316
+ this once per parent that has a `subagents/` registry — not once per child, and
317
+ it stops as soon as every registered child is bound — one bounded pass over
318
+ that session's own wire (≤ 64 MiB; the largest real parent wire here is
319
+ 2.6 MB). The binding is attached to the child's listing as
320
+ `native.parentToolUseId`. A missing or unreadable parent wire costs the
321
+ binding, never the edge or the child.
322
+
323
+ `agent_nickname` comes from the child's own `meta.json` (`subagent_type`, `explore`
324
+ in 5/5 real children) and `thread_role` is `subagent`. The binding participates in
325
+ the child's native fingerprint — `nativeSourceFingerprint` hashes `item.native` —
326
+ so learning (or losing) a binding re-ingests exactly that child. The store's
327
+ lineage backfill has no scan context and re-derives the path-provable edge only;
328
+ `updateSessionParentage` merges, so a backfill never erases a stored tool id.
329
+
330
+ **One child, one id.** A child is keyed by the parent its *path* names, on every
331
+ route that can list it: the parent's registry, or the store walk reaching
332
+ `subagents/<agent>/` because the parent's wire is missing (a partial copy, a
333
+ deleted transcript). A bare agent id is never used — it is unique only within one
334
+ parent, and it would be a second ledger id (and a second full ingest) for one
335
+ transcript the moment the parent's wire came back. Losing the parent's wire costs
336
+ the spawn binding, not the child's identity: the regression case *keeps one
337
+ identity for a child while the parent's wire comes and goes* ingests it before,
338
+ during, and after the loss and asserts one session, stable record ids, and a
339
+ `deduped` verdict each time.
340
+
341
+ ## Verification
342
+
343
+ **Suite.** `packages/core/tests/native-kimi.test.ts` — 15 cases:
344
+
345
+ | case | what it pins |
346
+ | --- | --- |
347
+ | discovery | the documented root, newest-first order, state title, `native.kind`, the `listable` outcome and the `missing_root` outcome for an absent root |
348
+ | dedup/symlink/page | a symlinked tree is not listed, an id is listed once, `--limit` bounds the page |
349
+ | single turn | the exact projected transcript, the cwd resolved through the workspace registry, the protocol stamp ignored, the lifecycle diagnostic with its kinds |
350
+ | child turn shape | a whole-string `TurnBegin` becomes the user turn; adjacent `think` parts stay separate records; a missing `state.json` is not a diagnostic |
351
+ | tools | call/result pairing by id, `tool_result_ok` 1 and 0, compact `tool_arguments_json`, the empty-output → message fallback, and the repeated-result count |
352
+ | argument reassembly | fragments across interleaved rows complete a call's arguments; a fragment is attributed by wire position (including the wrong-but-valid completion the contract allows); an unfinished prefix is never invented |
353
+ | resilience | lifecycle and unknown kinds counted, a torn line counted, the transcript still complete |
354
+ | interrupted session | no `state.json` → first-user title and stream-bounded span; a malformed sidecar → diagnostic, title from the user turn |
355
+ | lifecycle-only | a session with only lifecycle rows projects its envelope and no conversation |
356
+ | parentage | child listed under its parent-scoped id with the spawning tool call, title from `meta.json`, edges landing on both sessions' rows; a child with no wire is not a session; an unbound child keeps its path-provable parent |
357
+ | child identity | a child keeps its parent-scoped id, session, and record ids while the parent's wire disappears and returns; a bare agent id never becomes a second session for one transcript |
358
+ | identity/stability | unchanged re-read is `unchanged`; an appended turn adds records without moving or rewriting stored ones; a `state.json`-only change is `deduped` with a stable record set; the reader's diagnostics are stored in `ingest_diagnostics`; state-less sessions keep distinct `kimi:<uuid>` ids and no `kimi:default` exists |
359
+
360
+ The default-scan tests in `cli-canonical.test.ts` were extended with `kimi`
361
+ (source list, seeded root, and the "no root at all" set), and `watch-targets`
362
+ now covers `kimi` (and the `grok` root C9 missed) in its adapter-roots case.
363
+
364
+ **Real corpus.** Read-only `~/.kimi`, scratch store `/tmp/c10-kimi-scratch`
365
+ (never the production store; `~/.kimi` untouched):
366
+
367
+ | measurement | value |
368
+ | --- | --- |
369
+ | discovery | 8 sessions from `~/.kimi/sessions` (3 workspaces; one hash directory holds no sessions) — 3 top-level + 5 children |
370
+ | first `build --sources kimi --embedding none` | 8 scanned / 8 imported / 0 failed / 0 tombstoned, 806 records, wall ≈ 0.36 s |
371
+ | second pass (same store) | 8 scanned / 0 imported / 8 deduped — idempotent |
372
+ | records | `assistant-tool-call` 309, `tool` 309, `reasoning` 113, `user` 34, `assistant` 33, `meta` 8 |
373
+ | `source_type` | 806/806 `kimi` |
374
+ | failed tools preserved | 1 (`tool_result_ok = 0`) — the real `Shell` failure with its stderr output |
375
+ | parent edges | 5, all tool-call-bound (`parent_tool_use_id` non-null), all `agent_nickname = explore`, all `thread_role = subagent`, each binding matching a call row the parent stored; 0 edges for the 3 top-level sessions |
376
+ | cwd | resolved for 8/8 sessions (3/3 workspaces) |
377
+ | diagnostics | `noise_record_dropped` 1,215 occurrences over 8 sessions (489 lifecycle rows + 726 subagent mirrors), plus the library's `tool_result_truncated` 184 and `tool_arguments_truncated` 1 |
378
+ | coverage | `kimi` / `/Users/mac/.kimi/sessions` / `listable` / discovered 8 |
379
+ | search | 806 FTS rows; `consolidator` 20 hits across 4 sessions with the parent edge rendered, and `subagent_type` 11 hits inside a *reassembled* `Agent` call's arguments — the arguments the row alone did not carry are searchable |
380
+
381
+ ## Decisions
382
+
383
+ 1. **A borrowed lane, named in one table.** Kimi's records go through the same
384
+ identity, hashing, bounds, and validation path as every other source; the
385
+ divergence (which lane) is one exported constant plus one row in
386
+ `PROJECTION_LANES`. Building canonical records in Ledger remains rejected as a
387
+ second convention that would drift from the library's identity rules.
388
+ 2. **The shortest parseable prefix wins.** Tool arguments are reconstructed from
389
+ the source's own bytes, never extended past what parses and never invented when
390
+ the stream stops. Both alternatives were rejected: storing the announced prefix
391
+ (truncated arguments for 220/309 real calls) and treating fragments as noise.
392
+ 3. **A mirror is not a transcript.** The parent's `SubagentEvent` rows equal the
393
+ child's own wire row-for-row, so they are counted state. The parent still shows
394
+ the call that spawned the child, and the child is its own searchable session
395
+ under a parent-scoped id.
396
+ 4. **The path is the primary parent evidence.** The tool-call binding is a bonus
397
+ that makes the child attachable to the parent's call; it is read at scan time
398
+ and rides in `native_json`, so ingest pays no extra I/O and a binding learned
399
+ later re-ingests only the child.
400
+ 5. **Metadata degrades, transcripts do not.** `state.json`, `meta.json`, and
401
+ `kimi.json` each cost at most a title, a nickname, or a cwd. A torn line, an
402
+ unknown event, or an oversized sidecar never costs the session.
403
+ 6. **The skip discipline is C9's, unchanged.** Every dropped row is typed and
404
+ counted into `ingest_diagnostics`; the bounded sample names the kinds. A new
405
+ Kimi event becomes a visible count, not a silent hole.
406
+ 7. **This source is a bank for the open-source pitch and the Trajectory-format
407
+ gap.** Kimi is the second harness Ledger reads natively that the pinned
408
+ library cannot, and the second whose parentage comes from a registry rather
409
+ than a field on the child — the model generalizes, and the roadmap row is now
410
+ closed with it (see [the program](2026-09-16-wake-port-program.md), C10).
411
+ 8. **Production ingestion is gated on #69, and the cycle says so.** Everything
412
+ measured here is on scratch stores; the first production ingest will wait on
413
+ the queue-claim fix, exactly as C9's did. Recording the wall in the note is
414
+ cheaper than rediscovering it from an empty `records` table.
@@ -0,0 +1,216 @@
1
+ # C2: the derive-rule counter, 2026-09-16
2
+
3
+ ## Outcome
4
+
5
+ Index-shape changes no longer ride schema migrations. A store now records, in
6
+ `schema_meta`, the **rules** each derived index was produced under
7
+ (`fts_format`, `embeddings_format`), stamped by every writer open; a counter that
8
+ contradicts the build's raises a transient re-derivation flag, and the next
9
+ `sync` pass executes it **in store** — drop `records_fts`, recreate it,
10
+ re-project it from canonical `records` in one transaction that also clears the
11
+ flag. On the production store (225,959 records, 1.12 GB) that one-shot costs
12
+ **19.4–33.3 s, 381.8 MB of WAL, and zero store growth**, with the store's
13
+ cursors, queue, canonical records and vectors byte-identical, **no source file
14
+ re-read**, and the re-derived index answering **10/10 probe queries exactly like
15
+ the production index** (same totals, same ordered hits).
16
+
17
+ The lifecycle the cycle adds, every stage pinned by a case in
18
+ `packages/core/tests/derive-counters.test.ts`:
19
+
20
+ | | open (writer) | pass (`sync`, lease held) | read (`TrajectoryReader`, `status`) |
21
+ | --- | --- | --- | --- |
22
+ | counter equals this build's | nothing | nothing | serves |
23
+ | counter **older**, or **absent** | stamp this build's; raise `fts_reindex` when absent (see below) | re-derives once, clears the flag | serves the stale index as written |
24
+ | counter **newer** | stamp this build's; raise the flag | re-derives to this build's rules | refuses `FORMAT_MISMATCH` |
25
+ | flag pending, work interrupted | leaves it set | retries; the previous index survives | serves; never clears anything |
26
+
27
+ Files:
28
+
29
+ - `packages/core/src/schema.ts` — `FTS_FORMAT`, `EMBEDDINGS_FORMAT`,
30
+ `DERIVE_COUNTERS`, the `schema_meta` DDL (fresh store **and** every migration,
31
+ because `migrate()` runs the same `CREATE_TABLES_SQL`).
32
+ - `packages/core/src/db.ts` — `detectDeriveCounters` (open-time),
33
+ `needsFtsReindex`, `reindexRecordsFts` (one transaction: rebuild, gates,
34
+ automerge, flag clear).
35
+ - `packages/core/src/sync.ts` — the pass executes a pending re-derivation before
36
+ admission, under the writer lease it already holds, skipped under critical
37
+ pressure like the maintenance child; `timing.derive_ms` accounts for it.
38
+ - `packages/core/src/schema-gate.ts`, `reader.ts`, `store-status.ts` — the
39
+ `format_mismatch` state and `FORMAT_MISMATCH` (`ReaderError` /
40
+ `StoreStatusError`), one shared implementation and one message for both gates.
41
+ - `packages/core/src/cli.ts` — one prose line on a pass that re-derived
42
+ (`Index re-derivation: <ms> (records_fts rebuilt from this store's records; no
43
+ source was re-read).`), so a 20–30 s upgrade is not an unexplained stall.
44
+ - Gates: `bun test` **561 pass / 0 fail** (552 tests in the tree at HEAD; the 9
45
+ cases in `tests/derive-counters.test.ts` are this cycle's), `bun run typecheck`,
46
+ `bun run build`.
47
+
48
+ ## Ported mechanics (Wake MIT, `iAmCorey/Wake@d9f702b6` tag v0.6.5 — mechanics with attribution, no code copied)
49
+
50
+ Pin: `d9f702b6df0e85`, `crates/wake-core/src/{db,scanner}.rs`, MIT License © 2026
51
+ Corey Chiu.
52
+
53
+ | Ledger | Wake pin | What was kept |
54
+ | --- | --- | --- |
55
+ | `schema_meta` table beside `SCHEMA_VERSION` for derived-state versions | `db.rs#L25` (`schema_meta`), `#L128-L136` (`FTS_FORMAT` + its doc: bump the value when the derivation rules change) | the counter is an index-format version *distinct* from the schema version, with the value as the rules' name |
56
+ | counter comparison, stamp, and flag at **open** | `db.rs#L176-L196` | compare the stored value, flag a mismatch **only when the store already had sessions**, stamp the current value in the same transaction |
57
+ | flag as a transient key, not a column | `db.rs#L411-L427` (`has_meta_flag`/`clear_meta_flag`), `#L439-L447` (`needs_fts_reindex`/`finish_fts_reindex`) | a flag the pass reads at start and the work clears; crashes leave it set |
58
+ | the scan reads the flag once, forces the work, and clears it after | `scanner.rs#L179` (read at scan start), `#L281` (forced work), `#L439-L441` (clear after the scan) | executed by a **scan-path pass** under its lease, not by an open; a pass that fails leaves the flag for the next one |
59
+ | flag reprocesses rows the incremental path would skip, exactly once; a fresh store is not flagged | `tests/scanner_finale.rs#L309-L356` | the cycle's test skeleton: fresh store seeds silently, drift re-derives once, the second pass has nothing to do |
60
+ | old consumers refuse a store they do not understand, up front | `db.rs#L229-L233` (`NEWEST_COLUMN`/`NEWEST_TABLE`), `#L320-L334` (read-only open bails by name) | our read gate refuses a counter newer than this build derives, naming the counter and both values, instead of dying deep in a query |
61
+
62
+ Deliberate deviations, each with its reason:
63
+
64
+ - **The re-derivation is in-store SQL, not a re-parse.** Wake's flag forces
65
+ every source file to be re-parsed (`scanner.rs#L179` → `#L281`), because a
66
+ file is the only place its units exist. Ledger keeps canonical records in the
67
+ store, so the same increment is `DROP`+`CREATE`+`INSERT INTO records_fts
68
+ SELECT … FROM records` through the projection ingest already writes
69
+ (`FTS_REPOPULATE_SQL`, shared with the v11 migration so the two cannot drift).
70
+ Measured: zero source bytes read where the sources behind the same records
71
+ total 1,555,222,435 bytes (1,479,531,453 ingested) across 2,664 cursors.
72
+ - **The flag clear is inside the rebuild transaction.** Wake clears after the
73
+ scan (`scanner.rs#L439-L441`), which leaves a window where the flag is clear
74
+ and the index is not the one it names. Here the drop, the re-projection, the
75
+ post-copy gates, and the clear commit or roll back together — so an
76
+ interrupted pass leaves a consistent store by construction, and an unreachable
77
+ `finish` step cannot exist. (`finish_fts_reindex` has no Ledger counterpart.)
78
+ - **A pre-counter store is flagged for FTS but not for vectors.** An unrecorded
79
+ counter is not a *moved* one, but Wake's rule (stored ≠ current → flag)
80
+ applies to an absent value too, and for FTS that is the right direction: the
81
+ re-derivation is executable in this build, cheap relative to a re-parse, and
82
+ it repairs drift it cannot otherwise detect. For vectors it is not: the lane
83
+ that would consume `embeddings_rederive` is deferred, so raising the flag on
84
+ absence would leave every upgraded store carrying a promise no pass can keep.
85
+ `DERIVE_COUNTERS[].flagsWhenUnrecorded` records the difference, per counter.
86
+ - **A newer counter is stamped down by a writer, refused only by a reader.**
87
+ `records` is canonical, so a writer can restore the index to its own rules
88
+ losslessly in either direction, and an older `sync` keeps working across a
89
+ version crossing instead of hard-failing; a reader cannot re-derive anything,
90
+ so it refuses by name. The cost of crossing in either direction is one more
91
+ re-derivation, never data loss.
92
+ - **Counters are judged numerically, and an unparsable value is refused.** `"10"`
93
+ is newer than `"2"`, which a string comparison would invert — worth pinning
94
+ now, because C3/C4/C5 will reach two digits. A value this build cannot compare
95
+ is a value it cannot vouch for, so the read gate treats it as newer (the next
96
+ writer open stamps a comparable one).
97
+ - **No operator surface (yet).** The counters and flags are store state, and the
98
+ pass reports `derive_ms`; a `status` field or robot envelope for the counters
99
+ was deliberately left out of the cycle.
100
+
101
+ ## Measurements
102
+
103
+ Host: Apple M1, macOS 25.6, APFS. Fixture: a **byte-consistent copy** of the
104
+ production store (`~/.local/share/ledger/ledger.sqlite`: 1,121,468,416 bytes,
105
+ 225,959 records, 2,600 sessions, 2,664 ingest cursors, 4,487 queued items, 0
106
+ embeddings, WAL 0 B) — the user's store is never opened for writing, and the
107
+ copy was deleted afterwards. Harnesses were throwaway; the properties they
108
+ measured are pinned by `tests/derive-counters.test.ts`.
109
+
110
+ Reproduction: copy the store byte-for-byte (`db` + `-wal` + `-shm`) to a scratch
111
+ path — the production store is never opened for writing — then (1) `new
112
+ TrajectoryDb(copy)` and read `schema_meta` back (counter detection), (2) time
113
+ `reindexRecordsFts()`, (3) for the breakdown, re-raise the flag by hand and run
114
+ the same statements from `schema.ts` (`RECORDS_FTS_DDL`, `FTS_REPOPULATE_SQL`,
115
+ `verifyRecordsFtsRebuild`) individually inside one transaction, (4) compare
116
+ `TrajectoryReader.queryLexical(q, {limit: 50})` on the copy and on the
117
+ production store (totals plus ordered `record_id`s), and (5) diff the counts and
118
+ an `ingest_cursors` digest before/after, then `PRAGMA wal_checkpoint(TRUNCATE)`
119
+ and compare the database file size.
120
+
121
+ **OBSERVED**
122
+
123
+ | measurement | value |
124
+ | --- | --- |
125
+ | writer open of the 1.12 GB pre-counter store (WAL pragma, DDL, trigger repair, counter detection, stamp + flag) | **8.3 ms**, `schema_meta` after it: `fts_format=1`, `embeddings_format=1`, `fts_reindex=1` (no `embeddings_rederive`: unrecorded, not moved) |
126
+ | `reindexRecordsFts()` on the copy, first (cold) pass | **33,253 ms** |
127
+ | the same statements in one transaction, warm (two runs) | **19,443.8 / 19,521.1 ms** |
128
+ | phase breakdown (warm, same statements, timed individually) | drop+DDL **105.8 ms** · project (`INSERT INTO records_fts SELECT …`) **11,072.8 ms** · gates (`records`↔`records_fts` parity + FTS5 `integrity-check`) **5,036.8 ms** · automerge reset **14.2 ms** · flag clear **0.3 ms** |
129
+ | index parity after the rebuild | `records` 225,959 = `records_fts` 225,959 |
130
+ | store size | **1,121,468,416 bytes before and after** (identical: the rebuild reuses the freed pages); WAL peak **381.8–382.0 MB**; `wal_checkpoint(TRUNCATE)` afterwards 0.8 s |
131
+ | untouched durable state | records 225,959→225,959 · sessions 2,600→2,600 · embeddings 0→0 · cursors 2,664→2,664 with a **byte-identical digest** (mtime, size, `bytes_ingested`, `record_count`, `status`, `updated_at`) · queue 4,487→4,487 |
132
+ | query equality, re-derived copy vs production index | 10/10 probes identical in **total hits and ordered hit lists** (`zirconium` 0, `porter unicode61` 49, `index-shape` 17, `derive counter` 4, `ledger sync pending` 1, `records sqlite` 22, `canonical trajectory` 23, `trigram`/`wal_autocheckpoint`/`ERROR ENOENT` at the limit 50) — 0 mismatches |
133
+ | second pass, nothing pending | `timing.derive_ms` **0**, `records_fts` content byte-identical (test-pinned) |
134
+ | CLI, real store (`ledger sync` prose + `--robot`, `/tmp` fixture) | upgrade pass: `Index re-derivation: 1ms (records_fts rebuilt from this store's records; no source was re-read).`, `0` workers, `0` source bytes read, `0` admissions; the pass after it prints no line and reports `derive_ms: 0` |
135
+ | CLI, refusal surface | `search --robot` and `status --robot` on a store whose `fts_format` is `2`: `{"error":{"code":"FORMAT_MISMATCH","message":"Database at … was derived under newer rules (fts_format: 2; this build derives 1). Upgrade Ledger to read this store."}}`, exit 1 |
136
+ | existing suite | 561 pass / 0 fail (552 tests in the tree at HEAD) |
137
+
138
+ **INFERRED** (not directly measured, listed so reviewers can attack them)
139
+
140
+ - The cold/warm spread (33.3 s vs 19.4–19.5 s) is page cache and host load, not
141
+ a different code path: the cold run was the copy's first touch, the warm runs
142
+ followed it in separate processes. The projected cost on the production store
143
+ is the warm band; a first post-merge `ledger sync` on a cold page cache should
144
+ be expected at the high end.
145
+ - The gates are 26% of the warm cost (5.0 s of 19.5 s) because FTS5's
146
+ `integrity-check` walks the whole index. It is kept: the same gate is what
147
+ makes a drifted index a *repair* rather than a silent inheritance, and the
148
+ parity count alone cannot see a table that answers `MATCH` while its internals
149
+ disagree with its rows.
150
+ - The unrecorded-counter one-shot on a real upgrade is, in the typical case, a
151
+ repair rather than a fix: the v11 rebuild and ingest write the same projection,
152
+ so a pre-counter store's index is very likely already conforming. It is
153
+ charged anyway because the store cannot name its rules — and it is not wasted
154
+ work: the test plants a missing row *and* an orphan row, and re-projection
155
+ removes both.
156
+ - Detection cost is one indexed `schema_meta` lookup per counter per open
157
+ (primary key), measured only as part of the 8.3 ms open on a 1.12 GB store.
158
+ - Version skew costs one rebuild per crossing (older writer stamps its own value
159
+ and re-derives; the newer build re-derives again on its next pass). Not
160
+ measured — it needs two builds — but both directions are the same code path
161
+ the tests pin for a *newer* counter, and neither can lose canonical records.
162
+ - The WAL peak (382 MB) scales with the indexed text (~222 MB across the
163
+ counter's columns), not with the store size (1.12 GB), because the rebuild
164
+ rewrites only FTS pages; a store with much larger `record_json` would show the
165
+ same WAL with a bigger database file.
166
+ - On this host the deferred vectors flag cannot fire: the production store holds
167
+ 0 embeddings. The lane's absence is therefore not observable in production
168
+ today, only in tests.
169
+
170
+ ## Position in the program (C2 row, `docs/research/2026-09-16-wake-port-program.md`)
171
+
172
+ - Failure it fixes: *"Index-shape changes currently must ride schema migrations;
173
+ re-derivation has no first-class switch."*
174
+ - Open-source case: *"Lets contributors evolve the index without migration
175
+ ceremonies — the difference between 'rebuild your DB' and 'it re-derives once
176
+ on next scan'."*
177
+ - Ordering: C2 precedes C3/C4/C5 because they change index shape and need the
178
+ one-shot. Each of them now bumps one string (`FTS_FORMAT`) and gets exactly
179
+ one re-derivation per store on the next pass; C3's echo filter additionally
180
+ has to accept that a daemon-only machine re-derives when its next `sync` runs
181
+ (see below).
182
+
183
+ ## Boundaries and follow-ups (not in this cycle)
184
+
185
+ 1. **`sync` is the only pass that executes the flag.** `ledger build` and a
186
+ `ledger watch` drain are writer passes that could call the same one-liner
187
+ (`reindexRecordsFts`), and a machine that runs the watcher plus the scheduled
188
+ sync gets the re-derivation through the scheduled pass anyway. The program's
189
+ C2 row names the scan pass and this cycle keeps its blast radius to `sync`;
190
+ wiring the other two passes is a one-line change each, and the cycle that
191
+ first *needs* it on a daemon-only machine (C3) should take it.
192
+ 2. **The embeddings lane's execution is deferred, by the design's allowance.**
193
+ The counter, the flag, the stamp, and the refusal exist and are tested; no
194
+ pass clears `embeddings_rederive`, and none is raised by an unrecorded
195
+ counter, so no live store carries it today. Making it real needs a decision
196
+ the lane cannot avoid: vectors carry provider and dim per row but **no
197
+ per-row derivation-rule marker**, so "which rows are stale" is unanswerable
198
+ from the store alone. The two shapes are (a) a rule-generation stamp in
199
+ `schema_meta` plus rows written since it (a resumable, bounded re-embed), or
200
+ (b) whole-store re-embedding in one call, cleared only on completion
201
+ (Wake's scanner shape, idempotent but unbatched). Either way the query path's
202
+ `ensureQueryEmbeddings` gap-fill must not become an unbounded re-embed.
203
+ 3. **No operator surface for the counters.** `derive_ms`, the prose line, and
204
+ the store's own `schema_meta` are the visibility this cycle ships; a
205
+ `status`/robot field for the counters and pending flags is a candidate when a
206
+ bump actually ships (C3+).
207
+ 4. **Wall budget.** The one-shot is charged to the pass's wall (`elapsed_ms`
208
+ includes it) and is not interruptible mid-transaction; a dedicated budget
209
+ knob was not added. A pass killed during it rolls back and leaves the flag.
210
+ 5. **`parentage.test.ts` store reset.** The v3-migration case deleted the store
211
+ file but left its `-wal`/`-shm`, so the fixture written at the same path was
212
+ read through a WAL describing a different database (`SQLITE_IOERR_SHORT_READ`
213
+ once the counter's own writes changed the WAL's shape). The reset now removes
214
+ all three files, mirroring the file's own `beforeEach` — the latent hazard was
215
+ in the test, not in the migration path it checks (that path is covered by
216
+ `integration-migrations.test.ts` across every historical shape).