pi-weave 0.1.8 → 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 (39) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +52 -39
  22. package/src/web/client/graph/Graph.tsx +70 -19
  23. package/src/web/client/graph/column.model.ts +11 -70
  24. package/src/web/client/graph/dynamics.ts +176 -0
  25. package/src/web/client/graph/graph.model.ts +10 -2
  26. package/src/web/client/graph/positions.ts +51 -10
  27. package/src/web/client/graph/renderer.ts +61 -1
  28. package/src/web/client/selection.storage.ts +69 -0
  29. package/src/web/client/shell/Header.tsx +11 -1
  30. package/src/web/client/shell/Shell.tsx +17 -0
  31. package/src/web/client/shell/theme.ts +15 -2
  32. package/src/web/client/state.ts +11 -0
  33. package/src/web/client/tree/Tree.tsx +4 -1
  34. package/src/web/client/workspace.ts +69 -5
  35. package/src/web/server/page.ts +2 -0
  36. package/src/web/server/routes.ts +19 -7
  37. package/src/web/shared/graph.ts +7 -0
  38. package/src/web/shared/layout.ts +158 -341
  39. package/src/web/shared/logo.ts +11 -0
@@ -11,6 +11,7 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import { join } from "node:path";
14
+ import { mapWithConcurrency } from "./concurrency";
14
15
  import { parseFrontMatter, quoteField, unquoteField } from "./frontmatter";
15
16
  import { listFiles } from "./git";
16
17
  import { repoKnowledgeDir } from "./paths";
@@ -179,25 +180,6 @@ export function hashContent(content: string | Buffer): string {
179
180
  return createHash("sha1").update(content).digest("hex");
180
181
  }
181
182
 
182
- async function mapWithConcurrency<T, R>(
183
- items: readonly T[],
184
- concurrency: number,
185
- fn: (item: T, index: number) => Promise<R>,
186
- shouldStop?: () => boolean,
187
- ): Promise<R[]> {
188
- const results: R[] = new Array(items.length);
189
- let next = 0;
190
- async function worker(): Promise<void> {
191
- while (next < items.length) {
192
- if (shouldStop?.()) return;
193
- const i = next++;
194
- results[i] = await fn(items[i] as T, i);
195
- }
196
- }
197
- await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
198
- return results;
199
- }
200
-
201
183
  /**
202
184
  * Run the deep scan: summarize tracked source files into summary sidecars.
203
185
  * Incremental (content-hash skip) and failure-tolerant per file.
package/src/core/vault.ts CHANGED
@@ -1,7 +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
- import { isAbsolute, join, relative, sep } from "node:path";
4
- import { parseNoteFile, serializeNote } from "./frontmatter";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import {
5
+ MANAGED_FRONT_MATTER_KEYS,
6
+ parseFrontMatter,
7
+ parseNoteFile,
8
+ quoteField,
9
+ serializeNote,
10
+ upsertFrontMatterFields,
11
+ } from "./frontmatter";
5
12
  import { withMutationQueue } from "./mutex";
6
13
  import { NOTES_DIR, OKF_MANIFEST } from "./paths";
7
14
  import { slugify, uniqueSlug } from "./slug";
@@ -65,16 +72,21 @@ function notePath(root: string, slug: string): string {
65
72
 
66
73
  /**
67
74
  * Resolve a note slug to its on-disk path, or null when the slug is unsafe.
68
- * Slugs arrive from tool parameters, so they are untrusted: `../x`, nested
69
- * paths, and absolute escapes must never read or write outside the flat
70
- * <vault>/notes/ directory.
75
+ * Slugs arrive from tool parameters, so they are untrusted: `../x` and
76
+ * absolute escapes must never read or write outside `<vault>/notes/`
77
+ * (subdirectories *within* it are legitimate — the sessions collection).
71
78
  */
