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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
package/src/core/vault.ts CHANGED
@@ -1,10 +1,25 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { promises as fs } from "node:fs";
3
- import { isAbsolute, join, relative, sep } from "node:path";
4
- import { parseNoteFile, serializeNote } from "./frontmatter";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import {
5
+ MANAGED_FRONT_MATTER_KEYS,
6
+ parseFrontMatter,
7
+ parseNoteFile,
8
+ quoteField,
9
+ serializeNote,
10
+ upsertFrontMatterFields,
11
+ } from "./frontmatter";
12
+ import { withMutationQueue } from "./mutex";
5
13
  import { NOTES_DIR, OKF_MANIFEST } from "./paths";
6
14
  import { slugify, uniqueSlug } from "./slug";
7
- import type { Note, NoteMeta, NoteSearchHit, NoteSource, NoteSummary } from "./types";
15
+ import type {
16
+ Note,
17
+ NoteFrontMatter,
18
+ NoteMeta,
19
+ NoteSearchHit,
20
+ NoteSource,
21
+ NoteSummary,
22
+ } from "./types";
8
23
 
9
24
  /**
10
25
  * The vault: the "smart notepad" half of pi-weave.
@@ -57,36 +72,52 @@ function notePath(root: string, slug: string): string {
57
72
 
58
73
  /**
59
74
  * Resolve a note slug to its on-disk path, or null when the slug is unsafe.
60
- * Slugs arrive from tool parameters, so they are untrusted: `../x`, nested
61
- * paths, and absolute escapes must never read or write outside the flat
62
- * <vault>/notes/ directory.
75
+ * Slugs arrive from tool parameters, so they are untrusted: `../x` and
76
+ * absolute escapes must never read or write outside `<vault>/notes/`
77
+ * (subdirectories *within* it are legitimate — the sessions collection).
63
78
  */
64
79
  export function resolveNotePath(root: string, slug: string): string | null {
65
80
  if (slug.trim().length === 0) return null;
66
81
  const notesDir = join(root, NOTES_DIR);
67
82
  const candidate = join(notesDir, `${slug}.md`);
68
83
  const rel = relative(notesDir, candidate);
69
- if (rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
84
+ // Slugs may nest (`sessions/foo`) — that is how session memory stays in an
85
+ // inner folder of the vault graph (docs/session-scan.md) — but they may
86
+ // never escape the collection: `..` segments and absolute paths resolve
87
+ // outside notes/ and are rejected here, at the one door every read and
88
+ // write walks through.
89
+ if (rel.startsWith("..") || isAbsolute(rel) || rel.length === 0) return null;
70
90
  return candidate;
71
91
  }
72
92
 
73
- /** Create a note. Returns the written note (with its final, unique slug). */
93
+ /**
94
+ * Create a note. Returns the written note (with its final, unique slug).
95
+ *
96
+ * Serialized on the notes *directory* rather than on a note path, because
97
+ * what needs protecting is the slug allocation, and the slug is not known
98
+ * until it has been chosen. `uniqueSlug` is a check-then-create: two
99
+ * concurrent `addNote("Decision")` calls could both observe `decision.md`
100
+ * as free and the second would overwrite the first. Holding the directory
101
+ * makes choosing-and-writing a single step.
102
+ */
74
103
  export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
75
104
  await ensureVault(root);
76
- const now = (input.now ?? new Date()).toISOString();
77
- const base = slugify(input.title);
78
- const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
79
-
80
- const meta: NoteMeta = {
81
- title: input.title,
82
- created: now,
83
- updated: now,
84
- tags: input.tags ?? [],
85
- source: input.source ?? "agent",
86
- };
87
- const text = serializeNote(meta, input.body);
88
- await fs.writeFile(notePath(root, slug), text, "utf8");
89
- return { slug, ...meta, body: input.body };
105
+ return withNoteLocks([join(root, NOTES_DIR)], async () => {
106
+ const now = (input.now ?? new Date()).toISOString();
107
+ const base = slugify(input.title);
108
+ const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
109
+
110
+ const meta: NoteMeta = {
111
+ title: input.title,
112
+ created: now,
113
+ updated: now,
114
+ tags: input.tags ?? [],
115
+ source: input.source ?? "agent",
116
+ };
117
+ // No `frontMatter`: a brand-new note has no prior layout to respect, so
118
+ // the serializer writes its canonical block.
119
+ return writeNote(notePath(root, slug), slug, meta, input.body, undefined);
120
+ });
90
121
  }
