pi-weave 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -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/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- 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 +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
package/src/core/frontmatter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NoteMeta, NoteSource } from "./types";
|
|
1
|
+
import type { NoteFrontMatter, NoteMeta, NoteSource } from "./types";
|
|
2
2
|
import { NOTE_SOURCES } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,21 +7,94 @@ import { NOTE_SOURCES } from "./types";
|
|
|
7
7
|
* Deliberately a *subset*: notes are meant to be human-editable plain text,
|
|
8
8
|
* so we write only `key: value` scalars and `[a, b]` inline arrays, and we
|
|
9
9
|
* parse exactly that. Anything richer belongs in the Markdown body.
|
|
10
|
+
*
|
|
11
|
+
* ## Parsing a subset without writing a subset
|
|
12
|
+
*
|
|
13
|
+
* "We parse a subset" used to also mean "we write a subset", and those are
|
|
14
|
+
* not the same promise. `parseNoteFile` read five keys and `serializeNote`
|
|
15
|
+
* wrote five keys, so every write through core silently deleted whatever
|
|
16
|
+
* else the file carried — `aliases`, `cssclass`, `publish`, any property an
|
|
17
|
+
* Obsidian user or another tool had added. Reading a note and appending one
|
|
18
|
+
* line destroyed the rest of its metadata (weave-workspace §11 P5).
|
|
19
|
+
*
|
|
20
|
+
* The fix is *not* a fuller YAML parser. A vault is plain text a human edits,
|
|
21
|
+
* and a parser that understands more is a parser that **rewrites** more —
|
|
22
|
+
* every construct it learns to read is a construct it will re-emit in its own
|
|
23
|
+
* preferred spelling. The fix is to stop conflating "parsed" with
|
|
24
|
+
* "preserved":
|
|
25
|
+
*
|
|
26
|
+
* - The five keys in {@link MANAGED_FRONT_MATTER_KEYS} are **owned**: parsed
|
|
27
|
+
* into {@link NoteMeta}, and re-rendered from those values on write.
|
|
28
|
+
* - Every other line is **carried**: emitted as the exact bytes it was read
|
|
29
|
+
* as, in its original position. Unknown keys, blank lines, junk without a
|
|
30
|
+
* colon, and syntax this subset cannot represent at all — block lists,
|
|
31
|
+
* folded scalars, nested maps — all survive, because none of them is ever
|
|
32
|
+
* interpreted.
|
|
33
|
+
*
|
|
34
|
+
* The round-trip property is therefore: **owned fields round-trip by value,
|
|
35
|
+
* every other byte round-trips by byte, and key order is preserved.**
|
|
36
|
+
*
|
|
37
|
+
* ## Two rules that keep writes from inventing content
|
|
38
|
+
*
|
|
39
|
+
* **An owned key is never added to a file that did not have it, unless it
|
|
40
|
+
* carries a value the parser would not have defaulted to.** Real vaults are
|
|
41
|
+
* full of hand-written notes with no `created`/`updated`; `parseNoteFile`
|
|
42
|
+
* defaults those to `""`, and writing `created: ` back would be the engine
|
|
43
|
+
* fabricating metadata during an unrelated edit. Since no mutation path ever
|
|
44
|
+
* *sets* `created`, it stays `""` and stays absent; `updated` is appended
|
|
45
|
+
* only because a caller explicitly asked for a bump, which is a requested
|
|
46
|
+
* change rather than a silent one. See {@link isDefaulted}.
|
|
47
|
+
*
|
|
48
|
+
* **An owned key whose on-disk syntax this subset cannot represent is
|
|
49
|
+
* frozen**: carried verbatim, and not re-rendered or duplicated. Obsidian's
|
|
50
|
+
* property editor writes tags as a YAML block list —
|
|
51
|
+
*
|
|
52
|
+
* ```yaml
|
|
53
|
+
* tags:
|
|
54
|
+
* - reading
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* — which is exactly the shape a line-oriented serializer would destroy, by
|
|
58
|
+
* rewriting the parent as `tags: []` and orphaning the two children under it.
|
|
59
|
+
* So such a construct is left alone entirely and the append pass skips the
|
|
60
|
+
* key, because emitting a second `tags:` further down would leave the user
|
|
61
|
+
* with a duplicated property. The cost is that the engine neither reads nor
|
|
62
|
+
* updates those tags — the same blindness it has today, minus the data loss.
|
|
63
|
+
* It is observable rather than silent: every mutation API returns the note as
|
|
64
|
+
* re-parsed from what was actually written, so a caller that sets tags on
|
|
65
|
+
* such a note gets back a note whose tags did not change.
|
|
10
66
|
*/
|
|
11
67
|
|
|
12
68
|
export interface ParsedNoteFile {
|
|
13
69
|
meta: NoteMeta;
|
|
14
70
|
body: string;
|
|
71
|
+
/** The front-matter block verbatim, for lossless re-serialization. */
|
|
72
|
+
frontMatter: NoteFrontMatter;
|
|
15
73
|
}
|
|
16
74
|
|
|
17
75
|
/** Generic, tolerant front-matter parse (any fields) — for non-note OKF files. */
|
|
18
76
|
export interface ParsedFrontMatter {
|
|
19
77
|
fields: Map<string, string>;
|
|
20
78
|
body: string;
|
|
79
|
+
/** The raw block lines in file order, `---` fences excluded. */
|
|
80
|
+
lines: readonly string[];
|
|
21
81
|
}
|
|
22
82
|
|
|
23
83
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
24
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The front-matter keys this module owns — parsed into {@link NoteMeta} and
|
|
87
|
+
* re-rendered on write. Everything else is carried verbatim.
|
|
88
|
+
*
|
|
89
|
+
* The array order is also the order in which a missing owned key is appended,
|
|
90
|
+
* so a note that gains `updated` gains it in a predictable place.
|
|
91
|
+
*/
|
|
92
|
+
export const MANAGED_FRONT_MATTER_KEYS = ["title", "created", "updated", "tags", "source"] as const;
|
|
93
|
+
|
|
94
|
+
export type ManagedFrontMatterKey = (typeof MANAGED_FRONT_MATTER_KEYS)[number];
|
|
95
|
+
|
|
96
|
+
const MANAGED: ReadonlySet<string> = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
|
|
97
|
+
|
|
25
98
|
export function quoteField(value: string): string {
|
|
26
99
|
// Only quote when the value could confuse our subset parser.
|
|
27
100
|
if (/[:#[\]]|^\s|\s$|^$/.test(value)) {
|
|
@@ -38,6 +111,48 @@ export function unquoteField(value: string): string {
|
|
|
38
111
|
return trimmed;
|
|
39
112
|
}
|
|
40
113
|
|
|
114
|
+
/** One classified line of a front-matter block. */
|
|
115
|
+
export interface FrontMatterLine {
|
|
116
|
+
/** The line exactly as it appears in the file. */
|
|
117
|
+
text: string;
|
|
118
|
+
/** The top-level key it declares, or null for blank/junk/continuation lines. */
|
|
119
|
+
key: string | null;
|
|
120
|
+
/**
|
|
121
|
+
* True when the value is a scalar or inline array this subset can both read
|
|
122
|
+
* and write. False for a key introducing a block construct — its value
|
|
123
|
+
* lives in the indented lines below it, which no line-oriented rewrite can
|
|
124
|
+
* safely touch.
|
|
125
|
+
*/
|
|
126
|
+
scalar: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Classify a front-matter block line by line.
|
|
131
|
+
*
|
|
132
|
+
* The **single** rule for "which line is which key", shared by the parser and
|
|
133
|
+
* the serializer. A second copy of this rule is a second way for a write to
|
|
134
|
+
* disagree with a read about whether a line is owned — and disagreeing in
|
|
135
|
+
* that direction means either dropping a property or duplicating it.
|
|
136
|
+
*
|
|
137
|
+
* Block detection needs one line of lookahead, which is why this classifies
|
|
138
|
+
* the whole block rather than exposing a per-line predicate: `tags:` is a
|
|
139
|
+
* scalar with an empty value on its own, and the head of a list when the next
|
|
140
|
+
* line is indented.
|
|
141
|
+
*/
|
|
142
|
+
export function scanFrontMatter(lines: readonly string[]): FrontMatterLine[] {
|
|
143
|
+
return lines.map((text, i): FrontMatterLine => {
|
|
144
|
+
const idx = text.indexOf(":");
|
|
145
|
+
// Indented lines belong to whatever is above them: treating one as a
|
|
146
|
+
// top-level key would let the serializer hoist it out of its parent.
|
|
147
|
+
if (idx <= 0 || /^\s/.test(text)) return { text, key: null, scalar: false };
|
|
148
|
+
const key = text.slice(0, idx).trim();
|
|
149
|
+
if (key.length === 0) return { text, key: null, scalar: false };
|
|
150
|
+
const value = text.slice(idx + 1).trim();
|
|
151
|
+
const blockHead = value.length === 0 && /^\s+\S/.test(lines[i + 1] ?? "");
|
|
152
|
+
return { text, key, scalar: !blockHead };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
41
156
|
function parseTags(value: string): string[] {
|
|
42
157
|
const trimmed = value.trim();
|
|
43
158
|
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
|
|
@@ -58,21 +173,98 @@ function parseSource(value: string): NoteSource {
|
|
|
58
173
|
: "human";
|
|
59
174
|
}
|
|
60
175
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
176
|
+
/** Render one owned key from its parsed value. */
|
|
177
|
+
function renderManaged(key: ManagedFrontMatterKey, meta: NoteMeta): string {
|
|
178
|
+
switch (key) {
|
|
179
|
+
case "title":
|
|
180
|
+
return `title: ${quoteField(meta.title)}`;
|
|
181
|
+
case "created":
|
|
182
|
+
return `created: ${meta.created}`;
|
|
183
|
+
case "updated":
|
|
184
|
+
return `updated: ${meta.updated}`;
|
|
185
|
+
case "tags":
|
|
186
|
+
return `tags: [${meta.tags.map(quoteField).join(", ")}]`;
|
|
187
|
+
default:
|
|
188
|
+
return `source: ${meta.source}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* True when omitting `key` would parse back to the same value — the value is
|
|
194
|
+
* indistinguishable from the parser's default for an absent key.
|
|
195
|
+
*
|
|
196
|
+
* Gates **appending** an owned key that the file did not already declare, so
|
|
197
|
+
* an edit to a note's body cannot grow its front matter with fields the user
|
|
198
|
+
* never wrote. A key the file *does* declare is always re-rendered, never
|
|
199
|
+
* dropped: `tags: []` and `source: human` written by hand are the user's
|
|
200
|
+
* lines, and deleting them would be the mirror-image mutation.
|
|
201
|
+
*
|
|
202
|
+
* `title` is never defaulted: it is the one required field, and a note
|
|
203
|
+
* without it is malformed rather than empty.
|
|
204
|
+
*/
|
|
205
|
+
function isDefaulted(key: ManagedFrontMatterKey, meta: NoteMeta): boolean {
|
|
206
|
+
switch (key) {
|
|
207
|
+
case "title":
|
|
208
|
+
return false;
|
|
209
|
+
case "created":
|
|
210
|
+
return meta.created.length === 0;
|
|
211
|
+
case "updated":
|
|
212
|
+
return meta.updated.length === 0;
|
|
213
|
+
case "tags":
|
|
214
|
+
return meta.tags.length === 0;
|
|
215
|
+
default:
|
|
216
|
+
return meta.source === "human";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Serialize note metadata + body to the on-disk Markdown form.
|
|
222
|
+
*
|
|
223
|
+
* With `frontMatter` supplied — as `parseNoteFile` returns and every vault
|
|
224
|
+
* write path threads through — the original block is replayed line for line:
|
|
225
|
+
* owned scalar keys are re-rendered in place from `meta`, everything else is
|
|
226
|
+
* emitted as the exact bytes it was read as. Owned keys the original did not
|
|
227
|
+
* declare are appended at the end of the block, and only when they carry a
|
|
228
|
+
* non-default value.
|
|
229
|
+
*
|
|
230
|
+
* Without it, the canonical five-line block is written — the shape `addNote`
|
|
231
|
+
* creates for a brand-new note, where there is no prior layout to respect.
|
|
232
|
+
*/
|
|
233
|
+
export function serializeNote(meta: NoteMeta, body: string, frontMatter?: NoteFrontMatter): string {
|
|
234
|
+
const block = frontMatter === undefined ? canonicalBlock(meta) : replayBlock(meta, frontMatter);
|
|
235
|
+
return ["---", ...block, "---", "", body.replace(/\s+$/, ""), ""].join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function canonicalBlock(meta: NoteMeta): string[] {
|
|
239
|
+
return MANAGED_FRONT_MATTER_KEYS.map((key) => renderManaged(key, meta));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function replayBlock(meta: NoteMeta, frontMatter: NoteFrontMatter): string[] {
|
|
243
|
+
/** Top-level keys the file declares, in any syntax — including frozen ones. */
|
|
244
|
+
const declared = new Set<string>();
|
|
245
|
+
/** Owned keys already re-rendered, so a duplicate key collapses to one line. */
|
|
246
|
+
const rendered = new Set<string>();
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
|
|
249
|
+
for (const line of scanFrontMatter(frontMatter)) {
|
|
250
|
+
if (line.key !== null) declared.add(line.key);
|
|
251
|
+
if (line.key === null || !line.scalar || !MANAGED.has(line.key)) {
|
|
252
|
+
out.push(line.text); // carried verbatim — never interpreted, never reformatted
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// A repeated owned key is a typo, and `parseFrontMatter` keeps the last
|
|
256
|
+
// occurrence. Re-rendering the parsed value at the *first* position and
|
|
257
|
+
// dropping the rest preserves that value while collapsing the duplicate.
|
|
258
|
+
if (rendered.has(line.key)) continue;
|
|
259
|
+
rendered.add(line.key);
|
|
260
|
+
out.push(renderManaged(line.key as ManagedFrontMatterKey, meta));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const key of MANAGED_FRONT_MATTER_KEYS) {
|
|
264
|
+
if (declared.has(key) || isDefaulted(key, meta)) continue;
|
|
265
|
+
out.push(renderManaged(key, meta));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
76
268
|
}
|
|
77
269
|
|
|
78
270
|
/**
|
|
@@ -85,14 +277,69 @@ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
|
|
|
85
277
|
const match = FRONTMATTER_RE.exec(text);
|
|
86
278
|
if (!match || match[1] === undefined) return null;
|
|
87
279
|
const body = text.slice(match[0].length).replace(/^\n/, "").replace(/\s+$/, "");
|
|
280
|
+
const lines = match[1].split("\n");
|
|
88
281
|
const fields = new Map<string, string>();
|
|
89
|
-
for (const line of
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
282
|
+
for (const line of scanFrontMatter(lines)) {
|
|
283
|
+
// Blank/junk lines and block constructs are skipped: front matter is
|
|
284
|
+
// best-effort, and a value this subset cannot read is one it must not
|
|
285
|
+
// pretend to have read as `""`.
|
|
286
|
+
if (line.key === null || !line.scalar) continue;
|
|
287
|
+
fields.set(line.key, line.text.slice(line.text.indexOf(":") + 1).trim());
|
|
288
|
+
}
|
|
289
|
+
return { fields, body, lines };
|
|
290
|
+
}
|
|
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)}`);
|
|
94
341
|
}
|
|
95
|
-
return
|
|
342
|
+
return out;
|
|
96
343
|
}
|
|
97
344
|
|
|
98
345
|
/**
|
|
@@ -115,5 +362,5 @@ export function parseNoteFile(text: string): ParsedNoteFile {
|
|
|
115
362
|
tags: parseTags(parsed.fields.get("tags") ?? "[]"),
|
|
116
363
|
source: parseSource(parsed.fields.get("source") ?? "human"),
|
|
117
364
|
};
|
|
118
|
-
return { meta, body: parsed.body };
|
|
365
|
+
return { meta, body: parsed.body, frontMatter: parsed.lines };
|
|
119
366
|
}
|
package/src/core/git.ts
CHANGED
|
@@ -18,7 +18,26 @@ export interface GitExecOptions {
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Process-wide count of `git` subprocesses spawned by this module.
|
|
23
|
+
*
|
|
24
|
+
* Every git call in core funnels through the `git()` helper below, so this
|
|
25
|
+
* is an exact spawn count rather than a model of one. `src/core/cache/
|
|
26
|
+
* workspace` samples it around its own work to report `CacheStats.gitCalls`,
|
|
27
|
+
* which is what makes "a no-change rebuild spawns zero git processes"
|
|
28
|
+
* (weave-workspace §4.1) an assertion about observed behaviour.
|
|
29
|
+
*
|
|
30
|
+
* Monotonic and never reset: callers take deltas.
|
|
31
|
+
*/
|
|
32
|
+
let spawnCount = 0;
|
|
33
|
+
|
|
34
|
+
/** Number of git subprocesses spawned so far in this process. Monotonic. */
|
|
35
|
+
export function gitSpawnCount(): number {
|
|
36
|
+
return spawnCount;
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
async function git(args: string[], cwd: string, timeoutMs: number): Promise<string | null> {
|
|
40
|
+
spawnCount += 1;
|
|
22
41
|
return new Promise((resolve) => {
|
|
23
42
|
execFile("git", args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
|
|
24
43
|
if (err) resolve(null);
|
package/src/core/graph/build.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
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";
|
|
15
|
+
import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
|
|
14
16
|
import { extractWikilinks } from "./wikilinks";
|
|
15
17
|
|
|
16
18
|
/** Hard cap on note nodes (docs/weave-view.md M3 guard). */
|
|
@@ -29,6 +31,30 @@ export interface BuildGraphInput {
|
|
|
29
31
|
const SHORT_SHA_LEN = 7;
|
|
30
32
|
const PREVIEW_LEN = 240;
|
|
31
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
|
+
|
|
32
58
|
function moduleDetail(
|
|
33
59
|
path: string,
|
|
34
60
|
fileCount: number,
|
|
@@ -85,7 +111,14 @@ function parseRemote(raw: string): { label: string; url: string } {
|
|
|
85
111
|
return { label: tail.replace(/\.git$/, ""), url: s };
|
|
86
112
|
}
|
|
87
113
|
|
|
88
|
-
function buildVaultSide(
|
|
114
|
+
function buildVaultSide(
|
|
115
|
+
input: BuildGraphInput,
|
|
116
|
+
maxNotes: number,
|
|
117
|
+
nodes: GraphNode[],
|
|
118
|
+
edges: GraphEdge[],
|
|
119
|
+
danglingLinks: Record<string, string[]>,
|
|
120
|
+
paths: PathIndex,
|
|
121
|
+
): string[] {
|
|
89
122
|
const truncated = input.notes.length > maxNotes;
|
|
90
123
|
const kept = input.notes.slice(0, maxNotes);
|
|
91
124
|
|
|
@@ -99,6 +132,37 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
|
|
|
99
132
|
nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
|
|
100
133
|
|
|
101
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
|
+
|
|
102
166
|
for (const note of kept) {
|
|
103
167
|
const links = extractWikilinks(note.body);
|
|
104
168
|
const resolved = links.filter((slug) => keptSlugs.has(slug));
|
|
@@ -108,15 +172,29 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
|
|
|
108
172
|
updated: note.updated,
|
|
109
173
|
};
|
|
110
174
|
if (note.tags.length > 0) detail.tags = note.tags.join(", ");
|
|
111
|
-
|
|
112
|
-
|
|
175
|
+
// The names, not just the count (§4.2). `detail` keeps carrying the count
|
|
176
|
+
// because it is what the TUI's side panel prints; the structured targets
|
|
177
|
+
// go on the model, where a UI can turn them into ghost nodes.
|
|
178
|
+
const dangling = links.filter((slug) => !keptSlugs.has(slug));
|
|
179
|
+
if (dangling.length > 0) {
|
|
180
|
+
detail["dangling links"] = String(dangling.length);
|
|
181
|
+
danglingLinks[note.slug] = dangling;
|
|
182
|
+
}
|
|
113
183
|
detail.preview = preview(note.body);
|
|
114
184
|
|
|
115
185
|
nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
|
|
116
|
-
edges.push({ source:
|
|
186
|
+
edges.push({ source: parentOf(note.slug), target: `note:${note.slug}`, kind: "contains" });
|
|
117
187
|
for (const target of resolved) {
|
|
118
188
|
edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
|
|
119
189
|
}
|
|
190
|
+
// A note body naming a repo path → `mentions` (§4.4). Emitted after the
|
|
191
|
+
// wiki-links so a note's edges read vault-ward first, then code-ward, and
|
|
192
|
+
// only for paths that are already nodes — `paths` is built from the repo
|
|
193
|
+
// index, so a mention of an unindexed file resolves to its enclosing
|
|
194
|
+
// module or to nothing at all. Never to a phantom node.
|
|
195
|
+
for (const target of resolveMentions(note.body, paths)) {
|
|
196
|
+
edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
|
|
197
|
+
}
|
|
120
198
|
}
|
|
121
199
|
return [...keptSlugs];
|
|
122
200
|
}
|
|
@@ -243,7 +321,16 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
243
321
|
const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
|
|
244
322
|
const nodes: GraphNode[] = [];
|
|
245
323
|
const edges: GraphEdge[] = [];
|
|
246
|
-
|
|
324
|
+
const danglingLinks: Record<string, string[]> = {};
|
|
325
|
+
// Built before the vault side, because that is where `mentions` edges are
|
|
326
|
+
// emitted and they need to know which repo paths are real nodes. Derived
|
|
327
|
+
// from the same `structure` arrays `buildRepositorySide` walks, so the two
|
|
328
|
+
// cannot disagree about which ids exist. Empty when there is no repository:
|
|
329
|
+
// a vault-only graph has nothing to mention.
|
|
330
|
+
const paths = input.repository === null
|
|
331
|
+
? (new Map<string, string>() as PathIndex)
|
|
332
|
+
: buildPathIndex(input.repository.index.structure);
|
|
333
|
+
buildVaultSide(input, maxNotes, nodes, edges, danglingLinks, paths);
|
|
247
334
|
if (input.repository !== null) {
|
|
248
335
|
buildRepositorySide(input.repository, input.summaries, nodes, edges);
|
|
249
336
|
}
|
|
@@ -252,7 +339,12 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
252
339
|
staleness: input.repository?.staleness ?? null,
|
|
253
340
|
nodes,
|
|
254
341
|
edges,
|
|
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)),
|
|
255
346
|
};
|
|
256
347
|
}
|
|
257
348
|
|
|
258
349
|
export type { EdgeKind, GraphEdge, GraphModel, GraphNode, NodeKind } from "./model";
|
|
350
|
+
export { buildPathIndex, extractPathMentions, resolveMentions, type PathIndex } from "./mentions";
|
|
@@ -11,22 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
import { readFile } from "node:fs/promises";
|
|
13
13
|
import { join, resolve, sep } from "node:path";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
resolveVaultRoot,
|
|
26
|
-
type BuildGraphInput,
|
|
27
|
-
type GraphModel,
|
|
28
|
-
type Note,
|
|
29
|
-
} from "../index";
|
|
14
|
+
// Imported from the individual modules rather than the `../index` barrel:
|
|
15
|
+
// `src/core/cache/workspace` imports this file, and the barrel re-exports
|
|
16
|
+
// that cache, so going through the barrel would close an import cycle.
|
|
17
|
+
import { findGitRoot } from "../git";
|
|
18
|
+
import { resolveVaultRoot } from "../paths";
|
|
19
|
+
import { assessStaleness, readRepoIndex } from "../repoIndex";
|
|
20
|
+
import { readSummaryMap } from "../summaries";
|
|
21
|
+
import type { Note } from "../types";
|
|
22
|
+
import { getNote, readVault, resolveNotePath } from "../vault";
|
|
23
|
+
import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "./build";
|
|
24
|
+
import type { GraphModel } from "./model";
|
|
30
25
|
|
|
31
26
|
/** A note read for the viewers (read-only; never cached). Mirrors the vault `Note` shape. */
|
|
32
27
|
export interface ViewNote {
|
|
@@ -76,31 +71,49 @@ export async function readOkfFileForView(cwd: string, rel: string): Promise<{ pa
|
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Assemble the repository half of the graph input for `cwd`, or null when
|
|
76
|
+
* cwd is not inside a git repository with a readable `.okf` index. Shared
|
|
77
|
+
* with `src/core/cache/workspace` so the cached and uncached paths cannot
|
|
78
|
+
* drift in what they read.
|
|
79
|
+
*/
|
|
80
|
+
export async function readRepositorySide(
|
|
81
|
+
cwd: string,
|
|
82
|
+
): Promise<Pick<BuildGraphInput, "repository" | "summaries"> | null> {
|
|
83
|
+
const repoRoot = await findGitRoot(cwd);
|
|
84
|
+
if (repoRoot === null) return null;
|
|
85
|
+
const index = await readRepoIndex(repoRoot);
|
|
86
|
+
if (index === null) return null;
|
|
87
|
+
return {
|
|
88
|
+
repository: { index, staleness: await assessStaleness(repoRoot) },
|
|
89
|
+
summaries: await readSummaryMap(repoRoot), // deep-scan sidecars, read live
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
/**
|
|
80
94
|
* Assemble the fresh graph from disk. Called on every viewer fetch
|
|
81
95
|
* (no caching — docs/weave-view.md §2). Reads the vault (capped at
|
|
82
96
|
* DEFAULT_MAX_NOTES) and, when cwd is an indexed git repository, the repo
|
|
83
97
|
* index + deep-scan summary sidecars. Degrades to a vault-only graph when
|
|
84
98
|
* the repo has no index or the index is corrupt.
|
|
99
|
+
*
|
|
100
|
+
* One read per note: `readVault` returns bodies *and* the file count, so the
|
|
101
|
+
* old `listNotes` → `getNote`-per-slug → `noteCount` sequence (2N reads plus
|
|
102
|
+
* a third readdir) is now N reads and one readdir (weave-workspace §4.1).
|
|
85
103
|
*/
|
|
86
104
|
export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
|
|
87
|
-
const
|
|
88
|
-
const loaded = await Promise.all(noteSummaries.map((s) => getNote(vaultRoot, s.slug)));
|
|
89
|
-
const notes = loaded.filter((n): n is Note => n !== null);
|
|
105
|
+
const { notes, fileCount } = await readVault(vaultRoot);
|
|
90
106
|
|
|
91
107
|
const input: BuildGraphInput = {
|
|
92
|
-
vault: { root: vaultRoot, exists: true, noteCount:
|
|
93
|
-
notes,
|
|
108
|
+
vault: { root: vaultRoot, exists: true, noteCount: fileCount },
|
|
109
|
+
notes: notes.slice(0, DEFAULT_MAX_NOTES),
|
|
94
110
|
repository: null,
|
|
95
111
|
};
|
|
96
112
|
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
input.repository = { index, staleness: await assessStaleness(repoRoot) };
|
|
102
|
-
input.summaries = await readSummaryMap(repoRoot); // deep-scan sidecars, read live
|
|
103
|
-
}
|
|
113
|
+
const repo = await readRepositorySide(cwd);
|
|
114
|
+
if (repo !== null) {
|
|
115
|
+
input.repository = repo.repository;
|
|
116
|
+
if (repo.summaries !== undefined) input.summaries = repo.summaries;
|
|
104
117
|
}
|
|
105
118
|
return buildGraph(input);
|
|
106
119
|
}
|