72
79
  export function resolveNotePath(root: string, slug: string): string | null {
73
80
  if (slug.trim().length === 0) return null;
74
81
  const notesDir = join(root, NOTES_DIR);
75
82
  const candidate = join(notesDir, `${slug}.md`);
76
83
  const rel = relative(notesDir, candidate);
77
- if (rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
84
+ // Slugs may nest (`sessions/foo`) — that is how session memory stays in an
85
+ // inner folder of the vault graph (docs/session-scan.md) — but they may
86
+ // never escape the collection: `..` segments and absolute paths resolve
87
+ // outside notes/ and are rejected here, at the one door every read and
88
+ // write walks through.
89
+ if (rel.startsWith("..") || isAbsolute(rel) || rel.length === 0) return null;
78
90
  return candidate;
79
91
  }
80
92
 
@@ -146,6 +158,9 @@ async function writeNote(
146
158
  frontMatter: NoteFrontMatter | undefined,
147
159
  ): Promise<Note> {
148
160
  const text = serializeNote(meta, body, frontMatter);
161
+ // Path-slugs (`sessions/foo`) may introduce a new subdirectory; every
162
+ // write path funnels through here, so this is the one mkdir that matters.
163
+ await fs.mkdir(dirname(path), { recursive: true });
149
164
  await fs.writeFile(path, text, "utf8");
150
165
  const parsed = parseNoteFile(text);
151
166
  return { slug, ...parsed.meta, body: parsed.body, frontMatter: parsed.frontMatter };
@@ -193,23 +208,71 @@ function withNoteLocks<T>(paths: readonly string[], task: () => Promise<T>): Pro
193
208
  )();
194
209
  }
195
210
 
211
+ /** Options for {@link appendToNote}. */
212
+ export interface AppendToNoteOptions {
213
+ /**
214
+ * Append as **verbatim dictation** into the `## Raw` tail (the skill's raw
215
+ * tail format: separator, heading, never-edit notice, dated fenced block).
216
+ * Creates the tail when the note does not have one yet. Use this for raw
217
+ * user dictation; the default plain append adds structured Markdown to the
218
+ * editorial body above the tail.
219
+ */
220
+ raw?: boolean;
221
+ }
222
+
196
223
  /** Append Markdown to an existing note and bump `updated`. */
197
224
  export async function appendToNote(
198
225
  root: string,
199
226
  slug: string,
200
227
  addition: string,
201
228
  now: Date = new Date(),
229
+ options: AppendToNoteOptions = {},
202
230
  ): Promise<Note | null> {
203
231
  const path = resolveNotePath(root, slug);
204
232
  if (!path) return null;
205
233
  return withNoteLocks([path], async () => {
206
234
  const note = await getNote(root, slug);
207
235
  if (!note) return null;
208
- const body = note.body.replace(/\s+$/, "") + "\n\n" + addition.trim() + "\n";
236
+ const tail = extractRawTail(note.body);
237
+ let body: string;
238
+ if (options.raw) {
239
+ // A raw append always lands at the very end of the body — which is the
240
+ // end of the `## Raw` tail whenever one exists — so "append at end" is
241
+ // the correct placement; the only branch is tail creation.
242
+ const block =
243
+ tail === ""
244
+ ? `${rawTailOpening()}\n\n${formatRawAppend(addition, now)}`
245
+ : formatRawAppend(addition, now);
246
+ body = note.body.replace(/\s+$/, "") + "\n\n" + block + "\n";
247
+ } else if (tail === "") {
248
+ body = note.body.replace(/\s+$/, "") + "\n\n" + addition.trim() + "\n";
249
+ } else {
250
+ // Structured additions belong to the editorial body ABOVE the tail —
251
+ // the raw tail stays the note's bottom, append-only and untouched.
252
+ const idx = note.body.lastIndexOf(tail);
253
+ const head = note.body.slice(0, idx).replace(/\s+$/, "");
254
+ body = (head ? head + "\n\n" : "") + addition.trim() + "\n\n" + tail + "\n";
255
+ }
209
256
  return writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
210
257
  });
211
258
  }