91
122
 
92
123
  /** Read a note by slug. Returns null when missing, malformed, or an unsafe slug. */
@@ -100,28 +131,146 @@ export async function getNote(root: string, slug: string): Promise<Note | null>
100
131
  return null;
101
132
  }
102
133
  try {
103
- const { meta, body } = parseNoteFile(text);
104
- return { slug, ...meta, body };
134
+ const { meta, body, frontMatter } = parseNoteFile(text);
135
+ return { slug, ...meta, body, frontMatter };
105
136
  } catch {
106
137
  return null;
107
138
  }
108
139
  }
109
140
 
141
+ /**
142
+ * Write a note file and return the note as it now exists on disk.
143
+ *
144
+ * Every mutation goes through here, for two reasons. It is the one place
145
+ * that threads `frontMatter` into `serializeNote`, so no write path can
146
+ * forget to and quietly resume deleting the user's unknown properties. And
147
+ * it re-parses what was written rather than returning the in-memory `meta`,
148
+ * so the returned note is what a subsequent `getNote` would see — which
149
+ * matters because the serializer legitimately declines some changes (a
150
+ * `tags:` block list is frozen, see `frontmatter.ts`). Reporting the intent
151
+ * instead of the result would make that divergence invisible to the caller.
152
+ */
153
+ async function writeNote(
154
+ path: string,
155
+ slug: string,
156
+ meta: NoteMeta,
157
+ body: string,
158
+ frontMatter: NoteFrontMatter | undefined,
159
+ ): Promise<Note> {
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 });
164
+ await fs.writeFile(path, text, "utf8");
165
+ const parsed = parseNoteFile(text);
166
+ return { slug, ...parsed.meta, body: parsed.body, frontMatter: parsed.frontMatter };
167
+ }
168
+
169
+ /**
170
+ * Namespace prefix for this module's mutation-queue keys.
171
+ *
172
+ * `withMutationQueue` is a **non-reentrant** keyed queue, so a task that
173
+ * takes a key already held by an ancestor on the same call stack waits for
174
+ * itself. That is not hypothetical: `src/pi/tools/noteTool.ts` wraps
175
+ * `addNote`/`appendToNote`/`finalizeNote` in the queue keyed by the bare
176
+ * note path, from the days when locking lived in the adapter. Locking on
177
+ * the bare path in here too would deadlock every one of those tool calls.
178
+ *
179
+ * Prefixing gives core its own key space, so an outer lock held by any
180
+ * adapter is a coarser, harmless layer rather than a hang. The adapter's
181
+ * wrapper is now redundant — locking belongs in core per AGENTS.md rule 3,
182
+ * and it should be removed from the adapter in a change that owns that file
183
+ * — but redundant is a state the system can be in safely, and deadlocked is
184
+ * not.
185
+ */
186
+ const LOCK_NS = "vault:note:";
187
+
188
+ /**
189
+ * Run `task` with exclusive access to every given note path.
190
+ *
191
+ * Paths are locked in a fixed (sorted) order, which is what makes the
192
+ * two-path case — `renameNote`, the only operation touching two files —
193
+ * deadlock-free. Two concurrent renames in opposite directions (`a→b` and
194
+ * `b→a`) would otherwise be able to take one lock each and wait forever;
195
+ * with a total order on acquisition, one of them takes both and the other
196
+ * takes neither.
197
+ *
198
+ * Every mutation in this module goes through here, not just the new ones. A
199
+ * queue that half the writers ignore serializes nothing: an `appendToNote`
200
+ * racing an `updateNote` on the same file is a lost update whether or not
201
+ * the update took a lock.
202
+ */
203
+ function withNoteLocks<T>(paths: readonly string[], task: () => Promise<T>): Promise<T> {
204
+ const ordered = [...new Set(paths)].sort();
205
+ return ordered.reduceRight<() => Promise<T>>(
206
+ (inner, path) => () => withMutationQueue(LOCK_NS + path, inner),
207
+ task,
208
+ )();
209
+ }
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
+
110
223
  /** Append Markdown to an existing note and bump `updated`. */
