pi-weave 0.1.7 → 0.1.9

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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
package/src/pi/index.ts CHANGED
@@ -11,8 +11,10 @@ import {
11
11
  } from "../core";
12
12
  import { registerNoteTool } from "./tools/noteTool";
13
13
  import { registerRepoTool } from "./tools/repoTool";
14
+ import { formatSessionScanResult, scanPiSessions } from "./sessionScan";
14
15
  import { deepScanRepository, formatDeepScanResult } from "./summarize";
15
16
  import { runWeaveViewTui } from "./viewer/tui/run";
17
+ import { WebWorkspaceController } from "./viewer/web/run";
16
18
 
17
19
  /**
18
20
  * pi-weave — an agent-native knowledge workspace (docs/design.md):
@@ -27,13 +29,21 @@ export default function piWeave(pi: ExtensionAPI): void {
27
29
  registerNoteTool(pi);
28
30
  registerRepoTool(pi);
29
31
 
30
- // /weave-view is the in-terminal explorer (the old browser viewer was
31
- // retired for a pixi.js rewrite); it holds no session resources, so there
32
- // is no per-session lifecycle to tear down.
32
+ // /weave-view opens the browser workspace by default (weave-workspace §13);
33
+ // `tui` selects the in-terminal explorer. The browser workspace owns a
34
+ // server, a file watcher and live SSE streams for the rest of the session,
35
+ // so unlike the TUI it has a real teardown — see `session_shutdown` below.
33
36
  let lastCtx: ExtensionContext | ExtensionCommandContext | null = null;
34
37
  let lastStatusText: string | undefined = undefined;
35
38
  let isActive = false;
36
39
 
40
+ const web = new WebWorkspaceController({
41
+ exec: (command, args) => pi.exec(command, args),
42
+ // A boot or an idle shutdown changes what the status line should say, and
43
+ // neither happens inside a command handler that could refresh it itself.
44
+ onStateChange: () => updateStatus(),
45
+ });
46
+
37
47
  function updateStatus(ctx?: ExtensionContext | ExtensionCommandContext, text?: string): void {
38
48
  if (ctx) lastCtx = ctx;
39
49
  const c = ctx || lastCtx;
@@ -41,24 +51,50 @@ export default function piWeave(pi: ExtensionAPI): void {
41
51
  if (text !== undefined) {
42
52
  lastStatusText = text;
43
53
  }
44
- if (!lastStatusText) {
54
+
55
+ // A running browser workspace is a background process the user cannot
56
+ // otherwise see — an open port on their machine — so it always gets a
57
+ // marker. Resolved *before* the "nothing to show" test, because the port
58
+ // is itself something to show.
59
+ //
60
+ // This ordering is the fix for a real bug: the early return used to sit
61
+ // above this lookup, so a workspace started in a session that never
62
+ // emitted `session_start` (no seeded `lastStatusText`) did not merely
63
+ // fail to gain the marker — it actively cleared the status line while a
64
+ // server was listening. The browser-launch-failure path is exactly that
65
+ // shape, which is why it was the case that caught it.
66
+ const port = web.port();
67
+ const suffix = port === null ? "" : ` · web:${port}`;
68
+
69
+ // Clear only when there is genuinely nothing to say: no base text *and*
70
+ // no server.
71
+ if (!lastStatusText && port === null) {
45
72
  c.ui.setStatus("weave", undefined);
46
73
  return;
47
74
  }
75
+
48
76
  let theme: { fg?: (slot: string, text: string) => string } | undefined;
49
77
  try {
50
78
  theme = (c.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
51
79
  } catch {
52
80
  // ignore
53
81
  }
54
- const indicator = (isActive || inFlightDeepScans.size > 0)
82
+ const indicator = (isActive || inFlightDeepScans.size > 0 || inFlightSessionScans.size > 0)
55
83
  ? (theme?.fg ? theme.fg("accent", "●") : "●")
56
84
  : (theme?.fg ? theme.fg("dim", "○") : "○");
57
- c.ui.setStatus("weave", `${indicator} ${lastStatusText}`);
85
+ // With no base text the marker stands alone (`○ web:51234`) rather than
86
+ // rendering a leading ` · ` separator against nothing.
87
+ const body = lastStatusText ? `${lastStatusText}${suffix}` : `web:${port}`;
88
+ c.ui.setStatus("weave", `${indicator} ${body}`);
58
89
  }
59
90
 
60
91
  pi.on("session_shutdown", async () => {
61
- // The TUI explorer owns no session-scoped resources; nothing to stop.
92
+ // The TUI explorer owns nothing session-scoped, but the browser workspace
93
+ // owns an HTTP server, a recursive file watcher and any number of open SSE
94
+ // streams. `close()` releases all three (server.ts closes the hub and
95
+ // awaits the watcher); a leaked listener outliving the pi session is a
96
+ // real bug, not a tidiness concern.
97
+ await web.close();
62
98
  });
63
99
 
64
100
  pi.on("agent_start", async (_event, ctx) => {
@@ -97,20 +133,45 @@ export default function piWeave(pi: ExtensionAPI): void {
97
133
  });
98
134
 
99
135
  pi.registerCommand("weave-view", {
100
- description: "Open the in-terminal knowledge-graph explorer (vault + repository); reads from disk live",
136
+ description: "Open the knowledge workspace in your browser ('tui' for the in-terminal explorer, '--no-open' to just print the URL)",
101
137
  handler: async (args, ctx) => {
102
- const arg = args.trim().toLowerCase();
103
- if (arg !== "" && arg !== "tui") {
104
- ctx.ui.notify("usage: /weave-view [tui]", "warning");
138
+ const parsed = parseWeaveViewArgs(args);
139
+ if (parsed === null) {
140
+ ctx.ui.notify(WEAVE_VIEW_USAGE, "warning");
105
141
  return;
106
142
  }
107
- await runWeaveViewTui(ctx);
143
+ if (parsed.surface === "tui") {
144
+ await runWeaveViewTui(ctx);
145
+ return;
146
+ }
147
+ const outcome = await web.run(ctx, { open: parsed.open });
148
+ // A browser that would not launch on a machine that has a terminal
149
+ // still leaves the user somewhere to go. The server stays up — the URL
150
+ // in the notification remains valid — and the TUI opens on top of it.
151
+ if (outcome.fallbackToTui && ctx.mode === "tui") await runWeaveViewTui(ctx);
152
+ // Unconditionally, and last: the TUI writes its own status line on
153
+ // close, which would otherwise drop the `· web:PORT` marker for a
154
+ // server that is still running.
155
+ updateStatus(ctx);
108
156
  },
109
157
  });
110
158
 
111
159
  pi.registerCommand("weave-scan", {
112
- description: "Build or refresh the repository knowledge index (.okf); 'deep' also summarizes files with the session model",
160
+ description:
161
+ "Build or refresh the repository knowledge index (.okf); 'deep' also summarizes files with the session model; 'sessions' summarizes pi session history into the vault",
113
162
  handler: async (args, ctx) => {
163
+ const mode = args.trim().toLowerCase();
164
+ if (mode === "sessions") {
165
+ // Repo-agnostic by definition: no git requirement, works from any cwd.
166
+ if (inFlightSessionScans.size > 0) {
167
+ ctx.ui.notify("pi-weave: a session scan is already running — run /weave-scan-cancel to stop it.", "warning");
168
+ return;
169
+ }
170
+ const status = await getWorkspaceStatus(ctx.cwd);
171
+ startSessionScan(ctx, status, updateStatus);
172
+ return; // the background scan owns the status line until it settles
173
+ }
174
+
114
175
  const root = await findGitRoot(ctx.cwd);
115
176
  if (!root) {
116
177
  ctx.ui.notify("pi-weave: not inside a git repository.", "warning");
@@ -124,7 +185,7 @@ export default function piWeave(pi: ExtensionAPI): void {
124
185
  await writeRepoIndex(root, index);
125
186
  ctx.ui.notify(`pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`, "info");
126
187
 
127
- if (args.trim().toLowerCase() === "deep") {
188
+ if (mode === "deep") {
128
189
  if (inFlightDeepScans.has(root)) {
129
190
  ctx.ui.notify("pi-weave: a deep scan is already running for this repository — run /weave-scan-cancel to stop it.", "warning");
130
191
  } else {
@@ -142,20 +203,68 @@ export default function piWeave(pi: ExtensionAPI): void {
142
203
  });
143
204
 
144
205
  pi.registerCommand("weave-scan-cancel", {
145
- description: "Cancel an in-flight /weave-scan deep run",
206
+ description: "Cancel an in-flight /weave-scan deep or sessions run",
146
207
  handler: async (_args, ctx) => {
147
208
  const root = await findGitRoot(ctx.cwd);
148
- const scan = root ? inFlightDeepScans.get(root) : undefined;
149
- if (!scan) {
209
+ const deep = root ? inFlightDeepScans.get(root) : undefined;
210
+ const sessions = inFlightSessionScans.get(SESSIONS_SCAN_KEY);
211
+ if (!deep && !sessions) {
150
212
  ctx.ui.notify("pi-weave: no deep scan is currently running.", "info");
151
213
  return;
152
214
  }
153
- scan.controller.abort();
154
- ctx.ui.notify("pi-weave: deep scan cancellation requested.", "info");
215
+ deep?.controller.abort();
216
+ sessions?.controller.abort();
217
+ ctx.ui.notify("pi-weave: scan cancellation requested.", "info");
155
218
  },
156
219
  });
157
220
  }
158
221
 
222
+ /* ------------------------------------------------------------------ */
223
+ /* /weave-view argument parsing */
224
+ /* ------------------------------------------------------------------ */
225
+
226
+ export const WEAVE_VIEW_USAGE = "usage: /weave-view [tui|web] [--no-open]";
227
+
228
+ /** Which explorer `/weave-view` was asked for. */
229
+ export interface WeaveViewArgs {
230
+ surface: "tui" | "web";
231
+ /** `false` for `--no-open`. Meaningless for `tui`, which never opens one. */
232
+ open: boolean;
233
+ }
234
+
235
+ /**
236
+ * Parse `/weave-view`'s argument string, or `null` for "print the usage".
237
+ *
238
+ * Bare `/weave-view` is the **browser** workspace (weave-workspace §13):
239
+ * the default is the thing most people want most of the time, and `tui` is
240
+ * one word away for the SSH case. `--no-open` is accepted on the web
241
+ * surface only — silently ignoring it after `tui` would be a lie about what
242
+ * happened.
243
+ *
244
+ * Exported because a parser is exactly the kind of thing that should be
245
+ * tested as a table rather than through eight command invocations.
246
+ */
247
+ export function parseWeaveViewArgs(args: string): WeaveViewArgs | null {
248
+ const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
249
+ let surface: "tui" | "web" = "web";
250
+ let open = true;
251
+ let sawSurface = false;
252
+ for (const token of tokens) {
253
+ if (token === "--no-open") {
254
+ // Rejecting the repeat costs nothing and keeps "did I typo?" honest.
255
+ if (!open) return null;
256
+ open = false;
257
+ continue;
258
+ }
259
+ if (sawSurface) return null;
260
+ if (token !== "tui" && token !== "web") return null;
261
+ surface = token;
262
+ sawSurface = true;
263
+ }
264
+ if (surface === "tui" && !open) return null;
265
+ return { surface, open };
266
+ }
267
+
159
268
  /* ------------------------------------------------------------------ */
