@mindfoldhq/trellis 0.5.8 → 0.6.0-beta.0

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.
@@ -0,0 +1,1291 @@
1
+ /**
2
+ * mem.ts — search sessions across Claude Code / Codex / OpenCode.
3
+ *
4
+ * Commands:
5
+ * list list sessions (default if no command)
6
+ * search <keyword> find sessions whose contents match keyword
7
+ * context <session-id> drill-down: top-N hit turns + surrounding context
8
+ * extract <session-id> dump cleaned dialogue (use --grep KW to filter turns)
9
+ * projects list active project cwds (AI-routing entry point)
10
+ *
11
+ * Run `trellis mem help` for the full flag reference.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ 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
+ messageDir: z.string().optional(),
28
+ parent_id: z.string().optional(), // OpenCode only: parent session id (sub-agent chain)
29
+ });
30
+ const DialogueRoleSchema = z.enum(["user", "assistant"]);
31
+ const SearchExcerptSchema = z.object({
32
+ role: DialogueRoleSchema,
33
+ snippet: z.string(),
34
+ });
35
+ const SearchHitSchema = z.object({
36
+ count: z.number(), // total token occurrences across all matching turns
37
+ user_count: z.number(), // breakdown: user-turn occurrences
38
+ asst_count: z.number(), // breakdown: assistant-turn occurrences
39
+ total_turns: z.number(), // size of cleaned dialogue (denominator for density)
40
+ excerpts: z.array(SearchExcerptSchema),
41
+ });
42
+ /** Weighted-density relevance score:
43
+ * (3 * user_hits + asst_hits) / total_turns
44
+ * Higher = the session is more topically concentrated on the query AND the
45
+ * user themselves brought it up (user hits weighted ×3 because the user's own
46
+ * words anchor "what they actually cared about", while assistant elaboration
47
+ * is downstream noise). */
48
+ export function relevanceScore(h) {
49
+ if (h.total_turns === 0)
50
+ return 0;
51
+ return (3 * h.user_count + h.asst_count) / h.total_turns;
52
+ }
53
+ const FilterSchema = z.object({
54
+ platform: z.union([PlatformSchema, z.literal("all")]),
55
+ since: z.date().optional(),
56
+ until: z.date().optional(),
57
+ cwd: z.string().optional(),
58
+ limit: z.number(),
59
+ });
60
+ const ArgvSchema = z.object({
61
+ cmd: z.string(),
62
+ positional: z.array(z.string()),
63
+ flags: z.record(z.string(), z.union([z.string(), z.boolean()])),
64
+ });
65
+ // ---------- schemas: external file formats ----------
66
+ // Claude Code JSONL events. We only declare the fields we read; everything
67
+ // else passes through. Content of an assistant `message` is an array of
68
+ // blocks (text / thinking / tool_use); content of a user `message` is a
69
+ // string for real human input or an array of tool_result blocks (skipped).
70
+ const ClaudeBlockSchema = z
71
+ .object({
72
+ type: z.string().optional(),
73
+ text: z.string().optional(),
74
+ })
75
+ .loose();
76
+ const ClaudeMessageSchema = z
77
+ .object({
78
+ role: z.string().optional(),
79
+ content: z.union([z.string(), z.array(ClaudeBlockSchema)]).optional(),
80
+ })
81
+ .loose();
82
+ const ClaudeEventSchema = z
83
+ .object({
84
+ type: z.string().optional(),
85
+ cwd: z.string().optional(),
86
+ timestamp: z.string().optional(),
87
+ message: ClaudeMessageSchema.optional(),
88
+ isCompactSummary: z.boolean().optional(),
89
+ })
90
+ .loose();
91
+ const ClaudeIndexEntrySchema = z
92
+ .object({
93
+ id: z.string(),
94
+ cwd: z.string().optional(),
95
+ created: z.string().optional(),
96
+ title: z.string().optional(),
97
+ })
98
+ .loose();
99
+ const ClaudeIndexSchema = z
100
+ .object({ entries: z.array(ClaudeIndexEntrySchema).optional() })
101
+ .loose();
102
+ // Codex rollout JSONL events.
103
+ const CodexContentPartSchema = z
104
+ .object({
105
+ type: z.string().optional(),
106
+ text: z.string().optional(),
107
+ })
108
+ .loose();
109
+ const CodexCompactedItemSchema = z
110
+ .object({
111
+ type: z.string().optional(),
112
+ role: z.string().optional(),
113
+ content: z.array(CodexContentPartSchema).optional(),
114
+ })
115
+ .loose();
116
+ const CodexPayloadSchema = z
117
+ .object({
118
+ type: z.string().optional(),
119
+ role: z.string().optional(),
120
+ cwd: z.string().optional(),
121
+ id: z.string().optional(),
122
+ content: z.array(CodexContentPartSchema).optional(),
123
+ replacement_history: z.array(CodexCompactedItemSchema).optional(),
124
+ })
125
+ .loose();
126
+ const CodexEventSchema = z
127
+ .object({
128
+ timestamp: z.string().optional(),
129
+ type: z.string().optional(),
130
+ payload: CodexPayloadSchema.optional(),
131
+ })
132
+ .loose();
133
+ // OpenCode session/message/part files.
134
+ const OpenCodeSessionSchema = z
135
+ .object({
136
+ id: z.string(),
137
+ title: z.string().optional(),
138
+ directory: z.string().optional(),
139
+ parentID: z.string().optional(),
140
+ time: z
141
+ .object({
142
+ created: z.number().optional(),
143
+ updated: z.number().optional(),
144
+ })
145
+ .loose()
146
+ .optional(),
147
+ })
148
+ .loose();
149
+ const OpenCodeMessageSchema = z
150
+ .object({
151
+ id: z.string(),
152
+ role: z.string().optional(),
153
+ time: z.object({ created: z.number().optional() }).loose().optional(),
154
+ })
155
+ .loose();
156
+ const OpenCodePartSchema = z
157
+ .object({
158
+ type: z.string().optional(),
159
+ text: z.string().optional(),
160
+ synthetic: z.boolean().optional(),
161
+ })
162
+ .loose();
163
+ // ---------- argv ----------
164
+ export function parseArgv(argv) {
165
+ const cmd = argv[0] ?? "list";
166
+ const positional = [];
167
+ const flags = {};
168
+ for (let i = 1; i < argv.length; i++) {
169
+ const a = argv[i];
170
+ if (a === undefined)
171
+ continue;
172
+ if (a.startsWith("--")) {
173
+ const key = a.slice(2);
174
+ const next = argv[i + 1];
175
+ if (next !== undefined && !next.startsWith("--")) {
176
+ flags[key] = next;
177
+ i++;
178
+ }
179
+ else {
180
+ flags[key] = true;
181
+ }
182
+ }
183
+ else {
184
+ positional.push(a);
185
+ }
186
+ }
187
+ return ArgvSchema.parse({ cmd, positional, flags });
188
+ }
189
+ export function buildFilter(flags) {
190
+ const platformRaw = typeof flags.platform === "string" ? flags.platform : "all";
191
+ const platformParsed = z
192
+ .union([PlatformSchema, z.literal("all")])
193
+ .safeParse(platformRaw);
194
+ if (!platformParsed.success)
195
+ die(`unknown platform: ${platformRaw}`);
196
+ const sinceRaw = flags.since;
197
+ const since = typeof sinceRaw === "string" ? new Date(sinceRaw) : undefined;
198
+ if (since && Number.isNaN(+since))
199
+ die(`bad --since: ${sinceRaw}`);
200
+ const untilRaw = flags.until;
201
+ const until = typeof untilRaw === "string"
202
+ ? new Date(`${untilRaw}T23:59:59.999Z`)
203
+ : undefined;
204
+ if (until && Number.isNaN(+until))
205
+ die(`bad --until: ${untilRaw}`);
206
+ const cwd = flags.global
207
+ ? undefined
208
+ : path.resolve(typeof flags.cwd === "string" ? flags.cwd : process.cwd());
209
+ const limit = typeof flags.limit === "string" ? Number(flags.limit) : 50;
210
+ return FilterSchema.parse({
211
+ platform: platformParsed.data,
212
+ since,
213
+ until,
214
+ cwd,
215
+ limit,
216
+ });
217
+ }
218
+ function die(msg) {
219
+ console.error(`error: ${msg}`);
220
+ process.exit(2);
221
+ }
222
+ // ---------- common helpers ----------
223
+ const HOME = os.homedir();
224
+ export function inRange(iso, f) {
225
+ if (!iso)
226
+ return true;
227
+ const t = new Date(iso);
228
+ if (Number.isNaN(+t))
229
+ return true;
230
+ if (f.since && t < f.since)
231
+ return false;
232
+ if (f.until && t > f.until)
233
+ return false;
234
+ return true;
235
+ }
236
+ export function sameProject(sessionCwd, target) {
237
+ if (!target)
238
+ return true;
239
+ if (!sessionCwd)
240
+ return false;
241
+ const a = path.resolve(sessionCwd);
242
+ const b = path.resolve(target);
243
+ return a === b || a.startsWith(b + path.sep);
244
+ }
245
+ /** Walk JSONL line-by-line, calling `onLine` with each parsed object that
246
+ * matches the supplied schema. Bad JSON or schema-mismatched lines are skipped.
247
+ * Returning the literal "stop" from `onLine` halts iteration. */
248
+ function readJsonl(file, schema, onLine) {
249
+ let data;
250
+ try {
251
+ data = fs.readFileSync(file, "utf8");
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ for (const line of data.split("\n")) {
257
+ if (!line.trim())
258
+ continue;
259
+ let raw;
260
+ try {
261
+ raw = JSON.parse(line);
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ const parsed = schema.safeParse(raw);
267
+ if (!parsed.success)
268
+ continue;
269
+ if (onLine(parsed.data) === "stop")
270
+ return;
271
+ }
272
+ }
273
+ function readJsonlFirst(file, schema) {
274
+ let result;
275
+ readJsonl(file, schema, (obj) => {
276
+ result = obj;
277
+ return "stop";
278
+ });
279
+ return result;
280
+ }
281
+ function findInJsonl(file, schema, predicate, maxLines = 200) {
282
+ let count = 0;
283
+ let hit;
284
+ readJsonl(file, schema, (obj) => {
285
+ count++;
286
+ if (predicate(obj)) {
287
+ hit = obj;
288
+ return "stop";
289
+ }
290
+ if (count >= maxLines)
291
+ return "stop";
292
+ });
293
+ return hit;
294
+ }
295
+ function readJsonFile(file, schema) {
296
+ let raw;
297
+ try {
298
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
299
+ }
300
+ catch {
301
+ return undefined;
302
+ }
303
+ const parsed = schema.safeParse(raw);
304
+ return parsed.success ? parsed.data : undefined;
305
+ }
306
+ // ---------- dialogue cleaning ----------
307
+ const INJECTION_TAGS = [
308
+ "system-reminder",
309
+ "task-status",
310
+ "ready",
311
+ "current-state",
312
+ "workflow",
313
+ "workflow-state",
314
+ "guidelines",
315
+ "instructions",
316
+ "command-name",
317
+ "command-message",
318
+ "command-args",
319
+ "local-command-stdout",
320
+ "local-command-stderr",
321
+ "permissions instructions",
322
+ "collaboration_mode",
323
+ "environment_context",
324
+ "auto_compact_summary",
325
+ "user_instructions",
326
+ ];
327
+ /** True if this turn is a platform bootstrap injection (AGENTS.md, pure
328
+ * INSTRUCTIONS preamble, etc.) and should be dropped wholesale rather than
329
+ * partially cleaned. Detected after stripInjectionTags, so we look at what's
330
+ * left after tag-stripping. */
331
+ export function isBootstrapTurn(cleaned, originalLength) {
332
+ if (cleaned.startsWith("# AGENTS.md instructions for"))
333
+ return true;
334
+ // A turn that's mostly an INSTRUCTIONS block (Codex injects this as user role).
335
+ if (originalLength > 4000 && /^<INSTRUCTIONS>/i.test(cleaned))
336
+ return true;
337
+ return false;
338
+ }
339
+ export function stripInjectionTags(text) {
340
+ let out = text;
341
+ for (const tag of INJECTION_TAGS) {
342
+ const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
343
+ // Case-insensitive: Codex/Trellis injection tags appear as both <INSTRUCTIONS>
344
+ // and <instructions> across platforms.
345
+ out = out.replace(new RegExp(`<${escaped}[^>]*>[\\s\\S]*?</${escaped}>`, "gi"), "");
346
+ }
347
+ out = out.replace(/^# AGENTS\.md instructions for[\s\S]*?(?=\n\n[A-Z一-龥]|$)/m, "");
348
+ return out.replace(/\n{3,}/g, "\n\n").trim();
349
+ }
350
+ /** Find the paragraph-aligned chunk surrounding a hit position. A "chunk" is
351
+ * the contiguous text bounded by the nearest blank-line breaks (`\n\n`) on
352
+ * either side. If the natural paragraph exceeds `maxChars`, fall back to a
353
+ * centered char window — and report the truncation so callers can mark it. */
354
+ export function chunkAround(text, hitIdx, maxChars) {
355
+ const startPara = text.lastIndexOf("\n\n", hitIdx);
356
+ let start = startPara === -1 ? 0 : startPara + 2;
357
+ const endPara = text.indexOf("\n\n", hitIdx);
358
+ let end = endPara === -1 ? text.length : endPara;
359
+ let truncated = false;
360
+ if (end - start > maxChars) {
361
+ start = Math.max(0, hitIdx - Math.floor(maxChars / 2));
362
+ end = Math.min(text.length, hitIdx + Math.ceil(maxChars / 2));
363
+ truncated = true;
364
+ }
365
+ return { start, end, truncated };
366
+ }
367
+ /** Multi-token AND grep over cleaned dialogue. Whitespace-split tokens; a
368
+ * turn matches if every token (case-insensitive) appears anywhere in it.
369
+ * `count` is the total occurrence count across all tokens within matching
370
+ * turns. Excerpts are paragraph-aligned chunks (drawer-style): for each
371
+ * matching turn we collect chunks around every hit position, dedupe by
372
+ * chunk start so adjacent hits inside the same paragraph collapse to one
373
+ * chunk. User-role chunks are listed first (the user's own words anchor
374
+ * topic intent more reliably than AI elaboration). */
375
+ export function searchInDialogue(turns, kw, maxExcerpts = 3, chunkChars = 400) {
376
+ const tokens = kw.toLowerCase().split(/\s+/).filter(Boolean);
377
+ const empty = SearchHitSchema.parse({
378
+ count: 0,
379
+ user_count: 0,
380
+ asst_count: 0,
381
+ total_turns: turns.length,
382
+ excerpts: [],
383
+ });
384
+ if (tokens.length === 0)
385
+ return empty;
386
+ let userCount = 0;
387
+ let asstCount = 0;
388
+ const userExcerpts = [];
389
+ const asstExcerpts = [];
390
+ for (const t of turns) {
391
+ const hay = t.text.toLowerCase();
392
+ if (!tokens.every((tok) => hay.includes(tok)))
393
+ continue;
394
+ // Collect every hit position with the token that produced it (for both
395
+ // counting and rarity-aware chunk anchor selection).
396
+ const hitPositions = [];
397
+ const tokenFreq = new Map();
398
+ let turnHits = 0;
399
+ for (const tok of tokens) {
400
+ let from = 0;
401
+ let n = 0;
402
+ while (true) {
403
+ const idx = hay.indexOf(tok, from);
404
+ if (idx === -1)
405
+ break;
406
+ n++;
407
+ turnHits++;
408
+ hitPositions.push({ idx, tok });
409
+ from = idx + tok.length;
410
+ }
411
+ tokenFreq.set(tok, n);
412
+ }
413
+ if (t.role === "user")
414
+ userCount += turnHits;
415
+ else
416
+ asstCount += turnHits;
417
+ hitPositions.sort((a, b) => a.idx - b.idx);
418
+ const candidates = [];
419
+ const seenStarts = new Set();
420
+ for (const { idx, tok } of hitPositions) {
421
+ const { start, end, truncated } = chunkAround(t.text, idx, chunkChars);
422
+ if (seenStarts.has(start))
423
+ continue;
424
+ seenStarts.add(start);
425
+ const slice = hay.slice(start, end);
426
+ const coverage = tokens.filter((tk) => slice.includes(tk)).length;
427
+ const rarity = 1 / (tokenFreq.get(tok) ?? 1);
428
+ candidates.push({ start, end, truncated, coverage, rarity });
429
+ }
430
+ candidates.sort((a, b) => {
431
+ if (b.coverage !== a.coverage)
432
+ return b.coverage - a.coverage;
433
+ if (b.rarity !== a.rarity)
434
+ return b.rarity - a.rarity;
435
+ return a.start - b.start;
436
+ });
437
+ for (const c of candidates) {
438
+ let snippet = t.text.slice(c.start, c.end).trim();
439
+ if (c.truncated) {
440
+ if (c.start > 0)
441
+ snippet = "…" + snippet;
442
+ if (c.end < t.text.length)
443
+ snippet += "…";
444
+ }
445
+ (t.role === "user" ? userExcerpts : asstExcerpts).push({
446
+ role: t.role,
447
+ snippet,
448
+ });
449
+ }
450
+ }
451
+ const excerpts = [...userExcerpts, ...asstExcerpts].slice(0, maxExcerpts);
452
+ return SearchHitSchema.parse({
453
+ count: userCount + asstCount,
454
+ user_count: userCount,
455
+ asst_count: asstCount,
456
+ total_turns: turns.length,
457
+ excerpts,
458
+ });
459
+ }
460
+ // ---------- claude adapter ----------
461
+ const CLAUDE_PROJECTS = path.join(HOME, ".claude", "projects");
462
+ function claudeProjectDirFromCwd(cwd) {
463
+ // Claude sanitizes path: every '/' and '_' becomes '-'.
464
+ return path.join(CLAUDE_PROJECTS, cwd.replace(/[/_]/g, "-"));
465
+ }
466
+ export function claudeListSessions(f) {
467
+ if (!fs.existsSync(CLAUDE_PROJECTS))
468
+ return [];
469
+ const out = [];
470
+ const projectDirs = f.cwd
471
+ ? [claudeProjectDirFromCwd(f.cwd)].filter((d) => fs.existsSync(d))
472
+ : fs.readdirSync(CLAUDE_PROJECTS).map((d) => path.join(CLAUDE_PROJECTS, d));
473
+ for (const dir of projectDirs) {
474
+ let entries;
475
+ try {
476
+ entries = fs.readdirSync(dir, { withFileTypes: true });
477
+ }
478
+ catch {
479
+ continue;
480
+ }
481
+ const indexFile = path.join(dir, "sessions-index.json");
482
+ const index = readJsonFile(indexFile, ClaudeIndexSchema);
483
+ const indexById = new Map();
484
+ for (const e of index?.entries ?? [])
485
+ indexById.set(e.id, e);
486
+ for (const e of entries) {
487
+ if (!e.isFile() || !e.name.endsWith(".jsonl"))
488
+ continue;
489
+ const filePath = path.join(dir, e.name);
490
+ const id = e.name.replace(/\.jsonl$/, "");
491
+ const idx = indexById.get(id);
492
+ let cwd = idx?.cwd;
493
+ let created = idx?.created;
494
+ const title = idx?.title;
495
+ if (!cwd || !created) {
496
+ const evt = findInJsonl(filePath, ClaudeEventSchema, (o) => typeof o.cwd === "string", 100);
497
+ cwd = cwd ?? evt?.cwd;
498
+ created =
499
+ created ??
500
+ evt?.timestamp ??
501
+ readJsonlFirst(filePath, ClaudeEventSchema)?.timestamp;
502
+ }
503
+ const stat = fs.statSync(filePath);
504
+ const updated = stat.mtime.toISOString();
505
+ if (!inRange(created ?? updated, f))
506
+ continue;
507
+ if (f.cwd && cwd && !sameProject(cwd, f.cwd))
508
+ continue;
509
+ out.push(SessionInfoSchema.parse({
510
+ platform: "claude",
511
+ id,
512
+ title,
513
+ cwd,
514
+ created,
515
+ updated,
516
+ filePath,
517
+ }));
518
+ }
519
+ }
520
+ return out;
521
+ }
522
+ export function claudeExtractDialogue(s) {
523
+ // Mirrors session-insight/extract-session.py:
524
+ // - user: type=="user" + role=="user" + content is string (list = tool_result)
525
+ // - assistant: type=="assistant" + role=="assistant", keep only `text` blocks
526
+ // - thinking and tool_use blocks dropped entirely
527
+ // - injection tags stripped
528
+ // Compaction: when we hit a `user` event with isCompactSummary=true, drop all
529
+ // pre-compact turns and replace them with a synthetic [compact summary] turn —
530
+ // the pre-compact content is now redundant with the summary.
531
+ let turns = [];
532
+ readJsonl(s.filePath, ClaudeEventSchema, (obj) => {
533
+ const t = obj.type;
534
+ const msg = obj.message;
535
+ if (!msg)
536
+ return;
537
+ const content = msg.content;
538
+ if (t === "user" && obj.isCompactSummary === true) {
539
+ let summary = "";
540
+ if (typeof content === "string") {
541
+ summary = stripInjectionTags(content);
542
+ }
543
+ else if (Array.isArray(content)) {
544
+ const parts = [];
545
+ for (const block of content) {
546
+ if (block.type === "text" && typeof block.text === "string") {
547
+ const cleaned = stripInjectionTags(block.text);
548
+ if (cleaned)
549
+ parts.push(cleaned);
550
+ }
551
+ }
552
+ summary = parts.join("\n\n");
553
+ }
554
+ turns = summary
555
+ ? [{ role: "user", text: `[compact summary]\n${summary}` }]
556
+ : [];
557
+ return;
558
+ }
559
+ if (t === "user" && msg.role === "user") {
560
+ if (typeof content === "string") {
561
+ const text = stripInjectionTags(content);
562
+ if (text && !isBootstrapTurn(text, content.length)) {
563
+ turns.push({ role: "user", text });
564
+ }
565
+ }
566
+ }
567
+ else if (t === "assistant" &&
568
+ msg.role === "assistant" &&
569
+ Array.isArray(content)) {
570
+ const parts = [];
571
+ for (const block of content) {
572
+ if (block.type === "text" && typeof block.text === "string") {
573
+ const cleaned = stripInjectionTags(block.text);
574
+ if (cleaned)
575
+ parts.push(cleaned);
576
+ }
577
+ }
578
+ if (parts.length)
579
+ turns.push({ role: "assistant", text: parts.join("\n\n") });
580
+ }
581
+ });
582
+ return turns;
583
+ }
584
+ export function claudeSearch(s, kw) {
585
+ return searchInDialogue(claudeExtractDialogue(s), kw);
586
+ }
587
+ // ---------- codex adapter ----------
588
+ const CODEX_SESSIONS = path.join(HOME, ".codex", "sessions");
589
+ function* walkDir(root) {
590
+ if (!fs.existsSync(root))
591
+ return;
592
+ const stack = [root];
593
+ while (stack.length) {
594
+ const cur = stack.pop();
595
+ if (cur === undefined)
596
+ break;
597
+ let entries;
598
+ try {
599
+ entries = fs.readdirSync(cur, { withFileTypes: true });
600
+ }
601
+ catch {
602
+ continue;
603
+ }
604
+ for (const e of entries) {
605
+ const p = path.join(cur, e.name);
606
+ if (e.isDirectory())
607
+ stack.push(p);
608
+ else if (e.isFile())
609
+ yield p;
610
+ }
611
+ }
612
+ }
613
+ export function codexListSessions(f) {
614
+ if (!fs.existsSync(CODEX_SESSIONS))
615
+ return [];
616
+ const out = [];
617
+ for (const file of walkDir(CODEX_SESSIONS)) {
618
+ if (!file.endsWith(".jsonl"))
619
+ continue;
620
+ const base = path.basename(file, ".jsonl");
621
+ const m = base.match(/^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-(.+)$/);
622
+ const tsFromName = m?.[1]
623
+ ? new Date(m[1].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3") + "Z").toISOString()
624
+ : undefined;
625
+ if (tsFromName && !inRange(tsFromName, f))
626
+ continue;
627
+ const first = readJsonlFirst(file, CodexEventSchema);
628
+ const meta = first?.payload;
629
+ const id = meta?.id ?? m?.[2] ?? base;
630
+ const cwd = meta?.cwd;
631
+ const created = first?.timestamp ?? tsFromName ?? "";
632
+ if (f.cwd && !sameProject(cwd, f.cwd))
633
+ continue;
634
+ if (!inRange(created, f))
635
+ continue;
636
+ out.push(SessionInfoSchema.parse({
637
+ platform: "codex",
638
+ id,
639
+ cwd,
640
+ created,
641
+ updated: fs.statSync(file).mtime.toISOString(),
642
+ filePath: file,
643
+ }));
644
+ }
645
+ return out;
646
+ }
647
+ export function codexExtractDialogue(s) {
648
+ // Codex events: payload.type=="message" with role in {user, assistant, developer, system}.
649
+ // Keep user/assistant only. Each content part is {type: "input_text"|"output_text", text}.
650
+ // Codex inlines a lot of system prompt as the first user message (AGENTS.md, permission
651
+ // blocks, etc.) — stripInjectionTags removes the bulk; turns that are pure boilerplate
652
+ // collapse to empty after strip and get dropped here.
653
+ // Compaction: a top-level event with type=="compacted" carries a payload.replacement_history
654
+ // array — the new authoritative history replacing everything before. We reset turns and
655
+ // re-seed from replacement_history.
656
+ let turns = [];
657
+ const buildTurnFromMessage = (role, parts) => {
658
+ const collected = [];
659
+ let totalRaw = 0;
660
+ for (const c of parts ?? []) {
661
+ const txt = c.text;
662
+ if (typeof txt !== "string")
663
+ continue;
664
+ if (c.type !== "input_text" && c.type !== "output_text")
665
+ continue;
666
+ totalRaw += txt.length;
667
+ const cleaned = stripInjectionTags(txt);
668
+ if (cleaned)
669
+ collected.push(cleaned);
670
+ }
671
+ if (!collected.length)
672
+ return null;
673
+ const merged = collected.join("\n\n");
674
+ if (isBootstrapTurn(merged, totalRaw))
675
+ return null;
676
+ return { role, text: merged };
677
+ };
678
+ readJsonl(s.filePath, CodexEventSchema, (obj) => {
679
+ if (obj.type === "compacted") {
680
+ const rh = obj.payload?.replacement_history;
681
+ turns = [];
682
+ if (!Array.isArray(rh))
683
+ return;
684
+ for (const item of rh) {
685
+ if (item.type !== "message")
686
+ continue;
687
+ const r = DialogueRoleSchema.safeParse(item.role);
688
+ if (!r.success)
689
+ continue;
690
+ const turn = buildTurnFromMessage(r.data, item.content);
691
+ if (turn)
692
+ turns.push({ role: turn.role, text: `[compact]\n${turn.text}` });
693
+ }
694
+ return;
695
+ }
696
+ const p = obj.payload;
697
+ if (p?.type !== "message")
698
+ return;
699
+ const roleParsed = DialogueRoleSchema.safeParse(p.role);
700
+ if (!roleParsed.success)
701
+ return;
702
+ const turn = buildTurnFromMessage(roleParsed.data, p.content);
703
+ if (turn)
704
+ turns.push(turn);
705
+ });
706
+ return turns;
707
+ }
708
+ export function codexSearch(s, kw) {
709
+ return searchInDialogue(codexExtractDialogue(s), kw);
710
+ }
711
+ // ---------- opencode adapter ----------
712
+ const OC_ROOT = path.join(HOME, ".local", "share", "opencode", "storage");
713
+ const OC_SESSION_DIR = path.join(OC_ROOT, "session");
714
+ const OC_MESSAGE_DIR = path.join(OC_ROOT, "message");
715
+ const OC_PART_DIR = path.join(OC_ROOT, "part");
716
+ export function opencodeListSessions(f) {
717
+ if (!fs.existsSync(OC_SESSION_DIR))
718
+ return [];
719
+ const out = [];
720
+ for (const file of walkDir(OC_SESSION_DIR)) {
721
+ if (!file.endsWith(".json"))
722
+ continue;
723
+ const info = readJsonFile(file, OpenCodeSessionSchema);
724
+ if (!info)
725
+ continue;
726
+ const created = info.time?.created !== undefined
727
+ ? new Date(info.time.created).toISOString()
728
+ : undefined;
729
+ const updated = info.time?.updated !== undefined
730
+ ? new Date(info.time.updated).toISOString()
731
+ : undefined;
732
+ const cwd = info.directory;
733
+ if (f.cwd && !sameProject(cwd, f.cwd))
734
+ continue;
735
+ if (!inRange(updated ?? created, f))
736
+ continue;
737
+ out.push(SessionInfoSchema.parse({
738
+ platform: "opencode",
739
+ id: info.id,
740
+ title: info.title,
741
+ cwd,
742
+ created,
743
+ updated,
744
+ filePath: file,
745
+ messageDir: path.join(OC_MESSAGE_DIR, info.id),
746
+ parent_id: info.parentID,
747
+ }));
748
+ }
749
+ return out;
750
+ }
751
+ function opencodeListMessageFiles(messageDir) {
752
+ try {
753
+ return fs.readdirSync(messageDir).filter((n) => n.endsWith(".json"));
754
+ }
755
+ catch {
756
+ return [];
757
+ }
758
+ }
759
+ export function opencodeExtractDialogue(s) {
760
+ // OpenCode: messages live at message/<sid>/msg_*.json, part bodies at part/<msgId>/prt_*.json.
761
+ // Keep parts with type=="text" && synthetic !== true; group by message; dialogue role
762
+ // comes from the message file's `role` field. Synthetic parts are platform-injected
763
+ // preamble (mode prompts, agent boilerplate) and are dropped as noise.
764
+ const turns = [];
765
+ if (!s.messageDir || !fs.existsSync(s.messageDir))
766
+ return turns;
767
+ const ordered = [];
768
+ for (const mf of opencodeListMessageFiles(s.messageDir)) {
769
+ const msg = readJsonFile(path.join(s.messageDir, mf), OpenCodeMessageSchema);
770
+ if (msg)
771
+ ordered.push({ msg, created: msg.time?.created ?? 0 });
772
+ }
773
+ ordered.sort((a, b) => a.created - b.created);
774
+ for (const { msg } of ordered) {
775
+ const roleParsed = DialogueRoleSchema.safeParse(msg.role);
776
+ if (!roleParsed.success)
777
+ continue;
778
+ const partDir = path.join(OC_PART_DIR, msg.id);
779
+ if (!fs.existsSync(partDir))
780
+ continue;
781
+ let parts;
782
+ try {
783
+ parts = fs.readdirSync(partDir).filter((n) => n.endsWith(".json"));
784
+ }
785
+ catch {
786
+ continue;
787
+ }
788
+ const collected = [];
789
+ let totalRaw = 0;
790
+ for (const pf of parts) {
791
+ const part = readJsonFile(path.join(partDir, pf), OpenCodePartSchema);
792
+ if (!part)
793
+ continue;
794
+ if (part.type !== "text" || part.synthetic)
795
+ continue;
796
+ if (typeof part.text !== "string")
797
+ continue;
798
+ totalRaw += part.text.length;
799
+ const cleaned = stripInjectionTags(part.text);
800
+ if (cleaned)
801
+ collected.push(cleaned);
802
+ }
803
+ if (!collected.length)
804
+ continue;
805
+ const merged = collected.join("\n\n");
806
+ if (isBootstrapTurn(merged, totalRaw))
807
+ continue;
808
+ turns.push({ role: roleParsed.data, text: merged });
809
+ }
810
+ return turns;
811
+ }
812
+ function opencodeSearch(s, kw) {
813
+ const turns = opencodeExtractDialogue(s);
814
+ if (s.title)
815
+ turns.unshift({ role: "user", text: s.title });
816
+ return searchInDialogue(turns, kw);
817
+ }
818
+ // ---------- dispatch ----------
819
+ function listAll(f) {
820
+ const all = [];
821
+ if (f.platform === "all" || f.platform === "claude")
822
+ all.push(...claudeListSessions(f));
823
+ if (f.platform === "all" || f.platform === "codex")
824
+ all.push(...codexListSessions(f));
825
+ if (f.platform === "all" || f.platform === "opencode")
826
+ all.push(...opencodeListSessions(f));
827
+ all.sort((a, b) => (b.updated ?? b.created ?? "").localeCompare(a.updated ?? a.created ?? ""));
828
+ return all.slice(0, f.limit);
829
+ }
830
+ function extractDialogue(s) {
831
+ switch (s.platform) {
832
+ case "claude":
833
+ return claudeExtractDialogue(s);
834
+ case "codex":
835
+ return codexExtractDialogue(s);
836
+ case "opencode":
837
+ return opencodeExtractDialogue(s);
838
+ }
839
+ }
840
+ function searchSession(s, kw) {
841
+ switch (s.platform) {
842
+ case "claude":
843
+ return claudeSearch(s, kw);
844
+ case "codex":
845
+ return codexSearch(s, kw);
846
+ case "opencode":
847
+ return opencodeSearch(s, kw);
848
+ }
849
+ }
850
+ /** Build parent → descendants index for OpenCode (transitively flattened).
851
+ * Other platforms have no native parent_id so they pass through unchanged. */
852
+ function buildChildIndex(sessions) {
853
+ const directChildren = new Map();
854
+ for (const s of sessions) {
855
+ if (!s.parent_id)
856
+ continue;
857
+ const list = directChildren.get(s.parent_id) ?? [];
858
+ list.push(s);
859
+ directChildren.set(s.parent_id, list);
860
+ }
861
+ // Transitive flatten: each parent maps to *all* descendants.
862
+ const out = new Map();
863
+ for (const [pid] of directChildren) {
864
+ const stack = [...(directChildren.get(pid) ?? [])];
865
+ const flat = [];
866
+ while (stack.length) {
867
+ const cur = stack.pop();
868
+ if (cur === undefined)
869
+ break;
870
+ flat.push(cur);
871
+ for (const c of directChildren.get(cur.id) ?? [])
872
+ stack.push(c);
873
+ }
874
+ out.set(pid, flat);
875
+ }
876
+ return out;
877
+ }
878
+ function searchSessionWithChildren(s, kw, childIndex) {
879
+ const children = childIndex.get(s.id) ?? [];
880
+ if (children.length === 0)
881
+ return searchSession(s, kw);
882
+ // Concatenate parent + descendants' cleaned dialogue, then run a single
883
+ // search over the merged turn list. This way scores reflect total topic
884
+ // density across the sub-agent tree.
885
+ const merged = [...extractDialogue(s)];
886
+ for (const c of children)
887
+ merged.push(...extractDialogue(c));
888
+ return searchInDialogue(merged, kw);
889
+ }
890
+ function findSessionById(id, f) {
891
+ const wide = { ...f, cwd: undefined, limit: 1_000_000 };
892
+ const all = listAll(wide);
893
+ return all.find((s) => s.id === id) ?? all.find((s) => s.id.startsWith(id));
894
+ }
895
+ // ---------- formatting ----------
896
+ export function shortDate(iso) {
897
+ if (!iso)
898
+ return " ";
899
+ return iso.slice(0, 16).replace("T", " ");
900
+ }
901
+ export function shortPath(p) {
902
+ if (!p)
903
+ return "(no cwd)";
904
+ return p.replace(HOME, "~");
905
+ }
906
+ function printSessions(rows) {
907
+ if (rows.length === 0) {
908
+ console.log("(no sessions)");
909
+ return;
910
+ }
911
+ for (const s of rows) {
912
+ const id = s.id.length > 12 ? s.id.slice(0, 12) : s.id.padEnd(12);
913
+ const parentTag = s.parent_id
914
+ ? ` ↳ child of ${s.parent_id.slice(0, 12)}`
915
+ : "";
916
+ console.log(`[${s.platform.padEnd(8)}] ${shortDate(s.updated ?? s.created)} ${id} ${shortPath(s.cwd)}` +
917
+ (s.title ? ` — ${s.title}` : "") +
918
+ parentTag);
919
+ }
920
+ }
921
+ // ---------- commands ----------
922
+ function cmdList(argv) {
923
+ const f = buildFilter(argv.flags);
924
+ const rows = listAll(f);
925
+ if (argv.flags.json) {
926
+ console.log(JSON.stringify(rows, null, 2));
927
+ return;
928
+ }
929
+ console.log(`scope: ${f.cwd ? `project=${shortPath(f.cwd)}` : "global"} platform=${f.platform}` +
930
+ (f.since ? ` since=${f.since.toISOString().slice(0, 10)}` : "") +
931
+ (f.until ? ` until=${f.until.toISOString().slice(0, 10)}` : ""));
932
+ printSessions(rows);
933
+ console.log(`\n${rows.length} session(s)`);
934
+ }
935
+ function cmdSearch(argv) {
936
+ const kw = argv.positional[0];
937
+ if (!kw)
938
+ die("usage: search <keyword>");
939
+ const f = buildFilter(argv.flags);
940
+ const wide = { ...f, limit: 1_000_000 };
941
+ const candidates = listAll(wide);
942
+ const includeChildren = argv.flags["include-children"] === true;
943
+ // When --include-children is set: search over the merged dialogue of each
944
+ // session plus its descendants (only OpenCode populates parent_id natively).
945
+ // Children whose parent is also in the candidate set are dropped from the
946
+ // result list — they get absorbed into the parent's hit.
947
+ const childIndex = includeChildren ? buildChildIndex(candidates) : new Map();
948
+ const candidateIds = new Set(candidates.map((s) => s.id));
949
+ const isAbsorbedChild = (s) => includeChildren &&
950
+ s.parent_id !== undefined &&
951
+ candidateIds.has(s.parent_id);
952
+ const matches = [];
953
+ for (const s of candidates) {
954
+ if (isAbsorbedChild(s))
955
+ continue;
956
+ const hit = includeChildren
957
+ ? searchSessionWithChildren(s, kw, childIndex)
958
+ : searchSession(s, kw);
959
+ if (hit.count === 0)
960
+ continue;
961
+ matches.push({ s, hit, descendants: childIndex.get(s.id)?.length ?? 0 });
962
+ }
963
+ // Rank by weighted-density relevance score: user hits matter ×3, normalized
964
+ // by total dialogue length so a tight 18-hit short session beats a sprawling
965
+ // 58-hit long one. Tie-break on raw count, then recency.
966
+ matches.sort((a, b) => {
967
+ const sa = relevanceScore(a.hit);
968
+ const sb = relevanceScore(b.hit);
969
+ if (sb !== sa)
970
+ return sb - sa;
971
+ if (b.hit.count !== a.hit.count)
972
+ return b.hit.count - a.hit.count;
973
+ return (b.s.updated ?? b.s.created ?? "").localeCompare(a.s.updated ?? a.s.created ?? "");
974
+ });
975
+ const top = matches.slice(0, f.limit);
976
+ if (argv.flags.json) {
977
+ console.log(JSON.stringify(top.map(({ s, hit, descendants }) => ({
978
+ session: s,
979
+ score: Number(relevanceScore(hit).toFixed(4)),
980
+ hit_count: hit.count,
981
+ user_count: hit.user_count,
982
+ asst_count: hit.asst_count,
983
+ total_turns: hit.total_turns,
984
+ descendants_merged: includeChildren ? descendants : 0,
985
+ excerpts: hit.excerpts,
986
+ })), null, 2));
987
+ return;
988
+ }
989
+ console.log(`scope: ${f.cwd ? `project=${shortPath(f.cwd)}` : "global"} keyword="${kw}" platform=${f.platform}` +
990
+ (includeChildren ? ` include-children=on` : ""));
991
+ if (top.length === 0) {
992
+ console.log("(no matches)");
993
+ return;
994
+ }
995
+ for (const { s, hit, descendants } of top) {
996
+ const idShort = s.id.slice(0, 12);
997
+ const score = relevanceScore(hit).toFixed(3);
998
+ const childTag = includeChildren && descendants > 0 ? ` +${descendants} child` : "";
999
+ console.log(`\n[${s.platform.padEnd(8)}] ${shortDate(s.updated ?? s.created)} ${idShort} ${shortPath(s.cwd)}` +
1000
+ ` score=${score} hits=${hit.count} (u=${hit.user_count},a=${hit.asst_count}) turns=${hit.total_turns}${childTag}` +
1001
+ (s.title ? ` — ${s.title}` : ""));
1002
+ for (const ex of hit.excerpts) {
1003
+ console.log(` [${ex.role}] ${ex.snippet}`);
1004
+ }
1005
+ }
1006
+ console.log(`\n${top.length} session(s)${matches.length > top.length ? ` (of ${matches.length})` : ""}`);
1007
+ }
1008
+ function cmdProjects(argv) {
1009
+ // List distinct cwds across all platforms with last-active timestamp + per-platform
1010
+ // session counts. Designed for AI consumption: AI calls this first to learn which
1011
+ // "门牌号" (project paths) have recent activity, then picks one for `--cwd` in
1012
+ // a follow-up `search`.
1013
+ const f = buildFilter({ ...argv.flags, global: true });
1014
+ const wide = { ...f, cwd: undefined, limit: 1_000_000 };
1015
+ const all = listAll(wide);
1016
+ const byCwd = new Map();
1017
+ for (const s of all) {
1018
+ if (!s.cwd)
1019
+ continue;
1020
+ const ts = s.updated ?? s.created ?? "";
1021
+ let agg = byCwd.get(s.cwd);
1022
+ if (!agg) {
1023
+ agg = {
1024
+ cwd: s.cwd,
1025
+ last_active: ts,
1026
+ sessions: 0,
1027
+ by_platform: { claude: 0, codex: 0, opencode: 0 },
1028
+ };
1029
+ byCwd.set(s.cwd, agg);
1030
+ }
1031
+ agg.sessions++;
1032
+ agg.by_platform[s.platform]++;
1033
+ if (ts > agg.last_active)
1034
+ agg.last_active = ts;
1035
+ }
1036
+ const rows = [...byCwd.values()].sort((a, b) => b.last_active.localeCompare(a.last_active));
1037
+ const limit = typeof argv.flags.limit === "string" ? Number(argv.flags.limit) : 30;
1038
+ const top = rows.slice(0, limit);
1039
+ if (argv.flags.json) {
1040
+ console.log(JSON.stringify(top, null, 2));
1041
+ return;
1042
+ }
1043
+ console.log(`active projects` +
1044
+ (f.since ? ` since=${f.since.toISOString().slice(0, 10)}` : "") +
1045
+ (f.until ? ` until=${f.until.toISOString().slice(0, 10)}` : ""));
1046
+ if (top.length === 0) {
1047
+ console.log("(none)");
1048
+ return;
1049
+ }
1050
+ for (const r of top) {
1051
+ const parts = Object.entries(r.by_platform)
1052
+ .filter(([, n]) => n > 0)
1053
+ .map(([p, n]) => `${p}:${n}`)
1054
+ .join(" ");
1055
+ console.log(`${shortDate(r.last_active)} sessions=${r.sessions.toString().padStart(3)} (${parts}) ${shortPath(r.cwd)}`);
1056
+ }
1057
+ console.log(`\n${top.length} project(s)${rows.length > top.length ? ` (of ${rows.length})` : ""}`);
1058
+ }
1059
+ function cmdContext(argv) {
1060
+ // Drill-down step 2 in the search workflow:
1061
+ // 1. `search <kw>` → pick a session
1062
+ // 2. `context <id> --grep <kw> --turns N --around M` → top-N hit turns with M
1063
+ // turns of context on either side, token-budgeted for AI consumption
1064
+ //
1065
+ // Without --grep: returns the first N turns (lets AI inspect session opening).
1066
+ // With --grep: ranks turns by (user-role first, then hit density), takes top-N,
1067
+ // then expands each by --around turns of surrounding context.
1068
+ const id = argv.positional[0];
1069
+ if (!id)
1070
+ die("usage: context <session-id> [--grep KW] [--turns N] [--around M]");
1071
+ const f = buildFilter(argv.flags);
1072
+ const s = findSessionById(id, f);
1073
+ if (!s)
1074
+ die(`session not found: ${id}`);
1075
+ const grepRaw = argv.flags.grep;
1076
+ const grep = typeof grepRaw === "string" ? grepRaw : undefined;
1077
+ const nTurns = typeof argv.flags.turns === "string" ? Number(argv.flags.turns) : 3;
1078
+ const around = typeof argv.flags.around === "string" ? Number(argv.flags.around) : 1;
1079
+ const maxChars = typeof argv.flags["max-chars"] === "string"
1080
+ ? Number(argv.flags["max-chars"])
1081
+ : 6000;
1082
+ let turns = extractDialogue(s);
1083
+ let mergedChildren = 0;
1084
+ if (argv.flags["include-children"] === true) {
1085
+ const all = listAll({ ...f, cwd: undefined, limit: 1_000_000 });
1086
+ const childIndex = buildChildIndex(all);
1087
+ const kids = childIndex.get(s.id) ?? [];
1088
+ mergedChildren = kids.length;
1089
+ for (const c of kids)
1090
+ turns = [...turns, ...extractDialogue(c)];
1091
+ }
1092
+ let hitIndices = [];
1093
+ let totalHitTurns = 0;
1094
+ if (grep) {
1095
+ const tokens = grep.toLowerCase().split(/\s+/).filter(Boolean);
1096
+ if (tokens.length === 0)
1097
+ die("--grep requires non-empty value");
1098
+ const matchCount = (text) => {
1099
+ const hay = text.toLowerCase();
1100
+ if (!tokens.every((tok) => hay.includes(tok)))
1101
+ return 0;
1102
+ let n = 0;
1103
+ for (const tok of tokens) {
1104
+ let from = 0;
1105
+ while (true) {
1106
+ const idx = hay.indexOf(tok, from);
1107
+ if (idx === -1)
1108
+ break;
1109
+ n++;
1110
+ from = idx + tok.length;
1111
+ }
1112
+ }
1113
+ return n;
1114
+ };
1115
+ const ranked = [];
1116
+ for (let i = 0; i < turns.length; i++) {
1117
+ const turn = turns[i];
1118
+ if (!turn)
1119
+ continue;
1120
+ const h = matchCount(turn.text);
1121
+ if (h > 0)
1122
+ ranked.push({ idx: i, role: turn.role, hits: h });
1123
+ }
1124
+ totalHitTurns = ranked.length;
1125
+ ranked.sort((a, b) => {
1126
+ if (a.role !== b.role)
1127
+ return a.role === "user" ? -1 : 1;
1128
+ if (b.hits !== a.hits)
1129
+ return b.hits - a.hits;
1130
+ return a.idx - b.idx;
1131
+ });
1132
+ hitIndices = ranked.slice(0, nTurns).map((r) => r.idx);
1133
+ }
1134
+ else {
1135
+ hitIndices = [];
1136
+ for (let i = 0; i < Math.min(nTurns, turns.length); i++)
1137
+ hitIndices.push(i);
1138
+ }
1139
+ // Expand each hit by `around` turns on either side; dedupe via Set.
1140
+ const display = new Set();
1141
+ for (const idx of hitIndices) {
1142
+ for (let j = Math.max(0, idx - around); j <= Math.min(turns.length - 1, idx + around); j++) {
1143
+ display.add(j);
1144
+ }
1145
+ }
1146
+ const ordered = [...display].sort((a, b) => a - b);
1147
+ const hitSet = new Set(hitIndices);
1148
+ const out = [];
1149
+ let used = 0;
1150
+ for (const i of ordered) {
1151
+ const t = turns[i];
1152
+ if (!t)
1153
+ continue;
1154
+ let text = t.text;
1155
+ // Per-turn cap: if a single turn exceeds half the budget, truncate it so we
1156
+ // still fit the rest of the requested context.
1157
+ const cap = Math.floor(maxChars / 2);
1158
+ if (text.length > cap)
1159
+ text = text.slice(0, cap) + `\n…[+${t.text.length - cap} chars]`;
1160
+ if (used + text.length > maxChars && out.length > 0)
1161
+ break;
1162
+ out.push({ idx: i, role: t.role, text, is_hit: hitSet.has(i) });
1163
+ used += text.length;
1164
+ }
1165
+ if (argv.flags.json) {
1166
+ console.log(JSON.stringify({
1167
+ session: s,
1168
+ query: grep,
1169
+ total_turns: turns.length,
1170
+ total_hit_turns: totalHitTurns,
1171
+ merged_children: mergedChildren,
1172
+ turns: out,
1173
+ }, null, 2));
1174
+ return;
1175
+ }
1176
+ console.log(`# context: [${s.platform}] ${s.id}`);
1177
+ if (s.title)
1178
+ console.log(`# title: ${s.title}`);
1179
+ if (s.cwd)
1180
+ console.log(`# cwd: ${shortPath(s.cwd)}`);
1181
+ if (grep)
1182
+ console.log(`# query: "${grep}" hit_turns=${totalHitTurns} showing top ${hitIndices.length}`);
1183
+ else
1184
+ console.log(`# no grep — showing first ${hitIndices.length} turns of ${turns.length}`);
1185
+ if (mergedChildren > 0)
1186
+ console.log(`# merged_children: ${mergedChildren}`);
1187
+ console.log(`# turns shown: ${out.length} budget_used: ${used}/${maxChars} chars`);
1188
+ console.log("");
1189
+ for (const t of out) {
1190
+ const marker = t.is_hit ? " ← hit" : "";
1191
+ console.log(`## turn ${t.idx} (${t.role})${marker}\n`);
1192
+ console.log(t.text);
1193
+ console.log("\n---\n");
1194
+ }
1195
+ }
1196
+ function cmdExtract(argv) {
1197
+ const id = argv.positional[0];
1198
+ if (!id)
1199
+ die("usage: extract <session-id>");
1200
+ const f = buildFilter(argv.flags);
1201
+ const s = findSessionById(id, f);
1202
+ if (!s)
1203
+ die(`session not found: ${id}`);
1204
+ const turns = extractDialogue(s);
1205
+ const grepRaw = argv.flags.grep;
1206
+ const grep = typeof grepRaw === "string" ? grepRaw.toLowerCase() : undefined;
1207
+ if (argv.flags.json) {
1208
+ console.log(JSON.stringify({
1209
+ session: s,
1210
+ turns: grep
1211
+ ? turns.filter((t) => t.text.toLowerCase().includes(grep))
1212
+ : turns,
1213
+ }, null, 2));
1214
+ return;
1215
+ }
1216
+ console.log(`# session: [${s.platform}] ${s.id}`);
1217
+ if (s.title)
1218
+ console.log(`# title: ${s.title}`);
1219
+ if (s.cwd)
1220
+ console.log(`# cwd: ${shortPath(s.cwd)}`);
1221
+ if (s.created)
1222
+ console.log(`# date: ${shortDate(s.created)}`);
1223
+ console.log(`# turns: ${turns.length}${grep ? ` (filtered by /${grep}/)` : ""}`);
1224
+ console.log("");
1225
+ for (const t of turns) {
1226
+ if (grep && !t.text.toLowerCase().includes(grep))
1227
+ continue;
1228
+ console.log(`## ${t.role === "user" ? "Human" : "Assistant"}\n`);
1229
+ console.log(t.text);
1230
+ console.log("\n---\n");
1231
+ }
1232
+ }
1233
+ function cmdHelp() {
1234
+ console.log(`trellis mem — list/search Claude/Codex/OpenCode sessions
1235
+
1236
+ commands:
1237
+ list list sessions (default if no command)
1238
+ search <keyword> find sessions whose contents match keyword
1239
+ context <session-id> drill-down: top-N hit turns + surrounding context
1240
+ (paired with search; use --grep KW to anchor)
1241
+ extract <session-id> dump cleaned dialogue (use --grep KW to filter turns)
1242
+ projects list active projects (cwds) with session counts —
1243
+ use this to discover which --cwd to pass to search
1244
+
1245
+ flags:
1246
+ --platform claude|codex|opencode|all default all
1247
+ --since YYYY-MM-DD inclusive lower bound
1248
+ --until YYYY-MM-DD inclusive upper bound
1249
+ --global include all projects (default: cwd-scoped)
1250
+ --cwd <path> override the project cwd
1251
+ --limit N cap output (default 50)
1252
+ --grep KW extract / context: filter turns by keyword (multi-token AND)
1253
+ --turns N context: number of hit turns to return (default 3)
1254
+ --around N context: turns of surrounding context per hit (default 1)
1255
+ --max-chars N context: total char budget (default 6000, ~1500 tokens)
1256
+ --include-children search / context: merge OpenCode sub-agent sessions into parent
1257
+ --json emit JSON
1258
+ --help, -h show this help
1259
+
1260
+ examples:
1261
+ trellis mem list
1262
+ trellis mem list --global --platform claude --since 2026-04-01
1263
+ trellis mem search "session insight" --global
1264
+ trellis mem extract 5842592d --grep memory
1265
+ `);
1266
+ }
1267
+ // ---------- entry ----------
1268
+ export function runMem(args) {
1269
+ const argv = parseArgv(args);
1270
+ if (argv.flags.help ||
1271
+ argv.flags.h ||
1272
+ argv.cmd === "help" ||
1273
+ argv.cmd === "--help") {
1274
+ return cmdHelp();
1275
+ }
1276
+ switch (argv.cmd) {
1277
+ case "list":
1278
+ return cmdList(argv);
1279
+ case "search":
1280
+ return cmdSearch(argv);
1281
+ case "extract":
1282
+ return cmdExtract(argv);
1283
+ case "context":
1284
+ return cmdContext(argv);
1285
+ case "projects":
1286
+ return cmdProjects(argv);
1287
+ default:
1288
+ die(`unknown command: ${argv.cmd} (try 'help')`);
1289
+ }
1290
+ }
1291
+ //# sourceMappingURL=mem.js.map