111
224
  export async function appendToNote(
112
225
  root: string,
113
226
  slug: string,
114
227
  addition: string,
115
228
  now: Date = new Date(),
229
+ options: AppendToNoteOptions = {},
116
230
  ): Promise<Note | null> {
117
231
  const path = resolveNotePath(root, slug);
118
232
  if (!path) return null;
119
- const note = await getNote(root, slug);
120
- if (!note) return null;
121
- const body = note.body.replace(/\s+$/, "") + "\n\n" + addition.trim() + "\n";
122
- const meta: NoteMeta = { ...note, updated: now.toISOString() };
123
- await fs.writeFile(path, serializeNote(meta, body), "utf8");
124
- return { slug, ...meta, body };
233
+ return withNoteLocks([path], async () => {
234
+ const note = await getNote(root, slug);
235
+ if (!note) return null;
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
+ }
256
+ return writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
257
+ });
258
+ }
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}`;
125
274
  }
126
275
 
127
276
  /** Format a verbatim user scribble as an append-only raw block with a timestamp. */
@@ -133,13 +282,18 @@ export function formatRawAppend(rawText: string, date: Date = new Date()): strin
133
282
  const hh = pad(date.getHours());
134
283
  const min = pad(date.getMinutes());
135
284
  const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`;
285
+ const fence = fenceFor(rawText);
136
286
 
137
- return `<!-- appended ${timestamp} -->\n\`\`\`\n${rawText.trim()}\n\`\`\``;
287
+ return `<!-- appended ${timestamp} -->\n${fence}\n${rawText.trim()}\n${fence}`;
138
288
  }
139
289
 
140
290
  /** The append-only tail where verbatim user scribbles live. */
141
291
  export const RAW_NOTES_HEADING = "## Raw";
142
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
+
143
297
  /**
144
298
  * Extract the raw tail (including separator line, heading, and everything after) verbatim.
145
299
  */
