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.
- package/README.md +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +1 -1
- package/src/web/client/dist/app.js +52 -39
- package/src/web/client/graph/Graph.tsx +70 -19
- package/src/web/client/graph/column.model.ts +11 -70
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +10 -2
- package/src/web/client/graph/positions.ts +51 -10
- package/src/web/client/graph/renderer.ts +61 -1
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Header.tsx +11 -1
- package/src/web/client/shell/Shell.tsx +17 -0
- package/src/web/client/shell/theme.ts +15 -2
- package/src/web/client/state.ts +11 -0
- package/src/web/client/tree/Tree.tsx +4 -1
- package/src/web/client/workspace.ts +69 -5
- package/src/web/server/page.ts +2 -0
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +158 -341
- package/src/web/shared/logo.ts +11 -0
package/src/core/summaries.ts
CHANGED
|
@@ -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
|
|
4
|
-
import {
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
+
await walk("");
|
|
806
|
+
return out.sort();
|
|
559
807
|
}
|
|
560
808
|
|
|
561
809
|
/**
|
package/src/core/workspace.ts
CHANGED
|
@@ -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
|
|
53
|
-
if (!status.repository.indexed) return
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
327
|
+
store.set(key, { controller, done });
|
|
290
328
|
|
|
291
329
|
void (async () => {
|
|
292
330
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|