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/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 knowledge index (.okf); 'deep' also summarizes files with the session model",
163
+ "Build or refresh the repository index; 'deep' summarizes files; 'sessions [dir]' summarizes session history",
161
164
  handler: async (args, ctx) => {
162
- const mode = args.trim().toLowerCase();
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
- if (!deep) {
199
- ctx.ui.notify("pi-weave: no deep scan is currently running.", "info");
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(baseStatus));
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
+ }
@@ -45,7 +45,7 @@ const MAX_OUTPUT_TOKENS = 220;
45
45
  const REQUEST_TIMEOUT_MS = 30_000;
46
46
 
47
47
  /**
48
- * The shared model wiring behind the deep scan: resolve the session's
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">,
@@ -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),