@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.
- package/dist/commands/channel/adapters/codex.d.ts +4 -0
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
- package/dist/commands/channel/adapters/codex.js +27 -10
- package/dist/commands/channel/adapters/codex.js.map +1 -1
- package/dist/commands/channel/index.d.ts.map +1 -1
- package/dist/commands/channel/index.js +12 -7
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/messages.js +3 -3
- package/dist/commands/channel/messages.js.map +1 -1
- package/dist/commands/channel/send.d.ts +1 -0
- package/dist/commands/channel/send.d.ts.map +1 -1
- package/dist/commands/channel/send.js +3 -1
- package/dist/commands/channel/send.js.map +1 -1
- package/dist/commands/channel/spawn.d.ts +3 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +1 -0
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/channel/store/events.d.ts +1 -1
- package/dist/commands/channel/store/events.js +1 -1
- package/dist/commands/channel/store/watch.d.ts.map +1 -1
- package/dist/commands/channel/store/watch.js +17 -1
- package/dist/commands/channel/store/watch.js.map +1 -1
- package/dist/commands/channel/supervisor/inbox.d.ts +5 -0
- package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/inbox.js +69 -8
- package/dist/commands/channel/supervisor/inbox.js.map +1 -1
- package/dist/commands/channel/supervisor/stdout.d.ts +3 -1
- package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/stdout.js +17 -3
- package/dist/commands/channel/supervisor/stdout.js.map +1 -1
- package/dist/commands/channel/supervisor/turns.d.ts +20 -0
- package/dist/commands/channel/supervisor/turns.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/turns.js +28 -0
- package/dist/commands/channel/supervisor/turns.js.map +1 -0
- package/dist/commands/channel/supervisor.d.ts +4 -0
- package/dist/commands/channel/supervisor.d.ts.map +1 -1
- package/dist/commands/channel/supervisor.js +7 -0
- package/dist/commands/channel/supervisor.js.map +1 -1
- package/dist/commands/channel/threads.d.ts +2 -2
- package/dist/commands/channel/threads.d.ts.map +1 -1
- package/dist/commands/channel/threads.js +3 -3
- package/dist/commands/channel/threads.js.map +1 -1
- package/dist/commands/mem.d.ts +13 -217
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +142 -1587
- package/dist/commands/mem.js.map +1 -1
- package/dist/migrations/manifests/0.6.0-beta.15.json +9 -0
- package/dist/migrations/manifests/0.6.0-beta.16.json +9 -0
- package/dist/templates/claude/agents/trellis-check.md +11 -5
- package/dist/templates/codebuddy/agents/trellis-check.md +11 -5
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +27 -0
- package/dist/templates/cursor/agents/trellis-check.md +11 -5
- package/dist/templates/droid/droids/trellis-check.md +11 -5
- package/dist/templates/gemini/agents/trellis-check.md +11 -5
- package/dist/templates/kiro/agents/trellis-check.json +1 -1
- package/dist/templates/opencode/agents/trellis-check.md +11 -5
- package/dist/templates/pi/agents/trellis-check.md +5 -4
- package/dist/templates/pi/agents/trellis-implement.md +5 -4
- package/dist/templates/qoder/agents/trellis-check.md +11 -5
- package/dist/templates/trellis/workflow.md +20 -0
- package/package.json +2 -2
package/dist/commands/mem.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mem.ts —
|
|
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
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
179
|
-
return
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
// ----------
|
|
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
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1415
|
-
const candidates = listAll(wide);
|
|
154
|
+
maybeWarnOpencode(f);
|
|
1416
155
|
const includeChildren = argv.flags["include-children"] === true;
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
|
161
|
+
const top = result.matches;
|
|
1450
162
|
if (argv.flags.json) {
|
|
1451
|
-
console.log(JSON.stringify(top.map((
|
|
1452
|
-
session:
|
|
1453
|
-
score: Number(
|
|
1454
|
-
hit_count: hit.count,
|
|
1455
|
-
user_count: hit.
|
|
1456
|
-
asst_count: hit.
|
|
1457
|
-
total_turns: hit.
|
|
1458
|
-
descendants_merged: includeChildren ?
|
|
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
|
|
181
|
+
for (const m of top) {
|
|
182
|
+
const s = m.session;
|
|
1470
183
|
const idShort = s.id.slice(0, 12);
|
|
1471
|
-
const score =
|
|
1472
|
-
const childTag = includeChildren &&
|
|
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.
|
|
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)${
|
|
195
|
+
console.log(`\n${top.length} session(s)${result.totalMatches > top.length ? ` (of ${result.totalMatches})` : ""}`);
|
|
1481
196
|
}
|
|
1482
197
|
function cmdProjects(argv) {
|
|
1483
|
-
//
|
|
1484
|
-
// session counts.
|
|
1485
|
-
//
|
|
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
|
-
|
|
1489
|
-
const
|
|
1490
|
-
const
|
|
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
|
-
//
|
|
1536
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
let
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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:
|
|
1643
|
-
total_turns:
|
|
1644
|
-
total_hit_turns: totalHitTurns,
|
|
1645
|
-
merged_children: mergedChildren,
|
|
1646
|
-
turns:
|
|
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 ${
|
|
288
|
+
console.log(`# query: "${grep}" hit_turns=${result.totalHitTurns} showing top ${shown}`);
|
|
1657
289
|
else
|
|
1658
|
-
console.log(`# no grep — showing first ${
|
|
1659
|
-
if (mergedChildren > 0)
|
|
1660
|
-
console.log(`# merged_children: ${mergedChildren}`);
|
|
1661
|
-
console.log(`# turns shown: ${
|
|
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
|
|
1664
|
-
const marker = t.
|
|
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
|
-
|
|
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
|
-
|
|
1765
|
-
|
|
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:
|
|
1779
|
-
total_turns:
|
|
1780
|
-
groups,
|
|
1781
|
-
turns:
|
|
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
|
-
|
|
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
|
-
(
|
|
350
|
+
(result.windows.length > 0 ? ` windows: ${result.windows.length}` : ""));
|
|
1796
351
|
console.log("");
|
|
1797
|
-
for (const g of
|
|
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
|
|
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
|
|
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)
|