212
259
 
260
+ /**
261
+ * Pick a code fence that cannot be terminated by any backtick run inside
262
+ * `text` (CommonMark: a fence must be at least as long as the longest
263
+ * backtick run it encloses).
264
+ */
265
+ function fenceFor(text: string): string {
266
+ let longest = 0;
267
+ for (const match of text.matchAll(/`+/g)) longest = Math.max(longest, match[0].length);
268
+ return "`".repeat(Math.max(3, longest + 1));
269
+ }
270
+
271
+ /** The canonical opening of a `## Raw` tail: separator, heading, notice. */
272
+ function rawTailOpening(): string {
273
+ return `---\n\n${RAW_NOTES_HEADING}\n${RAW_TAIL_NOTICE}`;
274
+ }
275
+
213
276
  /** Format a verbatim user scribble as an append-only raw block with a timestamp. */
214
277
  export function formatRawAppend(rawText: string, date: Date = new Date()): string {
215
278
  const pad = (n: number) => String(n).padStart(2, "0");
@@ -219,13 +282,18 @@ export function formatRawAppend(rawText: string, date: Date = new Date()): strin
219
282
  const hh = pad(date.getHours());
220
283
  const min = pad(date.getMinutes());
221
284
  const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`;
285
+ const fence = fenceFor(rawText);
222
286
 
223
- return `<!-- appended ${timestamp} -->\n\`\`\`\n${rawText.trim()}\n\`\`\``;
287
+ return `<!-- appended ${timestamp} -->\n${fence}\n${rawText.trim()}\n${fence}`;
224
288
  }
225
289
 
226
290
  /** The append-only tail where verbatim user scribbles live. */
227
291
  export const RAW_NOTES_HEADING = "## Raw";
228
292
 
293
+ /** The never-edit notice comment at the top of a raw tail (skill format). */
294
+ export const RAW_TAIL_NOTICE =
295
+ "<!-- NEVER edit below this line. Verbatim user input preserved here. -->";
296
+
229
297
  /**
230
298
  * Extract the raw tail (including separator line, heading, and everything after) verbatim.
231
299
  */
@@ -273,7 +341,18 @@ export async function finalizeNote(
273
341
  const note = await getNote(root, slug);
274
342
  if (!note) return null;
275
343
  const rawTail = extractRawTail(note.body);
276
- const body = input.body.trim() + (rawTail ? `\n\n${rawTail}` : "");
344
+ const structured = input.body.trim();
345
+ // A note whose body carries no `## Raw` marker yet is treated as *all*
346
+ // raw: the entire pre-finalize body is preserved verbatim beneath the
347
+ // restructured body as a freshly created tail (docs/notepad.md §4 — the
348
+ // user's words are never silently destroyed by finalization).
349
+ const body =
350
+ structured +
351
+ (rawTail !== ""
352
+ ? `\n\n${rawTail}`
353
+ : note.body.trim() === ""
354
+ ? ""
355
+ : `\n\n${rawTailOpening()}\n\n${fenceFor(note.body)}\n${note.body.trim()}\n${fenceFor(note.body)}`);
277
356
  const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
278
357
  return writeNote(path, slug, meta, body, note.frontMatter);
279
358
  });
@@ -547,15 +626,184 @@ export async function deleteNote(root: string, slug: string): Promise<DeleteResu
547
626
  });
548
627
  }
549
628
 
