@mindfoldhq/trellis 0.6.0-beta.14 → 0.6.0-beta.16

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 (61) hide show
  1. package/dist/commands/channel/adapters/codex.d.ts +4 -0
  2. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  3. package/dist/commands/channel/adapters/codex.js +27 -10
  4. package/dist/commands/channel/adapters/codex.js.map +1 -1
  5. package/dist/commands/channel/index.d.ts.map +1 -1
  6. package/dist/commands/channel/index.js +12 -7
  7. package/dist/commands/channel/index.js.map +1 -1
  8. package/dist/commands/channel/messages.js +3 -3
  9. package/dist/commands/channel/messages.js.map +1 -1
  10. package/dist/commands/channel/send.d.ts +1 -0
  11. package/dist/commands/channel/send.d.ts.map +1 -1
  12. package/dist/commands/channel/send.js +3 -1
  13. package/dist/commands/channel/send.js.map +1 -1
  14. package/dist/commands/channel/spawn.d.ts +3 -0
  15. package/dist/commands/channel/spawn.d.ts.map +1 -1
  16. package/dist/commands/channel/spawn.js +1 -0
  17. package/dist/commands/channel/spawn.js.map +1 -1
  18. package/dist/commands/channel/store/events.d.ts +1 -1
  19. package/dist/commands/channel/store/events.js +1 -1
  20. package/dist/commands/channel/store/watch.d.ts.map +1 -1
  21. package/dist/commands/channel/store/watch.js +17 -1
  22. package/dist/commands/channel/store/watch.js.map +1 -1
  23. package/dist/commands/channel/supervisor/inbox.d.ts +5 -0
  24. package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
  25. package/dist/commands/channel/supervisor/inbox.js +69 -8
  26. package/dist/commands/channel/supervisor/inbox.js.map +1 -1
  27. package/dist/commands/channel/supervisor/stdout.d.ts +3 -1
  28. package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
  29. package/dist/commands/channel/supervisor/stdout.js +17 -3
  30. package/dist/commands/channel/supervisor/stdout.js.map +1 -1
  31. package/dist/commands/channel/supervisor/turns.d.ts +20 -0
  32. package/dist/commands/channel/supervisor/turns.d.ts.map +1 -0
  33. package/dist/commands/channel/supervisor/turns.js +28 -0
  34. package/dist/commands/channel/supervisor/turns.js.map +1 -0
  35. package/dist/commands/channel/supervisor.d.ts +4 -0
  36. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  37. package/dist/commands/channel/supervisor.js +7 -0
  38. package/dist/commands/channel/supervisor.js.map +1 -1
  39. package/dist/commands/channel/threads.d.ts +2 -2
  40. package/dist/commands/channel/threads.d.ts.map +1 -1
  41. package/dist/commands/channel/threads.js +3 -3
  42. package/dist/commands/channel/threads.js.map +1 -1
  43. package/dist/commands/mem.d.ts +13 -217
  44. package/dist/commands/mem.d.ts.map +1 -1
  45. package/dist/commands/mem.js +142 -1587
  46. package/dist/commands/mem.js.map +1 -1
  47. package/dist/migrations/manifests/0.6.0-beta.15.json +9 -0
  48. package/dist/migrations/manifests/0.6.0-beta.16.json +9 -0
  49. package/dist/templates/claude/agents/trellis-check.md +11 -5
  50. package/dist/templates/codebuddy/agents/trellis-check.md +11 -5
  51. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +27 -0
  52. package/dist/templates/cursor/agents/trellis-check.md +11 -5
  53. package/dist/templates/droid/droids/trellis-check.md +11 -5
  54. package/dist/templates/gemini/agents/trellis-check.md +11 -5
  55. package/dist/templates/kiro/agents/trellis-check.json +1 -1
  56. package/dist/templates/opencode/agents/trellis-check.md +11 -5
  57. package/dist/templates/pi/agents/trellis-check.md +5 -4
  58. package/dist/templates/pi/agents/trellis-implement.md +5 -4
  59. package/dist/templates/qoder/agents/trellis-check.md +11 -5
  60. package/dist/templates/trellis/workflow.md +20 -0
  61. package/package.json +2 -2
@@ -1,5 +1,9 @@
1
1
  /**
2
- * mem.ts — search sessions across Claude Code / Codex / OpenCode.
2
+ * mem.ts — CLI wrapper over `@mindfoldhq/trellis-core/mem`.
3
+ *
4
+ * The reusable retrieval / context-extraction logic lives in core; this file
5
+ * owns only CLI concerns: argument parsing, terminal rendering, the OpenCode
6
+ * "reader unavailable" notice, and process exit behavior.
3
7
  *
4
8
  * Commands:
5
9
  * list list sessions (default if no command)
@@ -10,126 +14,9 @@
10
14
  *
11
15
  * Run `trellis mem help` for the full flag reference.
12
16
  */
13
- import * as fs from "node:fs";
14
- import * as path from "node:path";
15
17
  import * as os from "node:os";
16
- import { z } from "zod";
17
- // ---------- schemas: domain types ----------
18
- const PlatformSchema = z.enum(["claude", "codex", "opencode"]);
19
- const SessionInfoSchema = z.object({
20
- platform: PlatformSchema,
21
- id: z.string(),
22
- title: z.string().optional(),
23
- cwd: z.string().optional(),
24
- created: z.string().optional(),
25
- updated: z.string().optional(),
26
- filePath: z.string(),
27
- parent_id: z.string().optional(), // OpenCode only: parent session id (sub-agent chain)
28
- });
29
- const DialogueRoleSchema = z.enum(["user", "assistant"]);
30
- const SearchExcerptSchema = z.object({
31
- role: DialogueRoleSchema,
32
- snippet: z.string(),
33
- });
34
- const SearchHitSchema = z.object({
35
- count: z.number(), // total token occurrences across all matching turns
36
- user_count: z.number(), // breakdown: user-turn occurrences
37
- asst_count: z.number(), // breakdown: assistant-turn occurrences
38
- total_turns: z.number(), // size of cleaned dialogue (denominator for density)
39
- excerpts: z.array(SearchExcerptSchema),
40
- });
41
- /** Weighted-density relevance score:
42
- * (3 * user_hits + asst_hits) / total_turns
43
- * Higher = the session is more topically concentrated on the query AND the
44
- * user themselves brought it up (user hits weighted ×3 because the user's own
45
- * words anchor "what they actually cared about", while assistant elaboration
46
- * is downstream noise). */
47
- export function relevanceScore(h) {
48
- if (h.total_turns === 0)
49
- return 0;
50
- return (3 * h.user_count + h.asst_count) / h.total_turns;
51
- }
52
- const FilterSchema = z.object({
53
- platform: z.union([PlatformSchema, z.literal("all")]),
54
- since: z.date().optional(),
55
- until: z.date().optional(),
56
- cwd: z.string().optional(),
57
- limit: z.number(),
58
- });
59
- const ArgvSchema = z.object({
60
- cmd: z.string(),
61
- positional: z.array(z.string()),
62
- flags: z.record(z.string(), z.union([z.string(), z.boolean()])),
63
- });
64
- // ---------- schemas: external file formats ----------
65
- // Claude Code JSONL events. We only declare the fields we read; everything
66
- // else passes through. Content of an assistant `message` is an array of
67
- // blocks (text / thinking / tool_use); content of a user `message` is a
68
- // string for real human input or an array of tool_result blocks (skipped).
69
- const ClaudeBlockSchema = z
70
- .object({
71
- type: z.string().optional(),
72
- text: z.string().optional(),
73
- })
74
- .loose();
75
- const ClaudeMessageSchema = z
76
- .object({
77
- role: z.string().optional(),
78
- content: z.union([z.string(), z.array(ClaudeBlockSchema)]).optional(),
79
- })
80
- .loose();
81
- const ClaudeEventSchema = z
82
- .object({
83
- type: z.string().optional(),
84
- cwd: z.string().optional(),
85
- timestamp: z.string().optional(),
86
- message: ClaudeMessageSchema.optional(),
87
- isCompactSummary: z.boolean().optional(),
88
- })
89
- .loose();
90
- const ClaudeIndexEntrySchema = z
91
- .object({
92
- id: z.string(),
93
- cwd: z.string().optional(),
94
- created: z.string().optional(),
95
- title: z.string().optional(),
96
- })
97
- .loose();
98
- const ClaudeIndexSchema = z
99
- .object({ entries: z.array(ClaudeIndexEntrySchema).optional() })
100
- .loose();
101
- // Codex rollout JSONL events.
102
- const CodexContentPartSchema = z
103
- .object({
104
- type: z.string().optional(),
105
- text: z.string().optional(),
106
- })
107
- .loose();
108
- const CodexCompactedItemSchema = z
109
- .object({
110
- type: z.string().optional(),
111
- role: z.string().optional(),
112
- content: z.array(CodexContentPartSchema).optional(),
113
- })
114
- .loose();
115
- const CodexPayloadSchema = z
116
- .object({
117
- type: z.string().optional(),
118
- role: z.string().optional(),
119
- cwd: z.string().optional(),
120
- id: z.string().optional(),
121
- content: z.array(CodexContentPartSchema).optional(),
122
- replacement_history: z.array(CodexCompactedItemSchema).optional(),
123
- })
124
- .loose();
125
- const CodexEventSchema = z
126
- .object({
127
- timestamp: z.string().optional(),
128
- type: z.string().optional(),
129
- payload: CodexPayloadSchema.optional(),
130
- })
131
- .loose();
132
- // ---------- argv ----------
18
+ import * as path from "node:path";
19
+ import { extractMemDialogue, listMemProjects, listMemSessions, MemSessionNotFoundError, readMemContext, searchMemSessions, } from "@mindfoldhq/trellis-core/mem";
133
20
  export function parseArgv(argv) {
134
21
  const cmd = argv[0] ?? "list";
135
22
  const positional = [];
@@ -153,1121 +40,57 @@ export function parseArgv(argv) {
153
40
  positional.push(a);
154
41
  }
155
42
  }
156
- return ArgvSchema.parse({ cmd, positional, flags });
43
+ return { cmd, positional, flags };
157
44
  }
