pi-weave 0.1.19 → 0.1.21
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/README.md +22 -21
- package/package.json +1 -1
- package/skills/weave-explore/SKILL.md +2 -0
- package/skills/weave-notepad/SKILL.md +10 -0
- package/src/core/concurrency.ts +1 -1
- package/src/core/frontmatter.ts +30 -0
- package/src/core/index.ts +25 -1
- package/src/core/paths.ts +1 -0
- package/src/core/sessions.ts +963 -0
- package/src/core/vault.ts +93 -1
- package/src/pi/index.ts +108 -9
- package/src/pi/sessionScan.ts +105 -0
- package/src/pi/summarize.ts +48 -5
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/web/client/dist/app.js +71 -41
- package/src/web/client/graph/ForceTuner.tsx +99 -0
- package/src/web/client/graph/Graph.tsx +77 -16
- package/src/web/client/graph/column.model.ts +35 -49
- package/src/web/client/graph/dynamics.ts +22 -1
- package/src/web/client/graph/graph.model.ts +63 -5
- package/src/web/client/graph/groups.ts +353 -0
- package/src/web/client/graph/positions.ts +14 -13
- package/src/web/client/graph/renderer.ts +6 -3
- package/src/web/client/graph/tuner.model.ts +210 -0
- package/src/web/client/main.tsx +12 -1
- package/src/web/client/shell/Columns.tsx +3 -0
- package/src/web/client/shell/Shell.tsx +7 -0
- package/src/web/client/shell/theme.ts +30 -1
- package/src/web/client/tree/Tree.tsx +126 -13
- package/src/web/client/tree/tree.model.ts +102 -1
- package/src/web/shared/layout.ts +76 -11
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session scan: summarize agent session transcripts into
|
|
3
|
+
* the vault — one generated note per session, incrementally maintained by
|
|
4
|
+
* hashing each transcript **while reading it** so unchanged sessions never
|
|
5
|
+
* cost an LLM call on re-scans.
|
|
6
|
+
*
|
|
7
|
+
* Core never talks to an LLM and never imports pi — the `summarize` function
|
|
8
|
+
* is injected (the pi adapter wires the session model; tests inject a fake),
|
|
9
|
+
* and both roots (sessions, vault) are injected too, which keeps this module
|
|
10
|
+
* harness-free and trivially testable with fixture directories.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type Dirent } from "node:fs";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
import { mapWithConcurrency } from "./concurrency";
|
|
19
|
+
import {
|
|
20
|
+
parseFrontMatter,
|
|
21
|
+
unquoteField,
|
|
22
|
+
upsertFrontMatterFields,
|
|
23
|
+
type ParsedFrontMatter,
|
|
24
|
+
} from "./frontmatter";
|
|
25
|
+
import { NOTES_DIR, SESSIONS_DIR } from "./paths";
|
|
26
|
+
import { slugify } from "./slug";
|
|
27
|
+
import { hashContent, type SummarizeFn } from "./summaries";
|
|
28
|
+
import { upsertNote } from "./vault";
|
|
29
|
+
|
|
30
|
+
/** The pi sessions directory: `~/.pi/agent/sessions/<encoded-cwd>/<ts>_<uuid>.jsonl`. */
|
|
31
|
+
export const DEFAULT_SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions");
|
|
32
|
+
export const SESSIONS_ENV_VAR = "PI_WEAVE_SESSIONS";
|
|
33
|
+
|
|
34
|
+
/** Resolve the pi sessions root. `env` is injectable for tests. */
|
|
35
|
+
export function resolveSessionsRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
36
|
+
const override = env[SESSIONS_ENV_VAR];
|
|
37
|
+
if (override && override.trim().length > 0) {
|
|
38
|
+
return override;
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_SESSIONS_ROOT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
/* Caps */
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
|
|
47
|
+
export const SESSION_SCAN_MAX_SESSIONS = 100;
|
|
48
|
+
export const SESSION_SCAN_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
49
|
+
export const SESSION_SCAN_CONCURRENCY = 2;
|
|
50
|
+
|
|
51
|
+
const DIGEST_MAX_CHARS = 12_000;
|
|
52
|
+
const USER_MSG_MAX_CHARS = 400;
|
|
53
|
+
const MAX_USER_MESSAGES = 60;
|
|
54
|
+
const COMPACTION_MAX_CHARS = 1200;
|
|
55
|
+
const MAX_COMPACTIONS = 3;
|
|
56
|
+
const BRANCH_MAX_CHARS = 800;
|
|
57
|
+
const MAX_BRANCH_SUMMARIES = 2;
|
|
58
|
+
const LAST_ASSISTANT_MAX_CHARS = 800;
|
|
59
|
+
const FIRST_MESSAGE_MAX_CHARS = 200;
|
|
60
|
+
|
|
61
|
+
function opaqueSession(file: SessionFileInfo): { header: SessionHeader; digest: SessionDigest } {
|
|
62
|
+
const startedAt = new Date(file.mtimeMs).toISOString();
|
|
63
|
+
const id = `file-${hashContent(file.path).slice(0, 16)}`;
|
|
64
|
+
return {
|
|
65
|
+
header: { id, cwd: dirname(file.path), startedAt },
|
|
66
|
+
digest: {
|
|
67
|
+
id,
|
|
68
|
+
cwd: dirname(file.path),
|
|
69
|
+
parentSession: null,
|
|
70
|
+
startedAt,
|
|
71
|
+
endedAt: startedAt,
|
|
72
|
+
name: file.name,
|
|
73
|
+
models: [],
|
|
74
|
+
userCount: 0,
|
|
75
|
+
assistantCount: 0,
|
|
76
|
+
toolResultCount: 0,
|
|
77
|
+
errors: 0,
|
|
78
|
+
bashCount: 0,
|
|
79
|
+
tools: {},
|
|
80
|
+
firstUserMessage: file.name,
|
|
81
|
+
userMessages: [],
|
|
82
|
+
compactions: [],
|
|
83
|
+
branchSummaries: [],
|
|
84
|
+
lastAssistantText: null,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* Discovery */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
|
|
93
|
+
/** One discovered history file under the selected root. */
|
|
94
|
+
export interface SessionFileInfo {
|
|
95
|
+
/** Absolute path. */
|
|
96
|
+
path: string;
|
|
97
|
+
/** File name (the progress line shows this, not the full path). */
|
|
98
|
+
name: string;
|
|
99
|
+
bytes: number;
|
|
100
|
+
mtimeMs: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* All regular files at or recursively under `root`, newest first (mtime desc; ties break by path so ordering is stable across scans). Files are opaque model input; pi JSONL parsing only enriches metadata when available.
|
|
105
|
+
*/
|
|
106
|
+
export async function listSessionFiles(
|
|
107
|
+
root: string,
|
|
108
|
+
opts: { limit?: number } = {},
|
|
109
|
+
): Promise<SessionFileInfo[]> {
|
|
110
|
+
let rootStat;
|
|
111
|
+
try {
|
|
112
|
+
rootStat = await fs.stat(root);
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
if (rootStat.isFile()) {
|
|
117
|
+
return [{ path: root, name: basename(root), bytes: rootStat.size, mtimeMs: rootStat.mtimeMs }];
|
|
118
|
+
}
|
|
119
|
+
let entries: Dirent<string>[];
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
122
|
+
} catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const out: SessionFileInfo[] = [];
|
|
126
|
+
async function walk(dir: string, children: readonly Dirent<string>[]): Promise<void> {
|
|
127
|
+
for (const ent of children) {
|
|
128
|
+
const path = join(dir, ent.name);
|
|
129
|
+
if (ent.isDirectory()) {
|
|
130
|
+
try {
|
|
131
|
+
await walk(path, await fs.readdir(path, { withFileTypes: true }));
|
|
132
|
+
} catch {
|
|
133
|
+
// raced a delete or unreadable directory
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!ent.isFile()) continue;
|
|
138
|
+
try {
|
|
139
|
+
const st = await fs.stat(path);
|
|
140
|
+
out.push({ path, name: ent.name, bytes: st.size, mtimeMs: st.mtimeMs });
|
|
141
|
+
} catch {
|
|
142
|
+
// raced a delete
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
await walk(root, entries);
|
|
147
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
|
|
148
|
+
return opts.limit !== undefined ? out.slice(0, opts.limit) : out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ------------------------------------------------------------------ */
|
|
152
|
+
/* Parsing: the digest */
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
|
|
155
|
+
/** The compact, summarizer-facing picture of one session. */
|
|
156
|
+
export interface SessionDigest {
|
|
157
|
+
/** Session uuid from the JSONL header — the stable identity. */
|
|
158
|
+
id: string;
|
|
159
|
+
/** Working directory from the header ("" when absent). */
|
|
160
|
+
cwd: string;
|
|
161
|
+
/** Parent session path for forked/branched sessions. */
|
|
162
|
+
parentSession: string | null;
|
|
163
|
+
/** Header timestamp (ISO). */
|
|
164
|
+
startedAt: string;
|
|
165
|
+
/** Timestamp of the last parsed entry (falls back to startedAt). */
|
|
166
|
+
endedAt: string;
|
|
167
|
+
/** Latest `session_info` display name, when the user set one. */
|
|
168
|
+
name: string | null;
|
|
169
|
+
/** Models used, in first-seen order (`model_change` + assistant messages). */
|
|
170
|
+
models: string[];
|
|
171
|
+
userCount: number;
|
|
172
|
+
assistantCount: number;
|
|
173
|
+
toolResultCount: number;
|
|
174
|
+
/** Assistant errors + tool-result errors. */
|
|
175
|
+
errors: number;
|
|
176
|
+
/** Direct `!!`-style shell executions (role `bashExecution`). */
|
|
177
|
+
bashCount: number;
|
|
178
|
+
/** toolName → call count, from tool-result messages. */
|
|
179
|
+
tools: Record<string, number>;
|
|
180
|
+
firstUserMessage: string | null;
|
|
181
|
+
/** User texts, each clipped, in order. */
|
|
182
|
+
userMessages: string[];
|
|
183
|
+
/** Compaction summaries (newest kept, clipped). */
|
|
184
|
+
compactions: string[];
|
|
185
|
+
/** Branch summaries (newest kept, clipped). */
|
|
186
|
+
branchSummaries: string[];
|
|
187
|
+
/** Last non-empty assistant text, clipped. */
|
|
188
|
+
lastAssistantText: string | null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Parse-time clip: truncate long text and mark it. */
|
|
192
|
+
function clip(text: string, max: number): string {
|
|
193
|
+
const flat = text.trim();
|
|
194
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Keep at most `cap` items; newest (latest push) wins. */
|
|
198
|
+
function pushCapped(arr: string[], item: string, cap: number): void {
|
|
199
|
+
arr.push(item);
|
|
200
|
+
if (arr.length > cap) arr.shift();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pushUnique(arr: string[], value: string): void {
|
|
204
|
+
if (!arr.includes(value)) arr.push(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Extract text from a message `content` (string or content-block array). */
|
|
208
|
+
function extractText(content: unknown): string {
|
|
209
|
+
if (typeof content === "string") return content;
|
|
210
|
+
if (!Array.isArray(content)) return "";
|
|
211
|
+
const parts: string[] = [];
|
|
212
|
+
for (const block of content) {
|
|
213
|
+
if (
|
|
214
|
+
block !== null &&
|
|
215
|
+
typeof block === "object" &&
|
|
216
|
+
(block as { type?: unknown }).type === "text" &&
|
|
217
|
+
typeof (block as { text?: unknown }).text === "string"
|
|
218
|
+
) {
|
|
219
|
+
parts.push((block as { text: string }).text);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return parts.join(" ");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function modelLabel(provider: unknown, model: unknown): string | null {
|
|
226
|
+
if (typeof provider !== "string" || typeof model !== "string") return null;
|
|
227
|
+
if (provider.length === 0 || model.length === 0) return null;
|
|
228
|
+
return `${provider}/${model}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse a session JSONL into its digest, or null when the file has no
|
|
233
|
+
* recognizable pi session header (anything else — unknown entry types,
|
|
234
|
+
* malformed lines, missing optional fields — is tolerated and skipped).
|
|
235
|
+
*/
|
|
236
|
+
export function parseSessionDigest(text: string): SessionDigest | null {
|
|
237
|
+
const digest: SessionDigest = {
|
|
238
|
+
id: "",
|
|
239
|
+
cwd: "",
|
|
240
|
+
parentSession: null,
|
|
241
|
+
startedAt: "",
|
|
242
|
+
endedAt: "",
|
|
243
|
+
name: null,
|
|
244
|
+
models: [],
|
|
245
|
+
userCount: 0,
|
|
246
|
+
assistantCount: 0,
|
|
247
|
+
toolResultCount: 0,
|
|
248
|
+
errors: 0,
|
|
249
|
+
bashCount: 0,
|
|
250
|
+
tools: {},
|
|
251
|
+
firstUserMessage: null,
|
|
252
|
+
userMessages: [],
|
|
253
|
+
compactions: [],
|
|
254
|
+
branchSummaries: [],
|
|
255
|
+
lastAssistantText: null,
|
|
256
|
+
};
|
|
257
|
+
let sawHeader = false;
|
|
258
|
+
|
|
259
|
+
for (const line of text.split("\n")) {
|
|
260
|
+
const trimmed = line.trim();
|
|
261
|
+
if (trimmed.length === 0) continue;
|
|
262
|
+
let entry: Record<string, unknown>;
|
|
263
|
+
try {
|
|
264
|
+
entry = JSON.parse(trimmed) as Record<string, unknown>;
|
|
265
|
+
} catch {
|
|
266
|
+
continue; // torn/partial line — skip, never fatal
|
|
267
|
+
}
|
|
268
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
269
|
+
|
|
270
|
+
switch (entry.type) {
|
|
271
|
+
case "session": {
|
|
272
|
+
if (sawHeader) break; // first header wins
|
|
273
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
274
|
+
if (id.length === 0) break; // not a pi session header
|
|
275
|
+
sawHeader = true;
|
|
276
|
+
digest.id = id;
|
|
277
|
+
digest.cwd = typeof entry.cwd === "string" ? entry.cwd : "";
|
|
278
|
+
digest.startedAt = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
|
279
|
+
digest.endedAt = digest.startedAt;
|
|
280
|
+
digest.parentSession = typeof entry.parentSession === "string" ? entry.parentSession : null;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "session_info": {
|
|
284
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
285
|
+
if (name.length > 0) digest.name = name;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case "model_change": {
|
|
289
|
+
const label = modelLabel(entry.provider, entry.modelId);
|
|
290
|
+
if (label) pushUnique(digest.models, label);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "compaction": {
|
|
294
|
+
const summary = typeof entry.summary === "string" ? entry.summary : "";
|
|
295
|
+
if (summary.trim().length > 0) {
|
|
296
|
+
pushCapped(digest.compactions, clip(summary, COMPACTION_MAX_CHARS), MAX_COMPACTIONS);
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "branch_summary": {
|
|
301
|
+
const summary = typeof entry.summary === "string" ? entry.summary : "";
|
|
302
|
+
if (summary.trim().length > 0) {
|
|
303
|
+
pushCapped(digest.branchSummaries, clip(summary, BRANCH_MAX_CHARS), MAX_BRANCH_SUMMARIES);
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "message": {
|
|
308
|
+
const ts = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
|
309
|
+
if (ts.length > 0) digest.endedAt = ts;
|
|
310
|
+
const msg = entry.message;
|
|
311
|
+
if (msg === null || typeof msg !== "object") break;
|
|
312
|
+
const role = (msg as Record<string, unknown>).role;
|
|
313
|
+
switch (role) {
|
|
314
|
+
case "user": {
|
|
315
|
+
digest.userCount += 1;
|
|
316
|
+
const text = extractText((msg as Record<string, unknown>).content).trim();
|
|
317
|
+
if (text.length === 0) break;
|
|
318
|
+
pushCapped(digest.userMessages, clip(text, USER_MSG_MAX_CHARS), MAX_USER_MESSAGES);
|
|
319
|
+
if (digest.firstUserMessage === null) {
|
|
320
|
+
digest.firstUserMessage = clip(text, FIRST_MESSAGE_MAX_CHARS);
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "assistant": {
|
|
325
|
+
digest.assistantCount += 1;
|
|
326
|
+
const m = msg as Record<string, unknown>;
|
|
327
|
+
const label = modelLabel(m.provider, m.model);
|
|
328
|
+
if (label) pushUnique(digest.models, label);
|
|
329
|
+
if (m.stopReason === "error") digest.errors += 1;
|
|
330
|
+
const text = extractText(m.content).trim();
|
|
331
|
+
if (text.length > 0) digest.lastAssistantText = clip(text, LAST_ASSISTANT_MAX_CHARS);
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case "toolResult": {
|
|
335
|
+
digest.toolResultCount += 1;
|
|
336
|
+
const m = msg as Record<string, unknown>;
|
|
337
|
+
if (m.isError === true) digest.errors += 1;
|
|
338
|
+
if (typeof m.toolName === "string" && m.toolName.length > 0) {
|
|
339
|
+
digest.tools[m.toolName] = (digest.tools[m.toolName] ?? 0) + 1;
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case "bashExecution": {
|
|
344
|
+
digest.bashCount += 1;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
default:
|
|
348
|
+
break; // custom / extension roles — not memory-worthy
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
default:
|
|
353
|
+
break; // label / custom / thinking_level_change / …
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return sawHeader ? digest : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Peek at just the header line for the session id — the cheap identity
|
|
362
|
+
* lookup used before deciding whether a file needs a full parse.
|
|
363
|
+
*/
|
|
364
|
+
export interface SessionHeader {
|
|
365
|
+
id: string;
|
|
366
|
+
cwd: string;
|
|
367
|
+
startedAt: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Peek at just the header line for identity, project, and start time — the
|
|
372
|
+
* cheap read used on sessions whose hash already matched (no full parse), and
|
|
373
|
+
* the raw material for the per-project chain.
|
|
374
|
+
*/
|
|
375
|
+
export function peekSessionHeader(text: string): SessionHeader | null {
|
|
376
|
+
for (const line of text.split("\n")) {
|
|
377
|
+
const trimmed = line.trim();
|
|
378
|
+
if (trimmed.length === 0) continue;
|
|
379
|
+
try {
|
|
380
|
+
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
|
381
|
+
if (entry.type !== "session" || typeof entry.id !== "string" || entry.id.length === 0) {
|
|
382
|
+
return null; // first line is not a header — not a session file
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
id: entry.id,
|
|
386
|
+
cwd: typeof entry.cwd === "string" ? entry.cwd : "",
|
|
387
|
+
startedAt: typeof entry.timestamp === "string" ? entry.timestamp : "",
|
|
388
|
+
};
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** True when the session carries anything worth remembering. */
|
|
397
|
+
export function sessionHasContent(d: SessionDigest): boolean {
|
|
398
|
+
return (
|
|
399
|
+
d.userCount > 0 || d.bashCount > 0 || d.compactions.length > 0 || d.branchSummaries.length > 0
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* ------------------------------------------------------------------ */
|
|
404
|
+
/* Rendering: digest, title, tags, note body */
|
|
405
|
+
/* ------------------------------------------------------------------ */
|
|
406
|
+
|
|
407
|
+
/** Flatten internal whitespace (user prompts are often multi-line). */
|
|
408
|
+
function flatten(text: string): string {
|
|
409
|
+
return text.replace(/\s+/g, " ").trim();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Tool histogram, descending count then name — deterministic output. */
|
|
413
|
+
function sortedTools(d: SessionDigest): [string, number][] {
|
|
414
|
+
return Object.entries(d.tools).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Render the digest as the text handed to the summarizer: a compact
|
|
419
|
+
* transcript, capped so one session always costs one bounded LLM call.
|
|
420
|
+
*/
|
|
421
|
+
export function renderSessionDigest(d: SessionDigest, opts: { maxChars?: number } = {}): string {
|
|
422
|
+
const lines: string[] = [];
|
|
423
|
+
const span =
|
|
424
|
+
d.startedAt.length === 0
|
|
425
|
+
? "unknown time"
|
|
426
|
+
: d.endedAt !== d.startedAt && d.endedAt.length > 0
|
|
427
|
+
? `${d.startedAt} → ${d.endedAt}`
|
|
428
|
+
: d.startedAt;
|
|
429
|
+
lines.push(`Session ${d.id} (${span})`);
|
|
430
|
+
if (d.cwd.length > 0) lines.push(`Project: ${d.cwd}`);
|
|
431
|
+
if (d.name !== null) lines.push(`Name: ${d.name}`);
|
|
432
|
+
if (d.models.length > 0) lines.push(`Models: ${d.models.join(", ")}`);
|
|
433
|
+
const counts = [`${d.userCount} user`, `${d.assistantCount} assistant`, `${d.toolResultCount} tool results`];
|
|
434
|
+
if (d.errors > 0) counts.push(`${d.errors} errors`);
|
|
435
|
+
lines.push(`Messages: ${counts.join(", ")}`);
|
|
436
|
+
const tools = sortedTools(d);
|
|
437
|
+
if (tools.length > 0) {
|
|
438
|
+
lines.push(`Tools: ${tools.map(([name, count]) => `${name} ×${count}`).join(", ")}`);
|
|
439
|
+
}
|
|
440
|
+
if (d.userMessages.length > 0) {
|
|
441
|
+
lines.push("", "## User messages");
|
|
442
|
+
d.userMessages.forEach((m, i) => lines.push(`${i + 1}. ${flatten(m)}`));
|
|
443
|
+
}
|
|
444
|
+
if (d.compactions.length > 0) {
|
|
445
|
+
lines.push("", "## Compaction summaries");
|
|
446
|
+
d.compactions.forEach((c, i) => lines.push(`[${i + 1}] ${c}`));
|
|
447
|
+
}
|
|
448
|
+
if (d.branchSummaries.length > 0) {
|
|
449
|
+
lines.push("", "## Branch summaries");
|
|
450
|
+
d.branchSummaries.forEach((c, i) => lines.push(`[${i + 1}] ${c}`));
|
|
451
|
+
}
|
|
452
|
+
if (d.lastAssistantText !== null) {
|
|
453
|
+
lines.push("", "## Last assistant message", d.lastAssistantText);
|
|
454
|
+
}
|
|
455
|
+
const cap = opts.maxChars ?? DIGEST_MAX_CHARS;
|
|
456
|
+
const text = lines.join("\n");
|
|
457
|
+
return text.length <= cap ? text : `${text.slice(0, cap - 1)}…`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The note title: the user's session name, else the first user message, else
|
|
462
|
+
* a fallback from the id — flattened and clipped. Deliberately unprefixed:
|
|
463
|
+
* session notes live in their own `sessions/` vault collection, and the
|
|
464
|
+
* directory is the context a "Pi session:" title prefix used to carry.
|
|
465
|
+
*/
|
|
466
|
+
export function deriveSessionTitle(d: SessionDigest): string {
|
|
467
|
+
const raw = flatten(d.name ?? d.firstUserMessage ?? "");
|
|
468
|
+
const base = raw.length > 0 ? raw : `session ${d.id.slice(0, 8) || "unknown"}`;
|
|
469
|
+
return base.length > 80 ? `${base.slice(0, 79).trimEnd()}…` : base;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Tag for every session note — grep- and cluster-friendly. */
|
|
473
|
+
export const SESSION_NOTE_TAG = "pi-session";
|
|
474
|
+
|
|
475
|
+
/** The project tag for a cwd: the directory name, slugified. */
|
|
476
|
+
export function projectTagOf(cwd: string): string {
|
|
477
|
+
return cwd.length > 0 ? slugify(basename(cwd)) : "";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `["pi-session", <project>]` — the project tag is the cwd's directory name. */
|
|
481
|
+
export function sessionNoteTags(d: SessionDigest): string[] {
|
|
482
|
+
const tags = [SESSION_NOTE_TAG];
|
|
483
|
+
// Guard before slugify: its empty-input fallback is "note", and a missing
|
|
484
|
+
// cwd must produce NO project tag, not a meaningless one.
|
|
485
|
+
const project = projectTagOf(d.cwd);
|
|
486
|
+
if (project.length > 0) tags.push(project);
|
|
487
|
+
return tags;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface SessionNoteRecord {
|
|
491
|
+
digest: SessionDigest;
|
|
492
|
+
file: Pick<SessionFileInfo, "path">;
|
|
493
|
+
/** sha1 of the transcript bytes at summarize time. */
|
|
494
|
+
hash: string;
|
|
495
|
+
summary: string;
|
|
496
|
+
/** Summarizer label for provenance (null when unknown). */
|
|
497
|
+
model: string | null;
|
|
498
|
+
/** ISO timestamp of the summarization. */
|
|
499
|
+
at: string;
|
|
500
|
+
/**
|
|
501
|
+
* Slug of the note already representing this session (from the marker
|
|
502
|
+
* index), so a re-summarize lands **in place** — even when the human
|
|
503
|
+
* renamed the note away from its derived slug. Null/absent on creation.
|
|
504
|
+
*/
|
|
505
|
+
existingSlug?: string | null;
|
|
506
|
+
/** Neighbour sessions of the same project, as `[[slug]]` links. */
|
|
507
|
+
chain?: SessionChain;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Chronological neighbours of a session within the same project. */
|
|
511
|
+
export interface SessionChain {
|
|
512
|
+
/** Slug of the closest older same-project session note, when known. */
|
|
513
|
+
previous?: string | null;
|
|
514
|
+
/** Slug of the closest newer same-project session note, when known. */
|
|
515
|
+
next?: string | null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The front-matter fields owned by session notes. */
|
|
519
|
+
export function sessionNoteFields(rec: SessionNoteRecord): Record<string, string> {
|
|
520
|
+
const fields: Record<string, string> = {
|
|
521
|
+
session_id: rec.digest.id,
|
|
522
|
+
session_hash: rec.hash,
|
|
523
|
+
session_cwd: rec.digest.cwd,
|
|
524
|
+
session_file: rec.file.path,
|
|
525
|
+
};
|
|
526
|
+
// Sorting key for the per-project chain; absent on very old notes.
|
|
527
|
+
if (rec.digest.startedAt.length > 0) fields.session_start = rec.digest.startedAt;
|
|
528
|
+
return fields;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Summary + a `## Details` provenance block. */
|
|
532
|
+
export function sessionNoteBody(rec: SessionNoteRecord): string {
|
|
533
|
+
const d = rec.digest;
|
|
534
|
+
const lines = [
|
|
535
|
+
rec.summary,
|
|
536
|
+
"",
|
|
537
|
+
"## Details",
|
|
538
|
+
"",
|
|
539
|
+
`- Session: \`${d.id}\``,
|
|
540
|
+
`- Started: ${d.startedAt.length > 0 ? d.startedAt : "unknown"}${
|
|
541
|
+
d.endedAt.length > 0 && d.endedAt !== d.startedAt ? ` · ended: ${d.endedAt}` : ""
|
|
542
|
+
}`,
|
|
543
|
+
`- Project: \`${d.cwd.length > 0 ? d.cwd : "unknown"}\``,
|
|
544
|
+
`- Messages: ${d.userCount} user · ${d.assistantCount} assistant · ${d.toolResultCount} tool results${
|
|
545
|
+
d.errors > 0 ? ` (${d.errors} errors)` : ""
|
|
546
|
+
}`,
|
|
547
|
+
];
|
|
548
|
+
const tools = sortedTools(d);
|
|
549
|
+
if (tools.length > 0) {
|
|
550
|
+
lines.push(`- Tools: ${tools.map(([name, count]) => `${name} ×${count}`).join(", ")}`);
|
|
551
|
+
}
|
|
552
|
+
if (d.bashCount > 0) lines.push(`- Shell commands (direct): ${d.bashCount}`);
|
|
553
|
+
if (d.models.length > 0) lines.push(`- Models: ${d.models.join(", ")}`);
|
|
554
|
+
if (rec.chain?.previous) lines.push(`- Previous session: [[${rec.chain.previous}]]`);
|
|
555
|
+
if (rec.chain?.next) lines.push(`- Next session: [[${rec.chain.next}]]`);
|
|
556
|
+
lines.push(`- Transcript: \`${rec.file.path}\``);
|
|
557
|
+
lines.push(`- Summarized: ${rec.at}${rec.model ? ` by ${rec.model}` : ""}`);
|
|
558
|
+
return lines.join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Write (create or update in place) the session note for one transcript. */
|
|
562
|
+
export async function writeSessionNote(vaultRoot: string, rec: SessionNoteRecord): Promise<string> {
|
|
563
|
+
const note = await upsertNote(vaultRoot, {
|
|
564
|
+
// `sessions/<name>` — an inner folder of the vault graph, so session
|
|
565
|
+
// notes are real notes (search, graph, wikilinks) without mixing the
|
|
566
|
+
// flat listing a human curates. `existingSlug` carries the full slug.
|
|
567
|
+
slug: rec.existingSlug ?? sessionNoteSlug(deriveSessionTitle(rec.digest)),
|
|
568
|
+
title: deriveSessionTitle(rec.digest),
|
|
569
|
+
tags: sessionNoteTags(rec.digest),
|
|
570
|
+
body: sessionNoteBody(rec),
|
|
571
|
+
fields: sessionNoteFields(rec),
|
|
572
|
+
source: "generated",
|
|
573
|
+
// Guard the slug against same-titled but different sessions.
|
|
574
|
+
identity: { field: "session_id", value: rec.digest.id },
|
|
575
|
+
// The record's `at` is the injected scan clock — note timestamps follow
|
|
576
|
+
// it rather than the wall clock (tests inject fixed times).
|
|
577
|
+
now: new Date(rec.at),
|
|
578
|
+
});
|
|
579
|
+
return note.slug;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/* ------------------------------------------------------------------ */
|
|
583
|
+
/* The note index (incremental-skip lookup) */
|
|
584
|
+
/* ------------------------------------------------------------------ */
|
|
585
|
+
|
|
586
|
+
export interface SessionNotePointer {
|
|
587
|
+
slug: string;
|
|
588
|
+
/** `session_hash` recorded when the note was last summarized; null if absent. */
|
|
589
|
+
hash: string | null;
|
|
590
|
+
/** `session_cwd`, when the note carries one — chain grouping. */
|
|
591
|
+
cwd: string | null;
|
|
592
|
+
/** `session_start`, when the note carries one — chain ordering. */
|
|
593
|
+
startedAt: string | null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* One pass over the `notes/sessions/` collection: `session_id → pointer`,
|
|
598
|
+
* from front matter. Marker-based, so renamed/retitled notes still resolve;
|
|
599
|
+
* the first note claiming an id wins (deterministic by file name order).
|
|
600
|
+
*/
|
|
601
|
+
export async function readSessionNoteIndex(
|
|
602
|
+
vaultRoot: string,
|
|
603
|
+
): Promise<Map<string, SessionNotePointer>> {
|
|
604
|
+
const dir = sessionNotesDir(vaultRoot);
|
|
605
|
+
let names: string[];
|
|
606
|
+
try {
|
|
607
|
+
names = await fs.readdir(dir);
|
|
608
|
+
} catch {
|
|
609
|
+
return new Map();
|
|
610
|
+
}
|
|
611
|
+
const map = new Map<string, SessionNotePointer>();
|
|
612
|
+
for (const name of names.sort()) {
|
|
613
|
+
if (!name.endsWith(".md")) continue;
|
|
614
|
+
let text: string;
|
|
615
|
+
try {
|
|
616
|
+
text = await fs.readFile(join(dir, name), "utf8");
|
|
617
|
+
} catch {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const fm = parseFrontMatter(text);
|
|
621
|
+
if (!fm) continue;
|
|
622
|
+
const id = unquoteField(fm.fields.get("session_id") ?? "");
|
|
623
|
+
if (id.length === 0) continue;
|
|
624
|
+
if (map.has(id)) continue;
|
|
625
|
+
const hash = unquoteField(fm.fields.get("session_hash") ?? "");
|
|
626
|
+
map.set(id, {
|
|
627
|
+
// The slug is the note's graph identity: the path relative to notes/.
|
|
628
|
+
slug: `${SESSIONS_DIR}/${name.slice(0, -".md".length)}`,
|
|
629
|
+
hash: hash.length > 0 ? hash : null,
|
|
630
|
+
cwd: nonEmpty(unquoteField(fm.fields.get("session_cwd") ?? "")),
|
|
631
|
+
startedAt: nonEmpty(unquoteField(fm.fields.get("session_start") ?? "")),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return map;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function nonEmpty(value: string): string | null {
|
|
638
|
+
return value.length > 0 ? value : null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The `notes/sessions/` directory inside the vault. */
|
|
642
|
+
export function sessionNotesDir(vaultRoot: string): string {
|
|
643
|
+
return join(vaultRoot, NOTES_DIR, SESSIONS_DIR);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** A session note's slug: `sessions/<name>` — nested inside the vault graph. */
|
|
647
|
+
export function sessionNoteSlug(titleOrName: string): string {
|
|
648
|
+
return `${SESSIONS_DIR}/${slugify(titleOrName)}`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/* ------------------------------------------------------------------ */
|
|
652
|
+
/* Legacy migration: notes/ → sessions/ */
|
|
653
|
+
/* ------------------------------------------------------------------ */
|
|
654
|
+
|
|
655
|
+
/** The title prefix the first layout carried; migration strips it. */
|
|
656
|
+
const LEGACY_TITLE_PREFIX = "Pi session: ";
|
|
657
|
+
const LEGACY_SLUG_PREFIX = "pi-session-";
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Move session notes from the first layout (`notes/*.md`, prefixed titles)
|
|
661
|
+
* into the dedicated `sessions/` collection.
|
|
662
|
+
*
|
|
663
|
+
* Per legacy note, best-effort, idempotent:
|
|
664
|
+
* 1. strip the `Pi session: ` title prefix (a systematic change, not a
|
|
665
|
+
* human edit — a human title that merely *begins* with the prefix keeps
|
|
666
|
+
* its remaining words),
|
|
667
|
+
* 2. backfill `session_start` from the Details block, so per-project chain
|
|
668
|
+
* ordering works for notes written before the field existed,
|
|
669
|
+
* 3. rename the file, dropping the `pi-session-` slug prefix, and move it
|
|
670
|
+
* into `sessions/`. A name collision leaves the legacy file untouched —
|
|
671
|
+
* a later re-summarize settles that session via the marker index.
|
|
672
|
+
*
|
|
673
|
+
* Returns the number of notes moved. Non-session notes are never touched.
|
|
674
|
+
*/
|
|
675
|
+
/**
|
|
676
|
+
* Rewrite one legacy session note's content for the current layout: strip
|
|
677
|
+
* the `Pi session: ` title prefix (a systematic change, not a human edit —
|
|
678
|
+
* a human title that merely *begins* with the prefix keeps its remaining
|
|
679
|
+
* words), backfill `session_start` from the Details block so per-project
|
|
680
|
+
* chain ordering works for notes written before the field existed, and
|
|
681
|
+
* qualify the body's `[[chain links]]` with their new `sessions/` directory.
|
|
682
|
+
*/
|
|
683
|
+
function migrateLegacyNoteText(fm: ParsedFrontMatter): string {
|
|
684
|
+
const rawTitle = unquoteField(fm.fields.get("title") ?? "");
|
|
685
|
+
const fields: Record<string, string> = {};
|
|
686
|
+
if (rawTitle.startsWith(LEGACY_TITLE_PREFIX)) {
|
|
687
|
+
fields.title = rawTitle.slice(LEGACY_TITLE_PREFIX.length);
|
|
688
|
+
}
|
|
689
|
+
const started = /^- Started: (\S+)/m.exec(fm.body)?.[1];
|
|
690
|
+
if (started) fields.session_start = started;
|
|
691
|
+
const lines =
|
|
692
|
+
Object.keys(fields).length > 0 ? upsertFrontMatterFields(fm.lines, fields) : fm.lines;
|
|
693
|
+
const body = fm.body.replace(
|
|
694
|
+
/^(- (?:Previous|Next) session: \[\[)([^\]/]+)(\]\])$/gm,
|
|
695
|
+
(_match, head: string, target: string, tail: string) =>
|
|
696
|
+
target.startsWith(SESSIONS_DIR + "/") ? `${head}${target}${tail}` : `${head}${SESSIONS_DIR}/${target}${tail}`,
|
|
697
|
+
);
|
|
698
|
+
return ["---", ...lines, "---", "", body, ""].join("\n");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function migrateLegacySessionNotes(vaultRoot: string): Promise<number> {
|
|
702
|
+
const targetDir = sessionNotesDir(vaultRoot);
|
|
703
|
+
// Both earlier layouts: the interim vault-level `sessions/` folder, and the
|
|
704
|
+
// original flat `notes/` listing.
|
|
705
|
+
const legacyDirs = [join(vaultRoot, SESSIONS_DIR), join(vaultRoot, NOTES_DIR)];
|
|
706
|
+
let moved = 0;
|
|
707
|
+
for (const legacyDir of legacyDirs) {
|
|
708
|
+
let names: string[];
|
|
709
|
+
try {
|
|
710
|
+
names = await fs.readdir(legacyDir);
|
|
711
|
+
} catch {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
for (const name of names.sort()) {
|
|
715
|
+
if (!name.endsWith(".md")) continue;
|
|
716
|
+
let text: string;
|
|
717
|
+
try {
|
|
718
|
+
text = await fs.readFile(join(legacyDir, name), "utf8");
|
|
719
|
+
} catch {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const fm = parseFrontMatter(text);
|
|
723
|
+
if (!fm) continue;
|
|
724
|
+
const id = unquoteField(fm.fields.get("session_id") ?? "");
|
|
725
|
+
if (id.length === 0) continue; // not ours — never touch human notes
|
|
726
|
+
|
|
727
|
+
const newText = migrateLegacyNoteText(fm);
|
|
728
|
+
const base = name.slice(0, -".md".length);
|
|
729
|
+
const stripped = base.startsWith(LEGACY_SLUG_PREFIX) ? base.slice(LEGACY_SLUG_PREFIX.length) : base;
|
|
730
|
+
const targetName = `${stripped.length > 0 ? stripped : base}.md`;
|
|
731
|
+
const target = join(targetDir, targetName);
|
|
732
|
+
try {
|
|
733
|
+
if (existsSync(target)) {
|
|
734
|
+
// A file already sits at the target name. When it carries the same
|
|
735
|
+
// session id it is the authoritative, rescanned copy — it may hold a
|
|
736
|
+
// newer summary, a human retitle, and a `## Raw` tail, so it is kept
|
|
737
|
+
// and the stale legacy duplicate is removed. A different session
|
|
738
|
+
// keeps the legacy file where it is; the marker index and identity
|
|
739
|
+
// guard separate them on that session's next re-summarize.
|
|
740
|
+
const occupant = parseFrontMatter(await fs.readFile(target, "utf8"));
|
|
741
|
+
const occupantId = occupant ? unquoteField(occupant.fields.get("session_id") ?? "") : "";
|
|
742
|
+
if (occupantId !== id) continue;
|
|
743
|
+
await fs.unlink(join(legacyDir, name));
|
|
744
|
+
moved += 1;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
748
|
+
await fs.writeFile(target, newText, "utf8");
|
|
749
|
+
await fs.unlink(join(legacyDir, name));
|
|
750
|
+
moved += 1;
|
|
751
|
+
} catch {
|
|
752
|
+
continue; // raced or unwritable — leave the legacy note alone
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// The interim vault-level `sessions/` folder is this migration's own
|
|
757
|
+
// leftover; once emptied, remove the shell too (never `notes/` itself).
|
|
758
|
+
try {
|
|
759
|
+
await fs.rmdir(join(vaultRoot, SESSIONS_DIR));
|
|
760
|
+
} catch {
|
|
761
|
+
// not empty or already gone — either is fine
|
|
762
|
+
}
|
|
763
|
+
return moved;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/* ------------------------------------------------------------------ */
|
|
767
|
+
/* The scan */
|
|
768
|
+
/* ------------------------------------------------------------------ */
|
|
769
|
+
|
|
770
|
+
export interface SessionScanOptions {
|
|
771
|
+
sessionsRoot: string;
|
|
772
|
+
vaultRoot: string;
|
|
773
|
+
summarize: SummarizeFn;
|
|
774
|
+
/** Summarizer label recorded for provenance. */
|
|
775
|
+
model?: string;
|
|
776
|
+
maxSessions?: number;
|
|
777
|
+
maxFileBytes?: number;
|
|
778
|
+
concurrency?: number;
|
|
779
|
+
/** Injectable clock (note timestamps). */
|
|
780
|
+
now?: () => Date;
|
|
781
|
+
/** Called before each candidate is processed; `current` is 1-based. */
|
|
782
|
+
onProgress?: (info: { current: number; total: number; path: string }) => void;
|
|
783
|
+
/** When aborted, stop scheduling new work and return partial results. */
|
|
784
|
+
signal?: AbortSignal;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export interface SessionScanFailure {
|
|
788
|
+
path: string;
|
|
789
|
+
error: string;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export interface SessionScanResult {
|
|
793
|
+
/** Transcript files found under the sessions root. */
|
|
794
|
+
discovered: number;
|
|
795
|
+
/** Candidates after the size filter and the maxSessions cap. */
|
|
796
|
+
considered: number;
|
|
797
|
+
written: number;
|
|
798
|
+
created: number;
|
|
799
|
+
updated: number;
|
|
800
|
+
/** Unchanged since their summary (content hash match) — no LLM call. */
|
|
801
|
+
skippedFresh: number;
|
|
802
|
+
/** Sessions with no user messages, compactions, branches, or shell use. */
|
|
803
|
+
skippedEmpty: number;
|
|
804
|
+
/** Transcripts exceeding the byte cap (filtered before reading). */
|
|
805
|
+
skippedTooBig: number;
|
|
806
|
+
/** Unreadable files and files without a parseable session header. */
|
|
807
|
+
skippedUnreadable: number;
|
|
808
|
+
/** Notes moved from the legacy `notes/` layout into `sessions/`. */
|
|
809
|
+
migrated: number;
|
|
810
|
+
failed: SessionScanFailure[];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Run the session scan, in three phases:
|
|
815
|
+
*
|
|
816
|
+
* 1. **Read** every candidate transcript exactly once — hash the bytes while
|
|
817
|
+
* reading, peek the header line for identity/project/start. Sessions whose
|
|
818
|
+
* note already carries that hash are skipped here: no parse, no LLM.
|
|
819
|
+
* 2. **Chain** the batch: per project, order the sessions by start time and
|
|
820
|
+
* give each changed session its `[[previous]]`/`[[next]]` neighbours.
|
|
821
|
+
* 3. **Summarize** the changed, non-empty sessions (the only LLM cost) and
|
|
822
|
+
* upsert their notes into the `sessions/` vault collection.
|
|
823
|
+
*
|
|
824
|
+
* Failure-tolerant per file; incremental by construction.
|
|
825
|
+
*/
|
|
826
|
+
export async function runSessionScan(options: SessionScanOptions): Promise<SessionScanResult> {
|
|
827
|
+
const maxSessions = options.maxSessions ?? SESSION_SCAN_MAX_SESSIONS;
|
|
828
|
+
const maxFileBytes = options.maxFileBytes ?? SESSION_SCAN_MAX_FILE_BYTES;
|
|
829
|
+
const concurrency = options.concurrency ?? SESSION_SCAN_CONCURRENCY;
|
|
830
|
+
const now = options.now ?? (() => new Date());
|
|
831
|
+
const onProgress = options.onProgress;
|
|
832
|
+
const aborted = () => options.signal?.aborted === true;
|
|
833
|
+
|
|
834
|
+
const result: SessionScanResult = {
|
|
835
|
+
discovered: 0,
|
|
836
|
+
considered: 0,
|
|
837
|
+
written: 0,
|
|
838
|
+
created: 0,
|
|
839
|
+
updated: 0,
|
|
840
|
+
skippedFresh: 0,
|
|
841
|
+
skippedEmpty: 0,
|
|
842
|
+
skippedTooBig: 0,
|
|
843
|
+
skippedUnreadable: 0,
|
|
844
|
+
migrated: 0,
|
|
845
|
+
failed: [],
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
// Legacy layout first: session notes move out of `notes/` into the
|
|
849
|
+
// `sessions/` collection the marker index is about to read.
|
|
850
|
+
result.migrated = await migrateLegacySessionNotes(options.vaultRoot);
|
|
851
|
+
|
|
852
|
+
const all = await listSessionFiles(options.sessionsRoot);
|
|
853
|
+
result.discovered = all.length;
|
|
854
|
+
const withinSize = all.filter((f) => f.bytes <= maxFileBytes);
|
|
855
|
+
result.skippedTooBig = all.length - withinSize.length;
|
|
856
|
+
const batch = withinSize.slice(0, maxSessions);
|
|
857
|
+
result.considered = batch.length;
|
|
858
|
+
|
|
859
|
+
const noteIndex = await readSessionNoteIndex(options.vaultRoot);
|
|
860
|
+
|
|
861
|
+
// -- Phase 1: read once, hash while reading, peek the header. -----------
|
|
862
|
+
interface Candidate {
|
|
863
|
+
file: SessionFileInfo;
|
|
864
|
+
hash: string;
|
|
865
|
+
header: SessionHeader;
|
|
866
|
+
pointer: SessionNotePointer | undefined;
|
|
867
|
+
/** Set together: parsed/opaque digest and the text handed to the model. */
|
|
868
|
+
digest: SessionDigest | null;
|
|
869
|
+
content: string;
|
|
870
|
+
}
|
|
871
|
+
const candidates: Candidate[] = [];
|
|
872
|
+
|
|
873
|
+
for (const file of batch) {
|
|
874
|
+
let buf: Buffer;
|
|
875
|
+
try {
|
|
876
|
+
buf = await fs.readFile(file.path);
|
|
877
|
+
} catch {
|
|
878
|
+
result.skippedUnreadable += 1;
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
// Hash from the same single read that feeds the parse — the file is
|
|
882
|
+
// never read twice — hash while reading.
|
|
883
|
+
const hash = hashContent(buf);
|
|
884
|
+
const text = buf.toString("utf8");
|
|
885
|
+
const parsed = parseSessionDigest(text);
|
|
886
|
+
const fallback = opaqueSession(file);
|
|
887
|
+
const digest = parsed ?? fallback.digest;
|
|
888
|
+
const header = peekSessionHeader(text) ?? { id: digest.id, cwd: digest.cwd, startedAt: digest.startedAt };
|
|
889
|
+
const pointer = noteIndex.get(header.id);
|
|
890
|
+
if (pointer && pointer.hash === hash) {
|
|
891
|
+
result.skippedFresh += 1;
|
|
892
|
+
candidates.push({ file, hash, header, pointer, digest: null, content: "" });
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if ((parsed === null && (text.trim() === "" || buf.includes(0))) || (parsed !== null && !sessionHasContent(digest))) {
|
|
896
|
+
result.skippedEmpty += 1;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const content = parsed === null
|
|
900
|
+
? `History file: ${file.path}\n\n${text.slice(0, DIGEST_MAX_CHARS)}`
|
|
901
|
+
: renderSessionDigest(digest);
|
|
902
|
+
candidates.push({ file, hash, header, pointer, digest, content });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// -- Phase 2: per-project chains over every session with a note. --------
|
|
906
|
+
// A session's slug is known before writing: its existing note's slug (the
|
|
907
|
+
// marker index), or the slug it is about to get (derived from the title).
|
|
908
|
+
const slugOf = (c: Candidate): string =>
|
|
909
|
+
c.pointer?.slug ?? sessionNoteSlug(deriveSessionTitle(c.digest as SessionDigest));
|
|
910
|
+
const startOf = (c: Candidate): string =>
|
|
911
|
+
c.digest?.startedAt || c.header.startedAt || c.pointer?.startedAt || "";
|
|
912
|
+
const projectOf = (c: Candidate): string =>
|
|
913
|
+
projectTagOf(c.header.cwd || c.pointer?.cwd || "");
|
|
914
|
+
const withNotes = candidates
|
|
915
|
+
.filter((c) => c.pointer !== undefined || c.digest !== null)
|
|
916
|
+
.sort((a, b) => (startOf(a) || "~").localeCompare(startOf(b) || "~"));
|
|
917
|
+
const chainOf = (self: Candidate): SessionChain => {
|
|
918
|
+
const project = projectOf(self);
|
|
919
|
+
const pos = withNotes.indexOf(self);
|
|
920
|
+
let previous: string | undefined;
|
|
921
|
+
let next: string | undefined;
|
|
922
|
+
for (let i = pos - 1; i >= 0 && previous === undefined; i--) {
|
|
923
|
+
const c = withNotes[i] as Candidate;
|
|
924
|
+
if (projectOf(c) === project) previous = slugOf(c);
|
|
925
|
+
}
|
|
926
|
+
for (let i = pos + 1; i < withNotes.length && next === undefined; i++) {
|
|
927
|
+
const c = withNotes[i] as Candidate;
|
|
928
|
+
if (projectOf(c) === project) next = slugOf(c);
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
...(previous !== undefined ? { previous } : {}),
|
|
932
|
+
...(next !== undefined ? { next } : {}),
|
|
933
|
+
};
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
// -- Phase 3: summarize + write the changed sessions. --------------------
|
|
937
|
+
const changed = candidates.filter((c) => c.digest !== null);
|
|
938
|
+
await mapWithConcurrency(changed, concurrency, async (candidate, index) => {
|
|
939
|
+
onProgress?.({ current: index + 1, total: changed.length, path: candidate.file.name });
|
|
940
|
+
const digest = candidate.digest as SessionDigest;
|
|
941
|
+
try {
|
|
942
|
+
const summary = (await options.summarize({ path: candidate.file.path, content: candidate.content })).trim();
|
|
943
|
+
if (summary.length === 0) throw new Error("model returned an empty summary");
|
|
944
|
+
await writeSessionNote(options.vaultRoot, {
|
|
945
|
+
digest,
|
|
946
|
+
file: { path: candidate.file.path },
|
|
947
|
+
hash: candidate.hash,
|
|
948
|
+
summary,
|
|
949
|
+
model: options.model ?? null,
|
|
950
|
+
at: now().toISOString(),
|
|
951
|
+
existingSlug: candidate.pointer?.slug ?? null,
|
|
952
|
+
chain: chainOf(candidate),
|
|
953
|
+
});
|
|
954
|
+
if (candidate.pointer !== undefined) result.updated += 1;
|
|
955
|
+
else result.created += 1;
|
|
956
|
+
result.written += 1;
|
|
957
|
+
} catch (err) {
|
|
958
|
+
result.failed.push({ path: candidate.file.name, error: err instanceof Error ? err.message : String(err) });
|
|
959
|
+
}
|
|
960
|
+
}, aborted);
|
|
961
|
+
|
|
962
|
+
return result;
|
|
963
|
+
}
|