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,591 @@
1
+ import { discoveryHeadBudget, pageDiscoveryInventory, uniqueDiscovered, type DiscoveryCheckpoint } from "./discovery.js";
2
+ import { existsSync, readdirSync, statSync, type Stats } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, join } from "node:path";
5
+ import {
6
+ type AnyTrajectorySource,
7
+ listTrajectories,
8
+ } from "@letta-ai/trajectory";
9
+ import type {
10
+ DiscoveredSession,
11
+ ScanOptions,
12
+ ScanOutcome,
13
+ ScanOutcomeStatus,
14
+ ScanResult,
15
+ } from "./types.js";
16
+ import {
17
+ type ParentageDiagnostic,
18
+ type ParentageOptions,
19
+ readPiSessionIdFromFile,
20
+ } from "./parentage.js";
21
+ import { cursorRoots, discoverCursorSessions } from "./native/cursor.js";
22
+ import { discoverGrokSessions, grokRoots } from "./native/grok.js";
23
+ import { discoverKimiSessions, kimiRoots } from "./native/kimi.js";
24
+ import { openCodeRoots, discoverOpenCodeSessions } from "./native/opencode.js";
25
+
26
+ export type SessionScanOptions = ScanOptions & Pick<ParentageOptions, "onDiagnostic">;
27
+
28
+ const DEFAULT_SOURCES = [
29
+ "claude-code",
30
+ "codex",
31
+ "pi",
32
+ "omp",
33
+ "hermes",
34
+ "letta-code",
35
+ "openclaw",
36
+ "droid",
37
+ "deepagents",
38
+ "openhands",
39
+ ];
40
+
41
+ const KNOWN_EXTRA_LOCATIONS: Record<string, string[]> = {
42
+ "gemini-cli": [join(homedir(), ".gemini", "sessions")],
43
+ "copilot-cli": [
44
+ join(homedir(), ".copilot", "session-state"),
45
+ join(homedir(), ".copilot", "history-session-state"),
46
+ ],
47
+ };
48
+
49
+ /** Outcome details name an error class and code, never message text, which can quote store content. */
50
+ export const SCAN_OUTCOME_DETAIL_LIMIT = 120;
51
+
52
+ export function capScanDetail(text: string): string {
53
+ return text.slice(0, SCAN_OUTCOME_DETAIL_LIMIT);
54
+ }
55
+
56
+ function failureDetail(error: unknown): string {
57
+ if (!(error instanceof Error)) return `Non-Error failure (${typeof error})`;
58
+ const code = errnoCode(error);
59
+ return capScanDetail(code === undefined ? error.name : `${error.name} (${code})`);
60
+ }
61
+
62
+ function errnoCode(error: unknown): string | undefined {
63
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
64
+ const code = error.code;
65
+ return typeof code === "string" ? code : undefined;
66
+ }
67
+
68
+ /** The path a listing branch reads, and the shape it must have to read it. */
69
+ interface ScanRoot {
70
+ path: string;
71
+ kind: "directory" | "file" | "any";
72
+ }
73
+
74
+ /**
75
+ * Where each source reads by default, mirroring @letta-ai/trajectory 0.3.0's
76
+ * per-adapter resolution (`dist/adapters/<source>/list.js`) so the probe stats
77
+ * exactly what the lister reads. Keep in step when the pinned library moves.
78
+ */
79
+ function defaultRoots(source: string): ScanRoot[] {
80
+ const home = homedir();
81
+ switch (source) {
82
+ case "claude-code": return [{ path: join(home, ".claude", "projects"), kind: "directory" }];
83
+ case "codex": return [{ path: join(home, ".codex", "sessions"), kind: "directory" }];
84
+ case "pi": return [{ path: join(piAgentDir(), "sessions"), kind: "directory" }];
85
+ case "omp": return [{ path: ompSessionsDir(), kind: "directory" }];
86
+ case "hermes": return [{ path: join(home, ".hermes", "state.db"), kind: "file" }];
87
+ case "letta-code": return [{ path: join(home, ".letta", "transcripts"), kind: "directory" }];
88
+ case "openclaw": return [{ path: openClawStateDir(), kind: "directory" }];
89
+ case "droid": return [{ path: join(home, ".factory", "sessions"), kind: "directory" }];
90
+ case "deepagents": return [{ path: join(home, ".deepagents", "sessions.db"), kind: "file" }];
91
+ case "openhands": return [{ path: join(home, ".openhands", "sessions"), kind: "directory" }];
92
+ case "cursor": return cursorRoots().map((path) => ({ path, kind: "directory" }));
93
+ case "opencode": return openCodeRoots().map((path) => ({ path, kind: "directory" }));
94
+ case "grok": return grokRoots().map((path) => ({ path, kind: "directory" }));
95
+ case "kimi": return kimiRoots().map((path) => ({ path, kind: "directory" }));
96
+ case "cline": return [{ path: process.env.CLINE_SESSION_DATA_DIR?.trim() || join(home, ".cline", "data", "sessions"), kind: "directory" }];
97
+ case "fx": return [{ path: join(home, ".fx", "sessions"), kind: "directory" }];
98
+ case "gemini-cli": return [{ path: join(home, ".gemini", "sessions"), kind: "directory" }];
99
+ case "copilot-cli": return KNOWN_EXTRA_LOCATIONS["copilot-cli"].map((path) => ({ path, kind: "directory" }));
100
+ default: return [];
101
+ }
102
+ }
103
+
104
+ /** Mirror of the pi adapter's `defaultAgentDir()`. */
105
+ function piAgentDir(): string {
106
+ return process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
107
+ }
108
+
109
+ /** Mirror of the omp adapter's `resolveOmpSessionsPath()` path inputs. */
110
+ function ompSessionsDir(): string {
111
+ const home = homedir();
112
+ const profile = ompProfile();
113
+ const configRoot = join(home, process.env.PI_CONFIG_DIR || ".omp", ...(profile ? ["profiles", profile] : []));
114
+ const agentOverride = profile ? undefined : process.env.PI_CODING_AGENT_DIR?.trim() || undefined;
115
+ const agentDir = agentOverride ?? join(configRoot, "agent");
116
+ if (agentOverride === undefined && (process.platform === "linux" || process.platform === "darwin")) {
117
+ const xdgData = process.env.XDG_DATA_HOME?.trim();
118
+ if (xdgData) {
119
+ const xdgRoot = join(xdgData, "omp", ...(profile ? ["profiles", profile] : []));
120
+ if (existsSync(xdgRoot)) return join(xdgRoot, "sessions");
121
+ }
122
+ }
123
+ return join(agentDir, "sessions");
124
+ }
125
+
126
+ /** Mirror of the omp adapter's profile validation. */
127
+ function ompProfile(): string | undefined {
128
+ const value = (process.env.OMP_PROFILE !== undefined ? process.env.OMP_PROFILE : process.env.PI_PROFILE)?.trim();
129
+ if (!value || value === "default") return undefined;
130
+ if (value === "." ||
131
+ value === ".." ||
132
+ value.endsWith(".") ||
133
+ !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value) ||
134
+ /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i.test(value)) {
135
+ return undefined;
136
+ }
137
+ return value;
138
+ }
139
+
140
+ /** Mirror of the openclaw adapter's `defaultStateDir()`. */
141
+ function openClawStateDir(): string {
142
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
143
+ if (override) return override;
144
+ const current = join(homedir(), ".openclaw");
145
+ return existsSync(current) ? current : join(homedir(), ".clawdbot");
146
+ }
147
+
148
+ /** Mirror of the SQLite listers' `resolveStorePath()`: a `.db` path, or a directory holding one. */
149
+ function storePath(root: string, filename: string): string {
150
+ return root.endsWith(".db") ? root : join(root, filename);
151
+ }
152
+
153
+ /**
154
+ * The roots a source will read: an explicit override, or the source's standard
155
+ * local locations. One source's root is another's directory of sessions, and
156
+ * the SQLite listers accept either a store file or the directory holding it.
157
+ */
158
+ function resolveScanRoots(source: string, override: string | undefined): ScanRoot[] {
159
+ if (override === undefined) return defaultRoots(source);
160
+ switch (source) {
161
+ case "pi":
162
+ case "omp": return [{ path: join(override, "sessions"), kind: "directory" }];
163
+ case "hermes": return [{ path: storePath(override, "state.db"), kind: "file" }];
164
+ case "deepagents": return [{ path: storePath(override, "sessions.db"), kind: "file" }];
165
+ case "cursor":
166
+ case "opencode":
167
+ case "grok":
168
+ case "kimi": return [{ path: override, kind: "any" }];
169
+ default: return DEFAULT_SOURCES.includes(source)
170
+ ? [{ path: override, kind: "directory" }]
171
+ : [{ path: override, kind: "any" }];
172
+ }
173
+ }
174
+
175
+ type RootVerdict = "readable" | "missing" | "unreadable" | "wrong_shape";
176
+
177
+ interface RootProbe {
178
+ status: Exclude<ScanOutcomeStatus, "listing_failed">;
179
+ /** The path behind the status: the first readable root when listable, the failing path otherwise. */
180
+ root: string;
181
+ detail: string | null;
182
+ }
183
+
184
+ /**
185
+ * Stat the resolved roots before listing. Upstream listers report a missing,
186
+ * unreadable, or file-shaped root as an empty listing, so the outcome cannot be
187
+ * recovered from item counts; it has to be probed here. One healthy root is
188
+ * enough to list — the remaining roots stay with the listing branch's own error
189
+ * handling.
190
+ */
191
+ function probeRoots(candidates: ScanRoot[]): RootProbe {
192
+ if (candidates.length === 0) {
193
+ return { status: "missing_root", root: "", detail: "no known store location" };
194
+ }
195
+ const verdicts: Array<{ candidate: ScanRoot; verdict: RootVerdict; detail: string | null }> = [];
196
+ for (const candidate of candidates) {
197
+ let stat: Stats;
198
+ try {
199
+ stat = statSync(candidate.path);
200
+ } catch (error) {
201
+ verdicts.push(errnoCode(error) === "ENOENT"
202
+ ? { candidate, verdict: "missing", detail: null }
203
+ : { candidate, verdict: "unreadable", detail: failureDetail(error) });
204
+ continue;
205
+ }
206
+ if (candidate.kind === "directory" && !stat.isDirectory()) {
207
+ verdicts.push({ candidate, verdict: "wrong_shape", detail: null });
208
+ continue;
209
+ }
210
+ if (candidate.kind === "file" && !stat.isFile()) {
211
+ verdicts.push({ candidate, verdict: "wrong_shape", detail: null });
212
+ continue;
213
+ }
214
+ if (candidate.kind === "directory") {
215
+ try {
216
+ readdirSync(candidate.path);
217
+ } catch (error) {
218
+ verdicts.push({ candidate, verdict: "unreadable", detail: failureDetail(error) });
219
+ continue;
220
+ }
221
+ }
222
+ verdicts.push({ candidate, verdict: "readable", detail: null });
223
+ }
224
+ const readable = verdicts.find((entry) => entry.verdict === "readable");
225
+ if (readable) return { status: "listable", root: readable.candidate.path, detail: null };
226
+ const unreadable = verdicts.find((entry) => entry.verdict === "unreadable");
227
+ if (unreadable) return { status: "unreadable_root", root: unreadable.candidate.path, detail: unreadable.detail };
228
+ const wrongShape = verdicts.find((entry) => entry.verdict === "wrong_shape");
229
+ if (wrongShape) return { status: "root_wrong_shape", root: wrongShape.candidate.path, detail: null };
230
+ return { status: "missing_root", root: candidates[0].path, detail: null };
231
+ }
232
+
233
+ function scanOutcome(
234
+ source: string,
235
+ root: string,
236
+ status: ScanOutcomeStatus,
237
+ discovered: number,
238
+ detail: string | null = null,
239
+ ): ScanOutcome {
240
+ return { source, root, status, discovered, detail };
241
+ }
242
+
243
+ export async function scanSessions(
244
+ options: SessionScanOptions = {},
245
+ ): Promise<ScanResult> {
246
+ const sourcesToScan = options.sources ?? [
247
+ ...DEFAULT_SOURCES,
248
+ "cursor",
249
+ "opencode",
250
+ "grok",
251
+ "kimi",
252
+ "gemini-cli",
253
+ "copilot-cli",
254
+ ];
255
+
256
+ const sessions: DiscoveredSession[] = [];
257
+ const outcomes: ScanOutcome[] = [];
258
+ const discovery: DiscoveryCheckpoint[] = [];
259
+ const roots = options.roots ?? {};
260
+ const limitPerSource = options.limit ?? 1000;
261
+ if (!Number.isSafeInteger(limitPerSource) || limitPerSource < 1) {
262
+ throw new RangeError("Session scan limit must be a positive safe integer.");
263
+ }
264
+
265
+ for (const source of sourcesToScan) {
266
+ const candidates = resolveScanRoots(source, roots[source]);
267
+ const rootKey = JSON.stringify({ version: 1, roots: candidates });
268
+ const saved = options.discovery?.[source];
269
+ const previous = saved?.root_key === rootKey ? saved : undefined;
270
+ let nextCursor = previous?.cursor ?? null;
271
+ let partial = previous?.cursor ? previous.degraded : false;
272
+ const finishDiscovery = (outcome: ScanOutcome): void => {
273
+ if (!options.discovery) return;
274
+ partial ||= outcome.status !== "listable" || !!outcome.detail;
275
+ const status = outcome.status !== "listable" ? "unknown"
276
+ : nextCursor ? "pending" : partial ? "unknown" : "exhausted";
277
+ const stamp = new Date().toISOString();
278
+ discovery.push({ source, root_key: rootKey, cursor: nextCursor, status,
279
+ degraded: partial, updated_at: stamp,
280
+ last_completed_at: status === "exhausted" ? stamp : previous?.last_completed_at ?? null });
281
+ };
282
+ const selectInventory = (inventory: DiscoveredSession[]): DiscoveredSession[] => {
283
+ const page = pageDiscoveryInventory(inventory, limitPerSource, previous?.cursor ?? null);
284
+ nextCursor = page.next;
285
+ return page.items;
286
+ };
287
+ const probe = probeRoots(candidates);
288
+ if (probe.status !== "listable") {
289
+ // Every branch already yields zero items for a root that cannot be read,
290
+ // so listing it would only cost time and add no sessions.
291
+ const outcome = scanOutcome(source, probe.root, probe.status, 0, probe.detail);
292
+ outcomes.push(outcome);
293
+ finishDiscovery(outcome);
294
+ continue;
295
+ }
296
+ const root = probe.root;
297
+
298
+ if (source === "cursor" || source === "opencode" || source === "grok" || source === "kimi") {
299
+ const collected: ParentageDiagnostic[] = [];
300
+ const nativeOptions = {
301
+ limit: limitPerSource,
302
+ ...(options.discovery ? { select: selectInventory } : {}),
303
+ onDiagnostic: (diagnostic: ParentageDiagnostic) => {
304
+ collected.push(diagnostic);
305
+ options.onDiagnostic?.(diagnostic);
306
+ },
307
+ };
308
+ let items: DiscoveredSession[] = [];
309
+ let failure: string | null = null;
310
+ try {
311
+ const paths = candidates.map((candidate) => candidate.path);
312
+ items = source === "cursor"
313
+ ? discoverCursorSessions(paths, nativeOptions)
314
+ : source === "grok"
315
+ ? discoverGrokSessions(paths, nativeOptions)
316
+ : source === "kimi"
317
+ ? discoverKimiSessions(paths, nativeOptions)
318
+ : discoverOpenCodeSessions(paths, nativeOptions);
319
+ } catch (error) {
320
+ failure = failureDetail(error);
321
+ }
322
+ sessions.push(...items);
323
+ // The native scanners already report unreadable stores and partial
324
+ // evidence through diagnostics; the outcome is what a caller can act on.
325
+ // A diagnostic that still produced sessions is partial evidence, not a
326
+ // failed listing: the detail carries it and the count stays real.
327
+ const diagnostic = collected.find((entry) => entry.code === "read_error" || entry.code === "incomplete_evidence");
328
+ const failedListing = failure !== null || (diagnostic !== undefined && items.length === 0);
329
+ outcomes.push(scanOutcome(
330
+ source,
331
+ root,
332
+ failedListing ? "listing_failed" : "listable",
333
+ items.length,
334
+ failure ?? (diagnostic ? capScanDetail(diagnostic.message) : null),
335
+ ));
336
+ finishDiscovery(outcomes.at(-1)!);
337
+ continue;
338
+ }
339
+
340
+ if (DEFAULT_SOURCES.includes(source)) {
341
+ const before = sessions.length;
342
+ let failure: string | null = null;
343
+ try {
344
+ if (options.discovery && source !== "omp") {
345
+ const cursor = previous?.cursor?.startsWith("u:") ? previous.cursor.slice(2) : undefined;
346
+ const headBudget = discoveryHeadBudget(limitPerSource, previous?.cursor ?? null);
347
+ const head = headBudget ? await listTrajectories({
348
+ source: source as AnyTrajectorySource, root: roots[source], limit: headBudget,
349
+ }) : { items: [] };
350
+ const historical: DiscoveredSession[] = [];
351
+ let resume = cursor;
352
+ do {
353
+ const page = await listTrajectories({ source: source as AnyTrajectorySource,
354
+ root: roots[source], limit: Math.min(1000, limitPerSource - headBudget - historical.length),
355
+ ...(resume ? { cursor: resume } : {}) });
356
+ if (page.nextCursor && page.nextCursor === resume) throw new Error("Discovery cursor did not advance");
357
+ historical.push(...page.items.map(item => ({ ...item, source })));
358
+ resume = page.nextCursor;
359
+ } while (resume && historical.length < limitPerSource - headBudget);
360
+ nextCursor = resume ? `u:${resume}` : null;
361
+ sessions.push(...uniqueDiscovered([...head.items.map(item => ({ ...item, source })), ...historical]));
362
+ } else {
363
+ let res = await listTrajectories({
364
+ source: source as AnyTrajectorySource,
365
+ root: roots[source],
366
+ limit: source === "omp" ? 1000 : Math.min(1000, limitPerSource),
367
+ });
368
+
369
+ // A recent child can belong to an old parent outside the requested page.
370
+ const items = [...res.items];
371
+ while (res.nextCursor && (source === "omp" || items.length < limitPerSource)) {
372
+ res = await listTrajectories({
373
+ source: source as AnyTrajectorySource,
374
+ root: roots[source],
375
+ limit: source === "omp" ? 1000 : Math.min(1000, limitPerSource - items.length),
376
+ cursor: res.nextCursor,
377
+ });
378
+ items.push(...res.items);
379
+ }
380
+
381
+ const listed: DiscoveredSession[] = items.map((item) => ({
382
+ id: item.id,
383
+ source,
384
+ path: item.path,
385
+ updatedAt: item.updatedAt,
386
+ title: item.title,
387
+ sizeBytes: item.sizeBytes,
388
+ }));
389
+ if (source === "omp") {
390
+ const candidates = [...listed, ...discoverOmpNestedSubagents(listed, options)].sort(newestFirst);
391
+ let count = 0;
392
+ const proved: DiscoveredSession[] = [];
393
+ for (const candidate of candidates) {
394
+ if (!options.discovery && count >= limitPerSource) break;
395
+ const id = candidate.id ?? readPiSessionIdFromFile(candidate.path, options);
396
+ if (!id) continue;
397
+ proved.push({ ...candidate, id });
398
+ count++;
399
+ }
400
+ sessions.push(...(options.discovery ? selectInventory(proved) : proved));
401
+ } else {
402
+ sessions.push(...listed);
403
+ }
404
+ }
405
+ } catch (error) {
406
+ // Nested OMP walk failures keep their diagnostics; only a primary
407
+ // listing failure is the source's outcome.
408
+ failure = failureDetail(error);
409
+ }
410
+ outcomes.push(scanOutcome(
411
+ source,
412
+ root,
413
+ failure ? "listing_failed" : "listable",
414
+ sessions.length - before,
415
+ failure,
416
+ ));
417
+ finishDiscovery(outcomes.at(-1)!);
418
+ continue;
419
+ }
420
+
421
+ // Custom or extra directory scanner
422
+ const byPath = new Map<string, DiscoveredSession>();
423
+ let failure: string | null = null;
424
+ let rootFailure = false;
425
+ for (const candidateRoot of candidates) {
426
+ if (!existsSync(candidateRoot.path)) continue;
427
+ const scanned = scanDirectoryForSessions(source, candidateRoot.path, 3);
428
+ if (failure === null && scanned.error !== null) {
429
+ failure = scanned.error;
430
+ // Only the primary location failing outright is an unreadable root: a
431
+ // failure inside the tree, or under a secondary location, is a partial
432
+ // listing and keeps the sessions that were read.
433
+ rootFailure = scanned.rootFailure && candidateRoot.path === candidates[0].path;
434
+ }
435
+ for (const item of scanned.items) {
436
+ byPath.set(item.path, item);
437
+ }
438
+ }
439
+
440
+ // Extra sources are directory scans rather than trajectory-library
441
+ // listings. Apply the same per-source limit and deterministic ordering
442
+ // here so duplicate roots cannot bypass the requested candidate limit.
443
+ const inventory = [...byPath.values()].sort(newestFirst);
444
+ const bounded = options.discovery ? selectInventory(inventory) : inventory.slice(0, limitPerSource);
445
+ sessions.push(...bounded);
446
+ outcomes.push(scanOutcome(
447
+ source,
448
+ root,
449
+ failure === null || bounded.length > 0
450
+ ? "listable"
451
+ : rootFailure ? "unreadable_root" : "listing_failed",
452
+ bounded.length,
453
+ failure,
454
+ ));
455
+ finishDiscovery(outcomes.at(-1)!);
456
+ }
457
+
458
+ outcomes.sort((a, b) => (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
459
+ return { sessions, outcomes, ...(options.discovery ? { discovery } : {}) };
460
+ }
461
+
462
+ type OmpCandidate = Omit<DiscoveredSession, "id"> & { id?: string };
463
+
464
+ function newestFirst(a: OmpCandidate, b: OmpCandidate): number {
465
+ return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "") || a.path.localeCompare(b.path);
466
+ }
467
+
468
+ function discoverOmpNestedSubagents(parents: DiscoveredSession[], options: SessionScanOptions): OmpCandidate[] {
469
+ const extras: OmpCandidate[] = [];
470
+ const seen = new Set(parents.map((item) => item.path));
471
+ for (const parent of parents) {
472
+ if (!parent.path.endsWith(".jsonl")) continue;
473
+ const nestedDir = parent.path.slice(0, -".jsonl".length);
474
+ if (!existsSync(nestedDir)) continue;
475
+ let entries: string[];
476
+ try {
477
+ entries = readdirSync(nestedDir);
478
+ } catch {
479
+ options.onDiagnostic?.({ code: "read_error", sourcePath: nestedDir, message: "Could not list OMP child transcripts." });
480
+ continue;
481
+ }
482
+ for (const entry of entries) {
483
+ if (!entry.endsWith(".jsonl")) continue;
484
+ const path = join(nestedDir, entry);
485
+ if (seen.has(path)) continue;
486
+ try {
487
+ const stat = statSync(path);
488
+ if (!stat.isFile()) continue;
489
+ seen.add(path);
490
+ extras.push({
491
+ source: "omp",
492
+ path,
493
+ updatedAt: stat.mtime.toISOString(),
494
+ sizeBytes: stat.size,
495
+ });
496
+ } catch {
497
+ options.onDiagnostic?.({ code: "read_error", sourcePath: path, message: "Could not inspect OMP child transcript." });
498
+ continue;
499
+ }
500
+ }
501
+ }
502
+ return extras;
503
+ }
504
+
505
+ interface DirectoryScan {
506
+ items: DiscoveredSession[];
507
+ /** First non-ENOENT filesystem failure met during the walk, as a short content-free detail. */
508
+ error: string | null;
509
+ /** True when that failure happened on the scanned root itself rather than inside it. */
510
+ rootFailure: boolean;
511
+ }
512
+
513
+ function safeDirectorySessionId(value: string): boolean {
514
+ return value.length > 0 && value.length <= 255 && value !== "." && value !== ".." && !/[\x00-\x1f/\\]/.test(value);
515
+ }
516
+
517
+ function genericJsonSidecar(base: string): boolean {
518
+ return ["meta.json", "package.json", "tsconfig.json", "manifest.json", "config.json", "settings.json", "compaction.json"]
519
+ .includes(base) || /\.(?:manifest|config|settings|compaction)\.json$/.test(base);
520
+ }
521
+
522
+ function scanDirectoryForSessions(
523
+ source: string,
524
+ dirPath: string,
525
+ maxDepth: number = 3,
526
+ ): DirectoryScan {
527
+ const items: DiscoveredSession[] = [];
528
+ let error: string | null = null;
529
+ let rootFailure = false;
530
+
531
+ function walk(current: string, depth: number) {
532
+ if (depth > maxDepth || !existsSync(current)) return;
533
+ try {
534
+ const stat = statSync(current);
535
+ if (stat.isFile()) {
536
+ const ext = extname(current).toLowerCase();
537
+ const base = basename(current).toLowerCase();
538
+ const nativeBase = basename(current);
539
+ const parent = basename(dirname(current));
540
+ if (source === "cline") {
541
+ const suffix = ".messages.json";
542
+ const id = nativeBase.endsWith(suffix) ? nativeBase.slice(0, -suffix.length) : "";
543
+ if (safeDirectorySessionId(id) && parent === id) {
544
+ items.push({ id, source, path: current, updatedAt: stat.mtime.toISOString(), sizeBytes: stat.size });
545
+ }
546
+ return;
547
+ }
548
+ if (source === "fx") {
549
+ if (nativeBase === "session.json" && safeDirectorySessionId(parent)) {
550
+ items.push({ id: parent, source, path: current, updatedAt: stat.mtime.toISOString(), sizeBytes: stat.size, native: { kind: "fx-directory" } });
551
+ }
552
+ return;
553
+ }
554
+ const isJsonlSession =
555
+ (source === "cursor" || source === "copilot-cli") && ext === ".jsonl";
556
+ const isOtherSessionFile =
557
+ source !== "cursor" &&
558
+ source !== "copilot-cli" &&
559
+ ext === ".json" &&
560
+ !genericJsonSidecar(base);
561
+ if (isJsonlSession || isOtherSessionFile) {
562
+ items.push({
563
+ id: basename(current, ext),
564
+ source,
565
+ path: current,
566
+ updatedAt: stat.mtime.toISOString(),
567
+ sizeBytes: stat.size,
568
+ });
569
+ }
570
+ return;
571
+ }
572
+
573
+ if (stat.isDirectory()) {
574
+ const entries = readdirSync(current);
575
+ for (const entry of entries) {
576
+ walk(join(current, entry), depth + 1);
577
+ }
578
+ }
579
+ } catch (caught) {
580
+ // A vanished entry is normal between listing and stat; anything else
581
+ // (permissions, I/O) is the source's outcome, not a silent skip.
582
+ if (error === null && errnoCode(caught) !== "ENOENT") {
583
+ error = failureDetail(caught);
584
+ rootFailure = depth === 0;
585
+ }
586
+ }
587
+ }
588
+
589
+ walk(dirPath, 0);
590
+ return { items, error, rootFailure };
591
+ }