550
- async function listNoteFiles(root: string): Promise<string[]> {
551
- const dir = join(root, NOTES_DIR);
552
- let entries: string[];
629
+ // ---------------------------------------------------------------------------
630
+ // Generated-note upsert (weave-scan sessions; docs/session-scan.md)
631
+ // ---------------------------------------------------------------------------
632
+
633
+ export interface UpsertNoteInput {
634
+ /** Desired slug (already slug-safe); uniquified (`-2`, `-3`…) when creating. */
635
+ slug: string;
636
+ title: string;
637
+ body: string;
638
+ tags?: string[];
639
+ /** Defaults to `"generated"` — the safe direction for AGENTS.md rule 4. */
640
+ source?: NoteSource;
641
+ /**
642
+ * Extra **owned scalar** front-matter fields (e.g. `session_hash`) upserted
643
+ * on every write. Managed keys and syntactically unsafe keys are silently
644
+ * dropped: the note engine owns those, and this function will not fight it.
645
+ */
646
+ fields?: Record<string, string>;
647
+ /**
648
+ * Content identity of the generated note, when the slug alone must not
649
+ * decide ownership: on the create path, a candidate slug already occupied
650
+ * by a note carrying a **different** identity value is skipped (the slug
651
+ * uniquifies to `-2`, `-3`…), while a same-identity occupant is treated as
652
+ * ours. Without this, two generated artifacts that derive the same slug —
653
+ * two sessions that began with the same first message, say — would have
654
+ * the second silently overwrite the first, marker keys and all.
655
+ */
656
+ identity?: { field: string; value: string };
657
+ /** Injectable clock for tests. */
658
+ now?: Date;
659
+ }
660
+
661
+ /**
662
+ * Idempotently create-or-update a note, for generated knowledge that is
663
+ * re-derivable from a source of truth (a session transcript, a scan) and
664
+ * keyed by content the note carries in its front matter.
665
+ *
666
+ * - **Create** (no file at `slug`): canonical managed block plus the extra
667
+ * fields, body as given, `created` = `updated` = now. The slug is passed
668
+ * through `uniqueSlug`, so a taken slug shifts to `-2` rather than
669
+ * overwriting a note the caller could not see.
670
+ * - **Update** (file exists): replace the body and the extra fields, bump
671
+ * `updated`, and change nothing else — `title`, `created`, `tags`, unknown
672
+ * front-matter keys, and the append-only `## Raw` tail all survive, per
673
+ * the vault's round-trip guarantees. Title and tags are deliberately
674
+ * creation-time values: the human may have retitled or retagged the note,
675
+ * and a re-scan must not clobber that.
676
+ *
677
+ * Both paths hold the notes-**directory** lock (like `addNote`): the create
678
+ * path runs a check-then-create slug allocation that must be atomic, and the
679
+ * update path's `getNote`-then-write is the same lost-update window.
680
+ */
681
+ export async function upsertNote(root: string, input: UpsertNoteInput): Promise<Note> {
682
+ await ensureVault(root);
683
+ return withNoteLocks([join(root, NOTES_DIR)], async () => {
684
+ const now = (input.now ?? new Date()).toISOString();
685
+ const noteAt = (slug: string) => notePath(root, slug);
686
+ const identity = input.identity;
687
+ let existing = await getNote(root, input.slug);
688
+ if (
689
+ existing !== null &&
690
+ identity &&
691
+ fieldFromFrontMatter(existing.frontMatter, identity.field) !== identity.value
692
+ ) {
693
+ // The note at this slug belongs to a different identity (or to no
694
+ // identity at all — a human note): never update it in place. Fall
695
+ // through to the create path, whose guard picks the next free slug.
696
+ existing = null;
697
+ }
698
+ if (existing === null) {
699
+ const slug = uniqueSlug(input.slug, (candidate) => {
700
+ if (!existsSync(noteAt(candidate))) return false; // free
701
+ if (!identity) return true; // slug ownership is the caller's problem
702
+ return occupantIdentity(root, candidate, identity.field) !== identity.value;
703
+ });
704
+ const meta: NoteMeta = {
705
+ title: input.title,
706
+ created: now,
707
+ updated: now,
708
+ tags: input.tags ?? [],
709
+ source: input.source ?? "generated",
710
+ };
711
+ // The managed lines below are re-rendered from `meta` by `serializeNote`
712
+ // (replayBlock substitutes rendered values for managed keys in place);
713
+ // spelling them here just fixes the block's key order.
714
+ const fields = sanitizeUpsertFields(input.fields);
715
+ const frontMatter = [
716
+ `title: ${quoteField(meta.title)}`,
717
+ `created: ${meta.created}`,
718
+ `updated: ${meta.updated}`,
719
+ `tags: [${meta.tags.map(quoteField).join(", ")}]`,
720
+ `source: ${meta.source}`,
721
+ ...upsertFrontMatterFields([], fields),
722
+ ];
723
+ return writeNote(noteAt(slug), slug, meta, input.body, frontMatter);
724
+ }
725
+ const meta: NoteMeta = { ...existing, updated: now };
726
+ const fields = sanitizeUpsertFields(input.fields);
727
+ const frontMatter = upsertFrontMatterFields(existing.frontMatter ?? [], fields);
728
+ const body = preserveRawTail(existing.body, input.body);
729
+ return writeNote(noteAt(input.slug), input.slug, meta, body, frontMatter);
730
+ });
731
+ }
732
+
733
+ /**
734
+ * The identity value carried in a note's own front-matter block, or null when
735
+ * absent — an absent identity never matches, so unmarked notes are never
736
+ * claimed as ours.
737
+ */
738
+ function fieldFromFrontMatter(lines: NoteFrontMatter | undefined, field: string): string | null {
739
+ if (!lines) return null;
740
+ const parsed = parseFrontMatter(["---", ...lines, "---", ""].join("\n"));
741
+ if (!parsed) return null;
742
+ return parsed.fields.get(field) ?? null;
743
+ }
744
+
745
+ /**
746
+ * The identity value a note at `slug` carries in its front matter, or null
747
+ * when the file is missing, malformed, or does not declare the field — a
748
+ * null never equals a real identity value, so such a file blocks the slug.
749
+ */
750
+ function occupantIdentity(root: string, slug: string, field: string): string | null {
751
+ let text: string;
553
752
  try {
554
- entries = await fs.readdir(dir);
753
+ text = readFileSync(notePath(root, slug), "utf8");
555
754
  } catch {
556
- return [];
755
+ return null;
756
+ }
757
+ const parsed = parseFrontMatter(text);
758
+ if (!parsed) return null;
759
+ return parsed.fields.get(field) ?? null;
760
+ }
761
+
762
+ /**
763
+ * Drop fields this function has no business writing: managed keys (the note
764
+ * engine renders those from `NoteMeta`) and keys that are not plain scalar
765
+ * identifiers (a hostile key could smuggle newlines or `---` into the block).
766
+ * Values are guarded by `quoteField` at render time.
767
+ */
768
+ function sanitizeUpsertFields(fields: Record<string, string> | undefined): Record<string, string> {
769
+ if (!fields) return {};
770
+ const managed = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
771
+ const out: Record<string, string> = {};
772
+ for (const [key, value] of Object.entries(fields)) {
773
+ if (managed.has(key)) continue;
774
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) continue;
775
+ out[key] = value;
776
+ }
777
+ return out;
778
+ }
779
+
780
+ async function listNoteFiles(root: string): Promise<string[]> {
781
+ const dir = join(root, NOTES_DIR);
782
+ const out: string[] = [];
783
+ // Recursive by design: vault notes may nest (`sessions/<name>` — session
784
+ // memory lives in an inner folder of the graph, docs/session-scan.md), and
785
+ // every consumer above this function (list, search, graph, cache) speaks
786
+ // in slugs, so the relative path *is* the slug.
787
+ async function walk(prefix: string): Promise<void> {
788
+ let entries;
789
+ try {
790
+ entries = await fs.readdir(prefix.length > 0 ? join(dir, prefix) : dir, { withFileTypes: true });
791
+ } catch {
792
+ return; // missing vault — nothing to list
793
+ }
794
+ for (const entry of entries) {
795
+ if (entry.isDirectory()) {
796
+ await walk(prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name);
797
+ continue;
798
+ }
799
+ if (entry.isFile() && entry.name.endsWith(".md")) {
800
+ const slug = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
801
+ out.push(slug);
802
+ }
803
+ }
557
804
  }
558
- return entries.filter((name) => name.endsWith(".md")).sort();
805
+ await walk("");
806
+ return out.sort();
559
807
  }