@@ -183,38 +337,568 @@ export async function finalizeNote(
183
337
  ): Promise<Note | null> {
184
338
  const path = resolveNotePath(root, slug);
185
339
  if (!path) return null;
340
+ return withNoteLocks([path], async () => {
341
+ const note = await getNote(root, slug);
342
+ if (!note) return null;
343
+ const rawTail = extractRawTail(note.body);
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)}`);
356
+ const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
357
+ return writeNote(path, slug, meta, body, note.frontMatter);
358
+ });
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Mutation APIs (weave-workspace §11 P5.2, P5.3)
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /**
366
+ * A note plus the stamp that identifies the on-disk state it was read from.
367
+ *
368
+ * The read half of the conflict primitive (§11 P5.3). An editor reads this,
369
+ * holds `revision` for as long as the user is typing, and hands it back on
370
+ * save; a `revision` that no longer matches the file means someone else — a
371
+ * `weave_note` tool call, `$EDITOR`, an Obsidian sync — wrote in between.
372
+ */
373
+ export interface RevisionedNote {
374
+ note: Note;
375
+ /**
376
+ * Opaque version stamp. **Compare it, do not interpret it.**
377
+ *
378
+ * It is currently `mtimeMs:size`, and the shape is deliberately not part
379
+ * of the contract: a caller that parses out the mtime is a caller that
380
+ * breaks when this becomes a content hash. Two fields rather than one
381
+ * because mtime alone has a real blind spot — the filesystem timestamp
382
+ * granularity. Two writes inside the same millisecond are indistinguishable
383
+ * by mtime, and "same millisecond" is not exotic when the writer is a
384
+ * program rather than a human; a size change catches the common case of
385
+ * such a pair differing in length.
386
+ *
387
+ * This does not make it a perfect detector. A same-millisecond write that
388
+ * preserves the byte count is invisible, which is the honest limitation of
389
+ * any stat-based scheme, and the reason this is typed as opaque: upgrading
390
+ * it to a digest is then a change here and nowhere else.
391
+ */
392
+ revision: string;
393
+ }
394
+
395
+ function revisionOf(st: { mtimeMs: number; size: number }): string {
396
+ return `${st.mtimeMs}:${st.size}`;
397
+ }
398
+
399
+ /**
400
+ * Read a note together with its {@link RevisionedNote.revision}.
401
+ *
402
+ * Stat-then-read rather than read-then-stat: if a writer lands between the
403
+ * two calls, the revision is of the *older* state than the content, so the
404
+ * save that follows sees a mismatch and is rejected. The opposite order
405
+ * yields a revision newer than the content, which would let a stale body be
406
+ * written back under a revision that looks current — failing safe versus
407
+ * failing silently.
408
+ */
409
+ export async function getNoteWithRevision(root: string, slug: string): Promise<RevisionedNote | null> {
410
+ const path = resolveNotePath(root, slug);
411
+ if (!path) return null;
412
+ let revision: string;
413
+ try {
414
+ revision = revisionOf(await fs.stat(path));
415
+ } catch {
416
+ return null;
417
+ }
186
418
  const note = await getNote(root, slug);
187
- if (!note) return null;
188
- const rawTail = extractRawTail(note.body);
189
- const body = input.body.trim() + (rawTail ? `\n\n${rawTail}` : "");
190
- const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
191
- await fs.writeFile(path, serializeNote(meta, body), "utf8");
192
- return { slug, ...meta, body };
419
+ return note === null ? null : { note, revision };
193
420
  }
194
421
 
195
- async function listNoteFiles(root: string): Promise<string[]> {
196
- const dir = join(root, NOTES_DIR);
197
- let entries: string[];
422
+ /**
423
+ * Why a mutation did not happen. The server maps these to status codes —
424
+ * `"missing"` → 404, `"conflict"` → 409, `"collision"` → 409 — but the
425
+ * mapping is the server's business; core reports the *situation* (§11 P5.3).
426
+ */
427
+ export type MutationFailure =
428
+ /** No such note, or a slug that failed the traversal guard. */
429
+ | { ok: false; reason: "missing" }
430
+ /** The file moved since `expectedRevision` was read. */
431
+ | { ok: false; reason: "conflict"; current: RevisionedNote }
432
+ /** A rename whose destination slug is already taken. */
433
+ | { ok: false; reason: "collision"; slug: string };
434
+
435
+ export type MutationResult = { ok: true; note: Note } | MutationFailure;
436
+
437
+ /** A successful delete, or why it did not happen. */
438
+ export type DeleteResult = { ok: true } | { ok: false; reason: "missing" };
439
+
440
+ export interface UpdateNoteInput {
441
+ /** Replacement Markdown body. Omit to change only metadata. */
442
+ body?: string;
443
+ /**
444
+ * Metadata to merge over the note's current values. `created` is not
445
+ * accepted: it records when the note came into existence, and an edit is
446
+ * not a re-creation.
447
+ */
448
+ meta?: Partial<Pick<NoteMeta, "title" | "tags" | "source">>;
449
+ /**
450
+ * The {@link RevisionedNote.revision} the caller last read. When supplied
451
+ * and no longer current, the write is refused with `reason: "conflict"`
452
+ * and the caller is handed the note as it now is, so a UI can offer
453
+ * reload-or-overwrite without a second round trip. Omit for
454
+ * last-write-wins.
455
+ */
456
+ expectedRevision?: string;
457
+ /** Injectable clock for tests. */
458
+ now?: Date;
459
+ }
460
+
461
+ /**
462
+ * Check `expectedRevision` against the file, inside the caller's lock.
463
+ *
464
+ * Returns the conflict to report, or null to proceed. Being inside the lock
465
+ * is the whole point: a check-then-write with the check outside the mutex is
466
+ * a race with a wider window than no check at all, because it looks like it
467
+ * is doing something.
468
+ */
469
+ async function checkRevision(
470
+ root: string,
471
+ slug: string,
472
+ expected: string | undefined,
473
+ ): Promise<MutationFailure | null> {
474
+ if (expected === undefined) return null;
475
+ const current = await getNoteWithRevision(root, slug);
476
+ if (current === null) return { ok: false, reason: "missing" };
477
+ return current.revision === expected ? null : { ok: false, reason: "conflict", current };
478
+ }
479
+
480
+ /**
481
+ * Update a note's body and/or metadata in place.
482
+ *
483
+ * Preserves unknown front-matter keys (they ride on `note.frontMatter`
484
+ * through `writeNote`) and, when `body` is given, the append-only `## Raw`
485
+ * tail: the replacement body is treated as the *editorial* region above the
486
+ * tail, exactly as `finalizeNote` treats it. A caller replacing the body of
487
+ * a dictated note therefore cannot delete the user's verbatim scribbles by
488
+ * omitting them, which is the one thing `docs/notepad.md` §4 says must never
489
+ * happen. A body that already carries its own `## Raw` tail is written as
490
+ * given, so a round-trip through an editor that shows the whole file is not
491
+ * penalised with a duplicated tail.
492
+ *
493
+ * `updated` is bumped on every successful call, including a metadata-only
494
+ * one — a tag change is a change to the note.
495
+ */
496
+ export async function updateNote(
497
+ root: string,
498
+ slug: string,
499
+ input: UpdateNoteInput,
500
+ now: Date = new Date(),
501
+ ): Promise<MutationResult> {
502
+ const path = resolveNotePath(root, slug);
503
+ if (!path) return { ok: false, reason: "missing" };
504
+ return withNoteLocks([path], async () => {
505
+ const conflict = await checkRevision(root, slug, input.expectedRevision);
506
+ if (conflict) return conflict;
507
+ const note = await getNote(root, slug);
508
+ if (!note) return { ok: false, reason: "missing" };
509
+
510
+ const body = input.body === undefined ? note.body : preserveRawTail(note.body, input.body);
511
+ const meta: NoteMeta = {
512
+ ...note,
513
+ ...input.meta,
514
+ updated: (input.now ?? now).toISOString(),
515
+ };
516
+ return { ok: true, note: await writeNote(path, slug, meta, body, note.frontMatter) };
517
+ });
518
+ }
519
+
520
+ /** Re-attach the existing `## Raw` tail unless the replacement already has one. */
521
+ function preserveRawTail(currentBody: string, nextBody: string): string {
522
+ const tail = extractRawTail(currentBody);
523
+ if (tail === "" || extractRawTail(nextBody) !== "") return nextBody.trim();
524
+ return nextBody.trim() + `\n\n${tail}`;
525
+ }
526
+
527
+ /**
528
+ * Rename a note: move `oldSlug.md` to `newSlug.md`.
529
+ *
530
+ * `newSlug` is passed through `slugify`, so a caller may hand over either a
531
+ * slug or a human title and get the same filesystem-safe result the rest of
532
+ * the vault uses.
533
+ *
534
+ * ## Inbound `[[wikilinks]]` are deliberately NOT rewritten
535
+ *
536
+ * A rename can break links from other notes, and there are two honest
537
+ * options. Rewriting every referring note is the bigger hammer, and it is
538
+ * the wrong one here:
539
+ *
540
+ * - **It is a multi-file write with no transaction.** Renaming one note
541
+ * would rewrite N others; a failure partway leaves the vault half-updated,
542
+ * and there is no rollback. Trading one dangling link for an unknown
543
+ * number of half-edited files is a bad trade.
544
+ * - **It edits prose to fix an index.** A wikilink lives in body text a
545
+ * human wrote, sometimes inside a quote, a code fence, or a `## Raw` tail
546
+ * that `docs/notepad.md` declares append-only and verbatim. A textual
547
+ * substitution across the vault cannot honour that; a rename would become
548
+ * the one operation allowed to modify preserved user input.
549
+ * - **The alternative is already visible, not silent.** Dangling targets are
550
+ * a first-class concept: `buildGraph` collects `danglingLinks`, the wire
551
+ * payload ships them as `dangling`, and the note column renders an
552
+ * unresolved wikilink as an unfollowable ghost. A stale link therefore
553
+ * shows up in the UI as something to fix, which is a better failure than a
554
+ * silent bulk edit the user cannot review.
555
+ *
556
+ * So: renaming leaves inbound links pointing at the old slug, where they
557
+ * render as dangling. If link-following-a-rename is wanted later, the right
558
+ * shape is an explicit, previewable "update N referring notes?" step — a
559
+ * separate operation the user opts into, not a side effect of this one.
560
+ */
561
+ export async function renameNote(
562
+ root: string,
563
+ oldSlug: string,
564
+ newSlug: string,
565
+ now: Date = new Date(),
566
+ ): Promise<MutationResult> {
567
+ const from = resolveNotePath(root, oldSlug);
568
+ if (!from) return { ok: false, reason: "missing" };
569
+ const target = slugify(newSlug);
570
+ const to = resolveNotePath(root, target);
571
+ // `slugify` cannot emit a traversing slug, but the guard is applied anyway:
572
+ // "this input is already safe" is exactly the assumption that stops being
573
+ // true when someone changes the other function.
574
+ if (!to) return { ok: false, reason: "missing" };
575
+ if (target === oldSlug) {
576
+ const note = await getNote(root, oldSlug);
577
+ return note === null ? { ok: false, reason: "missing" } : { ok: true, note };
578
+ }
579
+
580
+ return withNoteLocks([from, to], async () => {
581
+ const note = await getNote(root, oldSlug);
582
+ if (!note) return { ok: false, reason: "missing" };
583
+ // Refuse rather than uniquify: `addNote` may silently pick `decision-2`
584
+ // because nobody named a file there, but a rename onto an existing note
585
+ // is a user mistake, and quietly landing somewhere other than where they
586
+ // asked hides it. `fs.rename` would overwrite the destination outright.
587
+ if (await exists(to)) return { ok: false, reason: "collision", slug: target };
588
+
589
+ await fs.rename(from, to);
590
+ // The slug is the note's identity, so a rename is a change to the note.
591
+ const meta: NoteMeta = { ...note, updated: now.toISOString() };
592
+ return { ok: true, note: await writeNote(to, target, meta, note.body, note.frontMatter) };
593
+ });
594
+ }
595
+
596
+ /**
597
+ * Delete a note. **Hard delete** — the file is unlinked.
598
+ *
599
+ * No trash directory, and that is a deliberate omission rather than an
600
+ * oversight. A trash is a real feature: it needs a location that does not
601
+ * pollute `notes/` (everything there is indexed and graphed), a retention
602
+ * policy, a restore path, and an answer for what happens when a deleted slug
603
+ * is later reused. Inventing all of that as a side effect of "P5 needs a
604
+ * delete button" is how a vault grows a second, undocumented store of notes
605
+ * that the index does not know about — and AGENTS.md rule 5 says nothing in
606
+ * `.okf` may be the only copy of anything, which cuts both ways: a
607
+ * half-designed trash becomes exactly such a place.
608
+ *
609
+ * The vault is plain files in a directory most users keep under version
610
+ * control or a synced folder, so the recovery story is the one they already
611
+ * have and understand. If a trash is wanted, it should arrive as its own
612
+ * design decision with those questions answered.
613
+ */
614
+ export async function deleteNote(root: string, slug: string): Promise<DeleteResult> {
615
+ const path = resolveNotePath(root, slug);
616
+ if (!path) return { ok: false, reason: "missing" };
617
+ return withNoteLocks([path], async () => {
618
+ try {
619
+ await fs.unlink(path);
620
+ return { ok: true };
621
+ } catch {
622
+ // Already gone, or never existed. Both are "there is no such note",
623
+ // which is the caller's question — not "the unlink syscall failed".
624
+ return { ok: false, reason: "missing" };
625
+ }
626
+ });
627
+ }
628
+
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;
198
752
  try {
199
- entries = await fs.readdir(dir);
753
+ text = readFileSync(notePath(root, slug), "utf8");
200
754
  } catch {
201
- return [];
755
+ return null;
202
756
  }
203
- return entries.filter((name) => name.endsWith(".md")).sort();
757
+ const parsed = parseFrontMatter(text);
758
+ if (!parsed) return null;
759
+ return parsed.fields.get(field) ?? null;
204
760
  }
