@agfpd/iapeer-memory-core 0.2.8 → 0.3.0

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/src/memoryd.ts CHANGED
@@ -4,17 +4,16 @@
4
4
  * One process owns everything live:
5
5
  * - the WRITER role: sole SQLite owner (openDatabase + indexAll), fs.watch
6
6
  * over the vault with debounce, incremental re-index by content hash;
7
- * - the detect subsystems (stage 8 cores): human-edit attribution,
8
- * tags-dictionary mirror, permanent smart-hash diff with COALESCED
9
- * events; INBOX_NEW on new inbox files (baseline at start — pre-existing
10
- * files never replay, parity with the reference monitor);
11
- * - the event stream: signal lines on stdout (`INBOX_NEW: …`,
12
- * `PERMANENT_CHANGED: …`) a notifier watcher forwards each line to the
13
- * Index as an IAP signal (docs/06-pipelines-and-events.md);
7
+ * - the detect subsystems (stage 8 cores): human-edit attribution, the
8
+ * silent-edit (unstamped) belt, the tags-dictionary mirror, and the
9
+ * permanent smart-hash diff COALESCED into one curation pass;
10
+ * - the event stream: one signal line per curation pass on stdout
11
+ * (`CURATOR_TICK: [<paths…>]`) a notifier watcher forwards it to the
12
+ * curation receiver as an IAP signal (docs/06-pipelines-and-events.md);
14
13
  * - the heartbeat state file (consumer: every peer's SessionStart
15
14
  * health-check, ADR-009/010);
16
15
  * - the MCP-http endpoint (ADR-012): localhost, port from config, three
