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
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-weave
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/pi-weave-logo.jpg" alt="pi-weave — an agent-native knowledge workspace" width="220"/>
4
+ <img src="https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/pi-weave-logo.png" alt="pi-weave — an agent-native knowledge workspace" width="220"/>
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -28,7 +28,7 @@ format. Every generated artefact carries provenance (`human`, `agent` or `genera
28
28
  something you wrote.
29
29
 
30
30
  ```
31
- 🧵 vault:12 · my-project:ok ← pi's status line when weave is active
31
+ 🕸️ vault:12 · my-project:ok ← pi's status line when weave is active
32
32
  ```
33
33
 
34
34
  See [docs/design.md](docs/design.md) for the reasoning behind all of it.
@@ -56,7 +56,8 @@ On session start pi-weave detects the repository you are in, checks whether `.ok
56
56
  | Command | `/weave-view` | open the knowledge workspace in your browser |
57
57
  | Command | `/weave-scan` | build or refresh the repository index (light) |
58
58
  | Command | `/weave-scan deep` | light index plus model-written per-file summaries (opt-in, incremental, background) |
59
- | Command | `/weave-scan-cancel` | stop an in-flight `/weave-scan deep` run |
59
+ | Command | `/weave-scan sessions` | summarize pi session history into the vault as memory notes (incremental, background) |
60
+ | Command | `/weave-scan-cancel` | stop an in-flight `/weave-scan deep` or `sessions` run |
60
61
  | Skill | `weave-notepad` | how the agent should take good notes |
61
62
  | Skill | `weave-explore` | how the agent should explore repositories |
62
63
 
@@ -64,6 +65,11 @@ On session start pi-weave detects the repository you are in, checks whether `.ok
64
65
  whose content hash has not changed since their last summary. It costs tokens, so it never runs implicitly — and it runs in the background,
65
66
  so `/weave-scan-cancel` can stop it mid-flight.
66
67
 
68
+ `/weave-scan sessions` is the repo-agnostic sibling (docs/session-scan.md): it reads every pi session transcript under
69
+ `~/.pi/agent/sessions/`, hashes each file while reading it, and writes one generated vault note per changed session under
70
+ `~/.okf/notes/sessions/` — pi's memory as first-class, wikilinked notes in an inner folder of the vault graph. Unchanged transcripts cost no
71
+ LLM calls at all, and older layouts migrate automatically on the next scan.
72
+
67
73
  ## The workspace
68
74
 
69
75
  `/weave-view` opens a browser knowledge workspace over the same graph the tools see — the vault and the repository index as one model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-weave",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,7 +41,7 @@
41
41
  "skills": [
42
42
  "./skills"
43
43
  ],
44
- "image": "https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/pi-weave-logo.jpg"
44
+ "image": "https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/pi-weave-logo.png"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "vitest run",
@@ -15,10 +15,17 @@ In pi, use the `weave_note` tool. In other harnesses (or when the tool is not av
15
15
  - **Notes** live at `~/.okf/notes/<slug>.md` (vault root overridable via `PI_WEAVE_VAULT`).
16
16
  - Each note has YAML front matter: `title`, `created`, `updated` (ISO-8601), `tags: [..]`, and `source: human | agent | generated`.
17
17
  - `weave_note` actions: `list`, `get`, `add`, `append`, `finalize`, `search`. `finalize` restructures the body *above* the `## Raw` tail and
18
- preserves the tail verbatim.
18
+ preserves the tail verbatim — a body with no tail yet is preserved **in full** as a newly created tail, so finalization never destroys
19
+ dictation.
20
+ - **Dictation appends**: use `append` with `raw: true` — the tool appends the text verbatim into the `## Raw` tail as a dated fenced block,
21
+ creating the tail if the note has none. In pi, never hand-format the raw tail; the tool maintains it.
19
22
 
20
23
  ## Raw Tail Format
21
24
 
25
+ In pi you rarely format this by hand: `weave_note` append with `raw: true` appends a dated fenced block into the tail (and creates the whole
26
+ tail — separator, heading, notice — when the note has none). The format below is what that produces, and what to write when editing files
27
+ directly or working in other harnesses.
28
+
22
29
  Every note maintains a verbatim, append-only raw section at the bottom separated by a horizontal rule (`---`):
23
30
 
24
31
  ---
@@ -50,7 +57,8 @@ Every note maintains a verbatim, append-only raw section at the bottom separated
50
57
  During live dictation / interview note-taking (see the skill description), Pi does **not** wait until the end to organize the note. Every
51
58
  interactive append is immediately compiled into the body:
52
59
 
53
- 1. **Append the raw words verbatim** to the `## Raw` tail as usual (a dated `<!-- appended YYYY-MM-DD HH:MM -->` code block).
60
+ 1. **Append the raw words verbatim** into the `## Raw` tail (`weave_note` action=append with `raw: true` the tool adds the dated code block
61
+ and creates the tail if missing).
54
62
  2. **Then immediately finalize** (`weave_note` action=finalize): rewrite the body *above* the `## Raw` tail — front-loaded summary,
55
63
  sections, decisions, questions, tasks, entities, links — so the compiled document reflects everything said so far.
56
64
  3. **Never rewrite or remove the `## Raw` tail.** It stays append-only and verbatim; only the body above it changes.
@@ -74,13 +82,14 @@ down". Never promote conversation into a note on your own initiative — capture
74
82
  1. **Search first** (`weave_note` action=search): if a note exists, `append` to it rather than creating a duplicate.
75
83
  2. Title: short noun phrase ("Auth boundary decision", not "Notes").
76
84
  3. **Scribble in, verbatim.** When the user is dictating, append their words to the note as rough, verbatim scribbles — no silent rewording.
77
- Keep them under the `## Raw` tail format at the end of the note.
85
+ Append with `raw: true` so they land under the `## Raw` tail at the end of the note (the tail is created automatically if missing).
78
86
  4. **Compile continuously during dictation.** After *every* interactive append in dictation mode, immediately finalize the body *above* the
79
87
  raw tail so the compiled doc stays current (see [Dictation mode](#dictation-mode-continuous-compile)). Outside dictation mode,
80
88
  compilation stays on request.
81
89
  5. **Finalize on request.** When the user says "finalize this" / "clean this up", restructure the body *above* the raw tail: front-loaded
82
90
  summary, sections, entities, links. Use `weave_note` action=finalize (or edit the file directly in other harnesses). Move nothing out of
83
- `## Raw` — it is append-only and never rewritten.
91
+ `## Raw` — it is append-only and never rewritten. A note with no `## Raw` tail yet gets its entire pre-finalize body preserved as a new
92
+ raw tail: finalization is editorial, never destructive.
84
93
  6. Tags: 1–4 lowercase tags; reuse existing tags when possible.
85
94
  7. Provenance: notes the user scribbled stay `source: human` (finalization is editorial, not authorship) — pass `source: "human"` to `add`
86
95
  for user-scribbled notes. Notes you draft from scratch are `source: agent` (the default). Never overwrite a `source: human` note's
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Bounded-concurrency task runner shared by the deep scan (summaries.ts) and
3
+ * the session scan (sessions.ts).
4
+ *
5
+ * Extracted from summaries.ts so the two scanners cannot drift: a second copy
6
+ * of the scheduler is a second place for an off-by-one or a lost
7
+ * cancellation check.
8
+ */
9
+
10
+ /**
11
+ * Map `items` through `fn` with at most `concurrency` tasks in flight.
12
+ *
13
+ * Results keep input order. `shouldStop` is checked before each item is
14
+ * taken; when it returns true, workers drain immediately and unprocessed
15
+ * entries keep their slot in the results array (untouched — callers that
16
+ * care about partial output must check `shouldStop` themselves, exactly as
17
+ * the abort-aware scanners do).
18
+ */
19
+ export async function mapWithConcurrency<T, R>(
20
+ items: readonly T[],
21
+ concurrency: number,
22
+ fn: (item: T, index: number) => Promise<R>,
23
+ shouldStop?: () => boolean,
24
+ ): Promise<R[]> {
25
+ const results: R[] = new Array(items.length);
26
+ let next = 0;
27
+ async function worker(): Promise<void> {
28
+ while (next < items.length) {
29
+ if (shouldStop?.()) return;
30
+ const i = next++;
31
+ results[i] = await fn(items[i] as T, i);
32
+ }
33
+ }
34
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
35
+ return results;
36
+ }
@@ -289,6 +289,59 @@ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
289
289
  return { fields, body, lines };
290
290
  }
291
291
 
292
+ /**
293
+ * Upsert owned scalar fields into a front-matter block, preserving order.
294
+ *
295
+ * For each wanted key: the **first** line declaring it is replaced with a
296
+ * fresh `key: value` line in place, and any later declarations are dropped —
297
+ * mirroring how duplicate managed keys collapse in `replayBlock` (the
298
+ * subset parser keeps the last occurrence, so collapsing to one line with
299
+ * the fresh value is the consistent outcome). A key introducing a block
300
+ * construct (`scalar: false`) is replaced too: the value this function writes
301
+ * is a scalar, and leaving the old block head in place would orphan its
302
+ * indented children under a duplicated key.
303
+ *
304
+ * Wanted keys the block never declared are appended at the end, in the order
305
+ * given. Everything else — unknown keys, blank lines, junk — is carried
306
+ * through byte-identically, the same round-trip contract `serializeNote`
307
+ * honors for the note engine's writes.
308
+ *
309
+ * Keys arrive from pi-weave's own generated-note writers (`session_id`,
310
+ * `session_hash`, …); there is no escaping for the *key* because a key is
311
+ * caller-controlled code, not user input — `quoteField` guards the value.
312
+ */
313
+ export function upsertFrontMatterFields(
314
+ lines: NoteFrontMatter,
315
+ fields: Record<string, string>,
316
+ ): NoteFrontMatter {
317
+ const wanted = new Set(Object.keys(fields));
318
+ const out: string[] = [];
319
+ const written = new Set<string>();
320
+ let inDroppedBlock = false;
321
+ for (const line of scanFrontMatter(lines)) {
322
+ // Continuation lines of a block construct whose head we replaced: their
323
+ // parent key is gone, so carrying them would leave orphaned YAML children
324
+ // under a scalar. The block ends at the first non-indented line.
325
+ if (inDroppedBlock) {
326
+ if (/^\s/.test(line.text)) continue;
327
+ inDroppedBlock = false;
328
+ }
329
+ if (line.key !== null && wanted.has(line.key)) {
330
+ if (!written.has(line.key)) {
331
+ out.push(`${line.key}: ${quoteField(fields[line.key] ?? "")}`);
332
+ written.add(line.key);
333
+ if (!line.scalar) inDroppedBlock = true; // swallow the block body too
334
+ }
335
+ continue; // later duplicates collapse into the first occurrence
336
+ }
337
+ out.push(line.text);
338
+ }
339
+ for (const [key, value] of Object.entries(fields)) {
340
+ if (!written.has(key)) out.push(`${key}: ${quoteField(value)}`);
341
+ }
342
+ return out;
343
+ }
344
+
292
345
  /**
293
346
  * Parse a note file. Throws on missing/invalid front matter so callers can
294
347
  * treat the file as malformed rather than guessing.
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
12
+ import { createHash } from "node:crypto";
12
13
  import type { SummaryRecord } from "../summaries";
13
14
  import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
14
15
  import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
@@ -30,6 +31,30 @@ export interface BuildGraphInput {
30
31
  const SHORT_SHA_LEN = 7;
31
32
  const PREVIEW_LEN = 240;
32
33
 
34
+ /** Hex length of {@link noteBodyDigest} and of the model's `contentDigest`. */
35
+ const DIGEST_HEX_LEN = 32;
36
+
37
+ /**
38
+ * Content fingerprint of one note body. Pure, deterministic, truncated to
39
+ * 128 bits — a change-detection key, not a security boundary.
40
+ */
41
+ export function noteBodyDigest(body: string): string {
42
+ return createHash("sha256").update(body).digest("hex").slice(0, DIGEST_HEX_LEN);
43
+ }
44
+
45
+ /**
46
+ * The model's `contentDigest`: one hash over every note's slug and body
47
+ * digest, slug-sorted so it does not depend on note order. Empty when there
48
+ * are no notes — which is still a distinct value from any non-empty vault.
49
+ */
50
+ export function noteContentDigest(notes: readonly Note[]): string {
51
+ const hash = createHash("sha256");
52
+ for (const note of [...notes].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0))) {
53
+ hash.update(`${note.slug}\u0000${noteBodyDigest(note.body)}\n`);
54
+ }
55
+ return hash.digest("hex").slice(0, DIGEST_HEX_LEN);
56
+ }
57
+
33
58
  function moduleDetail(
34
59
  path: string,
35
60
  fileCount: number,
@@ -107,6 +132,37 @@ function buildVaultSide(
107
132
  nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
108
133
 
109
134
  const keptSlugs = new Set(kept.map((n) => n.slug));
135
+
136
+ // Nested notes (`sessions/foo` — session memory, docs/session-scan.md) nest
137
+ // under synthesized folder nodes so the vault tree groups them the way the
138
+ // repository tree groups directories. Ids are prefixed `vfolder:` because a
139
+ // repository module could legitimately share the path (`module:sessions`);
140
+ // the tree renders any `contains` chain, so the kind reuse needs no client
141
+ // change. Deterministic: dirs sorted, parents before children.
142
+ const folderIds = new Map<string, string>();
143
+ const noteDirs = [...new Set(kept.map((n) => n.slug.split("/").slice(0, -1).join("/")))]
144
+ .filter((d) => d.length > 0)
145
+ .sort();
146
+ const notesIn = (dir: string): number => kept.filter((n) => n.slug.startsWith(`${dir}/`)).length;
147
+ for (const dir of noteDirs) {
148
+ const id = `vfolder:${dir}`;
149
+ folderIds.set(dir, id);
150
+ nodes.push({
151
+ id,
152
+ kind: "module",
153
+ label: dir.split("/").pop() ?? dir,
154
+ provenance: null,
155
+ detail: { path: dir, notes: String(notesIn(dir)) },
156
+ });
157
+ const parentDir = dir.split("/").slice(0, -1).join("/");
158
+ const parent = folderIds.get(parentDir) ?? "vault";
159
+ edges.push({ source: parent, target: id, kind: "contains" });
160
+ }
161
+ const parentOf = (slug: string): string => {
162
+ const dir = slug.split("/").slice(0, -1).join("/");
163
+ return (dir.length > 0 && folderIds.get(dir)) || "vault";
164
+ };
165
+
110
166
  for (const note of kept) {
111
167
  const links = extractWikilinks(note.body);
112
168
  const resolved = links.filter((slug) => keptSlugs.has(slug));
@@ -127,7 +183,7 @@ function buildVaultSide(
127
183
  detail.preview = preview(note.body);
128
184
 
129
185
  nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
130
- edges.push({ source: "vault", target: `note:${note.slug}`, kind: "contains" });
186
+ edges.push({ source: parentOf(note.slug), target: `note:${note.slug}`, kind: "contains" });
131
187
  for (const target of resolved) {
132
188
  edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
133
189
  }
@@ -284,6 +340,9 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
284
340
  nodes,
285
341
  edges,
286
342
  danglingLinks,
343
+ // The same slice the vault side kept, so the digest describes exactly
344
+ // the notes that have nodes. Slug-ordered inside, hence order-stable.
345
+ contentDigest: noteContentDigest(input.notes.slice(0, maxNotes)),
287
346
  };
288
347
  }
289
348
 
@@ -84,4 +84,19 @@ export interface GraphModel {
84
84
  * unchanged inputs.
85
85
  */
86
86
  danglingLinks: Record<string, string[]>;
87
+ /**
88
+ * Content fingerprint of the note bodies the graph was built from
89
+ * (SHA-256 over `slug\0body-digest` pairs in slug order, truncated to
90
+ * 128 bits of hex).
91
+ *
92
+ * Exists so the wire payload's stamp — the digest the ETag and the SSE
93
+ * dedupe both key on — is sensitive to a **body-only** edit. The payload
94
+ * itself carries only display excerpts of a note (`detail.preview`, the
95
+ * first 240 flattened characters), so an edit below the fold with
96
+ * unchanged front matter used to leave the payload byte-identical: the
97
+ * frame was deduped away and the open note stayed stale until a manual
98
+ * reload. The digest is a function of content only, so it keeps the
99
+ * byte-determinism contract.
100
+ */
101
+ contentDigest: string;
87
102
  }
@@ -12,6 +12,10 @@ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|[^\]]*)?\]\]/g;
12
12
  * `[[some-note]]` and aliased `[[Some Note|alias]]` (alias ignored for
13
13
  * linking). Targets are slugified so `[[Release Plan]]` matches the note