45
+ const VALID_PLATFORMS = [
46
+ "claude",
47
+ "codex",
48
+ "opencode",
49
+ "all",
50
+ ];
51
+ /** Translate parsed CLI flags into a core `MemFilter`. Validation failures
52
+ * exit the process — core never sees raw CLI flags. */
158
53
  export function buildFilter(flags) {
159
54
  const platformRaw = typeof flags.platform === "string" ? flags.platform : "all";
160
- const platformParsed = z
161
- .union([PlatformSchema, z.literal("all")])
162
- .safeParse(platformRaw);
163
- if (!platformParsed.success)
55
+ if (!VALID_PLATFORMS.includes(platformRaw))
164
56
  die(`unknown platform: ${platformRaw}`);
57
+ const platform = platformRaw;
165
58
  const sinceRaw = flags.since;
166
59
  const since = typeof sinceRaw === "string" ? new Date(sinceRaw) : undefined;
167
60
  if (since && Number.isNaN(+since))
168
- die(`bad --since: ${sinceRaw}`);
61
+ die(`bad --since: ${String(sinceRaw)}`);
169
62
  const untilRaw = flags.until;
170
63
  const until = typeof untilRaw === "string"
171
64
  ? new Date(`${untilRaw}T23:59:59.999Z`)
172
65
  : undefined;
173
66
  if (until && Number.isNaN(+until))
174
- die(`bad --until: ${untilRaw}`);
67
+ die(`bad --until: ${String(untilRaw)}`);
175
68
  const cwd = flags.global
176
69
  ? undefined
177
70
  : path.resolve(typeof flags.cwd === "string" ? flags.cwd : process.cwd());
178
- const limit = typeof flags.limit === "string" ? Number(flags.limit) : 50;
179
- return FilterSchema.parse({
180
- platform: platformParsed.data,
181
- since,
182
- until,
183
- cwd,
184
- limit,
185
- });
71
+ const limit = parseOptionalNumberFlag(flags.limit, "--limit", 50);
72
+ return { platform, since, until, cwd, limit };
73
+ }
74
+ function parseOptionalNumberFlag(raw, name, fallback) {
75
+ if (raw === undefined || raw === false)
76
+ return fallback;
77
+ if (typeof raw !== "string")
78
+ die(`${name} requires a number`);
79
+ const value = Number(raw);
80
+ if (!Number.isFinite(value))
81
+ die(`bad ${name}: ${raw}`);
82
+ return value;
186
83
  }
187
84
  function die(msg) {
188
85
  console.error(`error: ${msg}`);
189
86
  process.exit(2);
190
87
  }
