@openparachute/vault 0.4.9-rc.4 → 0.4.9-rc.6

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.
@@ -72,7 +72,7 @@
72
72
  * See vault#308.
73
73
  */
74
74
 
75
- import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from "fs";
75
+ import { readdirSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from "fs";
76
76
  import { basename, join, relative, extname, dirname, resolve as resolvePath, sep as pathSep } from "path";
77
77
  import type { Store, Note, Link, Attachment } from "./types.js";
78
78
  import type { TagRecord } from "./tag-schemas.js";
@@ -1001,7 +1001,375 @@ export async function exportVaultToDir(
1001
1001
  };
1002
1002
  }
1003
1003
 
1004
- function hasSchemaContent(tag: TagRecord): boolean {
1004
+ // ---------------------------------------------------------------------------
1005
+ // Orphan sweep — for git-mirror's delete propagation
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ /**
1009
+ * Result of a `pruneOrphans` pass.
1010
+ */
1011
+ export interface PruneOrphansStats {
1012
+ /** Note content files removed (frontmatter id not in `validNoteIds`). */
1013
+ notes_removed: number;
1014
+ /** Sidecar metadata files removed (under `.parachute/notes-meta/`). */
1015
+ sidecars_removed: number;
1016
+ /** Schema sidecars removed (under `.parachute/schemas/`). */
1017
+ schemas_removed: number;
1018
+ /** Attachment directories removed (under `.parachute/attachments/`). */
1019
+ attachment_dirs_removed: number;
1020
+ /**
1021
+ * Files we couldn't parse / classify. Surfaced for operator audit;
1022
+ * the sweep doesn't touch them. Empty when everything classified
1023
+ * cleanly.
1024
+ */
1025
+ unparseable_files: Array<{ path: string; reason: string }>;
1026
+ }
1027
+
1028
+ /**
1029
+ * Options for the orphan-sweep pass. Run periodically (the mirror manager
1030
+ * arms a safety-net poll, default 1h) and after operator-visible deletions
1031
+ * (the mirror manager's targeted-deletion fast path also calls this for the
1032
+ * touched-set).
1033
+ *
1034
+ * The sweep is the bookkeeping cousin of the event-driven fast path: events
1035
+ * cover the common case (a single note deletion fires "deleted" → mirror
1036
+ * removes that file); the sweep covers anything the fast path missed
1037
+ * (direct SQL writes, app crashes between dispatch and handler, restart
1038
+ * gaps).
1039
+ */
1040
+ export interface PruneOrphansOptions {
1041
+ /** Directory to sweep — same shape as an `exportVaultToDir` outDir. */
1042
+ outDir: string;
1043
+ /** Note IDs that should be kept (everything else under the export gets removed). */
1044
+ validNoteIds: Set<string>;
1045
+ /** Tag names that should be kept (other schema sidecars under `.parachute/schemas/` get removed). */
1046
+ validTagNames: Set<string>;
1047
+ /** Attachment IDs that should be kept (other dirs under `.parachute/attachments/` get removed). */
1048
+ validAttachmentIds: Set<string>;
1049
+ /**
1050
+ * Override `extension`-based content-file extension recognition. The
1051
+ * default treats `.md` and `.mdx` as having inline frontmatter (with
1052
+ * `id:` reachable via the file head); everything else needs a sidecar
1053
+ * lookup to know what id owns it.
1054
+ */
1055
+ supportsInlineFrontmatter?: (ext: string) => boolean;
1056
+ }
1057
+
1058
+ /**
1059
+ * Sweep the export directory for files belonging to notes / tags /
1060
+ * attachments that no longer exist in the vault. Removes them so the
1061
+ * mirror's `git diff` reflects what vault actually has.
1062
+ *
1063
+ * Strategy:
1064
+ * 1. Walk content files. For each `.md` / `.mdx`, parse the frontmatter
1065
+ * `id` and compare against `validNoteIds`. Mismatch → remove the
1066
+ * file. Files we can't parse are recorded in `unparseable_files`
1067
+ * and left alone (best-effort, no destructive guesses).
1068
+ * 2. For non-inline-frontmatter extensions (`.csv`, `.yaml`, etc.),
1069
+ * check the matching `.parachute/notes-meta/<id>.yaml` sidecar — the
1070
+ * sidecar carries the canonical `id` + `path` + `extension` triple.
1071
+ * An orphaned sidecar (no matching content file) is also removed,
1072
+ * and an orphaned content file (no matching sidecar) without a
1073
+ * parseable frontmatter is left as unparseable.
1074
+ * 3. Walk `.parachute/schemas/`. Each file is `<tag>.yaml` (after
1075
+ * filename sanitization). Parse the `name:` field; compare against
1076
+ * `validTagNames`. Mismatch → remove.
1077
+ * 4. Walk `.parachute/attachments/`. Each subdir name IS the
1078
+ * attachment id (per `exportVaultToDir`'s layout). Compare against
1079
+ * `validAttachmentIds`. Mismatch → recursive remove.
1080
+ *
1081
+ * Returns counts so callers can log + decide whether to commit.
1082
+ *
1083
+ * Safe to call on a directory that's never been exported to (returns
1084
+ * zero counts; doesn't create anything).
1085
+ */
1086
+ export function pruneOrphans(opts: PruneOrphansOptions): PruneOrphansStats {
1087
+ const stats: PruneOrphansStats = {
1088
+ notes_removed: 0,
1089
+ sidecars_removed: 0,
1090
+ schemas_removed: 0,
1091
+ attachment_dirs_removed: 0,
1092
+ unparseable_files: [],
1093
+ };
1094
+
1095
+ const outDir = opts.outDir;
1096
+ if (!existsSync(outDir)) return stats;
1097
+
1098
+ const supportsInline = opts.supportsInlineFrontmatter ?? supportsInlineFrontmatter;
1099
+ const sidecarRoot = join(outDir, SIDECAR_DIR);
1100
+ const notesMetaRoot = join(sidecarRoot, NOTES_META_DIR);
1101
+ const schemasRoot = join(sidecarRoot, "schemas");
1102
+ const attachmentsRoot = join(sidecarRoot, "attachments");
1103
+
1104
+ // Path-traversal guard. `walkContentFiles` uses `statSync` which
1105
+ // follows symlinks — a symlink inside the mirror pointing OUTSIDE
1106
+ // `outDir` would resurface its target's files in the prune sweep,
1107
+ // and a bare `rmSync(filepath)` would delete them off-tree. Every
1108
+ // deletion in this function routes through `safeRm`, which calls
1109
+ // `realpathSync` (resolves through symlinks, unlike syntactic-only
1110
+ // `path.resolve`) and refuses to delete anything that isn't `outDir`
1111
+ // or beneath it after symlink resolution. Refusals get recorded in
1112
+ // `unparseable_files` so an operator can see what was skipped.
1113
+ // Reviewer-flagged on vault#382 (Critical #2).
1114
+ const outDirReal = realpathSync(outDir);
1115
+ const safeRm = (
1116
+ candidate: string,
1117
+ onSuccess: () => void,
1118
+ options: { recursive?: boolean } = {},
1119
+ ): void => {
1120
+ let real: string;
1121
+ try {
1122
+ real = realpathSync(candidate);
1123
+ } catch (err) {
1124
+ // realpathSync throws if the path doesn't exist or is unreadable.
1125
+ // Don't delete what we can't fully resolve.
1126
+ stats.unparseable_files.push({
1127
+ path: candidate,
1128
+ reason: `realpath failed: ${(err as Error).message ?? err}`,
1129
+ });
1130
+ return;
1131
+ }
1132
+ if (!isWithinDir(real, outDirReal)) {
1133
+ stats.unparseable_files.push({
1134
+ path: candidate,
1135
+ reason: "real path resolved outside mirror outDir — refusing to delete (symlink?)",
1136
+ });
1137
+ return;
1138
+ }
1139
+ try {
1140
+ // Delete via the resolved real path; never via the unresolved
1141
+ // candidate (which could route through a symlink we already
1142
+ // determined would escape).
1143
+ rmSync(real, { force: true, recursive: options.recursive ?? false });
1144
+ onSuccess();
1145
+ } catch (err) {
1146
+ stats.unparseable_files.push({
1147
+ path: candidate,
1148
+ reason: `unlink failed: ${(err as Error).message ?? err}`,
1149
+ });
1150
+ }
1151
+ };
1152
+
1153
+ // ---- 1 + 2. Notes + sidecars ----
1154
+ //
1155
+ // First pass: build the sidecar id → { path, extension } map so we can
1156
+ // resolve non-inline-frontmatter content files via their sidecar.
1157
+ // Sidecars whose claimed (path, extension) doesn't map to an existing
1158
+ // content file are tracked as "orphaned sidecar" candidates.
1159
+ const sidecarById = new Map<string, { path: string | null; extension: string | null }>();
1160
+ const sidecarFilesById = new Map<string, string>(); // id → absolute filepath
1161
+ if (existsSync(notesMetaRoot)) {
1162
+ try {
1163
+ for (const entry of readdirSync(notesMetaRoot)) {
1164
+ if (!entry.endsWith(".yaml")) continue;
1165
+ const id = entry.slice(0, -5);
1166
+ const full = join(notesMetaRoot, entry);
1167
+ sidecarFilesById.set(id, full);
1168
+ try {
1169
+ const text = readFileSync(full, "utf-8");
1170
+ const { frontmatter } = parseFrontmatter(`---\n${text}---\n`);
1171
+ sidecarById.set(id, {
1172
+ path: typeof frontmatter.path === "string" ? (frontmatter.path as string) : null,
1173
+ extension: typeof frontmatter.extension === "string" ? (frontmatter.extension as string) : null,
1174
+ });
1175
+ } catch (err) {
1176
+ stats.unparseable_files.push({
1177
+ path: full,
1178
+ reason: `failed to parse sidecar: ${(err as Error).message ?? err}`,
1179
+ });
1180
+ }
1181
+ }
1182
+ } catch (err) {
1183
+ // Notes-meta dir read-error is non-fatal — record + carry on.
1184
+ stats.unparseable_files.push({
1185
+ path: notesMetaRoot,
1186
+ reason: `notes-meta walk failed: ${(err as Error).message ?? err}`,
1187
+ });
1188
+ }
1189
+ }
1190
+
1191
+ // Now walk content files (everything outside the .parachute sidecar).
1192
+ const contentFiles = walkContentFiles(outDir);
1193
+ // Track which sidecars matched a content file so we can also remove
1194
+ // orphaned sidecars (sidecar present but content file gone).
1195
+ const pairedSidecarIds = new Set<string>();
1196
+ for (const filepath of contentFiles) {
1197
+ const ext = extname(filepath).slice(1).toLowerCase();
1198
+ if (supportsInline(ext)) {
1199
+ // Parse frontmatter, read id.
1200
+ try {
1201
+ const raw = readFileSync(filepath, "utf-8");
1202
+ const { frontmatter } = parseFrontmatter(raw);
1203
+ const id = typeof frontmatter.id === "string" ? (frontmatter.id as string) : null;
1204
+ if (!id) {
1205
+ stats.unparseable_files.push({
1206
+ path: filepath,
1207
+ reason: "no `id` in frontmatter",
1208
+ });
1209
+ continue;
1210
+ }
1211
+ if (!opts.validNoteIds.has(id)) {
1212
+ safeRm(filepath, () => {
1213
+ stats.notes_removed++;
1214
+ });
1215
+ }
1216
+ } catch (err) {
1217
+ stats.unparseable_files.push({
1218
+ path: filepath,
1219
+ reason: `read failed: ${(err as Error).message ?? err}`,
1220
+ });
1221
+ }
1222
+ } else {
1223
+ // Sidecar-required extension. Find the matching sidecar by
1224
+ // (path, extension) — sidecars are keyed by id, so we sweep the
1225
+ // sidecarById map.
1226
+ const relPath = relative(outDir, filepath).replace(/\\/g, "/");
1227
+ // Strip extension to get the canonical path stored in the sidecar
1228
+ // (vault paths don't carry extensions).
1229
+ const pathNoExt = relPath.slice(0, -(ext.length + 1));
1230
+ let foundId: string | null = null;
1231
+ for (const [id, info] of sidecarById.entries()) {
1232
+ if (
1233
+ info.path === pathNoExt &&
1234
+ (info.extension ?? "md").toLowerCase() === ext
1235
+ ) {
1236
+ foundId = id;
1237
+ break;
1238
+ }
1239
+ }
1240
+ if (!foundId) {
1241
+ // Content file with no sidecar — can't tell which note owns it.
1242
+ // Conservative: leave alone, record as unparseable.
1243
+ stats.unparseable_files.push({
1244
+ path: filepath,
1245
+ reason: "no sidecar metadata could be matched by (path, extension)",
1246
+ });
1247
+ continue;
1248
+ }
1249
+ pairedSidecarIds.add(foundId);
1250
+ if (!opts.validNoteIds.has(foundId)) {
1251
+ // Note is orphaned — remove both content and sidecar.
1252
+ safeRm(filepath, () => {
1253
+ stats.notes_removed++;
1254
+ });
1255
+ const sidecarPath = sidecarFilesById.get(foundId);
1256
+ if (sidecarPath) {
1257
+ safeRm(sidecarPath, () => {
1258
+ stats.sidecars_removed++;
1259
+ });
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ // Sweep up orphaned sidecars (sidecar exists but no content file
1266
+ // matched OR sidecar's id isn't in validNoteIds).
1267
+ for (const [id, sidecarPath] of sidecarFilesById.entries()) {
1268
+ if (opts.validNoteIds.has(id) && pairedSidecarIds.has(id)) continue;
1269
+ if (opts.validNoteIds.has(id) && !pairedSidecarIds.has(id)) {
1270
+ // Sidecar refers to a valid note but the content file is gone —
1271
+ // that's an inconsistency, not an orphan. Leave the sidecar so
1272
+ // the next export can rewrite the content file alongside it.
1273
+ continue;
1274
+ }
1275
+ safeRm(sidecarPath, () => {
1276
+ stats.sidecars_removed++;
1277
+ });
1278
+ }
1279
+
1280
+ // ---- 3. Schema sidecars ----
1281
+ if (existsSync(schemasRoot)) {
1282
+ try {
1283
+ for (const entry of readdirSync(schemasRoot)) {
1284
+ if (!entry.endsWith(".yaml")) continue;
1285
+ const full = join(schemasRoot, entry);
1286
+ try {
1287
+ const text = readFileSync(full, "utf-8");
1288
+ const { frontmatter } = parseFrontmatter(`---\n${text}---\n`);
1289
+ const name = typeof frontmatter.name === "string" ? (frontmatter.name as string) : null;
1290
+ if (!name) {
1291
+ // Fall back to filename — sanitizeTagFilename replaces `/`
1292
+ // with `__`, so reverse for the lookup.
1293
+ const fromFilename = entry.slice(0, -5).replace(/__/g, "/");
1294
+ if (!opts.validTagNames.has(fromFilename)) {
1295
+ safeRm(full, () => {
1296
+ stats.schemas_removed++;
1297
+ });
1298
+ }
1299
+ continue;
1300
+ }
1301
+ if (!opts.validTagNames.has(name)) {
1302
+ safeRm(full, () => {
1303
+ stats.schemas_removed++;
1304
+ });
1305
+ }
1306
+ } catch (err) {
1307
+ stats.unparseable_files.push({
1308
+ path: full,
1309
+ reason: `schema sweep failed: ${(err as Error).message ?? err}`,
1310
+ });
1311
+ }
1312
+ }
1313
+ } catch (err) {
1314
+ stats.unparseable_files.push({
1315
+ path: schemasRoot,
1316
+ reason: `schemas walk failed: ${(err as Error).message ?? err}`,
1317
+ });
1318
+ }
1319
+ }
1320
+
1321
+ // ---- 4. Attachment directories ----
1322
+ if (existsSync(attachmentsRoot)) {
1323
+ try {
1324
+ for (const entry of readdirSync(attachmentsRoot)) {
1325
+ const full = join(attachmentsRoot, entry);
1326
+ let stat;
1327
+ try {
1328
+ stat = statSync(full);
1329
+ } catch (err) {
1330
+ stats.unparseable_files.push({
1331
+ path: full,
1332
+ reason: `stat failed: ${(err as Error).message ?? err}`,
1333
+ });
1334
+ continue;
1335
+ }
1336
+ if (!stat.isDirectory()) continue;
1337
+ // The directory name IS the attachment id (per the export layout).
1338
+ if (!opts.validAttachmentIds.has(entry)) {
1339
+ safeRm(
1340
+ full,
1341
+ () => {
1342
+ stats.attachment_dirs_removed++;
1343
+ },
1344
+ { recursive: true },
1345
+ );
1346
+ }
1347
+ }
1348
+ } catch (err) {
1349
+ stats.unparseable_files.push({
1350
+ path: attachmentsRoot,
1351
+ reason: `attachments walk failed: ${(err as Error).message ?? err}`,
1352
+ });
1353
+ }
1354
+ }
1355
+
1356
+ return stats;
1357
+ }
1358
+
1359
+ /**
1360
+ * True iff the tag carries content the export writer will emit as a
1361
+ * schema sidecar (`.parachute/schemas/<tag>.yaml`). Bare-name tags
1362
+ * (the `tags` table has a row but description/fields/relationships/
1363
+ * parents are all empty) get no sidecar — and crucially, after
1364
+ * `deleteTagSchema` clears those fields the row persists with the
1365
+ * bare name. Callers building the `validTagNames` set for
1366
+ * `pruneOrphans` MUST filter through this predicate, otherwise the
1367
+ * stale sidecar lingers indefinitely.
1368
+ *
1369
+ * Reviewer-flagged on vault#382: without this filter, a cleared
1370
+ * schema's sidecar never gets pruned.
1371
+ */
1372
+ export function hasSchemaContent(tag: TagRecord): boolean {
1005
1373
  if (tag.description !== undefined && tag.description.length > 0) return true;
1006
1374
  if (tag.fields && Object.keys(tag.fields).length > 0) return true;
1007
1375
  if (tag.relationships && Object.keys(tag.relationships).length > 0) return true;
package/core/src/store.ts CHANGED
@@ -217,10 +217,23 @@ export class BunSqliteStore implements Store {
217
217
  }
218
218
 
219
219
  async deleteNote(id: string): Promise<void> {
220
- // Read before delete so we can invalidate config caches on the way out.
220
+ // Read before delete so we can invalidate config caches on the way out
221
+ // AND so the post-delete hook dispatch carries the minimum payload
222
+ // ({ id, path }). The full note can't be reconstructed post-delete —
223
+ // by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
224
+ // not a Note.
221
225
  const existing = noteOps.getNote(this.db, id);
222
226
  noteOps.deleteNote(this.db, id);
223
227
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
228
+ // Dispatch even when `existing` was null — the caller asked for a
229
+ // deletion, and downstream consumers (e.g. the mirror) reconcile via
230
+ // id. Path is undefined in that case; the mirror sweep will catch
231
+ // any orphans missed by the targeted-removal fast path.
232
+ this.hooks.dispatch(
233
+ "deleted",
234
+ { id, ...(existing?.path ? { path: existing.path } : {}) },
235
+ this,
236
+ );
224
237
  }
225
238
 
226
239
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
@@ -340,6 +353,10 @@ export class BunSqliteStore implements Store {
340
353
  // and may have declared `fields` powering schema validation.
341
354
  this._tagHierarchy = null;
342
355
  this._schemaConfig = null;
356
+ // Fire "deleted" only when SOMETHING happened (the underlying
357
+ // deleteTag returns `deleted: false` when the tag didn't exist).
358
+ // The git-mirror reacts to this by sweeping the schema sidecar.
359
+ if (result.deleted) this.hooks.dispatchTag("deleted", name, this);
343
360
  return result;
344
361
  }
345
362
 
@@ -352,6 +369,16 @@ export class BunSqliteStore implements Store {
352
369
  // the schema-config by parent_names + fields content.
353
370
  this._tagHierarchy = null;
354
371
  this._schemaConfig = null;
372
+ // Rename = delete-then-upsert from the perspective of any consumer
373
+ // that keys schema artifacts on the tag name (e.g. the git-mirror's
374
+ // `.parachute/schemas/<tag>.yaml` sidecar file). Fire both events
375
+ // so the consumer drops the old artifact and writes the new one.
376
+ // Only dispatch when the rename actually happened — error returns
377
+ // ({ error: ... }) shouldn't notify subscribers about phantom moves.
378
+ if ("renamed" in result) {
379
+ this.hooks.dispatchTag("deleted", oldName, this);
380
+ this.hooks.dispatchTag("upserted", newName, this);
381
+ }
355
382
  return result;
356
383
  }
357
384
 
@@ -365,6 +392,15 @@ export class BunSqliteStore implements Store {
365
392
  // bust the schema cache — `fields` declarations follow tag identity.
366
393
  this._tagHierarchy = null;
367
394
  this._schemaConfig = null;
395
+ // Each merged source vanishes from the tag set; the target's
396
+ // schema may have absorbed fields/relationships from the sources.
397
+ // Fire "deleted" for each source and "upserted" for the target so
398
+ // the mirror sweeps the source sidecars and rewrites the target.
399
+ for (const source of sources) {
400
+ if (source === target) continue;
401
+ this.hooks.dispatchTag("deleted", source, this);
402
+ }
403
+ this.hooks.dispatchTag("upserted", target, this);
368
404
  return result;
369
405
  }
370
406
 
@@ -440,12 +476,26 @@ export class BunSqliteStore implements Store {
440
476
  // `fields` drives validation — bust the schema cache so the next
441
477
  // create/update sees the new declarations.
442
478
  this._schemaConfig = null;
479
+ // The tag schema sidecar in the mirror needs to track this. Fire
480
+ // "upserted" regardless of whether the row was created or modified
481
+ // — the mirror writes the sidecar fresh either way.
482
+ this.hooks.dispatchTag("upserted", tag, this);
443
483
  return result;
444
484
  }
445
485
 
446
486
  async deleteTagSchema(tag: string) {
447
487
  const result = tagSchemaOps.deleteTagSchema(this.db, tag);
448
- if (result) this._schemaConfig = null;
488
+ if (result) {
489
+ this._schemaConfig = null;
490
+ // Schema-only delete: the tag may still exist as a name in the
491
+ // hierarchy, but the sidecar lost its content. Mirror reacts by
492
+ // sweeping the sidecar file. (If the underlying row was reduced
493
+ // to a bare name with no schema content, hasSchemaContent() in
494
+ // exportVaultToDir already wouldn't have written it on the next
495
+ // export pass — the targeted delete is the fast path; the sweep
496
+ // is the safety net.)
497
+ this.hooks.dispatchTag("deleted", tag, this);
498
+ }
449
499
  return result;
450
500
  }
451
501
 
@@ -494,6 +544,11 @@ export class BunSqliteStore implements Store {
494
544
  this._tagHierarchy = null;
495
545
  this._schemaConfig = null;
496
546
  }
547
+ // Tag-mutation event for the git-mirror and any other downstream
548
+ // consumer. Fire "upserted" on every successful tag-record write —
549
+ // schema/relationship/parent-name mutations all alter the sidecar
550
+ // contents the mirror persists.
551
+ this.hooks.dispatchTag("upserted", tag, this);
497
552
  return result;
498
553
  }
499
554
 
@@ -599,6 +654,17 @@ export class BunSqliteStore implements Store {
599
654
  const other = this.db.prepare(
600
655
  "SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
601
656
  ).get(row.path);
657
+
658
+ // Post-delete event for downstream consumers (e.g. the git-mirror's
659
+ // sweep of `.parachute/attachments/<id>/...`). Payload is the
660
+ // DeletedAttachmentRef — the row is gone, so we pass only id /
661
+ // note_id / path.
662
+ this.hooks.dispatchAttachment(
663
+ "deleted",
664
+ { id: attachmentId, noteId, path: row.path },
665
+ this,
666
+ );
667
+
602
668
  return { deleted: true, path: row.path, orphaned: !other };
603
669
  }
604
670
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.4",
3
+ "version": "0.4.9-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",