pi-weave 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-weave",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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,
@@ -53,8 +53,6 @@ export interface WorkspaceSnapshot {
53
53
  * caller mutating it would corrupt the next build.
54
54
  */
55
55
  notes: readonly Note[];
56
- /** Exactly the HTML artifacts represented by the graph. */
57
- artifacts: readonly HtmlArtifact[];
58
56
  }
59
57
 
60
58
  /** Cumulative counters, from construction. Callers take deltas. */
@@ -408,7 +406,6 @@ export class WorkspaceCache {
408
406
  const snapshot: WorkspaceSnapshot = {
409
407
  model: buildGraph(input),
410
408
  notes: Object.freeze(kept),
411
- artifacts: Object.freeze(input.artifacts ?? []),
412
409
  };
413
410
  this.lastSnapshot = snapshot;
414
411
  return snapshot;
package/src/core/index.ts CHANGED
@@ -17,6 +17,8 @@ export { runDeepScan, type DeepScanOptions, type DeepScanResult, type SummarizeF
17
17
  export {
18
18
  addNote,
19
19
  appendToNote,
20
+ deleteFolder,
21
+ deleteNote,
20
22
  extractRawTail,
21
23
  finalizeNote,
22
24
  formatNote,
@@ -24,7 +26,10 @@ export {
24
26
  getHtmlArtifact,
25
27
  getNote,
26
28
  listNotes,
29
+ moveNote,
27
30
  parseHtmlArtifact,
31
+ renameFolder,
32
+ renameNote,
28
33
  resolveHtmlPath,
29
34
  resolveNotePath,
30
35
  searchNotes,
package/src/core/vault.ts CHANGED
@@ -99,7 +99,7 @@ export function resolveNotePath(root: string, slug: string): string | null {
99
99
  */
100
100
  export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
101
101
  await ensureVault(root);
102
- return withNoteLocks([join(root, NOTES_DIR)], async () => {
102
+ return withVaultLock(root, async () => {
103
103
  const now = (input.now ?? new Date()).toISOString();
104
104
  const base = slugify(input.title);
105
105
  const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
@@ -183,18 +183,12 @@ async function writeNote(
183
183
  const LOCK_NS = "vault:note:";
184
184
 
185
185
  /**
186
- * Run `task` with exclusive access to every given note path.
187
- *
188
- * Paths are locked in a fixed order so callers that need multiple locks cannot
189
- * deadlock. Every mutation in this module goes through here, keeping appends
190
- * and finalization serialized with one another.
186
+ * Run `task` with exclusive mutation access to this vault.
191
187
  */
192
- function withNoteLocks<T>(paths: readonly string[], task: () => Promise<T>): Promise<T> {
193
- const ordered = [...new Set(paths)].sort();
194
- return ordered.reduceRight<() => Promise<T>>(
195
- (inner, path) => () => withMutationQueue(LOCK_NS + path, inner),
196
- task,
197
- )();
188
+ function withVaultLock<T>(root: string, task: () => Promise<T>): Promise<T> {
189
+ // ponytail: one vault-wide lock is enough for a local notepad; use
190
+ // hierarchical locks only if independent-note write throughput matters.
191
+ return withMutationQueue(LOCK_NS + join(root, NOTES_DIR), task);
198
192
  }
199
193
 
200
194
  /** Options for {@link appendToNote}. */
@@ -219,7 +213,7 @@ export async function appendToNote(
219
213
  ): Promise<Note | null> {
220
214
  const path = resolveNotePath(root, slug);
221
215
  if (!path) return null;
222
- return withNoteLocks([path], async () => {
216
+ return withVaultLock(root, async () => {
223
217
  const note = await getNote(root, slug);
224
218
  if (!note) return null;
225
219
  const tail = extractRawTail(note.body);
@@ -326,7 +320,7 @@ export async function finalizeNote(
326
320
  ): Promise<Note | null> {
327
321
  const path = resolveNotePath(root, slug);
328
322
  if (!path) return null;
329
- return withNoteLocks([path], async () => {
323
+ return withVaultLock(root, async () => {
330
324
  const note = await getNote(root, slug);
331
325
  if (!note) return null;
332
326
  const rawTail = extractRawTail(note.body);
@@ -347,6 +341,103 @@ export async function finalizeNote(
347
341
  });
348
342
  }
349
343
 
344
+ export type VaultMutationResult =
345
+ | { ok: true; slug?: string; path?: string }
346
+ | { ok: false; reason: "missing" | "collision" | "invalid" };
347
+
348
+ function resolveFolderPath(root: string, folder: string): string | null {
349
+ const parts = folder.split("/");
350
+ if (parts.length === 0 || parts.some((part) => part === "" || part === "." || part === "..")) return null;
351
+ const notesDir = join(root, NOTES_DIR);
352
+ const candidate = join(notesDir, ...parts);
353
+ const rel = relative(notesDir, candidate);
354
+ return rel.startsWith("..") || isAbsolute(rel) || rel.length === 0 ? null : candidate;
355
+ }
356
+
357
+ async function isDirectory(path: string): Promise<boolean> {
358
+ try {
359
+ return (await fs.stat(path)).isDirectory();
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
365
+ /** Rename a note in place and keep its front-matter title in sync. */
366
+ export async function renameNote(root: string, slug: string, name: string, now = new Date()): Promise<VaultMutationResult> {
367
+ const from = resolveNotePath(root, slug);
368
+ const title = name.trim();
369
+ if (from === null || title === "") return { ok: false, reason: "invalid" };
370
+ const parent = slug.split("/").slice(0, -1).join("/");
371
+ const target = [...(parent === "" ? [] : [parent]), slugify(title)].join("/");
372
+ const to = resolveNotePath(root, target);
373
+ if (to === null) return { ok: false, reason: "invalid" };
374
+ return withVaultLock(root, async () => {
375
+ const note = await getNote(root, slug);
376
+ if (note === null) return { ok: false, reason: "missing" };
377
+ if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
378
+ if (from !== to) await fs.rename(from, to);
379
+ await writeNote(to, target, { ...note, title, updated: now.toISOString() }, note.body, note.frontMatter);
380
+ return { ok: true, slug: target };
381
+ });
382
+ }
383
+
384
+ /** Move a note to an existing vault folder, or to the vault root with `null`. */
385
+ export async function moveNote(root: string, slug: string, folder: string | null): Promise<VaultMutationResult> {
386
+ const from = resolveNotePath(root, slug);
387
+ if (from === null) return { ok: false, reason: "invalid" };
388
+ const targetDir = folder === null ? join(root, NOTES_DIR) : resolveFolderPath(root, folder);
389
+ if (targetDir === null || !(await isDirectory(targetDir))) return { ok: false, reason: "missing" };
390
+ const target = folder === null ? basename(slug) : `${folder}/${basename(slug)}`;
391
+ const to = resolveNotePath(root, target);
392
+ if (to === null) return { ok: false, reason: "invalid" };
393
+ return withVaultLock(root, async () => {
394
+ if (!(await exists(from))) return { ok: false, reason: "missing" };
395
+ if (from === to) return { ok: true, slug };
396
+ if (await exists(to)) return { ok: false, reason: "collision" };
397
+ await fs.rename(from, to);
398
+ return { ok: true, slug: target };
399
+ });
400
+ }
401
+
402
+ /** Permanently delete one note. */
403
+ export async function deleteNote(root: string, slug: string): Promise<VaultMutationResult> {
404
+ const path = resolveNotePath(root, slug);
405
+ if (path === null) return { ok: false, reason: "invalid" };
406
+ return withVaultLock(root, async () => {
407
+ if (!(await exists(path))) return { ok: false, reason: "missing" };
408
+ await fs.unlink(path);
409
+ return { ok: true };
410
+ });
411
+ }
412
+
413
+ /** Rename a vault folder without changing its parent. */
414
+ export async function renameFolder(root: string, folder: string, name: string): Promise<VaultMutationResult> {
415
+ const from = resolveFolderPath(root, folder);
416
+ const title = name.trim();
417
+ if (from === null || title === "") return { ok: false, reason: "invalid" };
418
+ const parent = folder.split("/").slice(0, -1).join("/");
419
+ const target = [...(parent === "" ? [] : [parent]), slugify(title)].join("/");
420
+ const to = resolveFolderPath(root, target);
421
+ if (to === null) return { ok: false, reason: "invalid" };
422
+ return withVaultLock(root, async () => {
423
+ if (!(await isDirectory(from))) return { ok: false, reason: "missing" };
424
+ if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
425
+ if (from !== to) await fs.rename(from, to);
426
+ return { ok: true, path: target };
427
+ });
428
+ }
429
+
430
+ /** Permanently delete a vault folder and its contents. */
431
+ export async function deleteFolder(root: string, folder: string): Promise<VaultMutationResult> {
432
+ const path = resolveFolderPath(root, folder);
433
+ if (path === null) return { ok: false, reason: "invalid" };
434
+ return withVaultLock(root, async () => {
435
+ if (!(await isDirectory(path))) return { ok: false, reason: "missing" };
436
+ await fs.rm(path, { recursive: true });
437
+ return { ok: true };
438
+ });
439
+ }
440
+
350
441
  async function listVaultEntries(root: string): Promise<{ files: string[]; folders: string[] }> {
351
442
  const dir = join(root, NOTES_DIR);
352
443
  const files: string[] = [];
@@ -36,7 +36,7 @@
36
36
  * fortnight.
37
37
  */
38
38
 
39
- import type { GraphPayload, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
39
+ import type { GraphPayload, MutationResult, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
40
40
 
41
41
  // --- the injected HTTP port ------------------------------------------------------
42
42
 
@@ -229,6 +229,10 @@ export function isOpenResult(value: unknown): value is OpenResult {
229
229
  return isObject(value) && typeof value["opened"] === "boolean";
230
230
  }
231
231
 
232
+ export function isMutationResult(value: unknown): value is MutationResult {
233
+ return isObject(value) && value["ok"] === true && (value["id"] === undefined || typeof value["id"] === "string");
234
+ }
235
+
232
236
  // --- routes --------------------------------------------------------------------------
233
237
 
234
238
  /**
@@ -308,3 +312,19 @@ export function openNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<
308
312
  body: JSON.stringify({ slug }),
309
313
  });
310
314
  }
315
+
316
+ function mutation(fetchImpl: FetchLike, url: string, method: string, body?: unknown): Promise<ApiResult<MutationResult>> {
317
+ return request(fetchImpl, url, isMutationResult, {
318
+ method,
319
+ ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
320
+ });
321
+ }
322
+
323
+ const noteUrl = (slug: string, suffix = ""): string => `/api/note/${encodeURIComponent(slug)}${suffix}`;
324
+ const folderUrl = (path: string, suffix = ""): string => `/api/folder/${path.split("/").map(encodeURIComponent).join("/")}${suffix}`;
325
+
326
+ export const renameNote = (fetchImpl: FetchLike, slug: string, name: string) => mutation(fetchImpl, noteUrl(slug, "/rename"), "POST", { name });
327
+ export const moveNote = (fetchImpl: FetchLike, slug: string, folder: string | null) => mutation(fetchImpl, noteUrl(slug, "/move"), "POST", { folder });
328
+ export const deleteNote = (fetchImpl: FetchLike, slug: string) => mutation(fetchImpl, noteUrl(slug), "DELETE");
329
+ export const renameFolder = (fetchImpl: FetchLike, path: string, name: string) => mutation(fetchImpl, folderUrl(path, "/rename"), "POST", { name });
330
+ export const deleteFolder = (fetchImpl: FetchLike, path: string) => mutation(fetchImpl, folderUrl(path), "DELETE");