560
808
 
561
809
  /**
@@ -49,10 +49,10 @@ export async function getWorkspaceStatus(cwd: string, options: WorkspaceOptions
49
49
  /** One-line status string for footers/status bars. */
50
50
  export function formatStatusLine(status: WorkspaceStatus): string {
51
51
  const vault = `vault:${status.vault.noteCount}`;
52
- if (!status.repository) return `🧵 ${vault}`;
53
- if (!status.repository.indexed) return `🧵 ${vault} · repo:unindexed`;
52
+ if (!status.repository) return `🕸️ ${vault}`;
53
+ if (!status.repository.indexed) return `🕸️ ${vault} · repo:unindexed`;
54
54
  const mark = status.repository.staleness.state === "fresh" ? "ok" : status.repository.staleness.state;
55
- return `🧵 ${vault} · ${status.repository.name}:${mark}`;
55
+ return `🕸️ ${vault} · ${status.repository.name}:${mark}`;
56
56
  }
57
57
 
58
58
  /** Multi-line dashboard used by the /weave command and notifications. */
package/src/pi/index.ts CHANGED
@@ -11,6 +11,7 @@ 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";
16
17
  import { WebWorkspaceController } from "./viewer/web/run";
@@ -78,7 +79,7 @@ export default function piWeave(pi: ExtensionAPI): void {
78
79
  } catch {
79
80
  // ignore
80
81
  }