17
- * read-only tools (vault_search / vault_graph / vault_map — ADR-008,
16
+ * read-only tools (memory_search / memory_related / memory_map — ADR-008,
18
17
  * vault_read is NOT on the surface), caller identity from the
19
18
  * `X-IAPeer-Identity` header per request.
20
19
  *
@@ -39,12 +38,13 @@ import type { CoreConfig } from "./config.js";
39
38
  import { openDatabase, type CoreDb } from "./db.js";
40
39
  import { indexAll } from "./indexer.js";
41
40
  import { runSearch, runGraph, runMap } from "./mcp-tools.js";
41
+ import { runDedup } from "./search.js";
42
42
  import { decideUpdate, getZone, sha256 } from "./human-edit-detect.js";
43
43
  import { decideMirror, tagsDictionarySourceRel } from "./tags-mirror.js";
44
+ import { renderTagsProjection, DEFAULT_TAGS_BOUNDARY_MAXLEN } from "./tags-gate.js";
45
+ import { isArchivableZone, shouldArchive, archiveTargetRel } from "./archive.js";
44
46
  import {
45
47
  snapshotVault,
46
- snapshotInbox,
47
- snapshotFlatFolder,
48
48
  diffSnapshots,
49
49
  type VaultSnapshot,
50
50
  } from "./permanent-detect.js";
@@ -253,81 +253,129 @@ export function createMcpServer(opts: {
253
253
  const server = new McpServer(
254
254
  { name: MEMORYD_SERVER_NAME, version: readCoreVersion() },
255
255
  {
256
+ // Server instructions carry the PURPOSE + when-to-use + the behavioural
257
+ // cautions (Anthropic tool-design canon: tool descriptions must NOT
258
+ // instruct behaviour — those hints live here, injected once).
256
259
  instructions:
257
- "iapeer-memory vault shared team memory. vault_search is the default " +
258
- "entry point when you don't have an exact path; read notes with the " +
259
- "native Read tool after search. Stale-status notes are score-deboosted " +
260
- "but still returned treat them as history, not current truth.",
260
+ "iapeer-memory — the team's shared memory: knowledge, decisions, " +
261
+ "context, ideas, lists, project notes. The tools return ranked paths and " +
262
+ "snippets from an index SNAPSHOT, not live bodies open a note with the " +
263
+ "native Read tool, and re-check it before acting (its status or content " +
264
+ "may have moved on). Stale-status notes are deboosted but still returned: " +
265
+ "history, not current truth.",
261
266
  },
262
267
  );
263
268
 
264
269
  server.registerTool(
265
- "vault_search",
270
+ "memory_search",
266
271
  {
272
+ title: "Search team memory (by meaning)",
267
273
  description:
268
- "Search the vault for knowledge, decisions and context when you don't know the exact note path. " +
269
- "Hybrid pipeline: BM25 + optional vector/rerank providers → RRF fusion → status boost 1-hop graph expansion → backlink boost. " +
270
- "Returns up to max_results items (title, path, type, status, score, snippet, related). " +
271
- "Read the found note with the native Read tool. " +
272
- "The `pipeline` object reports per-component status (ok/disabled/skipped/timeout/error/circuit-open).",
274
+ "Find relevant memory by MEANING semantic + keyword search, ranked. " +
275
+ "Returns per item: title, path, type, status, score, snippet, and " +
276
+ "`related` neighbours; `pipeline` carries per-component status (BM25 / " +
277
+ "vector / rerank / graph). Does NOT walk a known note's links — use " +
278
+ "memory_related; for the whole-vault landscape use memory_map.",
273
279
  inputSchema: {
274
- query: z.string().min(1, "vault_search: query is required"),
275
- forCuration: z.boolean().optional(),
280
+ query: z
281
+ .string()
282
+ .min(1, "memory_search: query is required")
283
+ .describe("What to search for — keywords or a natural-language description. Quoted phrases supported."),
284
+ forCuration: z
285
+ .boolean()
286
+ .optional()
287
+ .describe("Internal: bias ranking for a curation pass (Index/Scriber). Omit for a normal author search."),
276
288
  },
277
289
  outputSchema: vaultSearchOutput.shape,
278
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
290
+ annotations: {
291
+ readOnlyHint: true,
292
+ destructiveHint: false,
293
+ idempotentHint: true,
294
+ openWorldHint: true,
295
+ },
279
296
  },
280
297
  async ({ query, forCuration }) => {
281
298
  try {
282
299
  return toResult(await runSearch(db, effectiveConfig, { query, forCuration }));
283
300
  } catch (err) {
284
- return toError("vault_search", err, logger);
301
+ return toError("memory_search", err, logger);
285
302
  }
286
303
  },
287
304
  );
288
305
 
289
306
  server.registerTool(
290
- "vault_graph",
307
+ "memory_related",
291
308
  {
309
+ title: "Related notes (link graph)",
292
310
  description:
293
- "Walk the wikilink graph outward from a note, 1–3 hops in both directions. " +
294
- "Returns nodes and edges of the subgraph; agent-memory backlinks of canonical notes are filtered (one-way). " +
295
- "Unknown center path {found:false}.",
311
+ "Walk the wikilink graph around a note you ALREADY have — 1–3 hops, both " +
312
+ "directions surfacing its cross-linked neighbours and backlinks. Returns " +
313
+ "the subgraph (nodes, edges); agent-memory backlinks of canon are " +
314
+ "one-way-filtered; an unknown center path → {found:false}. To find notes by " +
315
+ "meaning use memory_search; for the whole-vault landscape use memory_map.",
296
316
  inputSchema: {
297
- path: z.string().min(1, "vault_graph: path is required"),
298
- depth: z.number().int().min(1).max(3).optional(),
317
+ path: z
318
+ .string()
319
+ .min(1, "memory_related: path is required")
320
+ .describe("Vault-relative path of the note to center on (e.g. `01_Knowledge/Note.md`) — take it from a memory_search result's `path`."),
321
+ depth: z
322
+ .number()
323
+ .int()
324
+ .min(1)
325
+ .max(3)
326
+ .optional()
327
+ .describe("Hops outward (1–3). Default 1 — the note's immediate neighbours."),
299
328
  },
300
329
  outputSchema: vaultGraphOutput.shape,
301
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
330
+ annotations: {
331
+ readOnlyHint: true,
332
+ destructiveHint: false,
333
+ idempotentHint: true,
334
+ openWorldHint: false,
335
+ },
302
336
  },
303
337
  async ({ path: p, depth }) => {
304
338
  try {
305
339
  return toResult(runGraph(db, effectiveConfig, { path: p, depth }));
306
340
  } catch (err) {
307
- return toError("vault_graph", err, logger);
341
+ return toError("memory_related", err, logger);
308
342
  }
309
343
  },
310
344
  );
311
345
 
312
346
  server.registerTool(
313
- "vault_map",
347
+ "memory_map",
314
348
  {
349
+ title: "Vault landscape (map)",
315
350
  description:
316
- "Global topology of the CANONICAL vault graph (agent memory excluded): " +
317
- "clusters, hubs, bridges, orphans + orphan_wikilinks (opt-in part). " +
318
- "Stats always returned.",
351
+ "The holistic LANDSCAPE of the canonical graph (agent memory excluded), " +
352
+ "like the Obsidian graph view: clusters, hubs, bridges, orphans (+ optional " +
353
+ "orphan_wikilinks). For the BIG PICTURE — themes, structure, gaps — not a " +
354
+ "single note. For one note's neighbourhood use memory_related; to find notes " +
355
+ "by meaning use memory_search.",
319
356
  inputSchema: {
320
- detail: z.enum(["summary", "full"]).optional(),
321
- parts: z.array(mapPart).optional(),
357
+ detail: z
358
+ .enum(["summary", "full"])
359
+ .optional()
360
+ .describe("`summary` (counts + top items) or `full` (every cluster/hub). Default summary."),
361
+ parts: z
362
+ .array(mapPart)
363
+ .optional()
364
+ .describe("Which parts to include: clusters, hubs, bridges, orphans, orphan_wikilinks. Default: all but orphan_wikilinks (the opt-in heavy part)."),
322
365
  },
323
366
  outputSchema: vaultMapOutput.shape,
324
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
367
+ annotations: {
368
+ readOnlyHint: true,
369
+ destructiveHint: false,
370
+ idempotentHint: true,
371
+ openWorldHint: false,
372
+ },
325
373
  },
326
374
  async ({ detail, parts }) => {
327
375
  try {
328
376
  return toResult(runMap(db, effectiveConfig, { detail, parts }));
329
377
  } catch (err) {
330
- return toError("vault_map", err, logger);
378
+ return toError("memory_map", err, logger);
331
379
  }
332
380
  },
333
381
  );
@@ -353,6 +401,41 @@ export async function startMcpHttp(opts: {
353
401
  const httpServer = http.createServer((req, res) => {
354
402
  void (async () => {
355
403
  const url = (req.url ?? "").split("?")[0];
404
+ // Dedup hint (lean §3a) — a memoryd-INTERNAL RPC for the post-write hook,
405
+ // NOT an MCP tool (the MCP surface stays the three read tools). Loopback
406
+ // (host is 127.0.0.1) + read-only. The hook calls it fail-open with a
407
+ // short timeout, so a slow/down memoryd never hangs a write.
408
+ if (url === "/dedup" && req.method === "POST") {
409
+ const chunks: Buffer[] = [];
410
+ req.on("data", (c) => chunks.push(c as Buffer));
411
+ await new Promise<void>((resolve) => req.on("end", () => resolve()));
412
+ try {
413
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as {
414
+ content?: string;
415
+ threshold?: number;
416
+ limit?: number;
417
+ linkThreshold?: number;
418
+ };
419
+ const content = (body.content ?? "").trim();
420
+ const result = content
421
+ ? await runDedup(opts.db, opts.config, {
422
+ content,
423
+ threshold: body.threshold,
424
+ limit: body.limit,
425
+ linkThreshold: body.linkThreshold,
426
+ })
427
+ : { enabled: Boolean(opts.config.embedding), matches: [] };
428
+ res.writeHead(200, { "Content-Type": "application/json" });
429
+ res.end(JSON.stringify(result));
430
+ } catch (err) {
431
+ logger.error(`dedup request failed: ${String(err)}`);
432
+ if (!res.headersSent) {
433
+ res.writeHead(500, { "Content-Type": "application/json" });
434
+ res.end(JSON.stringify({ enabled: false, matches: [], error: "internal error" }));
435
+ }
436
+ }
437
+ return;
438
+ }
356
439
  if (url !== "/mcp") {
357
440
  res.writeHead(404, { "Content-Type": "application/json" });
358
441
  res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
@@ -417,17 +500,16 @@ export async function startMcpHttp(opts: {
417
500
  // a sync-storm right after a restart could still mis-attribute — so the
418
501
  // baseline survives restarts, atomically (same tmp+rename canon).
419
502
 
420
- /** Persisted batch baselines: inbox + permanent + human-inbox snapshots
421
- * (rel/name → smart hash) + the silent-edit stamp baseline (rel →
422
- * {updated, leb}; the unstamped detector judges stamp movement against
423
- * it). Survives restarts — порт паттерна старых мониторов «seen
424
- * переживает сбой». Migration is FIRST-SIGHT by construction: an absent
425
- * silentStamps key (pre-detector state file) reads as an empty map — one
426
- * warm-up pass records, never judges. */
503
+ /** Persisted batch baselines: the permanent-folders snapshot (rel → smart
504
+ * hash) + the silent-edit stamp baseline (rel → {updated, leb}; the
505
+ * unstamped detector judges stamp movement against it). Survives restarts —
506
+ * порт паттерна старых мониторов «seen переживает сбой». Migration is
507
+ * FIRST-SIGHT by construction: an absent silentStamps key (pre-detector
508
+ * state file) reads as an empty map — one warm-up pass records, never
509
+ * judges. Legacy `inbox`/`humanInbox` keys in an old state file are simply
510
+ * ignored on load. */
427
511
  export type BatchState = {
428
- inbox: VaultSnapshot | null;
429
512
  permanent: VaultSnapshot | null;
430
- humanInbox: VaultSnapshot | null;
431
513
  silentStamps: Map<string, StampRecord> | null;
432
514
  };
433
515
 
@@ -462,22 +544,18 @@ export function loadBatchState(filePath: string): BatchState {
462
544
  return m;
463
545
  };
464
546
  return {
465
- inbox: toMap(raw.inbox),
466
547
  permanent: toMap(raw.permanent),
467
- humanInbox: toMap(raw.humanInbox),
468
548
  silentStamps: toStamps(raw.silentStamps),
469
549
  };
470
550
  } catch {
471
- return { inbox: null, permanent: null, humanInbox: null, silentStamps: null };
551
+ return { permanent: null, silentStamps: null };
472
552
  }
473
553
  }
474
554
 
475
555
  export function persistBatchState(
476
556
  filePath: string,
477
557
  state: {
478
- inbox: VaultSnapshot;
479
558
  permanent: VaultSnapshot;
480
- humanInbox: VaultSnapshot;
481
559
  silentStamps: Map<string, StampRecord>;
482
560
  },
483
561
  ): void {
@@ -486,9 +564,7 @@ export function persistBatchState(
486
564
  guardedWriteFileSync(
487
565
  tmp,
488
566
  JSON.stringify({
489
- inbox: Object.fromEntries(state.inbox),
490
567
  permanent: Object.fromEntries(state.permanent),
491
- humanInbox: Object.fromEntries(state.humanInbox),
492
568
  silentStamps: Object.fromEntries(state.silentStamps),
493
569
  }),
494
570
  "utf-8",
@@ -531,13 +607,16 @@ export type MemorydOptions = {
531
607
  heartbeatPath?: string;
532
608
  /** Tags mirror target; default `<db dir>/tags-dictionary.md`. */
533
609
  tagsMirrorPath?: string;
610
+ /** Compact tags-projection target (injected to all peers, lean §3);
611
+ * default `<db dir>/tags-projection.md`. */
612
+ tagsProjectionPath?: string;
534
613
  /** Detect-hash persistence file; default `<db dir>/memoryd.hashes.json`. */
535
614
  hashStatePath?: string;
536
- /** Persisted batch baselines (inbox + permanent + human-inbox snapshots). */
615
+ /** Persisted batch baselines (the permanent-folders snapshot + silent
616
+ * stamps). */
537
617
  batchStatePath?: string;
538
- /** Cadence overrides (tests); default from config.batch. */
539
- permanentBatchMs?: number;
540
- humanInboxBatchMs?: number;
618
+ /** Curator-tick cadence override (tests); default from config.batch. */
619
+ curatorTickMs?: number;
541
620
  /** Periodic hash-persist interval (ms). */
542
621
  persistMs?: number;
543
622
  /** Human owner name; human-edit detection is OFF when absent (⚖7). */
@@ -583,8 +662,8 @@ export type MemorydHandle = {
583
662
  mcpPort: number | null;
584
663
  /** Force one detect pass immediately (used by tests and shutdown flush). */
585
664
  runDetectPass: () => Promise<void>;
586
- /** Force a cadence batch (tests / operator force-tick). */
587
- runBatchPass: (kind: "permanent" | "human-inbox") => void;
665
+ /** Force a curator tick (tests / operator force-tick). */
666
+ runCuratorTick: () => void;
588
667
  close: () => Promise<void>;
589
668
  };
590
669
 
@@ -597,6 +676,9 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
597
676
  const dbDir = path.dirname(config.index.dbPath);
598
677
  const heartbeatPath = opts.heartbeatPath ?? path.join(dbDir, "memoryd.heartbeat");
599
678
  const tagsMirrorPath = opts.tagsMirrorPath ?? path.join(dbDir, "tags-dictionary.md");
679
+ const tagsProjectionPath = opts.tagsProjectionPath ?? path.join(dbDir, "tags-projection.md");
680
+ const tagsBoundaryMaxLen =
681
+ Number(process.env.IAPEER_MEMORY_TAGS_BOUNDARY_MAXLEN) || DEFAULT_TAGS_BOUNDARY_MAXLEN;
600
682
  const hashStatePath = opts.hashStatePath ?? path.join(dbDir, "memoryd.hashes.json");
601
683
  const persistMs = opts.persistMs ?? 60_000;
602
684
  const taxonomy = config.taxonomy;
@@ -604,22 +686,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
604
686
  const db = openDatabase(config);
605
687
  await indexAll({ db, config, logger });
606
688
 
607
- // Baselines (каденции — директива Артура 10.06 ~15:31): inbox HASH-diffed
608
- // мгновенно (B-приёмка fix: правка существующего черновика = событие, цикл
609
- // reject→fix→re-review); канон + оперативка копятся и уходят ПАЧКОЙ раз в
610
- // batch.permanentMs (default 6h); human-inbox пачкой раз в
611
- // batch.humanInboxMs (default 4h). Снапшоты ПЕРСИСТЯТСЯ через рестарты
612
- // (порт паттерна старых мониторов: «seen переживает сбой») — рестарт не
613
- // глотает накопленное и не реплеит обработанное.
689
+ // Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
690
+ // копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
691
+ // Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
692
+ // переживает сбой») — рестарт не глотает накопленное и не реплеит
693
+ // обработанное.
614
694
  const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
615
695
  const persistedBatches = loadBatchState(batchStatePath);
616
- let inboxSnapshot: VaultSnapshot =
617
- persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
618
696
  let permanentBaseline: VaultSnapshot =
619
697
  persistedBatches.permanent ?? snapshotVault(config.vaultPath, taxonomy);
620
- const humanInboxDir = path.join(config.vaultPath, taxonomy.folders.inboxHuman);
621
- let humanInboxBaseline: VaultSnapshot =
622
- persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
623
698
  const lastSeenHashes = loadHashState(hashStatePath);
624
699
  /** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
625
700
  * old state file = empty map = first-sight warm-up (design §4). */
@@ -629,16 +704,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
629
704
  function persistBatches(): void {
630
705
  try {
631
706
  persistBatchState(batchStatePath, {
632
- inbox: inboxSnapshot,
633
707
  permanent: permanentBaseline,
634
- humanInbox: humanInboxBaseline,
635
708
  silentStamps,
636
709
  });
637
710
  } catch (err) {
638
711
  logger.error(`batch-state persist failed: ${String(err)}`);
639
712
  }
640
713
  }
641
- if (!persistedBatches.inbox) persistBatches(); // first run: write baselines
714
+ if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
642
715
 
643
716
  /** iCloud-mount guard (порт защиты старых мониторов): корень vault
644
717
  * недоступен → пропускаем проход целиком, baseline не трогаем — после
@@ -745,7 +818,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
745
818
  indexAgent,
746
819
  paths: fragments.paths,
747
820
  authorIndexPath: outFile,
748
- tagsDictionaryPath: peer.personality === indexAgent ? tagsMirrorPath : undefined,
821
+ // lean §3: the compact dictionary projection is injected to EVERY
822
+ // author now (pre-lean: only the Index got the full mirror).
823
+ tagsProjectionPath,
824
+ tagsTitle: taxonomy.systemFiles.tagsDictionary,
749
825
  },
750
826
  });
751
827
  rendered++;
@@ -785,23 +861,46 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
785
861
  mirrorContent = null;
786
862
  }
787
863
  const decision = decideMirror({ srcContent, mirrorContent });
788
- if (decision.action !== "write") return;
789
- fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
790
- const tmp = `${tagsMirrorPath}.tmp`;
791
- guardedWriteFileSync(tmp, srcContent!, "utf-8");
792
- fs.renameSync(tmp, tagsMirrorPath);
793
- logger.info(`tags mirror updated (${decision.reason})`);
864
+ if (decision.action === "write") {
865
+ fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
866
+ const tmp = `${tagsMirrorPath}.tmp`;
867
+ guardedWriteFileSync(tmp, srcContent!, "utf-8");
868
+ fs.renameSync(tmp, tagsMirrorPath);
869
+ mirrorContent = srcContent;
870
+ logger.info(`tags mirror updated (${decision.reason})`);
871
+ }
872
+ // Refresh the compact projection from the (up-to-date) mirror — even when
873
+ // the mirror was unchanged, so the projection materialises on first run
874
+ // after the feature ships (lean §3). Idempotent: writes only on a change.
875
+ syncTagsProjection(mirrorContent);
876
+ }
877
+
878
+ function syncTagsProjection(mirrorContent: string | null): void {
879
+ if (!mirrorContent || !mirrorContent.trim()) return; // no dict → keep existing
880
+ const proj = renderTagsProjection(mirrorContent, { boundaryMaxLen: tagsBoundaryMaxLen });
881
+ if (!proj.trim()) return;
882
+ let existing: string | null = null;
883
+ try {
884
+ existing = fs.readFileSync(tagsProjectionPath, "utf-8");
885
+ } catch {
886
+ existing = null;
887
+ }
888
+ if (existing === proj) return;
889
+ fs.mkdirSync(path.dirname(tagsProjectionPath), { recursive: true });
890
+ const tmp = `${tagsProjectionPath}.tmp`;
891
+ guardedWriteFileSync(tmp, proj, "utf-8");
892
+ fs.renameSync(tmp, tagsProjectionPath);
893
+ logger.info("tags projection updated");
794
894
  }
795
895
 
796
- /** Zone map of the unstamped detector (design §3): the agent inbox +
797
- * the six permanent folders (five canonical + agent memory — wider than
798
- * humanEditPass's getZone, which has no agent-memory notion). */
896
+ /** Zone map of the unstamped detector (design §3): the six monitored
897
+ * folders (five canonical + agent memory — wider than humanEditPass's
898
+ * getZone, which has no agent-memory notion). */
799
899
  function silentZoneOf(absPath: string): SilentZone | null {
800
900
  const rel = path.relative(config.vaultPath, absPath);
801
901
  if (rel.startsWith("..")) return null;
802
902
  const first = rel.split(path.sep)[0];
803
903
  const f = taxonomy.folders;
804
- if (first === f.inbox) return "inbox";
805
904
  if (
806
905
  first === f.knowledge ||
807
906
  first === f.decisions ||
@@ -817,13 +916,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
817
916
 
818
917
  /**
819
918
  * Unstamped-write detector (design doc, boris-accepted 11.06). Runs
820
- * BEFORE the inbox emission loop and BEFORE humanEditPass: a re-stamped
821
- * file then reads `last_edited_by: unstamped` — the curator source
822
- * filters pass it into the pipeline, and humanEditPass sees a fresh
823
- * agent stamp (echo-agent skip, no double stamp — order instead of ifs).
824
- * Candidates carry CHANGED files only (fs.watch set inbox diff) the
825
- * semantic-hash precondition of the rule; service-only echoes never
826
- * reach here (smart-hash blindness = echo safety of our own re-stamp).
919
+ * BEFORE humanEditPass: a re-stamped file then reads `last_edited_by:
920
+ * unstamped` — the curator source filters pass it into the curation pass,
921
+ * and humanEditPass sees a fresh agent stamp (echo-agent skip, no double
922
+ * stamp — order instead of ifs). Candidates carry CHANGED files only (the
923
+ * fs.watch set) the semantic-hash precondition of the rule; service-only
924
+ * echoes never reach here (smart-hash blindness = echo safety of our own
925
+ * re-stamp).
827
926
  */
828
927
  function silentEditPass(candidatesAbs: Set<string>): void {
829
928
  for (const abs of candidatesAbs) {
@@ -884,11 +983,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
884
983
  continue; // deleted mid-debounce
885
984
  }
886
985
  // FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
887
- // нет в hash-базе — это mv размещения Индексом (Входящие канон) или
888
- // первое наблюдение после установки, НЕ человеческая правка. Запиши
889
- // baseline и не стампь: фантомные last_edited_by на каждом размещении
890
- // системный churn. Цена: первая правка человеком never-seen файла
891
- // пройдёт без стампа один раз (поймается следующей).
986
+ // нет в hash-базе — это ПЕРВОЕ наблюдение (старт демона над уже
987
+ // населённым vault), НЕ обязательно человеческая правка. Запиши
988
+ // baseline и не стампь: иначе старт массово штампует все существующие
989
+ // ноты как «правки человека». (Размещений Индексом больше нет — инбокс
990
+ // устранён; единственный источник never-seen-путей старт/новый файл.)
991
+ // ЦЕНА (§9-край, на живой приёмке): свежая bare-body заметка человека
992
+ // достраивается со ВТОРОГО fs-события — первое лишь пишет baseline.
892
993
  if (!lastSeenHashes.has(filePath)) {
893
994
  lastSeenHashes.set(filePath, sha256(content));
894
995
  continue;
@@ -901,6 +1002,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
901
1002
  birthtimeMs: stat.birthtime ? stat.birthtime.getTime() : 0,
902
1003
  mtimeMs: stat.mtime.getTime(),
903
1004
  basename: path.basename(filePath),
1005
+ path: filePath,
1006
+ vault: config.vaultPath,
904
1007
  lastHash: lastSeenHashes.get(filePath) ?? null,
905
1008
  taxonomy,
906
1009
  freshEditWindowS: opts.freshEditWindowS,
@@ -926,6 +1029,44 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
926
1029
  }
927
1030
  }
928
1031
 
1032
+ /**
1033
+ * Deterministic archiving (lean §2.2a): move stale notes among the changed
1034
+ * files to the archive folder. Runs AFTER humanEditPass (a note just marked
1035
+ * stale carries its stamp). The move is invisible to permanent-detect
1036
+ * (deletions are ignored; the archive is outside `monitoredFolders`), and
1037
+ * `indexAll` below reconciles the path change (drops the source, indexes the
1038
+ * archived copy — still searchable with the stale boost). Returns the count.
1039
+ */
1040
+ function archiveStaleNotes(candidatesAbs: Set<string>): number {
1041
+ let moved = 0;
1042
+ for (const abs of candidatesAbs) {
1043
+ const rel = path.relative(config.vaultPath, abs);
1044
+ if (!isArchivableZone(rel, taxonomy)) continue;
1045
+ let content: string;
1046
+ try {
1047
+ content = fs.readFileSync(abs, "utf-8");
1048
+ } catch {
1049
+ continue; // deleted mid-debounce
1050
+ }
1051
+ if (!shouldArchive(rel, content, taxonomy)) continue;
1052
+ const targetRel = archiveTargetRel(path.basename(abs), taxonomy, (r) =>
1053
+ fs.existsSync(path.join(config.vaultPath, r)),
1054
+ );
1055
+ const targetAbs = path.join(config.vaultPath, targetRel);
1056
+ try {
1057
+ fs.mkdirSync(path.dirname(targetAbs), { recursive: true });
1058
+ fs.renameSync(abs, targetAbs);
1059
+ silentStamps.delete(rel); // baselines follow the move
1060
+ lastSeenHashes.delete(abs);
1061
+ moved += 1;
1062
+ logger.info(`archived (stale): ${rel} → ${targetRel}`);
1063
+ } catch (err) {
1064
+ logger.error(`archive failed for ${rel}: ${String(err)}`);
1065
+ }
1066
+ }
1067
+ return moved;
1068
+ }
1069
+
929
1070
  // ── fs.watch + debounce ──
930
1071
  const pending = new Set<string>();
931
1072
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -937,62 +1078,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
937
1078
 
938
1079
  if (!vaultAvailable()) return;
939
1080
 
940
- // INBOX_NEW мгновенно: new OR semantically changed drafts (hash-diff
941
- // over the WHOLE inbox folder; no dependency on fs.watch filenames).
942
- const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
943
- const inboxChanged = diffSnapshots(inboxSnapshot, inboxNext);
944
-
945
- // Unstamped detector FIRST (design §3 order): re-stamps land before the
946
- // curator suppress below reads leb and before humanEditPass judges.
947
- // Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
948
- // diff (the diff is semantic — a re-stamp does not invalidate it).
949
- silentEditPass(
950
- new Set([
951
- ...changed,
952
- ...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
953
- ]),
954
- );
955
-
956
- for (const name of inboxChanged) {
957
- const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
958
- // Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
959
- // 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
960
- // повторный INBOX_NEW по тому же черновику — вычитывающая сессия и
961
- // есть конвейер). Правки автора (reject→fix→re-review) проходят:
962
- // last_edited_by остаётся автором. Цена документирована: черновик,
963
- // чья ПОСЛЕДНЯЯ правка до первого анонса — кураторская, не
964
- // анонсируется; в конвейере не возникает (кураторы трогают черновики
965
- // только по событиям, т.е. после анонса). Baseline обновляется ниже
966
- // безусловно — подавленное НЕ реплеится, авторская правка поверх
967
- // кураторской диффится от свежего снапшота и проходит.
968
- const leb = lastEditedByOf(abs);
969
- if (leb && config.curatorSet.includes(leb)) {
970
- logger.info(`inbox event suppressed (curator ${leb}): ${name}`);
971
- continue;
972
- }
973
- logger.info(`inbox event: ${name}`);
974
- // ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
975
- // vault root — a role peer once `find`-guessed a stale copy.
976
- emit(`INBOX_NEW: ${abs}`);
977
- }
978
- if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
979
- inboxSnapshot = inboxNext;
980
- persistBatches();
981
- }
1081
+ // Unstamped detector FIRST (design §3 order): re-stamps land before
1082
+ // humanEditPass judges (a re-stamped file then reads `last_edited_by:
1083
+ // unstamped` and humanEditPass sees a fresh agent stamp → echo-agent
1084
+ // skip, no double stamp — order instead of ifs). Candidates are the
1085
+ // fs.watch changed set; service-only echoes never reach the rule
1086
+ // (smart-hash blindness = echo safety of our own re-stamp).
1087
+ silentEditPass(changed);
982
1088
 
983
1089
  humanEditPass(changed);
1090
+ archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
984
1091
  syncTagsMirror();
985
1092
  await indexAll({ db, config, logger }); // incremental by content hash
986
1093
  renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
987
- // PERMANENT правки НЕ эмитятся мгновеннокопятся до batch-прохода
988
- // (каденция 6h); см. runPermanentBatch.
1094
+ // Canon edits are NOT emitted instantly they accumulate to the curator
1095
+ // tick (cadence 6h); see runCuratorTick.
989
1096
  }
990
1097
 
991
- /** PERMANENT_BATCHодин проход каденции: diff канона+оперативки против
992
- * baseline пачки, кураторские правки отфильтрованы ИСТОЧНИКОМ, остальное
993
- * уходит ОДНОЙ строкой (JSON-массив АБСОЛЮТНЫХ путейодна доставка
994
- * одна ephemeral-сессия Scriber'аодин отчёт). */
995
- function runPermanentBatch(): void {
1098
+ /** CURATOR_TICKone cadence pass: diff canon + agent memory against the
1099
+ * carried baseline, curator-authored edits filtered BY SOURCE, the rest
1100
+ * goes out as ONE line (a JSON array of ABSOLUTE paths one delivery
1101
+ * one ephemeral curation session one report). In lean the emit is
1102
+ * suppressed (no proactive receiver), but the baseline still advances. */
1103
+ function runCuratorTick(): void {
996
1104
  if (!vaultAvailable()) return;
997
1105
  const next = snapshotVault(config.vaultPath, taxonomy);
998
1106
  const changedRel = diffSnapshots(permanentBaseline, next);
@@ -1005,23 +1113,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1005
1113
  absPaths.push(abs);
1006
1114
  }
1007
1115
  if (absPaths.length) {
1008
- logger.info(`permanent batch: ${absPaths.length} path(s)`);
1009
- emit(`PERMANENT_BATCH: ${JSON.stringify(absPaths)}`);
1010
- }
1011
- persistBatches();
1012
- }
1013
-
1014
- /** HUMAN_INBOX_BATCH — каденция human-inbox (человек пишет долго,
1015
- * мгновенный триггер схватил бы недоделанное). */
1016
- function runHumanInboxBatch(): void {
1017
- if (!vaultAvailable()) return;
1018
- const next = snapshotFlatFolder(humanInboxDir);
1019
- const names = diffSnapshots(humanInboxBaseline, next);
1020
- humanInboxBaseline = next;
1021
- if (names.length) {
1022
- const absPaths = names.map((n) => path.join(humanInboxDir, n));
1023
- logger.info(`human-inbox batch: ${absPaths.length} draft(s)`);
1024
- emit(`HUMAN_INBOX_BATCH: ${JSON.stringify(absPaths)}`);
1116
+ logger.info(`curator tick: ${absPaths.length} path(s)`);
1117
+ emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
1025
1118
  }
1026
1119
  persistBatches();
1027
1120
  }
@@ -1096,42 +1189,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1096
1189
  const persistTimer = setInterval(persistQuiet, persistMs);
1097
1190
  persistTimer.unref?.();
1098
1191
 
1099
- // ── cadence timers (директива ~15:31): первый прогон через полный период
1100
- const permanentBatchMs = opts.permanentBatchMs ?? config.batch.permanentMs;
1101
- const permanentTimer = setInterval(() => {
1192
+ // ── cadence timer (директива ~15:31): первый прогон через полный период
1193
+ const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
1194
+ const curatorTimer = setInterval(() => {
1102
1195
  try {
1103
- runPermanentBatch();
1196
+ runCuratorTick();
1104
1197
  } catch (err) {
1105
- logger.error(`permanent batch failed: ${String(err)}`);
1106
- }
1107
- }, permanentBatchMs);
1108
- permanentTimer.unref?.();
1109
- // HUMAN-INBOX: раз в СУТКИ в config.batch.humanInboxHour локального
1110
- // (подтверждение Артура ~15:4x: ночная партия, как в старом контуре).
1111
- // Тестовый override opts.humanInboxBatchMs — простой интервал.
1112
- let humanInboxTimer: ReturnType<typeof setTimeout> | null = null;
1113
- function scheduleHumanInbox(): void {
1114
- let delay: number;
1115
- if (opts.humanInboxBatchMs !== undefined) {
1116
- delay = opts.humanInboxBatchMs;
1117
- } else {
1118
- const now = new Date();
1119
- const next = new Date(now);
1120
- next.setHours(config.batch.humanInboxHour, 0, 0, 0);
1121
- if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
1122
- delay = next.getTime() - now.getTime();
1198
+ logger.error(`curator tick failed: ${String(err)}`);
1123
1199
  }
1124
- humanInboxTimer = setTimeout(() => {
1125
- try {
1126
- runHumanInboxBatch();
1127
- } catch (err) {
1128
- logger.error(`human-inbox batch failed: ${String(err)}`);
1129
- }
1130
- scheduleHumanInbox();
1131
- }, delay);
1132
- humanInboxTimer.unref?.();
1133
- }
1134
- scheduleHumanInbox();
1200
+ }, curatorTickMs);
1201
+ curatorTimer.unref?.();
1135
1202
 
1136
1203
  // ── MCP http ──
1137
1204
  let mcp: { port: number; close: () => Promise<void> } | null = null;
@@ -1145,10 +1212,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1145
1212
 
1146
1213
  return {
1147
1214
  mcpPort: mcp?.port ?? null,
1148
- runBatchPass: (kind) => {
1149
- if (kind === "permanent") runPermanentBatch();
1150
- else runHumanInboxBatch();
1151
- },
1215
+ runCuratorTick,
1152
1216
  runDetectPass: async () => {
1153
1217
  if (flushTimer) {
1154
1218
  clearTimeout(flushTimer);
@@ -1161,11 +1225,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1161
1225
  watcher?.close();
1162
1226
  if (flushTimer) clearTimeout(flushTimer);
1163
1227
  clearInterval(heartbeatTimer);
1164
- clearInterval(permanentTimer);
1165
- if (humanInboxTimer) clearTimeout(humanInboxTimer);
1228
+ clearInterval(curatorTimer);
1166
1229
  clearInterval(persistTimer);
1167
1230
  await flushing;
1168
1231
  persistQuiet();
1232
+ // Batch state (silentStamps + the permanent baseline) also persists on
1233
+ // shutdown — otherwise silentStamps moved since the last curator tick are
1234
+ // lost, breaking the "survives restarts" invariant (first-sight warm-up
1235
+ // would then re-skip a real edit after a graceful restart).
1236
+ persistBatches();
1169
1237
  if (mcp) await mcp.close();
1170
1238
  try {
1171
1239
  guardedUnlinkSync(heartbeatPath);