14
14
  * `release-plan`. Duplicates are removed, order of first appearance kept.
15
+ *
16
+ * Path separators survive: a nested note's slug is its path relative to
17
+ * `notes/` (`sessions/foo`), so `[[sessions/foo]]` targets the session note,
18
+ * not a flattened name. Each path segment is slugified independently.
15
19
  */
16
20
  export function extractWikilinks(body: string): string[] {
17
21
  const out: string[] = [];
@@ -19,7 +23,7 @@ export function extractWikilinks(body: string): string[] {
19
23
  for (const match of body.matchAll(WIKILINK_RE)) {
20
24
  const raw = (match[1] ?? "").trim();
21
25
  if (raw.length === 0) continue;
22
- const slug = slugify(raw);
26
+ const slug = raw.split("/").map((part) => slugify(part)).join("/");
23
27
  if (seen.has(slug)) continue;
24
28
  seen.add(slug);
25
29
  out.push(slug);
package/src/core/index.ts CHANGED
@@ -13,6 +13,8 @@ export * from "./git";
13
13
  export * from "./vault";
14
14
  export * from "./repoIndex";
15
15
  export * from "./summaries";
16
+ export * from "./sessions";
17
+ export * from "./concurrency";
16
18
  export * from "./workspace";
17
19
  export * from "./openInEditor";
18
20
  export * from "./graph/model";
package/src/core/paths.ts CHANGED
@@ -14,6 +14,13 @@ import { join } from "node:path";
14
14
  export const OKF_DIR = ".okf";
15
15
  export const OKF_MANIFEST = "okf.json";
16
16
  export const NOTES_DIR = "notes";
17
+ /**
18
+ * Vault collection for generated session memory (docs/session-scan.md).
19
+ * A sibling of `notes/` rather than a subdirectory of it: session notes are
20
+ * machine-derived memory, not hand-curated knowledge, so they stay out of
21
+ * the note graph's flat listing — and out of its slug namespace.
22
+ */
23
+ export const SESSIONS_DIR = "sessions";
17
24
  export const REPOSITORY_DIR = "repository";
18
25
  export const VAULT_ENV_VAR = "PI_WEAVE_VAULT";
19
26