81
- const indicator = (isActive || inFlightDeepScans.size > 0)
82
+ const indicator = (isActive || inFlightDeepScans.size > 0 || inFlightSessionScans.size > 0)
82
83
  ? (theme?.fg ? theme.fg("accent", "●") : "●")
83
84
  : (theme?.fg ? theme.fg("dim", "○") : "○");
84
85
  // With no base text the marker stands alone (`○ web:51234`) rather than
@@ -156,8 +157,21 @@ export default function piWeave(pi: ExtensionAPI): void {
156
157
  });
157
158
 
158
159
  pi.registerCommand("weave-scan", {
159
- 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",
160
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
+
161
175
  const root = await findGitRoot(ctx.cwd);
162
176
  if (!root) {
163
177
  ctx.ui.notify("pi-weave: not inside a git repository.", "warning");
@@ -171,7 +185,7 @@ export default function piWeave(pi: ExtensionAPI): void {
171
185
  await writeRepoIndex(root, index);
172
186
  ctx.ui.notify(`pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`, "info");
173
187
 
174
- if (args.trim().toLowerCase() === "deep") {
188
+ if (mode === "deep") {
175
189
  if (inFlightDeepScans.has(root)) {
176
190
  ctx.ui.notify("pi-weave: a deep scan is already running for this repository — run /weave-scan-cancel to stop it.", "warning");
177
191
  } else {
@@ -189,16 +203,18 @@ export default function piWeave(pi: ExtensionAPI): void {
189
203
  });
190
204
 
191
205
  pi.registerCommand("weave-scan-cancel", {
192
- description: "Cancel an in-flight /weave-scan deep run",
206
+ description: "Cancel an in-flight /weave-scan deep or sessions run",
193
207
  handler: async (_args, ctx) => {
194
208
  const root = await findGitRoot(ctx.cwd);
195
- const scan = root ? inFlightDeepScans.get(root) : undefined;
196
- if (!scan) {
209
+ const deep = root ? inFlightDeepScans.get(root) : undefined;
210
+ const sessions = inFlightSessionScans.get(SESSIONS_SCAN_KEY);
211
+ if (!deep && !sessions) {
197
212
  ctx.ui.notify("pi-weave: no deep scan is currently running.", "info");
198
213
  return;
199
214
  }
200
- scan.controller.abort();
201
- 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");
202
218
  },
203
219
  });
204
220
  }
@@ -262,60 +278,151 @@ interface InFlightDeepScan {
262
278
  /** In-flight deep scans keyed by repo root — the /weave-scan-cancel target. */
263
279
  const inFlightDeepScans = new Map<string, InFlightDeepScan>();
264
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
+
265
288
  /** Test seam: resolve when the in-flight deep scan for `root` settles. */
266
289
  export async function deepScanDone(root: string): Promise<void | undefined> {
267
290
  const canonical = await findGitRoot(root).catch(() => null);
268
291
  return inFlightDeepScans.get(canonical ?? root)?.done;
269
292
  }
270
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
+
271
304
  /**
272
- * Kick off a deep scan in the background so the user keeps control of the
273
- * session (a blocking command can't be cancelled in the TUI — Esc only aborts
274
- * streaming/bash). Progress is pushed to the status line; completion or
275
- * cancellation is reported via a notification. `baseStatus` is the workspace
276
- * 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).
277
312
  */
278
- function startDeepScan(
279
- root: string,
313
+ function startBackgroundScan(
314
+ store: Map<string, InFlightDeepScan>,
315
+ key: string,
280
316
  ctx: ExtensionCommandContext,
281
317
  baseStatus: WorkspaceStatus,
282
318
  updateStatus: (ctx?: ExtensionContext | ExtensionCommandContext, text?: string) => void,
319
+ run: (signal: AbortSignal) => Promise<SettledMessage | null>,
320
+ settledStatus: (() => Promise<WorkspaceStatus>) | undefined = undefined,
283
321
  ): void {
284
322
  const controller = new AbortController();
285
323
  let doneResolve: () => void;
286
324
  const done = new Promise<void>((resolve) => {
287
325
  doneResolve = resolve;
288
326
  });
289
- inFlightDeepScans.set(root, { controller, done });
327
+ store.set(key, { controller, done });
290
328
 
291
329
  void (async () => {
292
330
  try {
293
- updateStatus(ctx, "🧵 deep scan: starting…");
294
- const outcome = await deepScanRepository(root, ctx, {
295
- onProgress: ({ current, total, path }) => {
296
- const pct = total > 0 ? Math.round((current / total) * 100) : 100;
297
- updateStatus(ctx, `🧵 deep scan: ${current}/${total} (${pct}%) — ${path}`);
298
- },
299
- signal: controller.signal,
300
- });
301
- if (controller.signal.aborted) {
302
- ctx.ui.notify("pi-weave: deep scan cancelled.", "warning");
303
- } else if (outcome.kind === "no-model") {
304
- ctx.ui.notify(
305
- "pi-weave: deep scan needs an active session model — none configured. Light index only.",
306
- "warning",
307
- );
308
- } else if (outcome.kind === "ok") {
309
- ctx.ui.notify(`pi-weave: deep scan complete — ${formatDeepScanResult(outcome.result)}`, "info");
310
- }
331
+ const settled = await run(controller.signal);
332
+ if (settled !== null) ctx.ui.notify(settled.text, settled.level);
311
333
  } catch {
312
334
  // session ended or extension torn down — stop quietly
313
335
  } finally {
314
336
  // Restore the settled status before removing the map entry, so a caller
315
- // awaiting deepScanDone() observes the settled status line.
316
- inFlightDeepScans.delete(root);
317
- 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));
318
343
  doneResolve!();
319
344
  }
320
345
  })();
321
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
+ }