pi-weave 0.1.20 → 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/core/vault.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import {
|
|
5
|
+
MANAGED_FRONT_MATTER_KEYS,
|
|
5
6
|
parseFrontMatter,
|
|
6
7
|
parseNoteFile,
|
|
8
|
+
quoteField,
|
|
7
9
|
serializeNote,
|
|
8
10
|
unquoteField,
|
|
11
|
+
upsertFrontMatterFields,
|
|
9
12
|
} from "./frontmatter";
|
|
10
13
|
import { withMutationQueue } from "./mutex";
|
|
11
14
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
@@ -349,6 +352,95 @@ export async function finalizeNote(
|
|
|
349
352
|
});
|
|
350
353
|
}
|
|
351
354
|
|
|
355
|
+
export interface UpsertNoteInput {
|
|
356
|
+
slug: string;
|
|
357
|
+
title: string;
|
|
358
|
+
body: string;
|
|
359
|
+
tags?: string[];
|
|
360
|
+
source?: NoteSource;
|
|
361
|
+
fields?: Record<string, string>;
|
|
362
|
+
identity?: { field: string; value: string };
|
|
363
|
+
now?: Date;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Create or refresh generated knowledge without overwriting a different identity. */
|
|
367
|
+
export async function upsertNote(root: string, input: UpsertNoteInput): Promise<Note> {
|
|
368
|
+
await ensureVault(root);
|
|
369
|
+
return withVaultLock(root, async () => {
|
|
370
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
371
|
+
const identity = input.identity;
|
|
372
|
+
let existing = await getNote(root, input.slug);
|
|
373
|
+
if (existing !== null && identity && frontMatterField(existing.frontMatter, identity.field) !== identity.value) {
|
|
374
|
+
existing = null;
|
|
375
|
+
}
|
|
376
|
+
if (existing === null) {
|
|
377
|
+
const slug = uniqueSlug(input.slug, (candidate) => {
|
|
378
|
+
if (!existsSync(notePath(root, candidate))) return false;
|
|
379
|
+
return !identity || fileFrontMatterField(notePath(root, candidate), identity.field) !== identity.value;
|
|
380
|
+
});
|
|
381
|
+
const meta: NoteMeta = {
|
|
382
|
+
title: input.title,
|
|
383
|
+
created: now,
|
|
384
|
+
updated: now,
|
|
385
|
+
tags: input.tags ?? [],
|
|
386
|
+
source: input.source ?? "generated",
|
|
387
|
+
};
|
|
388
|
+
const fields = safeGeneratedFields(input.fields);
|
|
389
|
+
const frontMatter = [
|
|
390
|
+
`title: ${quoteField(meta.title)}`,
|
|
391
|
+
`created: ${meta.created}`,
|
|
392
|
+
`updated: ${meta.updated}`,
|
|
393
|
+
`tags: [${meta.tags.map(quoteField).join(", ")}]`,
|
|
394
|
+
`source: ${meta.source}`,
|
|
395
|
+
...upsertFrontMatterFields([], fields),
|
|
396
|
+
];
|
|
397
|
+
return writeNote(notePath(root, slug), slug, meta, input.body, frontMatter);
|
|
398
|
+
}
|
|
399
|
+
const fields = safeGeneratedFields(input.fields);
|
|
400
|
+
const frontMatter = upsertFrontMatterFields(existing.frontMatter ?? [], fields);
|
|
401
|
+
// The existing tail is re-attached whenever there is one. `input.body` is
|
|
402
|
+
// generated content (a model summary), so probing it for a `## Raw` marker
|
|
403
|
+
// would let a summary that merely mentions the heading delete the human's
|
|
404
|
+
// verbatim tail.
|
|
405
|
+
const tail = extractRawTail(existing.body);
|
|
406
|
+
const body = tail === "" ? input.body.trim() : `${input.body.trim()}\n\n${tail}`;
|
|
407
|
+
return writeNote(
|
|
408
|
+
notePath(root, input.slug),
|
|
409
|
+
input.slug,
|
|
410
|
+
{ ...existing, updated: now },
|
|
411
|
+
body,
|
|
412
|
+
frontMatter,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Identity values are compared **unquoted**, matching how the session note
|
|
419
|
+
* index reads them: `quoteField` wraps any value containing `:` (an ISO
|
|
420
|
+
* timestamp used as an id, say), and comparing a quoted value against a raw
|
|
421
|
+
* one never matches — which would fork a new `-2`, `-3`… note on every scan.
|
|
422
|
+
*/
|
|
423
|
+
function frontMatterField(lines: NoteFrontMatter | undefined, field: string): string | null {
|
|
424
|
+
if (!lines) return null;
|
|
425
|
+
const value = parseFrontMatter(["---", ...lines, "---", ""].join("\n"))?.fields.get(field);
|
|
426
|
+
return value === undefined ? null : unquoteField(value);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function fileFrontMatterField(path: string, field: string): string | null {
|
|
430
|
+
try {
|
|
431
|
+
const value = parseFrontMatter(readFileSync(path, "utf8"))?.fields.get(field);
|
|
432
|
+
return value === undefined ? null : unquoteField(value);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function safeGeneratedFields(fields: Record<string, string> | undefined): Record<string, string> {
|
|
439
|
+
if (!fields) return {};
|
|
440
|
+
const managed = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
|
|
441
|
+
return Object.fromEntries(Object.entries(fields).filter(([key]) => !managed.has(key) && /^[A-Za-z][A-Za-z0-9_-]*$/.test(key)));
|
|
442
|
+
}
|
|
443
|
+
|
|
352
444
|
export type VaultMutationResult =
|
|
353
445
|
| { ok: true; slug?: string; path?: string }
|
|
354
446
|
| { ok: false; reason: "missing" | "collision" | "invalid" };
|
package/src/pi/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
2
4
|
import {
|
|
3
5
|
buildRepoIndex,
|
|
4
6
|
findGitRoot,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
} from "../core";
|
|
12
14
|
import { registerNoteTool } from "./tools/noteTool";
|
|
13
15
|
import { registerRepoTool } from "./tools/repoTool";
|
|
16
|
+
import { formatSessionScanResult, scanPiSessions } from "./sessionScan";
|
|
14
17
|
import { deepScanRepository, formatDeepScanResult } from "./summarize";
|
|
15
18
|
import { runWeaveViewTui } from "./viewer/tui/run";
|
|
16
19
|
import { WebWorkspaceController } from "./viewer/web/run";
|
|
@@ -78,7 +81,7 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
78
81
|
} catch {
|
|
79
82
|
// ignore
|
|
80
83
|
}
|
|
81
|
-
const indicator = (isActive || inFlightDeepScans.size > 0)
|
|
84
|
+
const indicator = (isActive || inFlightDeepScans.size > 0 || inFlightSessionScans.size > 0)
|
|
82
85
|
? (theme?.fg ? theme.fg("accent", "●") : "●")
|
|
83
86
|
: (theme?.fg ? theme.fg("dim", "○") : "○");
|
|
84
87
|
// With no base text the marker stands alone (`○ web:51234`) rather than
|
|
@@ -157,9 +160,22 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
157
160
|
|
|
158
161
|
pi.registerCommand("weave-scan", {
|
|
159
162
|
description:
|
|
160
|
-
"Build or refresh the repository
|
|
163
|
+
"Build or refresh the repository index; 'deep' summarizes files; 'sessions [dir]' summarizes session history",
|
|
161
164
|
handler: async (args, ctx) => {
|
|
162
|
-
const
|
|
165
|
+
const input = args.trim();
|
|
166
|
+
const [mode = "", ...rest] = input.split(/\s+/);
|
|
167
|
+
if (mode.toLowerCase() === "sessions") {
|
|
168
|
+
// Repo-agnostic by definition: no git requirement, works from any cwd.
|
|
169
|
+
if (inFlightSessionScans.size > 0) {
|
|
170
|
+
ctx.ui.notify("pi-weave: a session scan is already running — run /weave-scan-cancel to stop it.", "warning");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const status = await getWorkspaceStatus(ctx.cwd);
|
|
174
|
+
const path = rest.length > 0 ? resolveHistoryPath(ctx.cwd, rest.join(" ")) : undefined;
|
|
175
|
+
startSessionScan(ctx, status, updateStatus, path);
|
|
176
|
+
return; // the background scan owns the status line until it settles
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
const root = await findGitRoot(ctx.cwd);
|
|
164
180
|
if (!root) {
|
|
165
181
|
ctx.ui.notify("pi-weave: not inside a git repository.", "warning");
|
|
@@ -173,7 +189,7 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
173
189
|
await writeRepoIndex(root, index);
|
|
174
190
|
ctx.ui.notify(`pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`, "info");
|
|
175
191
|
|
|
176
|
-
if (mode === "deep") {
|
|
192
|
+
if (mode.toLowerCase() === "deep") {
|
|
177
193
|
if (inFlightDeepScans.has(root)) {
|
|
178
194
|
ctx.ui.notify("pi-weave: a deep scan is already running for this repository — run /weave-scan-cancel to stop it.", "warning");
|
|
179
195
|
} else {
|
|
@@ -191,15 +207,17 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
191
207
|
});
|
|
192
208
|
|
|
193
209
|
pi.registerCommand("weave-scan-cancel", {
|
|
194
|
-
description: "Cancel an in-flight /weave-scan deep run",
|
|
210
|
+
description: "Cancel an in-flight /weave-scan deep or sessions run",
|
|
195
211
|
handler: async (_args, ctx) => {
|
|
196
212
|
const root = await findGitRoot(ctx.cwd);
|
|
197
213
|
const deep = root ? inFlightDeepScans.get(root) : undefined;
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
const sessions = inFlightSessionScans.get(SESSIONS_SCAN_KEY);
|
|
215
|
+
if (!deep && !sessions) {
|
|
216
|
+
ctx.ui.notify("pi-weave: no scan is currently running.", "info");
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
219
|
deep?.controller.abort();
|
|
220
|
+
sessions?.controller.abort();
|
|
203
221
|
ctx.ui.notify("pi-weave: scan cancellation requested.", "info");
|
|
204
222
|
},
|
|
205
223
|
});
|
|
@@ -209,6 +227,20 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
209
227
|
/* /weave-view argument parsing */
|
|
210
228
|
/* ------------------------------------------------------------------ */
|
|
211
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the optional `/weave-scan sessions <path>` argument.
|
|
232
|
+
*
|
|
233
|
+
* Command arguments reach the handler unexpanded, and the whole point of this
|
|
234
|
+
* argument is another harness's history under `$HOME` — so `~/` is the form
|
|
235
|
+
* users type, and leaving it literal silently scans nothing.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveHistoryPath(cwd: string, input: string): string {
|
|
238
|
+
const trimmed = input.trim();
|
|
239
|
+
if (trimmed === "~") return homedir();
|
|
240
|
+
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
|
241
|
+
return resolve(cwd, trimmed);
|
|
242
|
+
}
|
|
243
|
+
|
|
212
244
|
export const WEAVE_VIEW_USAGE = "usage: /weave-view [tui|web] [--no-open]";
|
|
213
245
|
|
|
214
246
|
/** Which explorer `/weave-view` was asked for. */
|
|
@@ -264,12 +296,24 @@ interface InFlightDeepScan {
|
|
|
264
296
|
/** In-flight deep scans keyed by repo root — the /weave-scan-cancel target. */
|
|
265
297
|
const inFlightDeepScans = new Map<string, InFlightDeepScan>();
|
|
266
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The in-flight session scan, under a reserved key that cannot collide with
|
|
301
|
+
* a git root (absolute paths always start with `/`).
|
|
302
|
+
*/
|
|
303
|
+
export const SESSIONS_SCAN_KEY = "(pi-weave:sessions)";
|
|
304
|
+
const inFlightSessionScans = new Map<string, InFlightDeepScan>();
|
|
305
|
+
|
|
267
306
|
/** Test seam: resolve when the in-flight deep scan for `root` settles. */
|
|
268
307
|
export async function deepScanDone(root: string): Promise<void | undefined> {
|
|
269
308
|
const canonical = await findGitRoot(root).catch(() => null);
|
|
270
309
|
return inFlightDeepScans.get(canonical ?? root)?.done;
|
|
271
310
|
}
|
|
272
311
|
|
|
312
|
+
/** Test seam: resolve when the background session scan settles. */
|
|
313
|
+
export async function sessionScanDone(): Promise<void | undefined> {
|
|
314
|
+
return inFlightSessionScans.get(SESSIONS_SCAN_KEY)?.done;
|
|
315
|
+
}
|
|
316
|
+
|
|
273
317
|
interface SettledMessage {
|
|
274
318
|
text: string;
|
|
275
319
|
level: "info" | "warning";
|
|
@@ -280,7 +324,9 @@ interface SettledMessage {
|
|
|
280
324
|
* off the command handler so the user keeps control of the session (a
|
|
281
325
|
* blocking command can't be cancelled in the TUI — Esc only aborts
|
|
282
326
|
* streaming/bash). Progress is pushed to the status line; the completion
|
|
283
|
-
* message is notified; the settled status is restored when the scan settles
|
|
327
|
+
* message is notified; the settled status is restored when the scan settles
|
|
328
|
+
* (`settledStatus` lets a scan recompute it — a session scan grows the vault,
|
|
329
|
+
* so its restored line should say so).
|
|
284
330
|
*/
|
|
285
331
|
function startBackgroundScan(
|
|
286
332
|
store: Map<string, InFlightDeepScan>,
|
|
@@ -289,6 +335,7 @@ function startBackgroundScan(
|
|
|
289
335
|
baseStatus: WorkspaceStatus,
|
|
290
336
|
updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
|
|
291
337
|
run: (signal: AbortSignal) => Promise<SettledMessage | null>,
|
|
338
|
+
settledStatus: (() => Promise<WorkspaceStatus>) | undefined = undefined,
|
|
292
339
|
): void {
|
|
293
340
|
const controller = new AbortController();
|
|
294
341
|
let doneResolve: () => void;
|
|
@@ -306,8 +353,11 @@ function startBackgroundScan(
|
|
|
306
353
|
} finally {
|
|
307
354
|
// Restore the settled status before removing the map entry, so a caller
|
|
308
355
|
// awaiting the done seam observes the settled status line.
|
|
356
|
+
const final = settledStatus
|
|
357
|
+
? await settledStatus().catch(() => baseStatus)
|
|
358
|
+
: baseStatus;
|
|
309
359
|
store.delete(key);
|
|
310
|
-
updateStatus(ctx, formatStatusLine(
|
|
360
|
+
updateStatus(ctx, formatStatusLine(final));
|
|
311
361
|
doneResolve!();
|
|
312
362
|
}
|
|
313
363
|
})();
|
|
@@ -347,3 +397,52 @@ function startDeepScan(
|
|
|
347
397
|
return null;
|
|
348
398
|
});
|
|
349
399
|
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Kick off a session scan in the background — same
|
|
403
|
+
* lifecycle as deep scans; keyed globally, not per repo.
|
|
404
|
+
*/
|
|
405
|
+
function startSessionScan(
|
|
406
|
+
ctx: ExtensionCommandContext,
|
|
407
|
+
baseStatus: WorkspaceStatus,
|
|
408
|
+
updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
|
|
409
|
+
sessionsRoot?: string,
|
|
410
|
+
): void {
|
|
411
|
+
startBackgroundScan(
|
|
412
|
+
inFlightSessionScans,
|
|
413
|
+
SESSIONS_SCAN_KEY,
|
|
414
|
+
ctx,
|
|
415
|
+
baseStatus,
|
|
416
|
+
updateStatus,
|
|
417
|
+
async (signal) => {
|
|
418
|
+
updateStatus(ctx, "🕸️ session scan: starting…");
|
|
419
|
+
const outcome = await scanPiSessions(ctx, {
|
|
420
|
+
...(sessionsRoot !== undefined ? { sessionsRoot } : {}),
|
|
421
|
+
onProgress: ({ current, total, path }) => {
|
|
422
|
+
const pct = total > 0 ? Math.round((current / total) * 100) : 100;
|
|
423
|
+
updateStatus(ctx, `🕸️ session scan: ${current}/${total} (${pct}%) — ${path}`);
|
|
424
|
+
},
|
|
425
|
+
signal,
|
|
426
|
+
});
|
|
427
|
+
if (signal.aborted) {
|
|
428
|
+
return { text: "pi-weave: session scan cancelled.", level: "warning" };
|
|
429
|
+
}
|
|
430
|
+
if (outcome.kind === "no-model") {
|
|
431
|
+
return {
|
|
432
|
+
text: "pi-weave: session scan needs an active session model — none configured.",
|
|
433
|
+
level: "warning",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const result = outcome.result;
|
|
437
|
+
if (result.discovered === 0) {
|
|
438
|
+
return { text: "pi-weave: session scan complete — no pi sessions found.", level: "info" };
|
|
439
|
+
}
|
|
440
|
+
return { text: `pi-weave: session scan complete — ${formatSessionScanResult(result)}`, level: "info" };
|
|
441
|
+
},
|
|
442
|
+
// Unlike the deep scan (whose settled status is precomputed to avoid git
|
|
443
|
+
// contention), the session scan writes vault notes and takes no git lock:
|
|
444
|
+
// recompute the workspace status so the settled line counts the notes it
|
|
445
|
+
// just wrote.
|
|
446
|
+
() => getWorkspaceStatus(ctx.cwd),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi adapter for the session scan: summarize agent
|
|
3
|
+
* session transcripts into the vault as incremental memory notes.
|
|
4
|
+
*
|
|
5
|
+
* Model wiring is shared with the deep scan (`createModelSummarizer` — the
|
|
6
|
+
* session's already-configured model, auth via `modelRegistry`); only the
|
|
7
|
+
* prompt differs, because a session transcript wants different sentences
|
|
8
|
+
* than a source file. `deps.complete` is the test seam — no network in unit
|
|
9
|
+
* tests.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
resolveSessionsRoot,
|
|
15
|
+
resolveVaultRoot,
|
|
16
|
+
runSessionScan,
|
|
17
|
+
type SessionScanOptions,
|
|
18
|
+
type SessionScanResult,
|
|
19
|
+
type SummarizeFn,
|
|
20
|
+
} from "../core";
|
|
21
|
+
import { createModelSummarizer, type SummarizerDeps } from "./summarize";
|
|
22
|
+
|
|
23
|
+
const SESSION_SYSTEM_PROMPT = [
|
|
24
|
+
"You write durable memory notes that compact coding-agent sessions into their bottom line.",
|
|
25
|
+
"First summarize what happened, what shipped (features, files, commands, decisions), and",
|
|
26
|
+
"what went less well (dead ends, breakage, unfinished work) using concrete technical details.",
|
|
27
|
+
"Then add a '## Takeaways' section with 2–4 reusable lessons: gotchas, root causes of tricky",
|
|
28
|
+
"failures, non-obvious syntax rules, or architecture patterns a future agent can apply.",
|
|
29
|
+
"Past tense. No preamble or code fences. Omit lessons unsupported by the transcript.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
|
|
32
|
+
const SESSION_MAX_OUTPUT_TOKENS = 3_000;
|
|
33
|
+
|
|
34
|
+
/** Session summarizer over the session model, or null when none is active. */
|
|
35
|
+
export function createSessionSummarizer(
|
|
36
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
37
|
+
deps: SummarizerDeps = {},
|
|
38
|
+
): { summarize: SummarizeFn; label: string } | null {
|
|
39
|
+
return createModelSummarizer(ctx, SESSION_SYSTEM_PROMPT, SESSION_MAX_OUTPUT_TOKENS, deps);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SessionScanOutcome =
|
|
43
|
+
| { kind: "ok"; result: SessionScanResult }
|
|
44
|
+
| { kind: "no-model" };
|
|
45
|
+
|
|
46
|
+
export interface SessionScanAdapterOptions {
|
|
47
|
+
complete?: SummarizerDeps["complete"];
|
|
48
|
+
maxSessions?: SessionScanOptions["maxSessions"];
|
|
49
|
+
maxFileBytes?: SessionScanOptions["maxFileBytes"];
|
|
50
|
+
concurrency?: SessionScanOptions["concurrency"];
|
|
51
|
+
sessionsRoot?: SessionScanOptions["sessionsRoot"];
|
|
52
|
+
vaultRoot?: SessionScanOptions["vaultRoot"];
|
|
53
|
+
now?: SessionScanOptions["now"];
|
|
54
|
+
onProgress?: SessionScanOptions["onProgress"];
|
|
55
|
+
signal?: SessionScanOptions["signal"];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scan pi sessions into the vault using the session model. Roots default to
|
|
60
|
+
* the real locations (PI_WEAVE_SESSIONS / PI_WEAVE_VAULT overrides apply);
|
|
61
|
+
* tests inject both.
|
|
62
|
+
*/
|
|
63
|
+
export async function scanPiSessions(
|
|
64
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
65
|
+
deps: SessionScanAdapterOptions = {},
|
|
66
|
+
): Promise<SessionScanOutcome> {
|
|
67
|
+
const llm = createSessionSummarizer(
|
|
68
|
+
ctx,
|
|
69
|
+
deps.complete !== undefined ? { complete: deps.complete } : {},
|
|
70
|
+
);
|
|
71
|
+
if (!llm) return { kind: "no-model" };
|
|
72
|
+
// exactOptionalPropertyTypes: only present keys may be spread in.
|
|
73
|
+
const result = await runSessionScan({
|
|
74
|
+
sessionsRoot: deps.sessionsRoot ?? resolveSessionsRoot(),
|
|
75
|
+
vaultRoot: deps.vaultRoot ?? resolveVaultRoot(),
|
|
76
|
+
summarize: llm.summarize,
|
|
77
|
+
model: llm.label,
|
|
78
|
+
...(deps.maxSessions !== undefined ? { maxSessions: deps.maxSessions } : {}),
|
|
79
|
+
...(deps.maxFileBytes !== undefined ? { maxFileBytes: deps.maxFileBytes } : {}),
|
|
80
|
+
...(deps.concurrency !== undefined ? { concurrency: deps.concurrency } : {}),
|
|
81
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
82
|
+
...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
|
|
83
|
+
...(deps.signal !== undefined ? { signal: deps.signal } : {}),
|
|
84
|
+
});
|
|
85
|
+
return { kind: "ok", result };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One-line human summary of a session-scan result (for notify output). */
|
|
89
|
+
export function formatSessionScanResult(result: SessionScanResult): string {
|
|
90
|
+
const parts = [
|
|
91
|
+
`${result.written} summarized (${result.created} new, ${result.updated} updated)`,
|
|
92
|
+
`${result.skippedFresh} unchanged`,
|
|
93
|
+
];
|
|
94
|
+
if (result.skippedEmpty > 0) parts.push(`${result.skippedEmpty} empty`);
|
|
95
|
+
if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size)`);
|
|
96
|
+
if (result.skippedUnreadable > 0) parts.push(`${result.skippedUnreadable} unreadable`);
|
|
97
|
+
let text = `${parts.join(", ")} — ${result.considered} sessions considered`;
|
|
98
|
+
if (result.failed.length > 0) {
|
|
99
|
+
const [failed0] = result.failed;
|
|
100
|
+
if (failed0) {
|
|
101
|
+
text += `; ${result.failed.length} failed, first: ${failed0.path}: ${failed0.error}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return text;
|
|
105
|
+
}
|
package/src/pi/summarize.ts
CHANGED
|
@@ -45,7 +45,7 @@ const MAX_OUTPUT_TOKENS = 220;
|
|
|
45
45
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* Shared model wiring for deep and session scans: resolve the session's
|
|
49
49
|
* already-configured model — no
|
|
50
50
|
* extra keys or providers (docs/scan-modes.md) — drive completion through
|
|
51
51
|
* `ctx.modelRegistry`, which owns auth, and reject empty outputs so a
|
|
@@ -62,7 +62,7 @@ export function createModelSummarizer(
|
|
|
62
62
|
deps: SummarizerDeps = {},
|
|
63
63
|
): LlmSummarizer | null {
|
|
64
64
|
const model = ctx.model;
|
|
65
|
-
if (!model) return null;
|
|
65
|
+
if (!model || !isUsableModel(model)) return null;
|
|
66
66
|
const complete: CompleteFn =
|
|
67
67
|
deps.complete ?? ((m, c, o) => ctx.modelRegistry.complete(m, c, o));
|
|
68
68
|
const label = `${model.provider}/${model.id}`;
|
|
@@ -82,14 +82,57 @@ export function createModelSummarizer(
|
|
|
82
82
|
{ maxTokens: maxOutputTokens, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
|
|
83
83
|
);
|
|
84
84
|
const text = contentText(message.content).trim();
|
|
85
|
-
if (text.length === 0)
|
|
86
|
-
throw new Error("model returned an empty summary");
|
|
87
|
-
}
|
|
85
|
+
if (text.length === 0) throw new Error(emptySummaryReason(message, maxOutputTokens));
|
|
88
86
|
return text;
|
|
89
87
|
};
|
|
90
88
|
return { summarize, label };
|
|
91
89
|
}
|
|
92
90
|
|
|
91
|
+
/**
|
|
92
|
+
* True when `ctx.model` is a real model rather than pi's placeholder.
|
|
93
|
+
*
|
|
94
|
+
* A session with no resolved model does not hand back `undefined` — it hands
|
|
95
|
+
* back pi's `DEFAULT_MODEL`, whose provider and id are the literal string
|
|
96
|
+
* `"unknown"`. That object is truthy, so a `!model` guard passes it straight
|
|
97
|
+
* through to `modelRegistry.complete`, which throws `Unknown provider:
|
|
98
|
+
* unknown` once per file. Treating it as "no model" makes the caller report
|
|
99
|
+
* the actionable "needs an active session model" instead, before spending a
|
|
100
|
+
* single call.
|
|
101
|
+
*/
|
|
102
|
+
function isUsableModel(model: { provider?: string; id?: string }): boolean {
|
|
103
|
+
return model.provider !== undefined && model.provider !== "unknown";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Explain an empty completion using what the response actually carries.
|
|
108
|
+
*
|
|
109
|
+
* "model returned an empty summary" is true of every failure mode here and
|
|
110
|
+
* diagnostic of none: an auth error, a reasoning model that spent its whole
|
|
111
|
+
* budget thinking, and a refusal all produce zero text blocks. Reporting that
|
|
112
|
+
* bare string once is unhelpful; reporting it 85 times, once per session, is
|
|
113
|
+
* an outage with no evidence attached. The provider already distinguishes
|
|
114
|
+
* these through `stopReason`, `errorMessage` and the reasoning-token count,
|
|
115
|
+
* so the message says which one happened and what to do about it.
|
|
116
|
+
*/
|
|
117
|
+
export function emptySummaryReason(message: AssistantMessage, maxOutputTokens: number): string {
|
|
118
|
+
const detail = message.errorMessage?.trim();
|
|
119
|
+
if (message.stopReason === "error") {
|
|
120
|
+
return `model call failed${detail ? `: ${detail}` : " with no error detail"}`;
|
|
121
|
+
}
|
|
122
|
+
// Reasoning tokens are billed against the same budget as output, so a model
|
|
123
|
+
// thinking at a high effort level can exhaust it before emitting any text.
|
|
124
|
+
const reasoning = message.usage?.reasoning ?? 0;
|
|
125
|
+
if (message.stopReason === "length") {
|
|
126
|
+
return reasoning > 0
|
|
127
|
+
? `model spent its entire ${maxOutputTokens}-token budget on reasoning (${reasoning} tokens) and produced no summary — lower the thinking level or raise the cap`
|
|
128
|
+
: `model hit the ${maxOutputTokens}-token cap before producing a summary`;
|
|
129
|
+
}
|
|
130
|
+
if (reasoning > 0) {
|
|
131
|
+
return `model returned only reasoning (${reasoning} tokens), no summary text`;
|
|
132
|
+
}
|
|
133
|
+
return `model returned an empty summary (stopReason: ${message.stopReason})${detail ? `: ${detail}` : ""}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
/** Create the file summarizer for deep scans, or null when no model is active. */
|
|
94
137
|
export function createLlmSummarizer(
|
|
95
138
|
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
37
37
|
promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
|
|
38
38
|
promptGuidelines: [
|
|
39
39
|
"Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
|
|
40
|
-
"Use weave_note with action=search before answering questions about past decisions, people, or projects.",
|
|
40
|
+
"Use weave_note with action=search before answering questions about past decisions, people, or projects; generated notes under sessions/ carry takeaways from earlier sessions.",
|
|
41
41
|
],
|
|
42
42
|
parameters: Type.Object({
|
|
43
43
|
action: StringEnum(["list", "get", "add", "append", "finalize", "search"] as const),
|