191
- // ---------- common helpers ----------
192
- const HOME = os.homedir();
193
- export function inRange(iso, f) {
194
- if (!iso)
195
- return true;
196
- const t = new Date(iso);
197
- if (Number.isNaN(+t))
198
- return true;
199
- if (f.since && t < f.since)
200
- return false;
201
- if (f.until && t > f.until)
202
- return false;
203
- return true;
204
- }
205
- /**
206
- * Interval-overlap version of `inRange` for sessions with both start and end
207
- * timestamps. A session is kept iff its lifetime `[start, end]` overlaps the
208
- * query window `[f.since, f.until]`.
209
- *
210
- * Why this exists: long / cross-day sessions (created on day N, still updated
211
- * on day N+M) were being dropped by `inRange(created, f)` when `--since` fell
212
- * after `created`. Switching to interval overlap keeps sessions that were
213
- * active inside the window even when they started before it.
214
- *
215
- * Degenerate inputs:
216
- * - both undefined → pass through (no timestamp = don't filter)
217
- * - one undefined → fall back to single-point semantics on the other end
218
- * - unparseable iso → defer to the parsable end (or pass through if both bad)
219
- */
220
- export function inRangeOverlap(start, end, f) {
221
- const s = start ?? end;
222
- const e = end ?? start;
223
- if (!s && !e)
224
- return true;
225
- if (f.since && e) {
226
- const eT = new Date(e);
227
- if (!Number.isNaN(+eT) && eT < f.since)
228
- return false;
229
- }
230
- if (f.until && s) {
231
- const sT = new Date(s);
232
- if (!Number.isNaN(+sT) && sT > f.until)
233
- return false;
234
- }
235
- return true;
236
- }
237
- export function sameProject(sessionCwd, target) {
238
- if (!target)
239
- return true;
240
- if (!sessionCwd)
241
- return false;
242
- const a = path.resolve(sessionCwd);
243
- const b = path.resolve(target);
244
- return a === b || a.startsWith(b + path.sep);
245
- }
246
- /** Walk JSONL line-by-line, calling `onLine` with each parsed object that
247
- * matches the supplied schema. Bad JSON or schema-mismatched lines are skipped.
248
- * Returning the literal "stop" from `onLine` halts iteration.
249
- *
250
- * Chunked sync streaming: 256 KB read window, leftover preserved across
251
- * chunks for split-line reassembly. Two practical wins over the original
252
- * `fs.readFileSync` + `data.split("\n")`:
253
- *
254
- * 1. **Bounded peek** — `readJsonlFirst` / `findInJsonl(maxLines<100)` only
255
- * pull the first chunk (256 KB) and stop, instead of loading multi-MB
256
- * rollout files in full just to read the head. 30-100× speedup on the
257
- * listing fan-out path.
258
- * 2. **Heap floor** — full-scan paths (`extract` / `search`) keep ~256 KB +
259
- * one leftover line resident instead of 36 MB sessions held as one big
260
- * UTF-8 string. Roughly 30× peak-heap drop on long sessions.
261
- *
262
- * Byte-prefix fast-reject: a JSONL event line virtually always begins with
263
- * `{` (object). Lines starting with any other byte are blanks, log
264
- * preambles, or trailing whitespace — `JSON.parse` would throw and
265
- * `safeParse` would fail. Checking the first byte before allocating the
266
- * parse exception path saves measurable wall time on heavy sessions. */
267
- function readJsonl(file, schema, onLine) {
268
- let fd;
269
- try {
270
- fd = fs.openSync(file, "r");
271
- }
272
- catch {
273
- return;
274
- }
275
- const CHUNK = 256 * 1024;
276
- const OPEN_BRACE = 0x7b; // '{'
277
- const buf = Buffer.alloc(CHUNK);
278
- let leftover = "";
279
- try {
280
- let stop = false;
281
- while (!stop) {
282
- const n = fs.readSync(fd, buf, 0, CHUNK, null);
283
- if (n === 0)
284
- break;
285
- const chunk = leftover + buf.toString("utf8", 0, n);
286
- let from = 0;
287
- while (true) {
288
- const nl = chunk.indexOf("\n", from);
289
- if (nl === -1) {
290
- leftover = chunk.slice(from);
291
- break;
292
- }
293
- const line = chunk.slice(from, nl);
294
- from = nl + 1;
295
- if (!line)
296
- continue;
297
- // Byte-prefix fast-reject before JSON.parse / zod.
298
- if (line.charCodeAt(0) !== OPEN_BRACE)
299
- continue;
300
- let raw;
301
- try {
302
- raw = JSON.parse(line);
303
- }
304
- catch {
305
- continue;
306
- }
307
- const parsed = schema.safeParse(raw);
308
- if (!parsed.success)
309
- continue;
310
- if (onLine(parsed.data) === "stop") {
311
- stop = true;
312
- break;
313
- }
314
- }
315
- }
316
- if (!stop && leftover) {
317
- // File ended without trailing newline — process the last partial line.
318
- const line = leftover;
319
- if (line?.charCodeAt(0) === OPEN_BRACE) {
320
- try {
321
- const raw = JSON.parse(line);
322
- const parsed = schema.safeParse(raw);
323
- if (parsed.success)
324
- onLine(parsed.data);
325
- }
326
- catch {
327
- /* skip */
328
- }
329
- }
330
- }
331
- }
332
- finally {
333
- fs.closeSync(fd);
334
- }
335
- }
336
- function readJsonlFirst(file, schema) {
337
- let result;
338
- readJsonl(file, schema, (obj) => {
339
- result = obj;
340
- return "stop";
341
- });
342
- return result;
343
- }
344
- function findInJsonl(file, schema, predicate, maxLines = 200) {
345
- let count = 0;
346
- let hit;
347
- readJsonl(file, schema, (obj) => {
348
- count++;
349
- if (predicate(obj)) {
350
- hit = obj;
351
- return "stop";
352
- }
353
- if (count >= maxLines)
354
- return "stop";
355
- });
356
- return hit;
357
- }
358
- function readJsonFile(file, schema) {
359
- let raw;
360
- try {
361
- raw = JSON.parse(fs.readFileSync(file, "utf8"));
362
- }
363
- catch {
364
- return undefined;
365
- }
366
- const parsed = schema.safeParse(raw);
367
- return parsed.success ? parsed.data : undefined;
368
- }
369
- // ---------- dialogue cleaning ----------
370
- const INJECTION_TAGS = [
371
- "system-reminder",
372
- "task-status",
373
- "ready",
374
- "current-state",
375
- "workflow",
376
- "workflow-state",
377
- "guidelines",
378
- "instructions",
379
- "command-name",
380
- "command-message",
381
- "command-args",
382
- "local-command-stdout",
383
- "local-command-stderr",
384
- "permissions instructions",
385
- "collaboration_mode",
386
- "environment_context",
387
- "auto_compact_summary",
388
- "user_instructions",
389
- ];
390
- /** True if this turn is a platform bootstrap injection (AGENTS.md, pure
391
- * INSTRUCTIONS preamble, etc.) and should be dropped wholesale rather than
392
- * partially cleaned. Detected after stripInjectionTags, so we look at what's
393
- * left after tag-stripping. */
394
- export function isBootstrapTurn(cleaned, originalLength) {
395
- if (cleaned.startsWith("# AGENTS.md instructions for"))
396
- return true;
397
- // A turn that's mostly an INSTRUCTIONS block (Codex injects this as user role).
398
- if (originalLength > 4000 && /^<INSTRUCTIONS>/i.test(cleaned))
399
- return true;
400
- return false;
401
- }
402
- export function stripInjectionTags(text) {
403
- let out = text;
404
- for (const tag of INJECTION_TAGS) {
405
- const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
406
- // Case-insensitive: Codex/Trellis injection tags appear as both <INSTRUCTIONS>
407
- // and <instructions> across platforms.
408
- out = out.replace(new RegExp(`<${escaped}[^>]*>[\\s\\S]*?</${escaped}>`, "gi"), "");
409
- }
410
- out = out.replace(/^# AGENTS\.md instructions for[\s\S]*?(?=\n\n[A-Z一-龥]|$)/m, "");
411
- return out.replace(/\n{3,}/g, "\n\n").trim();
412
- }
413
- /** Find the paragraph-aligned chunk surrounding a hit position. A "chunk" is
414
- * the contiguous text bounded by the nearest blank-line breaks (`\n\n`) on
415
- * either side. If the natural paragraph exceeds `maxChars`, fall back to a
416
- * centered char window — and report the truncation so callers can mark it. */
417
- export function chunkAround(text, hitIdx, maxChars) {
418
- const startPara = text.lastIndexOf("\n\n", hitIdx);
419
- let start = startPara === -1 ? 0 : startPara + 2;
420
- const endPara = text.indexOf("\n\n", hitIdx);
421
- let end = endPara === -1 ? text.length : endPara;
422
- let truncated = false;
423
- if (end - start > maxChars) {
424
- start = Math.max(0, hitIdx - Math.floor(maxChars / 2));
425
- end = Math.min(text.length, hitIdx + Math.ceil(maxChars / 2));
426
- truncated = true;
427
- }
428
- return { start, end, truncated };
429
- }
430
- /** Multi-token AND grep over cleaned dialogue. Whitespace-split tokens; a
431
- * turn matches if every token (case-insensitive) appears anywhere in it.
432
- * `count` is the total occurrence count across all tokens within matching
433
- * turns. Excerpts are paragraph-aligned chunks (drawer-style): for each
434
- * matching turn we collect chunks around every hit position, dedupe by
435
- * chunk start so adjacent hits inside the same paragraph collapse to one
436
- * chunk. User-role chunks are listed first (the user's own words anchor
437
- * topic intent more reliably than AI elaboration). */
438
- export function searchInDialogue(turns, kw, maxExcerpts = 3, chunkChars = 400) {
439
- const tokens = kw.toLowerCase().split(/\s+/).filter(Boolean);
440
- const empty = SearchHitSchema.parse({
441
- count: 0,
442
- user_count: 0,
443
- asst_count: 0,
444
- total_turns: turns.length,
445
- excerpts: [],
446
- });
447
- if (tokens.length === 0)
448
- return empty;
449
- let userCount = 0;
450
- let asstCount = 0;
451
- const userExcerpts = [];
452
- const asstExcerpts = [];
453
- for (const t of turns) {
454
- const hay = t.text.toLowerCase();
455
- if (!tokens.every((tok) => hay.includes(tok)))
456
- continue;
457
- // Collect every hit position with the token that produced it (for both
458
- // counting and rarity-aware chunk anchor selection).
459
- const hitPositions = [];
460
- const tokenFreq = new Map();
461
- let turnHits = 0;
462
- for (const tok of tokens) {
463
- let from = 0;
464
- let n = 0;
465
- while (true) {
466
- const idx = hay.indexOf(tok, from);
467
- if (idx === -1)
468
- break;
469
- n++;
470
- turnHits++;
471
- hitPositions.push({ idx, tok });
472
- from = idx + tok.length;
473
- }
474
- tokenFreq.set(tok, n);
475
- }
476
- if (t.role === "user")
477
- userCount += turnHits;
478
- else
479
- asstCount += turnHits;
480
- hitPositions.sort((a, b) => a.idx - b.idx);
481
- const candidates = [];
482
- const seenStarts = new Set();
483
- for (const { idx, tok } of hitPositions) {
484
- const { start, end, truncated } = chunkAround(t.text, idx, chunkChars);
485
- if (seenStarts.has(start))
486
- continue;
487
- seenStarts.add(start);
488
- const slice = hay.slice(start, end);
489
- const coverage = tokens.filter((tk) => slice.includes(tk)).length;
490
- const rarity = 1 / (tokenFreq.get(tok) ?? 1);
491
- candidates.push({ start, end, truncated, coverage, rarity });
492
- }
493
- candidates.sort((a, b) => {
494
- if (b.coverage !== a.coverage)
495
- return b.coverage - a.coverage;
496
- if (b.rarity !== a.rarity)
497
- return b.rarity - a.rarity;
498
- return a.start - b.start;
499
- });
500
- for (const c of candidates) {
501
- let snippet = t.text.slice(c.start, c.end).trim();
502
- if (c.truncated) {
503
- if (c.start > 0)
504
- snippet = "…" + snippet;
505
- if (c.end < t.text.length)
506
- snippet += "…";
507
- }
508
- (t.role === "user" ? userExcerpts : asstExcerpts).push({
509
- role: t.role,
510
- snippet,
511
- });
512
- }
513
- }
514
- const excerpts = [...userExcerpts, ...asstExcerpts].slice(0, maxExcerpts);
515
- return SearchHitSchema.parse({
516
- count: userCount + asstCount,
517
- user_count: userCount,
518
- asst_count: asstCount,
519
- total_turns: turns.length,
520
- excerpts,
521
- });
522
- }
523
- // ---------- claude adapter ----------
524
- const CLAUDE_PROJECTS = path.join(HOME, ".claude", "projects");
525
- function claudeProjectDirFromCwd(cwd) {
526
- // Claude sanitizes path: every '/' and '_' becomes '-'.
527
- return path.join(CLAUDE_PROJECTS, cwd.replace(/[/_]/g, "-"));
528
- }
529
- export function claudeListSessions(f) {
530
- if (!fs.existsSync(CLAUDE_PROJECTS))
531
- return [];
532
- const out = [];
533
- const projectDirs = f.cwd
534
- ? [claudeProjectDirFromCwd(f.cwd)].filter((d) => fs.existsSync(d))
535
- : fs.readdirSync(CLAUDE_PROJECTS).map((d) => path.join(CLAUDE_PROJECTS, d));
536
- for (const dir of projectDirs) {
537
- let entries;
538
- try {
539
- entries = fs.readdirSync(dir, { withFileTypes: true });
540
- }
541
- catch {
542
- continue;
543
- }
544
- const indexFile = path.join(dir, "sessions-index.json");
545
- const index = readJsonFile(indexFile, ClaudeIndexSchema);
546
- const indexById = new Map();
547
- for (const e of index?.entries ?? [])
548
- indexById.set(e.id, e);
549
- for (const e of entries) {
550
- if (!e.isFile() || !e.name.endsWith(".jsonl"))
551
- continue;
552
- const filePath = path.join(dir, e.name);
553
- const id = e.name.replace(/\.jsonl$/, "");
554
- const idx = indexById.get(id);
555
- let cwd = idx?.cwd;
556
- let created = idx?.created;
557
- const title = idx?.title;
558
- if (!cwd || !created) {
559
- const evt = findInJsonl(filePath, ClaudeEventSchema, (o) => typeof o.cwd === "string", 100);
560
- cwd = cwd ?? evt?.cwd;
561
- created =
562
- created ??
563
- evt?.timestamp ??
564
- readJsonlFirst(filePath, ClaudeEventSchema)?.timestamp;
565
- }
566
- const stat = fs.statSync(filePath);
567
- const updated = stat.mtime.toISOString();
568
- // Interval overlap: keep sessions whose lifetime [created, updated]
569
- // intersects the query window. Cross-day sessions (created before
570
- // --since but still active inside it) must survive — see PRD
571
- // 05-08-mem-since-cross-day-filter.
572
- if (!inRangeOverlap(created, updated, f))
573
- continue;
574
- if (f.cwd && cwd && !sameProject(cwd, f.cwd))
575
- continue;
576
- out.push(SessionInfoSchema.parse({
577
- platform: "claude",
578
- id,
579
- title,
580
- cwd,
581
- created,
582
- updated,
583
- filePath,
584
- }));
585
- }
586
- }
587
- return out;
588
- }
589
- export function claudeExtractDialogue(s) {
590
- // Mirrors session-insight/extract-session.py:
591
- // - user: type=="user" + role=="user" + content is string (list = tool_result)
592
- // - assistant: type=="assistant" + role=="assistant", keep only `text` blocks
593
- // - thinking and tool_use blocks dropped entirely
594
- // - injection tags stripped
595
- // Compaction: when we hit a `user` event with isCompactSummary=true, drop all
596
- // pre-compact turns and replace them with a synthetic [compact summary] turn —
597
- // the pre-compact content is now redundant with the summary.
598
- let turns = [];
599
- readJsonl(s.filePath, ClaudeEventSchema, (obj) => {
600
- const t = obj.type;
601
- const msg = obj.message;
602
- if (!msg)
603
- return;
604
- const content = msg.content;
605
- if (t === "user" && obj.isCompactSummary === true) {
606
- let summary = "";
607
- if (typeof content === "string") {
608
- summary = stripInjectionTags(content);
609
- }
610
- else if (Array.isArray(content)) {
611
- const parts = [];
612
- for (const block of content) {
613
- if (block.type === "text" && typeof block.text === "string") {
614
- const cleaned = stripInjectionTags(block.text);
615
- if (cleaned)
616
- parts.push(cleaned);
617
- }
618
- }
619
- summary = parts.join("\n\n");
620
- }
621
- turns = summary
622
- ? [{ role: "user", text: `[compact summary]\n${summary}` }]
623
- : [];
624
- return;
625
- }
626
- if (t === "user" && msg.role === "user") {
627
- if (typeof content === "string") {
628
- const text = stripInjectionTags(content);
629
- if (text && !isBootstrapTurn(text, content.length)) {
630
- turns.push({ role: "user", text });
631
- }
632
- }
633
- }
634
- else if (t === "assistant" &&
635
- msg.role === "assistant" &&
636
- Array.isArray(content)) {
637
- const parts = [];
638
- for (const block of content) {
639
- if (block.type === "text" && typeof block.text === "string") {
640
- const cleaned = stripInjectionTags(block.text);
641
- if (cleaned)
642
- parts.push(cleaned);
643
- }
644
- }
645
- if (parts.length)
646
- turns.push({ role: "assistant", text: parts.join("\n\n") });
647
- }
648
- });
649
- return turns;
650
- }
651
- export function claudeSearch(s, kw) {
652
- return searchInDialogue(claudeExtractDialogue(s), kw);
653
- }
654
- /** Find ALL `task.py create|start` invocations in a single Bash command
655
- * string. A real Bash invocation can contain several (e.g.
656
- * `SMOKE=$(task.py create …); task.py start "$SMOKE"; …`); the original
657
- * single-match `parseTaskPyCommand` only saw the first one and silently
658
- * dropped the rest, breaking pairing in any session that used such patterns.
659
- *
660
- * Returned in source order. Each entry's `restRaw` is bounded to the next
661
- * `task.py` invocation or end-of-line, whichever comes first, so multi-action
662
- * one-liners are split safely without leaking later args into earlier ones. */
663
- export function parseTaskPyCommandsAll(cmd) {
664
- if (typeof cmd !== "string" || cmd.length === 0)
665
- return [];
666
- // Find every `task.py (create|start)` occurrence with a left boundary of
667
- // start-of-string, whitespace, or path separator (forward or backward
668
- // slash). This rejects flag-value embedding like `--slug=task.py-create-foo`.
669
- const all = [];
670
- const findRe = /(^|[\s/\\])task\.py\s+(create|start)(?:\s+|$)/g;
671
- const matches = [];
672
- for (const m of cmd.matchAll(findRe)) {
673
- const action = m[2];
674
- // bodyStart = right after the matched whitespace following the action verb
675
- const bodyStart = m.index + m[0].length;
676
- matches.push({ action, bodyStart });
677
- }
678
- for (let i = 0; i < matches.length; i++) {
679
- const cur = matches[i];
680
- if (!cur)
681
- continue;
682
- const next = matches[i + 1];
683
- // restRaw stops at the next `task.py` invocation (so we don't claim args
684
- // from later commands), or end-of-string otherwise. Take only up to the
685
- // first newline — multi-line scripts have one task.py per line as the
686
- // dominant pattern.
687
- const slice = cmd.slice(cur.bodyStart, next?.bodyStart ?? cmd.length);
688
- const restRaw = (slice.split("\n")[0] ?? "").trim();
689
- // Reject prose-embedded matches. The pattern is: a bare alphanumeric word
690
- // followed by another all-letters word with a single space gap — that's
691
- // English prose like "task.py start exits with hint", not a real
692
- // invocation (CLI args after the action are typically quoted titles,
693
- // dashed flags, paths starting with `.` `/` `~` `$`, or followed by shell
694
- // metacharacters like `2>&1` / `|` / `;`). A real `create my-task`
695
- // (single bare positional with no trailing English) is kept.
696
- if (/^[A-Za-z][A-Za-z0-9_-]*\s+[A-Za-z]{2,}\b/.test(restRaw))
697
- continue;
698
- const parsed = parseRestOfTaskPyCommand(cur.action, restRaw);
699
- // Drop entries with no extractable info — likely prose with quote-like
700
- // punctuation but no real arg.
701
- if (cur.action === "create" &&
702
- parsed.action === "create" &&
703
- !parsed.slug &&
704
- !parsed.titleArg)
705
- continue;
706
- if (cur.action === "start" && parsed.action === "start" && !parsed.taskDir)
707
- continue;
708
- all.push(parsed);
709
- }
710
- return all;
711
- }
712
- /** Single-result wrapper for backwards compatibility (returns the first
713
- * occurrence, or null if none). Existing tests that assume single-match
714
- * semantics still pass via this helper; new code should call
715
- * `parseTaskPyCommandsAll`. */
716
- export function parseTaskPyCommand(cmd) {
717
- const all = parseTaskPyCommandsAll(cmd);
718
- return all[0] ?? null;
719
- }
720
- function parseRestOfTaskPyCommand(action, restRaw) {
721
- if (action === "create") {
722
- const args = splitShellArgs(restRaw);
723
- // First positional arg (skip any flags). For `task.py create`, the title
724
- // is typically the first quoted positional; --slug FOO appears as a flag.
725
- let slug;
726
- let titleArg;
727
- for (let i = 0; i < args.length; i++) {
728
- const a = args[i];
729
- if (a === undefined)
730
- continue;
731
- if (a === "--slug" || a === "-s") {
732
- slug = args[i + 1];
733
- i++;
734
- continue;
735
- }
736
- if (a.startsWith("--slug=")) {
737
- slug = a.slice("--slug=".length);
738
- continue;
739
- }
740
- if (a.startsWith("-"))
741
- continue;
742
- titleArg ??= a;
743
- }
744
- return { action: "create", slug, titleArg };
745
- }
746
- // start
747
- const args = splitShellArgs(restRaw);
748
- let taskDir;
749
- for (const a of args) {
750
- if (a.startsWith("-"))
751
- continue;
752
- taskDir = a;
753
- break;
754
- }
755
- return { action: "start", taskDir };
756
- }
757
- /** Best-effort shell-arg splitter: respects `"…"` and `'…'` quoting, splits on
758
- * whitespace, treats shell metacharacters `;`, `|`, `&`, `(`, `)`, `>` as
759
- * **token boundaries** (so `$(...)` substitution boundaries, command chains,
760
- * and redirects don't leak into the next positional arg). Also strips any
761
- * trailing shell-meta cruft from individual tokens — e.g. a `--slug` value
762
- * captured inside `$(... --slug FOO)` gets the closing `)` lopped off.
763
- * Sufficient for parsing slugs/paths out of `task.py create|start`
764
- * invocations; not a full POSIX parser. */
765
- function splitShellArgs(s) {
766
- const out = [];
767
- let cur = "";
768
- let quote = null;
769
- const flush = () => {
770
- if (!cur)
771
- return;
772
- // Strip trailing shell metas that snuck in from $(...) substitution edges,
773
- // command chains, redirects, etc. Keep leading chars (paths may start with
774
- // `.` or `/`).
775
- const cleaned = cur.replace(/[)};&|>]+$/, "");
776
- if (cleaned)
777
- out.push(cleaned);
778
- cur = "";
779
- };
780
- for (const ch of s) {
781
- if (quote) {
782
- if (ch === quote) {
783
- quote = null;
784
- continue;
785
- }
786
- cur += ch;
787
- continue;
788
- }
789
- if (ch === '"' || ch === "'") {
790
- quote = ch;
791
- continue;
792
- }
793
- if (/\s/.test(ch)) {
794
- flush();
795
- continue;
796
- }
797
- // Hard token boundaries — these never belong inside a slug or path arg.
798
- // Drop them (don't keep as standalone token; the caller never wants them).
799
- if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")") {
800
- flush();
801
- continue;
802
- }
803
- cur += ch;
804
- }
805
- flush();
806
- return out;
807
- }
808
- /** Derive a slug from a `start` task-dir path like
809
- * `.trellis/tasks/05-08-mem-phase-slice/` → `mem-phase-slice` (the
810
- * `MM-DD-` date prefix is stripped so this matches the slug supplied via
811
- * `--slug` on the corresponding `task.py create` invocation). */
812
- function slugFromTaskDir(p) {
813
- if (!p)
814
- return undefined;
815
- // Normalize separators and trim trailing slash + shell metas leaked from
816
- // `$(...)` substitution / heredoc edges.
817
- const norm = p.replace(/\\+/g, "/").replace(/\/+$/g, "");
818
- const parts = norm.split("/").filter(Boolean);
819
- const last = parts[parts.length - 1];
820
- if (last === undefined)
821
- return undefined;
822
- // Strip leading `MM-DD-` (e.g. `05-08-`) added by task.py.
823
- return last.replace(/^\d{2}-\d{2}-/, "");
824
- }
825
- /**
826
- * Single-pass scan of a Claude JSONL file that produces both:
827
- * 1. the cleaned dialogue turns (semantically identical to
828
- * `claudeExtractDialogue`)
829
- * 2. the list of `task.py create|start` Bash tool_use events with their
830
- * `turnIndex` (= turns.length AT THE TIME the tool_use was seen).
831
- *
832
- * Why one pass: we need the turnIndex to align with `claudeExtractDialogue`'s
833
- * output exactly, including compaction-reset behavior. A second pass would
834
- * have to re-derive turn indices from timestamps, which is fragile when
835
- * timestamps repeat or are missing.
836
- *
837
- * For non-Claude platforms this returns turns + an empty event list; callers
838
- * are expected to handle Codex/OpenCode boundary detection separately (or
839
- * gracefully degrade — see PRD MVP scope).
840
- */
841
- export function collectClaudeTurnsAndEvents(s) {
842
- let turns = [];
843
- let events = [];
844
- readJsonl(s.filePath, ClaudeEventSchema, (obj) => {
845
- const t = obj.type;
846
- const msg = obj.message;
847
- if (!msg)
848
- return;
849
- const content = msg.content;
850
- if (t === "user" && obj.isCompactSummary === true) {
851
- let summary = "";
852
- if (typeof content === "string") {
853
- summary = stripInjectionTags(content);
854
- }
855
- else if (Array.isArray(content)) {
856
- const parts = [];
857
- for (const block of content) {
858
- if (block.type === "text" && typeof block.text === "string") {
859
- const cleaned = stripInjectionTags(block.text);
860
- if (cleaned)
861
- parts.push(cleaned);
862
- }
863
- }
864
- summary = parts.join("\n\n");
865
- }
866
- turns = summary
867
- ? [{ role: "user", text: `[compact summary]\n${summary}` }]
868
- : [];
869
- // Reset events too: pre-compact task.py events anchor to turnIndex
870
- // values that no longer correspond to real turns (the underlying
871
- // dialogue is collapsed into a single synthetic [compact summary]).
872
- // Pairing pre-compact events to post-compact turns would produce
873
- // incoherent windows.
874
- events = [];
875
- return;
876
- }
877
- if (t === "user" && msg.role === "user") {
878
- if (typeof content === "string") {
879
- const text = stripInjectionTags(content);
880
- if (text && !isBootstrapTurn(text, content.length)) {
881
- turns.push({ role: "user", text });
882
- }
883
- }
884
- return;
885
- }
886
- if (t === "assistant" &&
887
- msg.role === "assistant" &&
888
- Array.isArray(content)) {
889
- // Walk blocks: text blocks contribute to the eventual cleaned turn;
890
- // tool_use blocks with name="Bash" are scanned for task.py invocations.
891
- const parts = [];
892
- for (const block of content) {
893
- if (block.type === "text" && typeof block.text === "string") {
894
- const cleaned = stripInjectionTags(block.text);
895
- if (cleaned)
896
- parts.push(cleaned);
897
- }
898
- else if (block.type === "tool_use") {
899
- // Schema is loose so we read fields off the block directly.
900
- const b = block;
901
- if (b.name !== "Bash")
902
- continue;
903
- const inp = b.input;
904
- if (!inp || typeof inp !== "object")
905
- continue;
906
- const command = inp.command;
907
- if (typeof command !== "string")
908
- continue;
909
- // A Bash command may invoke task.py multiple times (e.g.
910
- // `SMOKE=$(task.py create …); task.py start "$SMOKE"`). Capture
911
- // every occurrence — the original single-match version dropped
912
- // the second invocation and produced unpaired windows.
913
- const parsedAll = parseTaskPyCommandsAll(command);
914
- for (const parsed of parsedAll) {
915
- // turnIndex = current turns.length (the index this assistant turn
916
- // WILL occupy if its text parts are non-empty; either way, it's
917
- // the cut point for "everything before this Bash event"). For
918
- // assistant messages where text comes BEFORE tool_use blocks, the
919
- // assistant turn is appended AFTER this loop completes, so using
920
- // turns.length here means the boundary lies just before that turn.
921
- // We accept this small drift: brainstorm slicing is at granularity
922
- // of full turns, not intra-turn substrings.
923
- const ev = {
924
- action: parsed.action,
925
- timestamp: obj.timestamp ?? "",
926
- turnIndex: turns.length,
927
- ...(parsed.action === "create"
928
- ? { slug: parsed.slug }
929
- : { taskDir: parsed.taskDir }),
930
- };
931
- events.push(ev);
932
- }
933
- }
934
- }
935
- if (parts.length)
936
- turns.push({ role: "assistant", text: parts.join("\n\n") });
937
- }
938
- });
939
- return { turns, events };
940
- }
941
- /**
942
- * Pair `create` → `start` events into brainstorm windows.
943
- *
944
- * Pairing strategy:
945
- * 1. Walk events in order.
946
- * 2. For each `create`, find the next unmatched `start` whose slug matches
947
- * (slug derived from `start` taskDir's last path segment) — slug match
948
- * wins regardless of position.
949
- * 3. If no slug match: pair with the next unmatched `start` by position
950
- * (FIFO).
951
- * 4. Unmatched `create` (no following `start`): window = [create, totalTurns).
952
- * 5. Unmatched `start` (no preceding `create`): window = [0, start).
953
- *
954
- * Window labels: `<slug>` if known, else `window-N`.
955
- */
956
- export function buildBrainstormWindows(events, totalTurns) {
957
- const creates = events
958
- .map((e, i) => ({ e, i }))
959
- .filter(({ e }) => e.action === "create");
960
- const starts = events
961
- .map((e, i) => ({ e, i }))
962
- .filter(({ e }) => e.action === "start");
963
- const usedStartIdx = new Set();
964
- const windows = [];
965
- let windowCounter = 0;
966
- const usedCreateIdx = new Set();
967
- // Pass 1: pair by slug match (slug present on the `create`, matches the
968
- // last segment of the `start` taskDir). Slug match wins over position.
969
- for (const { e: createEv, i: ci } of creates) {
970
- if (!createEv.slug)
971
- continue;
972
- const matchIdx = starts.findIndex(({ e, i }) => !usedStartIdx.has(i) && slugFromTaskDir(e.taskDir) === createEv.slug);
973
- if (matchIdx === -1)
974
- continue;
975
- const startEntry = starts[matchIdx];
976
- if (!startEntry)
977
- continue;
978
- usedStartIdx.add(startEntry.i);
979
- usedCreateIdx.add(ci);
980
- pushWindow(windows, createEv.turnIndex, startEntry.e.turnIndex, createEv.slug, ++windowCounter);
981
- }
982
- // Pass 2: FIFO pair remaining creates with remaining starts that appear
983
- // AFTER the create (by event order).
984
- for (const { e: createEv, i: ci } of creates) {
985
- if (usedCreateIdx.has(ci))
986
- continue;
987
- const pairedStart = starts.find(({ i }) => !usedStartIdx.has(i) && i > ci);
988
- if (pairedStart) {
989
- usedStartIdx.add(pairedStart.i);
990
- usedCreateIdx.add(ci);
991
- const slug = createEv.slug ?? slugFromTaskDir(pairedStart.e.taskDir);
992
- pushWindow(windows, createEv.turnIndex, pairedStart.e.turnIndex, slug, ++windowCounter);
993
- }
994
- else {
995
- // Fallback A: create with no start → [create, end).
996
- usedCreateIdx.add(ci);
997
- pushWindow(windows, createEv.turnIndex, totalTurns, createEv.slug, ++windowCounter);
998
- }
999
- }
1000
- // Pass 3: unmatched starts (start with no preceding create) → [0, start).
1001
- // Fallback B: task was created in an earlier session.
1002
- for (const { e: startEv, i } of starts) {
1003
- if (usedStartIdx.has(i))
1004
- continue;
1005
- pushWindow(windows, 0, startEv.turnIndex, slugFromTaskDir(startEv.taskDir), ++windowCounter);
1006
- }
1007
- // Sort windows by startTurn for stable output ordering.
1008
- windows.sort((a, b) => a.startTurn - b.startTurn);
1009
- return windows;
1010
- }
1011
- function pushWindow(windows, startTurn, endTurn, slug, counter) {
1012
- // Guard: if start > end (e.g., start before create due to event interleave),
1013
- // skip the malformed window rather than emit an empty / negative slice.
1014
- if (endTurn < startTurn)
1015
- return;
1016
- windows.push({
1017
- label: slug ?? `window-${counter}`,
1018
- startTurn,
1019
- endTurn,
1020
- });
1021
- }
1022
- // ---------- codex adapter ----------
1023
- const CODEX_SESSIONS = path.join(HOME, ".codex", "sessions");
1024
- function* walkDir(root) {
1025
- if (!fs.existsSync(root))
1026
- return;
1027
- const stack = [root];
1028
- while (stack.length) {
1029
- const cur = stack.pop();
1030
- if (cur === undefined)
1031
- break;
1032
- let entries;
1033
- try {
1034
- entries = fs.readdirSync(cur, { withFileTypes: true });
1035
- }
1036
- catch {
1037
- continue;
1038
- }
1039
- for (const e of entries) {
1040
- const p = path.join(cur, e.name);
1041
- if (e.isDirectory())
1042
- stack.push(p);
1043
- else if (e.isFile())
1044
- yield p;
1045
- }
1046
- }
1047
- }
1048
- export function codexListSessions(f) {
1049
- if (!fs.existsSync(CODEX_SESSIONS))
1050
- return [];
1051
- const out = [];
1052
- for (const file of walkDir(CODEX_SESSIONS)) {
1053
- if (!file.endsWith(".jsonl"))
1054
- continue;
1055
- const base = path.basename(file, ".jsonl");
1056
- const m = base.match(/^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-(.+)$/);
1057
- const tsFromName = m?.[1]
1058
- ? new Date(m[1].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3") + "Z").toISOString()
1059
- : undefined;
1060
- // Note: we previously short-circuited on `!inRange(tsFromName, f)` here,
1061
- // but the filename ts is the session's creation time — a cross-day session
1062
- // that started before --since but was active inside it would be dropped.
1063
- // Filter at the same place as claude/opencode using interval overlap.
1064
- const first = readJsonlFirst(file, CodexEventSchema);
1065
- const meta = first?.payload;
1066
- const id = meta?.id ?? m?.[2] ?? base;
1067
- const cwd = meta?.cwd;
1068
- const created = first?.timestamp ?? tsFromName ?? "";
1069
- if (f.cwd && !sameProject(cwd, f.cwd))
1070
- continue;
1071
- const updated = fs.statSync(file).mtime.toISOString();
1072
- if (!inRangeOverlap(created, updated, f))
1073
- continue;
1074
- out.push(SessionInfoSchema.parse({
1075
- platform: "codex",
1076
- id,
1077
- cwd,
1078
- created,
1079
- updated,
1080
- filePath: file,
1081
- }));
1082
- }
1083
- return out;
1084
- }
1085
- export function codexExtractDialogue(s) {
1086
- // Codex events: payload.type=="message" with role in {user, assistant, developer, system}.
1087
- // Keep user/assistant only. Each content part is {type: "input_text"|"output_text", text}.
1088
- // Codex inlines a lot of system prompt as the first user message (AGENTS.md, permission
1089
- // blocks, etc.) — stripInjectionTags removes the bulk; turns that are pure boilerplate
1090
- // collapse to empty after strip and get dropped here.
1091
- // Compaction: a top-level event with type=="compacted" carries a payload.replacement_history
1092
- // array — the new authoritative history replacing everything before. We reset turns and
1093
- // re-seed from replacement_history.
1094
- let turns = [];
1095
- const buildTurnFromMessage = (role, parts) => {
1096
- const collected = [];
1097
- let totalRaw = 0;
1098
- for (const c of parts ?? []) {
1099
- const txt = c.text;
1100
- if (typeof txt !== "string")
1101
- continue;
1102
- if (c.type !== "input_text" && c.type !== "output_text")
1103
- continue;
1104
- totalRaw += txt.length;
1105
- const cleaned = stripInjectionTags(txt);
1106
- if (cleaned)
1107
- collected.push(cleaned);
1108
- }
1109
- if (!collected.length)
1110
- return null;
1111
- const merged = collected.join("\n\n");
1112
- if (isBootstrapTurn(merged, totalRaw))
1113
- return null;
1114
- return { role, text: merged };
1115
- };
1116
- readJsonl(s.filePath, CodexEventSchema, (obj) => {
1117
- if (obj.type === "compacted") {
1118
- const rh = obj.payload?.replacement_history;
1119
- turns = [];
1120
- if (!Array.isArray(rh))
1121
- return;
1122
- for (const item of rh) {
1123
- if (item.type !== "message")
1124
- continue;
1125
- const r = DialogueRoleSchema.safeParse(item.role);
1126
- if (!r.success)
1127
- continue;
1128
- const turn = buildTurnFromMessage(r.data, item.content);
1129
- if (turn)
1130
- turns.push({ role: turn.role, text: `[compact]\n${turn.text}` });
1131
- }
1132
- return;
1133
- }
1134
- const p = obj.payload;
1135
- if (p?.type !== "message")
1136
- return;
1137
- const roleParsed = DialogueRoleSchema.safeParse(p.role);
1138
- if (!roleParsed.success)
1139
- return;
1140
- const turn = buildTurnFromMessage(roleParsed.data, p.content);
1141
- if (turn)
1142
- turns.push(turn);
1143
- });
1144
- return turns;
1145
- }
1146
- export function codexSearch(s, kw) {
1147
- return searchInDialogue(codexExtractDialogue(s), kw);
1148
- }
1149
- /** Codex twin of `collectClaudeTurnsAndEvents`. Single pass over the rollout
1150
- * file; emits both the cleaned dialogue turns (semantically identical to
1151
- * `codexExtractDialogue`) AND the list of `task.py create|start` invocations
1152
- * found inside `function_call` events whose `name === "exec_command"` (Codex's
1153
- * stable shell tool). Compaction resets both turns AND events for the same
1154
- * reason as the Claude collector — pre-compact event indices stop pointing at
1155
- * real turns once history is replaced. */
1156
- export function collectCodexTurnsAndEvents(s) {
1157
- let turns = [];
1158
- let events = [];
1159
- const buildTurnFromMessage = (role, parts) => {
1160
- const collected = [];
1161
- let totalRaw = 0;
1162
- for (const c of parts ?? []) {
1163
- const txt = c.text;
1164
- if (typeof txt !== "string")
1165
- continue;
1166
- if (c.type !== "input_text" && c.type !== "output_text")
1167
- continue;
1168
- totalRaw += txt.length;
1169
- const cleaned = stripInjectionTags(txt);
1170
- if (cleaned)
1171
- collected.push(cleaned);
1172
- }
1173
- if (!collected.length)
1174
- return null;
1175
- const merged = collected.join("\n\n");
1176
- if (isBootstrapTurn(merged, totalRaw))
1177
- return null;
1178
- return { role, text: merged };
1179
- };
1180
- readJsonl(s.filePath, CodexEventSchema, (obj) => {
1181
- if (obj.type === "compacted") {
1182
- const rh = obj.payload?.replacement_history;
1183
- turns = [];
1184
- events = [];
1185
- if (!Array.isArray(rh))
1186
- return;
1187
- for (const item of rh) {
1188
- if (item.type !== "message")
1189
- continue;
1190
- const r = DialogueRoleSchema.safeParse(item.role);
1191
- if (!r.success)
1192
- continue;
1193
- const turn = buildTurnFromMessage(r.data, item.content);
1194
- if (turn)
1195
- turns.push({ role: turn.role, text: `[compact]\n${turn.text}` });
1196
- }
1197
- return;
1198
- }
1199
- const p = obj.payload;
1200
- if (!p)
1201
- return;
1202
- // Function-call events (Codex's shell tool dispatch). The schema is loose
1203
- // so we read fields off the raw payload.
1204
- if (p.type === "function_call") {
1205
- const fnName = p.name;
1206
- if (fnName !== "exec_command" && fnName !== "shell")
1207
- return;
1208
- const argsRaw = p.arguments;
1209
- let cmd;
1210
- if (typeof argsRaw === "string") {
1211
- try {
1212
- const parsed = JSON.parse(argsRaw);
1213
- if (parsed && typeof parsed === "object") {
1214
- const c = parsed.cmd;
1215
- if (typeof c === "string")
1216
- cmd = c;
1217
- else {
1218
- const c2 = parsed.command;
1219
- if (typeof c2 === "string")
1220
- cmd = c2;
1221
- }
1222
- }
1223
- }
1224
- catch {
1225
- // arguments not JSON (some Codex versions inline a string) — try as
1226
- // raw shell.
1227
- cmd = argsRaw;
1228
- }
1229
- }
1230
- if (!cmd)
1231
- return;
1232
- const parsedAll = parseTaskPyCommandsAll(cmd);
1233
- for (const parsed of parsedAll) {
1234
- const ev = {
1235
- action: parsed.action,
1236
- timestamp: obj.timestamp ?? "",
1237
- turnIndex: turns.length,
1238
- ...(parsed.action === "create"
1239
- ? { slug: parsed.slug }
1240
- : { taskDir: parsed.taskDir }),
1241
- };
1242
- events.push(ev);
1243
- }
1244
- return;
1245
- }
1246
- // Real conversational turn.
1247
- if (p.type !== "message")
1248
- return;
1249
- const roleParsed = DialogueRoleSchema.safeParse(p.role);
1250
- if (!roleParsed.success)
1251
- return;
1252
- const turn = buildTurnFromMessage(roleParsed.data, p.content);
1253
- if (turn)
1254
- turns.push(turn);
1255
- });
1256
- return { turns, events };
1257
- }
1258
- // ---------- opencode adapter (temporarily unavailable) ----------
1259
- //
1260
- // OpenCode 1.2+ migrated to a SQLite database at
1261
- // ~/.local/share/opencode/opencode.db. The previous SQLite reader required
1262
- // `better-sqlite3` (a native dep). In 0.6.0-beta.4 we reverted that dep
1263
- // because its prebuilt-tarball download from GitHub Releases was unreliable
1264
- // in some networks (notably Windows + China), and the source-build fallback
1265
- // requires a C compiler that most users don't have — `npm install` was
1266
- // failing for the entire CLI, not just the OpenCode reader.
88
+ // ---------- OpenCode reader notice ----------
1267
89
  //
1268
- // The three exported adapter functions are kept (callers in dispatch /
1269
- // slicePhase rely on them) but degraded to no-ops with a one-shot stderr
1270
- // warning. Re-enabled in a future release once a non-native fallback ships.
90
+ // OpenCode 1.2+ moved to a SQLite store; the native dependency was reverted in
91
+ // 0.6.0-beta.4 due to install failures. Core's OpenCode adapter is a silent
92
+ // no-op surfacing the degraded state is a CLI presentation concern, emitted
93
+ // once per process whenever the OpenCode source is in scope.
1271
94
  let opencodeWarned = false;
1272
95
  function warnOpencodeUnavailable() {
1273
96
  if (opencodeWarned)
@@ -1277,96 +100,12 @@ function warnOpencodeUnavailable() {
1277
100
  " OpenCode 1.2+ moved to SQLite; the native dependency was reverted in\n" +
1278
101
  " 0.6.0-beta.4 due to install failures. Re-enabled in a future release.\n");
1279
102
  }
1280
- export function opencodeListSessions(_f) {
1281
- warnOpencodeUnavailable();
1282
- return [];
1283
- }
1284
- export function opencodeExtractDialogue(_s) {
1285
- warnOpencodeUnavailable();
1286
- return [];
1287
- }
1288
- function opencodeSearch(_s, kw) {
1289
- warnOpencodeUnavailable();
1290
- return searchInDialogue([], kw);
1291
- }
1292
- // ---------- dispatch ----------
1293
- function listAll(f) {
1294
- const all = [];
1295
- if (f.platform === "all" || f.platform === "claude")
1296
- all.push(...claudeListSessions(f));
1297
- if (f.platform === "all" || f.platform === "codex")
1298
- all.push(...codexListSessions(f));
103
+ function maybeWarnOpencode(f) {
1299
104
  if (f.platform === "all" || f.platform === "opencode")
1300
- all.push(...opencodeListSessions(f));
1301
- all.sort((a, b) => (b.updated ?? b.created ?? "").localeCompare(a.updated ?? a.created ?? ""));
1302
- return all.slice(0, f.limit);
1303
- }
1304
- function extractDialogue(s) {
1305
- switch (s.platform) {
1306
- case "claude":
1307
- return claudeExtractDialogue(s);
1308
- case "codex":
1309
- return codexExtractDialogue(s);
1310
- case "opencode":
1311
- return opencodeExtractDialogue(s);
1312
- }
1313
- }
1314
- function searchSession(s, kw) {
1315
- switch (s.platform) {
1316
- case "claude":
1317
- return claudeSearch(s, kw);
1318
- case "codex":
1319
- return codexSearch(s, kw);
1320
- case "opencode":
1321
- return opencodeSearch(s, kw);
1322
- }
1323
- }
1324
- /** Build parent → descendants index for OpenCode (transitively flattened).
1325
- * Other platforms have no native parent_id so they pass through unchanged. */
1326
- function buildChildIndex(sessions) {
1327
- const directChildren = new Map();
1328
- for (const s of sessions) {
1329
- if (!s.parent_id)
1330
- continue;
1331
- const list = directChildren.get(s.parent_id) ?? [];
1332
- list.push(s);
1333
- directChildren.set(s.parent_id, list);
1334
- }
1335
- // Transitive flatten: each parent maps to *all* descendants.
1336
- const out = new Map();
1337
- for (const [pid] of directChildren) {
1338
- const stack = [...(directChildren.get(pid) ?? [])];
1339
- const flat = [];
1340
- while (stack.length) {
1341
- const cur = stack.pop();
1342
- if (cur === undefined)
1343
- break;
1344
- flat.push(cur);
1345
- for (const c of directChildren.get(cur.id) ?? [])
1346
- stack.push(c);
1347
- }
1348
- out.set(pid, flat);
1349
- }
1350
- return out;
1351
- }
1352
- function searchSessionWithChildren(s, kw, childIndex) {
1353
- const children = childIndex.get(s.id) ?? [];
1354
- if (children.length === 0)
1355
- return searchSession(s, kw);
1356
- // Concatenate parent + descendants' cleaned dialogue, then run a single
1357
- // search over the merged turn list. This way scores reflect total topic
1358
- // density across the sub-agent tree.
1359
- const merged = [...extractDialogue(s)];
1360
- for (const c of children)
1361
- merged.push(...extractDialogue(c));
1362
- return searchInDialogue(merged, kw);
1363
- }
1364
- function findSessionById(id, f) {
1365
- const wide = { ...f, cwd: undefined, limit: 1_000_000 };
1366
- const all = listAll(wide);
1367
- return all.find((s) => s.id === id) ?? all.find((s) => s.id.startsWith(id));
105
+ warnOpencodeUnavailable();
1368
106
  }
1369
107
  // ---------- formatting ----------
108
+ const HOME = os.homedir();
1370
109
  export function shortDate(iso) {
1371
110
  if (!iso)
1372
111
  return " ";
@@ -1395,7 +134,8 @@ function printSessions(rows) {
1395
134
  // ---------- commands ----------
1396
135
  function cmdList(argv) {
1397
136
  const f = buildFilter(argv.flags);
1398
- const rows = listAll(f);
137
+ maybeWarnOpencode(f);
138
+ const rows = listMemSessions({ filter: f });
1399
139
  if (argv.flags.json) {
1400
140
  console.log(JSON.stringify(rows, null, 2));
1401
141
  return;
@@ -1411,52 +151,24 @@ function cmdSearch(argv) {
1411
151
  if (!kw)
1412
152
  die("usage: search <keyword>");
1413
153
  const f = buildFilter(argv.flags);
1414
- const wide = { ...f, limit: 1_000_000 };
1415
- const candidates = listAll(wide);
154
+ maybeWarnOpencode(f);
1416
155
  const includeChildren = argv.flags["include-children"] === true;
1417
- // When --include-children is set: search over the merged dialogue of each
1418
- // session plus its descendants (only OpenCode populates parent_id natively).
1419
- // Children whose parent is also in the candidate set are dropped from the
1420
- // result list — they get absorbed into the parent's hit.
1421
- const childIndex = includeChildren ? buildChildIndex(candidates) : new Map();
1422
- const candidateIds = new Set(candidates.map((s) => s.id));
1423
- const isAbsorbedChild = (s) => includeChildren &&
1424
- s.parent_id !== undefined &&
1425
- candidateIds.has(s.parent_id);
1426
- const matches = [];
1427
- for (const s of candidates) {
1428
- if (isAbsorbedChild(s))
1429
- continue;
1430
- const hit = includeChildren
1431
- ? searchSessionWithChildren(s, kw, childIndex)
1432
- : searchSession(s, kw);
1433
- if (hit.count === 0)
1434
- continue;
1435
- matches.push({ s, hit, descendants: childIndex.get(s.id)?.length ?? 0 });
1436
- }
1437
- // Rank by weighted-density relevance score: user hits matter ×3, normalized
1438
- // by total dialogue length so a tight 18-hit short session beats a sprawling
1439
- // 58-hit long one. Tie-break on raw count, then recency.
1440
- matches.sort((a, b) => {
1441
- const sa = relevanceScore(a.hit);
1442
- const sb = relevanceScore(b.hit);
1443
- if (sb !== sa)
1444
- return sb - sa;
1445
- if (b.hit.count !== a.hit.count)
1446
- return b.hit.count - a.hit.count;
1447
- return (b.s.updated ?? b.s.created ?? "").localeCompare(a.s.updated ?? a.s.created ?? "");
156
+ const result = searchMemSessions({
157
+ keyword: kw,
158
+ filter: f,
159
+ includeChildren,
1448
160
  });
1449
- const top = matches.slice(0, f.limit);
161
+ const top = result.matches;
1450
162
  if (argv.flags.json) {
1451
- console.log(JSON.stringify(top.map(({ s, hit, descendants }) => ({
1452
- session: s,
1453
- score: Number(relevanceScore(hit).toFixed(4)),
1454
- hit_count: hit.count,
1455
- user_count: hit.user_count,
1456
- asst_count: hit.asst_count,
1457
- total_turns: hit.total_turns,
1458
- descendants_merged: includeChildren ? descendants : 0,
1459
- excerpts: hit.excerpts,
163
+ console.log(JSON.stringify(top.map((m) => ({
164
+ session: m.session,
165
+ score: Number(m.score.toFixed(4)),
166
+ hit_count: m.hit.count,
167
+ user_count: m.hit.userCount,
168
+ asst_count: m.hit.asstCount,
169
+ total_turns: m.hit.totalTurns,
170
+ descendants_merged: includeChildren ? m.descendantsMerged : 0,
171
+ excerpts: m.hit.excerpts,
1460
172
  })), null, 2));
1461
173
  return;
1462
174
  }
@@ -1466,49 +178,30 @@ function cmdSearch(argv) {
1466
178
  console.log("(no matches)");
1467
179
  return;
1468
180
  }
1469
- for (const { s, hit, descendants } of top) {
181
+ for (const m of top) {
182
+ const s = m.session;
1470
183
  const idShort = s.id.slice(0, 12);
1471
- const score = relevanceScore(hit).toFixed(3);
1472
- const childTag = includeChildren && descendants > 0 ? ` +${descendants} child` : "";
184
+ const score = m.score.toFixed(3);
185
+ const childTag = includeChildren && m.descendantsMerged > 0
186
+ ? ` +${m.descendantsMerged} child`
187
+ : "";
1473
188
  console.log(`\n[${s.platform.padEnd(8)}] ${shortDate(s.updated ?? s.created)} ${idShort} ${shortPath(s.cwd)}` +
1474
- ` score=${score} hits=${hit.count} (u=${hit.user_count},a=${hit.asst_count}) turns=${hit.total_turns}${childTag}` +
189
+ ` score=${score} hits=${m.hit.count} (u=${m.hit.userCount},a=${m.hit.asstCount}) turns=${m.hit.totalTurns}${childTag}` +
1475
190
  (s.title ? ` — ${s.title}` : ""));
1476
- for (const ex of hit.excerpts) {
191
+ for (const ex of m.hit.excerpts) {
1477
192
  console.log(` [${ex.role}] ${ex.snippet}`);
1478
193
  }
1479
194
  }
1480
- console.log(`\n${top.length} session(s)${matches.length > top.length ? ` (of ${matches.length})` : ""}`);
195
+ console.log(`\n${top.length} session(s)${result.totalMatches > top.length ? ` (of ${result.totalMatches})` : ""}`);
1481
196
  }
1482
197
  function cmdProjects(argv) {
1483
- // List distinct cwds across all platforms with last-active timestamp + per-platform
1484
- // session counts. Designed for AI consumption: AI calls this first to learn which
1485
- // "门牌号" (project paths) have recent activity, then picks one for `--cwd` in
1486
- // a follow-up `search`.
198
+ // Distinct cwds across all platforms with last-active timestamp + per-platform
199
+ // session counts. AI calls this first to learn which project paths have
200
+ // recent activity, then picks one for `--cwd` in a follow-up `search`.
1487
201
  const f = buildFilter({ ...argv.flags, global: true });
1488
- const wide = { ...f, cwd: undefined, limit: 1_000_000 };
1489
- const all = listAll(wide);
1490
- const byCwd = new Map();
1491
- for (const s of all) {
1492
- if (!s.cwd)
1493
- continue;
1494
- const ts = s.updated ?? s.created ?? "";
1495
- let agg = byCwd.get(s.cwd);
1496
- if (!agg) {
1497
- agg = {
1498
- cwd: s.cwd,
1499
- last_active: ts,
1500
- sessions: 0,
1501
- by_platform: { claude: 0, codex: 0, opencode: 0 },
1502
- };
1503
- byCwd.set(s.cwd, agg);
1504
- }
1505
- agg.sessions++;
1506
- agg.by_platform[s.platform]++;
1507
- if (ts > agg.last_active)
1508
- agg.last_active = ts;
1509
- }
1510
- const rows = [...byCwd.values()].sort((a, b) => b.last_active.localeCompare(a.last_active));
1511
- const limit = typeof argv.flags.limit === "string" ? Number(argv.flags.limit) : 30;
202
+ maybeWarnOpencode(f);
203
+ const rows = listMemProjects({ filter: f });
204
+ const limit = parseOptionalNumberFlag(argv.flags.limit, "--limit", 30);
1512
205
  const top = rows.slice(0, limit);
1513
206
  if (argv.flags.json) {
1514
207
  console.log(JSON.stringify(top, null, 2));
@@ -1531,137 +224,76 @@ function cmdProjects(argv) {
1531
224
  console.log(`\n${top.length} project(s)${rows.length > top.length ? ` (of ${rows.length})` : ""}`);
1532
225
  }
1533
226
  function cmdContext(argv) {
1534
- // Drill-down step 2 in the search workflow:
1535
- // 1. `search <kw>` pick a session
1536
- // 2. `context <id> --grep <kw> --turns N --around M` → top-N hit turns with M
1537
- // turns of context on either side, token-budgeted for AI consumption
1538
- //
1539
- // Without --grep: returns the first N turns (lets AI inspect session opening).
1540
- // With --grep: ranks turns by (user-role first, then hit density), takes top-N,
1541
- // then expands each by --around turns of surrounding context.
227
+ // Drill-down step 2 in the search workflow: `search <kw>` picks a session,
228
+ // then `context <id> --grep <kw>` returns top-N hit turns with surrounding
229
+ // context, token-budgeted for AI consumption. Without --grep: first N turns.
1542
230
  const id = argv.positional[0];
1543
231
  if (!id)
1544
232
  die("usage: context <session-id> [--grep KW] [--turns N] [--around M]");
1545
233
  const f = buildFilter(argv.flags);
1546
- const s = findSessionById(id, f);
1547
- if (!s)
1548
- die(`session not found: ${id}`);
234
+ maybeWarnOpencode(f);
1549
235
  const grepRaw = argv.flags.grep;
1550
236
  const grep = typeof grepRaw === "string" ? grepRaw : undefined;
1551
- const nTurns = typeof argv.flags.turns === "string" ? Number(argv.flags.turns) : 3;
1552
- const around = typeof argv.flags.around === "string" ? Number(argv.flags.around) : 1;
1553
- const maxChars = typeof argv.flags["max-chars"] === "string"
1554
- ? Number(argv.flags["max-chars"])
1555
- : 6000;
1556
- let turns = extractDialogue(s);
1557
- let mergedChildren = 0;
1558
- if (argv.flags["include-children"] === true) {
1559
- const all = listAll({ ...f, cwd: undefined, limit: 1_000_000 });
1560
- const childIndex = buildChildIndex(all);
1561
- const kids = childIndex.get(s.id) ?? [];
1562
- mergedChildren = kids.length;
1563
- for (const c of kids)
1564
- turns = [...turns, ...extractDialogue(c)];
1565
- }
1566
- let hitIndices = [];
1567
- let totalHitTurns = 0;
1568
- if (grep) {
1569
- const tokens = grep.toLowerCase().split(/\s+/).filter(Boolean);
1570
- if (tokens.length === 0)
1571
- die("--grep requires non-empty value");
1572
- const matchCount = (text) => {
1573
- const hay = text.toLowerCase();
1574
- if (!tokens.every((tok) => hay.includes(tok)))
1575
- return 0;
1576
- let n = 0;
1577
- for (const tok of tokens) {
1578
- let from = 0;
1579
- while (true) {
1580
- const idx = hay.indexOf(tok, from);
1581
- if (idx === -1)
1582
- break;
1583
- n++;
1584
- from = idx + tok.length;
1585
- }
1586
- }
1587
- return n;
1588
- };
1589
- const ranked = [];
1590
- for (let i = 0; i < turns.length; i++) {
1591
- const turn = turns[i];
1592
- if (!turn)
1593
- continue;
1594
- const h = matchCount(turn.text);
1595
- if (h > 0)
1596
- ranked.push({ idx: i, role: turn.role, hits: h });
1597
- }
1598
- totalHitTurns = ranked.length;
1599
- ranked.sort((a, b) => {
1600
- if (a.role !== b.role)
1601
- return a.role === "user" ? -1 : 1;
1602
- if (b.hits !== a.hits)
1603
- return b.hits - a.hits;
1604
- return a.idx - b.idx;
237
+ if (grep?.split(/\s+/).filter(Boolean).length === 0)
238
+ die("--grep requires non-empty value");
239
+ const nTurns = parseOptionalNumberFlag(argv.flags.turns, "--turns", 3);
240
+ const around = parseOptionalNumberFlag(argv.flags.around, "--around", 1);
241
+ const maxChars = parseOptionalNumberFlag(argv.flags["max-chars"], "--max-chars", 6000);
242
+ const includeChildren = argv.flags["include-children"] === true;
243
+ let result;
244
+ try {
245
+ result = readMemContext({
246
+ sessionId: id,
247
+ filter: f,
248
+ grep,
249
+ turns: nTurns,
250
+ around,
251
+ maxChars,
252
+ includeChildren,
1605
253
  });
1606
- hitIndices = ranked.slice(0, nTurns).map((r) => r.idx);
1607
254
  }
1608
- else {
1609
- hitIndices = [];
1610
- for (let i = 0; i < Math.min(nTurns, turns.length); i++)
1611
- hitIndices.push(i);
1612
- }
1613
- // Expand each hit by `around` turns on either side; dedupe via Set.
1614
- const display = new Set();
1615
- for (const idx of hitIndices) {
1616
- for (let j = Math.max(0, idx - around); j <= Math.min(turns.length - 1, idx + around); j++) {
1617
- display.add(j);
1618
- }
1619
- }
1620
- const ordered = [...display].sort((a, b) => a - b);
1621
- const hitSet = new Set(hitIndices);
1622
- const out = [];
1623
- let used = 0;
1624
- for (const i of ordered) {
1625
- const t = turns[i];
1626
- if (!t)
1627
- continue;
1628
- let text = t.text;
1629
- // Per-turn cap: if a single turn exceeds half the budget, truncate it so we
1630
- // still fit the rest of the requested context.
1631
- const cap = Math.floor(maxChars / 2);
1632
- if (text.length > cap)
1633
- text = text.slice(0, cap) + `\n…[+${t.text.length - cap} chars]`;
1634
- if (used + text.length > maxChars && out.length > 0)
1635
- break;
1636
- out.push({ idx: i, role: t.role, text, is_hit: hitSet.has(i) });
1637
- used += text.length;
255
+ catch (error) {
256
+ if (error instanceof MemSessionNotFoundError)
257
+ die(`session not found: ${id}`);
258
+ throw error;
1638
259
  }
260
+ const s = result.session;
1639
261
  if (argv.flags.json) {
1640
262
  console.log(JSON.stringify({
1641
263
  session: s,
1642
- query: grep,
1643
- total_turns: turns.length,
1644
- total_hit_turns: totalHitTurns,
1645
- merged_children: mergedChildren,
1646
- turns: out,
264
+ query: result.query,
265
+ total_turns: result.totalTurns,
266
+ total_hit_turns: result.totalHitTurns,
267
+ merged_children: result.mergedChildren,
268
+ turns: result.turns.map((t) => ({
269
+ idx: t.idx,
270
+ role: t.role,
271
+ text: t.text,
272
+ is_hit: t.isHit,
273
+ })),
1647
274
  }, null, 2));
1648
275
  return;
1649
276
  }
277
+ // `hitIndices.length` from the legacy implementation — recomputed here for
278
+ // the human-readable header only.
279
+ const shown = grep
280
+ ? Math.min(result.totalHitTurns, nTurns)
281
+ : Math.min(nTurns, result.totalTurns);
1650
282
  console.log(`# context: [${s.platform}] ${s.id}`);
1651
283
  if (s.title)
1652
284
  console.log(`# title: ${s.title}`);
1653
285
  if (s.cwd)
1654
286
  console.log(`# cwd: ${shortPath(s.cwd)}`);
1655
287
  if (grep)
1656
- console.log(`# query: "${grep}" hit_turns=${totalHitTurns} showing top ${hitIndices.length}`);
288
+ console.log(`# query: "${grep}" hit_turns=${result.totalHitTurns} showing top ${shown}`);
1657
289
  else
1658
- console.log(`# no grep — showing first ${hitIndices.length} turns of ${turns.length}`);
1659
- if (mergedChildren > 0)
1660
- console.log(`# merged_children: ${mergedChildren}`);
1661
- console.log(`# turns shown: ${out.length} budget_used: ${used}/${maxChars} chars`);
290
+ console.log(`# no grep — showing first ${shown} turns of ${result.totalTurns}`);
291
+ if (result.mergedChildren > 0)
292
+ console.log(`# merged_children: ${result.mergedChildren}`);
293
+ console.log(`# turns shown: ${result.turns.length} budget_used: ${result.budgetUsed}/${result.maxChars} chars`);
1662
294
  console.log("");
1663
- for (const t of out) {
1664
- const marker = t.is_hit ? " ← hit" : "";
295
+ for (const t of result.turns) {
296
+ const marker = t.isHit ? " ← hit" : "";
1665
297
  console.log(`## turn ${t.idx} (${t.role})${marker}\n`);
1666
298
  console.log(t.text);
1667
299
  console.log("\n---\n");
@@ -1674,111 +306,35 @@ function parsePhaseFlag(raw) {
1674
306
  return raw;
1675
307
  die(`unknown --phase: ${String(raw)} (expected brainstorm|implement|all)`);
1676
308
  }
1677
- /** Slice cleaned dialogue by phase. Claude and Codex have native boundary
1678
- * detection (via raw JSONL `task.py create|start` invocations in tool_use /
1679
- * function_call events). OpenCode does not — its session storage doesn't
1680
- * expose Bash tool calls in a comparable shape, so it degrades to "all turns
1681
- * + warning". */
1682
- function slicePhase(s, phase) {
1683
- const warnings = [];
1684
- if (phase === "all" || s.platform === "opencode") {
1685
- if (phase !== "all" && s.platform === "opencode") {
1686
- warnings.push(`--phase ${phase} on platform=opencode is not yet supported; ` +
1687
- `returning full dialogue.`);
1688
- }
1689
- const turns = extractDialogue(s);
1690
- return {
1691
- groups: [{ label: null, turns }],
1692
- windows: [],
1693
- totalTurns: turns.length,
1694
- warnings,
1695
- };
1696
- }
1697
- // Claude / Codex path: collect turns + task.py events in one raw-JSONL pass,
1698
- // then build brainstorm windows.
1699
- const { turns, events } = s.platform === "claude"
1700
- ? collectClaudeTurnsAndEvents(s)
1701
- : collectCodexTurnsAndEvents(s);
1702
- const windows = buildBrainstormWindows(events, turns.length);
1703
- if (phase === "brainstorm") {
1704
- if (windows.length === 0) {
1705
- warnings.push(`no task.py create/start boundary found in session — returning full dialogue.`);
1706
- return {
1707
- groups: [{ label: null, turns }],
1708
- windows: [],
1709
- totalTurns: turns.length,
1710
- warnings,
1711
- };
1712
- }
1713
- const groups = windows.map((w) => ({
1714
- label: w.label,
1715
- turns: turns.slice(w.startTurn, w.endTurn),
1716
- }));
1717
- return { groups, windows, totalTurns: turns.length, warnings };
1718
- }
1719
- // phase === "implement": all turns NOT inside any brainstorm window.
1720
- if (windows.length === 0) {
1721
- warnings.push(`no task.py create/start boundary found in session — implement phase is empty.`);
1722
- return {
1723
- groups: [{ label: null, turns: [] }],
1724
- windows: [],
1725
- totalTurns: turns.length,
1726
- warnings,
1727
- };
1728
- }
1729
- // Build set of indices covered by any brainstorm window.
1730
- const covered = new Set();
1731
- for (const w of windows) {
1732
- for (let i = w.startTurn; i < w.endTurn; i++)
1733
- covered.add(i);
1734
- }
1735
- const implementTurns = [];
1736
- for (let i = 0; i < turns.length; i++) {
1737
- if (!covered.has(i)) {
1738
- const t = turns[i];
1739
- if (t)
1740
- implementTurns.push(t);
1741
- }
1742
- }
1743
- return {
1744
- groups: [{ label: null, turns: implementTurns }],
1745
- windows,
1746
- totalTurns: turns.length,
1747
- warnings,
1748
- };
1749
- }
1750
309
  function cmdExtract(argv) {
1751
310
  const id = argv.positional[0];
1752
311
  if (!id)
1753
312
  die("usage: extract <session-id>");
1754
313
  const f = buildFilter(argv.flags);
1755
- const s = findSessionById(id, f);
1756
- if (!s)
1757
- die(`session not found: ${id}`);
314
+ maybeWarnOpencode(f);
1758
315
  const phase = parsePhaseFlag(argv.flags.phase);
1759
- const slice = slicePhase(s, phase);
1760
- for (const w of slice.warnings)
1761
- console.error(`warning: ${w}`);
1762
316
  const grepRaw = argv.flags.grep;
1763
317
  const grep = typeof grepRaw === "string" ? grepRaw.toLowerCase() : undefined;
1764
- // Apply --grep AFTER phase slicing.
1765
- const filterTurns = (turns) => grep ? turns.filter((t) => t.text.toLowerCase().includes(grep)) : turns;
318
+ let result;
319
+ try {
320
+ result = extractMemDialogue({ sessionId: id, filter: f, phase, grep });
321
+ }
322
+ catch (error) {
323
+ if (error instanceof MemSessionNotFoundError)
324
+ die(`session not found: ${id}`);
325
+ throw error;
326
+ }
327
+ for (const w of result.warnings)
328
+ console.error(`warning: ${w.message}`);
329
+ const s = result.session;
1766
330
  if (argv.flags.json) {
1767
- const groups = slice.groups.map((g) => ({
1768
- label: g.label,
1769
- turns: filterTurns(g.turns),
1770
- }));
1771
- // For backwards compat when phase=all (single unlabeled group), expose
1772
- // a flat `turns` field too. New `groups` / `windows` fields are added
1773
- // unconditionally so AI consumers can rely on them.
1774
- const flat = groups.flatMap((g) => g.turns);
1775
331
  console.log(JSON.stringify({
1776
332
  session: s,
1777
- phase,
1778
- windows: slice.windows,
1779
- total_turns: slice.totalTurns,
1780
- groups,
1781
- turns: flat,
333
+ phase: result.phase,
334
+ windows: result.windows,
335
+ total_turns: result.totalTurns,
336
+ groups: result.groups,
337
+ turns: result.turns,
1782
338
  }, null, 2));
1783
339
  return;
1784
340
  }
@@ -1789,15 +345,14 @@ function cmdExtract(argv) {
1789
345
  console.log(`# cwd: ${shortPath(s.cwd)}`);
1790
346
  if (s.created)
1791
347
  console.log(`# date: ${shortDate(s.created)}`);
1792
- const totalShown = slice.groups.reduce((n, g) => n + filterTurns(g.turns).length, 0);
1793
- console.log(`# phase: ${phase} turns: ${totalShown}/${slice.totalTurns}` +
348
+ console.log(`# phase: ${result.phase} turns: ${result.turns.length}/${result.totalTurns}` +
1794
349
  (grep ? ` (filtered by /${grep}/)` : "") +
1795
- (slice.windows.length > 0 ? ` windows: ${slice.windows.length}` : ""));
350
+ (result.windows.length > 0 ? ` windows: ${result.windows.length}` : ""));
1796
351
  console.log("");
1797
- for (const g of slice.groups) {
352
+ for (const g of result.groups) {
1798
353
  if (g.label !== null)
1799
354
  console.log(`--- task: ${g.label} ---\n`);
1800
- for (const t of filterTurns(g.turns)) {
355
+ for (const t of g.turns) {
1801
356
  console.log(`## ${t.role === "user" ? "Human" : "Assistant"}\n`);
1802
357
  console.log(t.text);
1803
358
  console.log("\n---\n");
@@ -1826,7 +381,7 @@ flags:
1826
381
  --grep KW extract / context: filter turns by keyword (multi-token AND)
1827
382
  --phase brainstorm|implement|all extract: slice by Trellis brainstorm windows
1828
383
  (default all; brainstorm = [task.py create, task.py start);
1829
- Claude-only Codex/OpenCode warn + return all)
384
+ Claude/Codex supported; OpenCode warns + returns all)
1830
385
  --turns N context: number of hit turns to return (default 3)
1831
386
  --around N context: turns of surrounding context per hit (default 1)
1832
387
  --max-chars N context: total char budget (default 6000, ~1500 tokens)