205
761
 
206
- /** List all notes with their metadata, newest-updated first. */
207
- export async function listNotes(root: string): Promise<NoteSummary[]> {
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
+ }
804
+ }
805
+ await walk("");
806
+ return out.sort();
807
+ }
808
+
809
+ /**
810
+ * Derive the list-shaped summary of a note (drops the body, keeps its length).
811
+ *
812
+ * `frontMatter` is dropped along with the body, and explicitly rather than by
813
+ * omission: a spread is exempt from TypeScript's excess-property check, so
814
+ * carrying it would type-check fine and then ship every note's raw metadata
815
+ * block through `listNotes` into the search results and the wire payload —
816
+ * a field no consumer reads, on a shape the contract test pins.
817
+ * Preservation is a property of the *write* path re-reading the file, not of
818
+ * summaries carrying it around.
819
+ */
820
+ export function summarizeNote(note: Note): NoteSummary {
821
+ const { body, frontMatter, ...rest } = note;
822
+ void frontMatter;
823
+ return { ...rest, bodyLength: body.length };
824
+ }
825
+
826
+ /**
827
+ * Newest-updated first. Ties fall back to slug ascending: the input arrives
828
+ * in readdir-sorted (slug) order and `Array.prototype.sort` is stable, so
829
+ * equal timestamps keep that order.
830
+ */
831
+ function byUpdatedDesc(a: { updated: string }, b: { updated: string }): number {
832
+ return b.updated.localeCompare(a.updated);
833
+ }
834
+
835
+ /**
836
+ * Everything one pass over the vault can tell you: every readable note with
837
+ * its body, plus how many `.md` files exist.
838
+ *
839
+ * Callers that need both the note list *and* the bodies (the graph builder,
840
+ * search) must use this instead of `listNotes` + `getNote` per slug — that
841
+ * pattern reads and parses every file twice (weave-workspace §4.1).
842
+ */
843
+ export interface VaultSnapshot {
844
+ /** Readable, parseable notes, newest-updated first. */
845
+ notes: Note[];
846
+ /**
847
+ * Number of `*.md` files present, *including* ones too malformed to parse.
848
+ * `notes.length` can be smaller; this is the honest on-disk count.
849
+ */
850
+ fileCount: number;
851
+ }
852
+
853
+ /** Read the whole vault in one pass: one readdir, one read per note. */
854
+ export async function readVault(root: string): Promise<VaultSnapshot> {
208
855
  const files = await listNoteFiles(root);
209
- const summaries: NoteSummary[] = [];
856
+ const notes: Note[] = [];
210
857
  for (const file of files) {
211
- const slug = file.slice(0, -".md".length);
212
- const note = await getNote(root, slug);
858
+ const note = await getNote(root, file.slice(0, -".md".length));
213
859
  if (!note) continue; // unreadable/malformed files are skipped, not fatal
214
- const { body, ...summary } = note;
215
- summaries.push({ ...summary, bodyLength: body.length });
860
+ notes.push(note);
216
861
  }
217
- return summaries.sort((a, b) => b.updated.localeCompare(a.updated));
862
+ return { notes: notes.sort(byUpdatedDesc), fileCount: files.length };
863
+ }
864
+
865
+ /** One note's identity and change-detection stamp, without reading its content. */
866
+ export interface NoteStat {
867
+ slug: string;
868
+ path: string;
869
+ mtimeMs: number;
870
+ size: number;
871
+ }
872
+
873
+ /**
874
+ * Stat-only pass over the vault: enough to decide *which* notes changed,
875
+ * without reading or parsing any of them. The change-detection primitive
876
+ * behind `src/core/cache/workspace` — a no-change rebuild costs N stats and
877
+ * zero reads.
878
+ *
879
+ * Files that vanish between the readdir and the stat are dropped, so a note
880
+ * deleted mid-pass is simply absent rather than fatal.
881
+ */
882
+ export async function statNotes(root: string): Promise<NoteStat[]> {
883
+ const dir = join(root, NOTES_DIR);
884
+ const files = await listNoteFiles(root);
885
+ const stats = await Promise.all(
886
+ files.map(async (file): Promise<NoteStat | null> => {
887
+ const path = join(dir, file);
888
+ try {
889
+ const st = await fs.stat(path);
890
+ return { slug: file.slice(0, -".md".length), path, mtimeMs: st.mtimeMs, size: st.size };
891
+ } catch {
892
+ return null; // raced a delete
893
+ }
894
+ }),
895
+ );
896
+ return stats.filter((s): s is NoteStat => s !== null);
897
+ }
898
+
899
+ /** List all notes with their metadata, newest-updated first. */
900
+ export async function listNotes(root: string): Promise<NoteSummary[]> {
901
+ return (await readVault(root)).notes.map(summarizeNote);
218
902
  }
219
903
 
220
904
  export async function noteCount(root: string): Promise<number> {
@@ -231,10 +915,8 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
231
915
  if (q.length === 0) return [];
232
916
 
233
917
  const hits: NoteSearchHit[] = [];
234
- for (const summary of await listNotes(root)) {
235
- const note = await getNote(root, summary.slug);
236
- if (!note) continue;
237
-
918
+ // One pass: `listNotes` + a `getNote` per slug would read every file twice.
919
+ for (const note of (await readVault(root)).notes) {
238
920
  let score = 0;
239
921
  if (note.title.toLowerCase().includes(q)) score += 3;
240
922
  if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 2;
@@ -249,7 +931,7 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
249
931
  score += bodyMatches;
250
932
 
251
933
  if (score === 0) continue;
252
- hits.push({ summary, score, snippet: makeSnippet(note.body, q) });
934
+ hits.push({ summary: summarizeNote(note), score, snippet: makeSnippet(note.body, q) });
253
935
  }
254
936
  return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
255
937
  }