160
269
  /* In-flight deep scans (background, cancellable) */
161
270
  /* ------------------------------------------------------------------ */
@@ -169,60 +278,151 @@ interface InFlightDeepScan {
169
278
  /** In-flight deep scans keyed by repo root — the /weave-scan-cancel target. */
170
279
  const inFlightDeepScans = new Map<string, InFlightDeepScan>();
171
280
 
281
+ /**
282
+ * The in-flight session scan, under a reserved key that cannot collide with
283
+ * a git root (absolute paths always start with `/`).
284
+ */
285
+ export const SESSIONS_SCAN_KEY = "(pi-weave:sessions)";
286
+ const inFlightSessionScans = new Map<string, InFlightDeepScan>();
287
+
172
288
  /** Test seam: resolve when the in-flight deep scan for `root` settles. */
173
289
  export async function deepScanDone(root: string): Promise<void | undefined> {
174
290
  const canonical = await findGitRoot(root).catch(() => null);
175
291
  return inFlightDeepScans.get(canonical ?? root)?.done;
176
292
  }
177
293
 
294
+ /** Test seam: resolve when the background session scan settles. */
295
+ export async function sessionScanDone(): Promise<void | undefined> {
296
+ return inFlightSessionScans.get(SESSIONS_SCAN_KEY)?.done;
297
+ }
298
+
299
+ interface SettledMessage {
300
+ text: string;
301
+ level: "info" | "warning";
302
+ }
303
+
178
304
  /**
179
- * Kick off a deep scan in the background so the user keeps control of the
180
- * session (a blocking command can't be cancelled in the TUI — Esc only aborts
181
- * streaming/bash). Progress is pushed to the status line; completion or
182
- * cancellation is reported via a notification. `baseStatus` is the workspace
183
- * status captured before the scan and restored when it settles.
305
+ * Shared background-scan lifecycle for the LLM-backed modes: the scan runs
306
+ * off the command handler so the user keeps control of the session (a
307
+ * blocking command can't be cancelled in the TUI — Esc only aborts
308
+ * streaming/bash). Progress is pushed to the status line; the completion
309
+ * message is notified; the settled status is restored when the scan settles
310
+ * (`settledStatus` lets a scan recompute it — a session scan grows the vault,
311
+ * so its restored line should say so).
184
312
  */
185
- function startDeepScan(
186
- root: string,
313
+ function startBackgroundScan(
314
+ store: Map<string, InFlightDeepScan>,
315
+ key: string,
187
316
  ctx: ExtensionCommandContext,
188
317
  baseStatus: WorkspaceStatus,
189
318
  updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
319
+ run: (signal: AbortSignal) => Promise<SettledMessage | null>,
320
+ settledStatus: (() => Promise<WorkspaceStatus>) | undefined = undefined,
190
321
  ): void {
191
322
  const controller = new AbortController();
192
323
  let doneResolve: () => void;
193
324
  const done = new Promise<void>((resolve) => {
194
325
  doneResolve = resolve;
195
326
  });
196
- inFlightDeepScans.set(root, { controller, done });
327
+ store.set(key, { controller, done });
197
328
 
198
329
  void (async () => {
199
330
  try {
200
- updateStatus(ctx, "🧵 deep scan: starting…");
201
- const outcome = await deepScanRepository(root, ctx, {
202
- onProgress: ({ current, total, path }) => {
203
- const pct = total > 0 ? Math.round((current / total) * 100) : 100;
204
- updateStatus(ctx, `🧵 deep scan: ${current}/${total} (${pct}%) — ${path}`);
205
- },
206
- signal: controller.signal,
207
- });
208
- if (controller.signal.aborted) {
209
- ctx.ui.notify("pi-weave: deep scan cancelled.", "warning");
210
- } else if (outcome.kind === "no-model") {
211
- ctx.ui.notify(
212
- "pi-weave: deep scan needs an active session model — none configured. Light index only.",
213
- "warning",
214
- );
215
- } else if (outcome.kind === "ok") {
216
- ctx.ui.notify(`pi-weave: deep scan complete — ${formatDeepScanResult(outcome.result)}`, "info");
217
- }
331
+ const settled = await run(controller.signal);
332
+ if (settled !== null) ctx.ui.notify(settled.text, settled.level);
218
333
  } catch {
219
334
  // session ended or extension torn down — stop quietly
220
335
  } finally {
221
336
  // Restore the settled status before removing the map entry, so a caller
222
- // awaiting deepScanDone() observes the settled status line.
223
- inFlightDeepScans.delete(root);
224
- updateStatus(ctx, formatStatusLine(baseStatus));
337
+ // awaiting the done seam observes the settled status line.
338
+ const final = settledStatus
339
+ ? await settledStatus().catch(() => baseStatus)
340
+ : baseStatus;
341
+ store.delete(key);
342
+ updateStatus(ctx, formatStatusLine(final));
225
343
  doneResolve!();
226
344
  }
227
345
  })();
228
346
  }
347
+
348
+ /**
349
+ * Kick off a deep scan in the background. `baseStatus` is the workspace
350
+ * status captured before the scan and restored when it settles.
351
+ */
352
+ function startDeepScan(
353
+ root: string,
354
+ ctx: ExtensionCommandContext,
355
+ baseStatus: WorkspaceStatus,
356
+ updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
357
+ ): void {
358
+ startBackgroundScan(inFlightDeepScans, root, ctx, baseStatus, updateStatus, async (signal) => {
359
+ updateStatus(ctx, "🕸️ deep scan: starting…");
360
+ const outcome = await deepScanRepository(root, ctx, {
361
+ onProgress: ({ current, total, path }) => {
362
+ const pct = total > 0 ? Math.round((current / total) * 100) : 100;
363
+ updateStatus(ctx, `🕸️ deep scan: ${current}/${total} (${pct}%) — ${path}`);
364
+ },
365
+ signal,
366
+ });
367
+ if (signal.aborted) {
368
+ return { text: "pi-weave: deep scan cancelled.", level: "warning" };
369
+ }
370
+ if (outcome.kind === "no-model") {
371
+ return {
372
+ text: "pi-weave: deep scan needs an active session model — none configured. Light index only.",
373
+ level: "warning",
374
+ };
375
+ }
376
+ if (outcome.kind === "ok") {
377
+ return { text: `pi-weave: deep scan complete — ${formatDeepScanResult(outcome.result)}`, level: "info" };
378
+ }
379
+ return null;
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Kick off a session scan (docs/session-scan.md) in the background — same
385
+ * lifecycle as deep scans; keyed globally, not per repo.
386
+ */
387
+ function startSessionScan(
388
+ ctx: ExtensionCommandContext,
389
+ baseStatus: WorkspaceStatus,
390
+ updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
391
+ ): void {
392
+ startBackgroundScan(
393
+ inFlightSessionScans,
394
+ SESSIONS_SCAN_KEY,
395
+ ctx,
396
+ baseStatus,
397
+ updateStatus,
398
+ async (signal) => {
399
+ updateStatus(ctx, "🕸️ session scan: starting…");
400
+ const outcome = await scanPiSessions(ctx, {
401
+ onProgress: ({ current, total, path }) => {
402
+ const pct = total > 0 ? Math.round((current / total) * 100) : 100;
403
+ updateStatus(ctx, `🕸️ session scan: ${current}/${total} (${pct}%) — ${path}`);
404
+ },
405
+ signal,
406
+ });
407
+ if (signal.aborted) {
408
+ return { text: "pi-weave: session scan cancelled.", level: "warning" };
409
+ }
410
+ if (outcome.kind === "no-model") {
411
+ return {
412
+ text: "pi-weave: session scan needs an active session model — none configured.",
413
+ level: "warning",
414
+ };
415
+ }
416
+ const result = outcome.result;
417
+ if (result.discovered === 0) {
418
+ return { text: "pi-weave: session scan complete — no pi sessions found.", level: "info" };
419
+ }
420
+ return { text: `pi-weave: session scan complete — ${formatSessionScanResult(result)}`, level: "info" };
421
+ },
422
+ // Unlike the deep scan (whose settled status is precomputed to avoid git
423
+ // contention), the session scan writes vault notes and takes no git lock:
424
+ // recompute the workspace status so the settled line counts the notes it
425
+ // just wrote.
426
+ () => getWorkspaceStatus(ctx.cwd),
427
+ );
428
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * pi adapter for the session scan (docs/session-scan.md): summarize pi
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
+ "Answer, in order: what happened, what we did, what shipped (features, files, commands,",
26
+ "decisions), and what went less well (dead ends, breakage, anything left unfinished).",
27
+ "Technical sessions get specifics — real file paths, commands, outcomes — not generalities.",
28
+ "3–6 sentences, past tense. No preamble, no headings, no code fences.",
29
+ ].join("\n");
30
+
31
+ const SESSION_MAX_OUTPUT_TOKENS = 400;
32
+
33
+ /** Session summarizer over the session model, or null when none is active. */
34
+ export function createSessionSummarizer(
35
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
36
+ deps: SummarizerDeps = {},
37
+ ): { summarize: SummarizeFn; label: string } | null {
38
+ return createModelSummarizer(ctx, SESSION_SYSTEM_PROMPT, SESSION_MAX_OUTPUT_TOKENS, deps);
39
+ }
40
+
41
+ export type SessionScanOutcome =
42
+ | { kind: "ok"; result: SessionScanResult }
43
+ | { kind: "no-model" };
44
+
45
+ export interface SessionScanAdapterOptions {
46
+ complete?: SummarizerDeps["complete"];
47
+ maxSessions?: SessionScanOptions["maxSessions"];
48
+ maxFileBytes?: SessionScanOptions["maxFileBytes"];
49
+ concurrency?: SessionScanOptions["concurrency"];
50
+ sessionsRoot?: SessionScanOptions["sessionsRoot"];
51
+ vaultRoot?: SessionScanOptions["vaultRoot"];
52
+ now?: SessionScanOptions["now"];
53
+ onProgress?: SessionScanOptions["onProgress"];
54
+ signal?: SessionScanOptions["signal"];
55
+ }
56
+
57
+ /**
58
+ * Scan pi sessions into the vault using the session model. Roots default to
59
+ * the real locations (PI_WEAVE_SESSIONS / PI_WEAVE_VAULT overrides apply);
60
+ * tests inject both.
61
+ */
62
+ export async function scanPiSessions(
63
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
64
+ deps: SessionScanAdapterOptions = {},
65
+ ): Promise<SessionScanOutcome> {
66
+ const llm = createSessionSummarizer(
67
+ ctx,
68
+ deps.complete !== undefined ? { complete: deps.complete } : {},
69
+ );
70
+ if (!llm) return { kind: "no-model" };
71
+ // exactOptionalPropertyTypes: only present keys may be spread in.
72
+ const result = await runSessionScan({
73
+ sessionsRoot: deps.sessionsRoot ?? resolveSessionsRoot(),
74
+ vaultRoot: deps.vaultRoot ?? resolveVaultRoot(),
75
+ summarize: llm.summarize,
76
+ model: llm.label,
77
+ ...(deps.maxSessions !== undefined ? { maxSessions: deps.maxSessions } : {}),
78
+ ...(deps.maxFileBytes !== undefined ? { maxFileBytes: deps.maxFileBytes } : {}),
79
+ ...(deps.concurrency !== undefined ? { concurrency: deps.concurrency } : {}),
80
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
81
+ ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
82
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
83
+ });
84
+ return { kind: "ok", result };
85
+ }
86
+
87
+ /** One-line human summary of a session-scan result (for notify output). */
88
+ export function formatSessionScanResult(result: SessionScanResult): string {
89
+ const parts = [
90
+ `${result.written} summarized (${result.created} new, ${result.updated} updated)`,
91
+ `${result.skippedFresh} unchanged`,
92
+ ];
93
+ if (result.skippedEmpty > 0) parts.push(`${result.skippedEmpty} empty`);
94
+ if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size)`);
95
+ if (result.skippedUnreadable > 0) parts.push(`${result.skippedUnreadable} unreadable`);
96
+ let text = `${parts.join(", ")} — ${result.considered} sessions considered`;
97
+ if (result.failed.length > 0) {
98
+ const [failed0] = result.failed;
99
+ if (failed0) {
100
+ text += `; ${result.failed.length} failed, first: ${failed0.path}: ${failed0.error}`;
101
+ }
102
+ }
103
+ return text;
104
+ }
@@ -44,9 +44,21 @@ const SYSTEM_PROMPT = [
44
44
  const MAX_OUTPUT_TOKENS = 220;
45
45
  const REQUEST_TIMEOUT_MS = 30_000;
46
46
 
47
- /** Create the model-backed summarizer, or null when the session has no active model. */
48
- export function createLlmSummarizer(
47
+ /**
48
+ * The shared session-model wiring behind every pi-weave summarizer (deep
49
+ * scan, session scan): resolve the session's already-configured model — no
50
+ * extra keys or providers (docs/scan-modes.md) — drive completion through
51
+ * `ctx.modelRegistry`, which owns auth, and reject empty outputs so a
52
+ * degraded model cannot silently blank a note.
53
+ *
54
+ * Returns null when the session has no active model (headless runs without a
55
+ * provider, etc.) — callers fall back or notify. `deps.complete` is the test
56
+ * seam: unit tests inject a fake and never touch a network.
57
+ */
58
+ export function createModelSummarizer(
49
59
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
60
+ systemPrompt: string,
61
+ maxOutputTokens: number,
50
62
  deps: SummarizerDeps = {},
51
63
  ): LlmSummarizer | null {
52
64
  const model = ctx.model;
@@ -58,7 +70,7 @@ export function createLlmSummarizer(
58
70
  const message = await complete(
59
71
  model,
60
72
  {
61
- systemPrompt: SYSTEM_PROMPT,
73
+ systemPrompt,
62
74
  messages: [
63
75
  {
64
76
  role: "user",
@@ -67,7 +79,7 @@ export function createLlmSummarizer(
67
79
  },
68
80
  ],
69
81
  },
70
- { maxTokens: MAX_OUTPUT_TOKENS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
82
+ { maxTokens: maxOutputTokens, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
71
83
  );
72
84
  const text = contentText(message.content).trim();
73
85
  if (text.length === 0) {
@@ -78,6 +90,14 @@ export function createLlmSummarizer(
78
90
  return { summarize, label };
79
91
  }
80
92
 
93
+ /** Create the file summarizer for deep scans, or null when no model is active. */
94
+ export function createLlmSummarizer(
95
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
96
+ deps: SummarizerDeps = {},
97
+ ): LlmSummarizer | null {
98
+ return createModelSummarizer(ctx, SYSTEM_PROMPT, MAX_OUTPUT_TOKENS, deps);
99
+ }
100
+
81
101
  export type DeepScanOutcome =
82
102
  | { kind: "ok"; result: DeepScanResult }
83
103
  | { kind: "no-model" }
@@ -5,6 +5,7 @@ import { Type } from "typebox";
5
5
  import {
6
6
  addNote,
7
7
  appendToNote,
8
+ extractRawTail,
8
9
  finalizeNote,
9
10
  formatNote,
10
11
  formatRawAppend,
@@ -30,7 +31,8 @@ export function registerNoteTool(pi: ExtensionAPI): void {
30
31
  description:
31
32
  "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
32
33
  "of Markdown notes. Actions: list (all notes), get (one note by slug), add (new note), " +
33
- "append (extend a note), finalize (restructure a note above its raw tail), search (title/tags/body). " +
34
+ "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
35
+ "finalize (restructure a note above its raw tail), search (title/tags/body). " +
34
36
  "Use it to remember decisions, facts, and user preferences across sessions.",
35
37
  promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
36
38
  promptGuidelines: [
@@ -43,6 +45,7 @@ export function registerNoteTool(pi: ExtensionAPI): void {
43
45
  text: Type.Optional(Type.String({ description: "Markdown body (add), addition (append), or restructured body above the raw tail (finalize)" })),
44
46
  tags: Type.Optional(Type.Array(Type.String(), { description: "Tags (add)" })),
45
47
  slug: Type.Optional(Type.String({ description: "Note slug (get, append, finalize)" })),
48
+ raw: Type.Optional(Type.Boolean({ description: "append: add text as verbatim dictation to the ## Raw tail (timestamped fenced block; tail created if missing). Use for dictation/scribbles; omit for structured Markdown additions" })),
46
49
  source: Type.Optional(StringEnum(["human", "agent"] as const, { description: "Provenance (add): human for user-scribbled notes, agent for Pi-drafted (default agent)" })),
47
50
  query: Type.Optional(Type.String({ description: "Search query (search)" })),
48
51
  }),
@@ -117,12 +120,14 @@ export function registerNoteTool(pi: ExtensionAPI): void {
117
120
  // Serialized read-modify-write: parallel weave_note appends (and
118
121
  // pi's own file tools) targeting the same note would otherwise
119
122
  // lose each other's additions.
120
- const note = await withMutationQueue(path, () => appendToNote(vault, slug, text));
123
+ const note = await withMutationQueue(path, () =>
124
+ appendToNote(vault, slug, text, new Date(), params.raw ? { raw: true } : {}),
125
+ );
121
126
  if (!note) {
122
127
  return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "append", found: false } };
123
128
  }
124
129
  return {
125
- content: [{ type: "text", text: `Appended to ${note.slug} (updated ${note.updated}).` }],
130
+ content: [{ type: "text", text: params.raw ? `Appended verbatim to the ## Raw tail of ${note.slug} (updated ${note.updated}).` : `Appended to ${note.slug} (updated ${note.updated}).` }],
126
131
  details: { action: "append", found: true, note },
127
132
  };
128
133
  }
@@ -150,8 +155,12 @@ export function registerNoteTool(pi: ExtensionAPI): void {
150
155
  if (!note) {
151
156
  return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "finalize", found: false } };
152
157
  }
158
+ // Be truthful about what was preserved: a note with no `## Raw`
159
+ // marker yet gets its whole pre-finalize body preserved as a new
160
+ // raw tail (never silently dropped).
161
+ const preserved = extractRawTail(note.body) !== "";
153
162
  return {
154
- content: [{ type: "text", text: `Finalized ${note.slug} (updated ${note.updated}). Raw notes tail preserved.` }],
163
+ content: [{ type: "text", text: `Finalized ${note.slug} (updated ${note.updated}). ${preserved ? "Raw tail preserved beneath the structured body." : "Note body was empty — nothing to preserve."}` }],
155
164
  details: { action: "finalize", found: true, note },
156
165
  };
157
166
  }