@mindfoldhq/trellis 0.5.9 → 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.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +19 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/mem.d.ts +118 -0
- package/dist/commands/mem.d.ts.map +1 -0
- package/dist/commands/mem.js +1291 -0
- package/dist/commands/mem.js.map +1 -0
- package/dist/migrations/manifests/0.5.7.json +1 -1
- package/dist/templates/shared-hooks/inject-workflow-state.py +8 -38
- package/dist/templates/trellis/config.yaml +6 -8
- package/dist/templates/trellis/scripts/common/workflow_phase.py +7 -19
- package/dist/templates/trellis/workflow.md +27 -27
- package/package.json +3 -2
- package/dist/migrations/manifests/0.5.9.json +0 -9
|
@@ -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
|