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/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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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**
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/frontmatter.ts
CHANGED
|
@@ -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.
|
package/src/core/graph/build.ts
CHANGED
|
@@ -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:
|
|
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
|
|
package/src/core/graph/model.ts
CHANGED
|
@@ -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(
|
|
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
|
|