@agfpd/iapeer-memory-core 0.2.9 → 0.3.1

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
  *
@@ -46,8 +45,6 @@ import { renderTagsProjection, DEFAULT_TAGS_BOUNDARY_MAXLEN } from "./tags-gate.
46
45
  import { isArchivableZone, shouldArchive, archiveTargetRel } from "./archive.js";
47
46
  import {
48
47
  snapshotVault,
49
- snapshotInbox,
50
- snapshotFlatFolder,
51
48
  diffSnapshots,
52
49
  type VaultSnapshot,
53
50
  } from "./permanent-detect.js";
@@ -256,81 +253,129 @@ export function createMcpServer(opts: {
256
253
  const server = new McpServer(
257
254
  { name: MEMORYD_SERVER_NAME, version: readCoreVersion() },
258
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).
259
259
  instructions:
260
- "iapeer-memory vault shared team memory. vault_search is the default " +
261
- "entry point when you don't have an exact path; read notes with the " +
262
- "native Read tool after search. Stale-status notes are score-deboosted " +
263
- "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.",
264
266
  },
265
267
  );
266
268
 
267
269
  server.registerTool(
268
- "vault_search",
270
+ "memory_search",
269
271
  {
272
+ title: "Search team memory (by meaning)",
270
273
  description:
271
- "Search the vault for knowledge, decisions and context when you don't know the exact note path. " +
272
- "Hybrid pipeline: BM25 + optional vector/rerank providers → RRF fusion → status boost 1-hop graph expansion → backlink boost. " +
273
- "Returns up to max_results items (title, path, type, status, score, snippet, related). " +
274
- "Read the found note with the native Read tool. " +
275
- "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.",
276
279
  inputSchema: {
277
- query: z.string().min(1, "vault_search: query is required"),
278
- 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."),
279
288
  },
280
289
  outputSchema: vaultSearchOutput.shape,
281
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
290
+ annotations: {
291
+ readOnlyHint: true,
292
+ destructiveHint: false,
293
+ idempotentHint: true,
294
+ openWorldHint: true,
295
+ },
282
296
  },
283
297
  async ({ query, forCuration }) => {
284
298
  try {
285
299
  return toResult(await runSearch(db, effectiveConfig, { query, forCuration }));
286
300
  } catch (err) {
287
- return toError("vault_search", err, logger);
301
+ return toError("memory_search", err, logger);
288
302
  }
289
303
  },
290
304
  );
291
305
 
292
306
  server.registerTool(
293
- "vault_graph",
307
+ "memory_related",
294
308
  {
309
+ title: "Related notes (link graph)",
295
310
  description:
296
- "Walk the wikilink graph outward from a note, 1–3 hops in both directions. " +
297
- "Returns nodes and edges of the subgraph; agent-memory backlinks of canonical notes are filtered (one-way). " +
298
- "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.",
299
316
  inputSchema: {
300
- path: z.string().min(1, "vault_graph: path is required"),
301
- 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."),
302
328
  },
303
329
  outputSchema: vaultGraphOutput.shape,
304
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
330
+ annotations: {
331
+ readOnlyHint: true,
332
+ destructiveHint: false,
333
+ idempotentHint: true,
334
+ openWorldHint: false,
335
+ },
305
336
  },
306
337
  async ({ path: p, depth }) => {
307
338
  try {
308
339
  return toResult(runGraph(db, effectiveConfig, { path: p, depth }));
309
340
  } catch (err) {
310
- return toError("vault_graph", err, logger);
341
+ return toError("memory_related", err, logger);
311
342
  }
312
343
  },
313
344
  );
314
345
 
315
346
  server.registerTool(
316
- "vault_map",
347
+ "memory_map",
317
348
  {
349
+ title: "Vault landscape (map)",
318
350
  description:
319
- "Global topology of the CANONICAL vault graph (agent memory excluded): " +
320
- "clusters, hubs, bridges, orphans + orphan_wikilinks (opt-in part). " +
321
- "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.",
322
356
  inputSchema: {
323
- detail: z.enum(["summary", "full"]).optional(),
324
- 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)."),
325
365
  },
326
366
  outputSchema: vaultMapOutput.shape,
327
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
367
+ annotations: {
368
+ readOnlyHint: true,
369
+ destructiveHint: false,
370
+ idempotentHint: true,
371
+ openWorldHint: false,
372
+ },
328
373
  },
329
374
  async ({ detail, parts }) => {
330
375
  try {
331
376
  return toResult(runMap(db, effectiveConfig, { detail, parts }));
332
377
  } catch (err) {
333
- return toError("vault_map", err, logger);
378
+ return toError("memory_map", err, logger);
334
379
  }
335
380
  },
336
381
  );
@@ -369,6 +414,7 @@ export async function startMcpHttp(opts: {
369
414
  content?: string;
370
415
  threshold?: number;
371
416
  limit?: number;
417
+ linkThreshold?: number;
372
418
  };
373
419
  const content = (body.content ?? "").trim();
374
420
  const result = content
@@ -376,6 +422,7 @@ export async function startMcpHttp(opts: {
376
422
  content,
377
423
  threshold: body.threshold,
378
424
  limit: body.limit,
425
+ linkThreshold: body.linkThreshold,
379
426
  })
380
427
  : { enabled: Boolean(opts.config.embedding), matches: [] };
381
428
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -453,17 +500,16 @@ export async function startMcpHttp(opts: {
453
500
  // a sync-storm right after a restart could still mis-attribute — so the
454
501
  // baseline survives restarts, atomically (same tmp+rename canon).
455
502
 
456
- /** Persisted batch baselines: inbox + permanent + human-inbox snapshots
457
- * (rel/name → smart hash) + the silent-edit stamp baseline (rel →
458
- * {updated, leb}; the unstamped detector judges stamp movement against
459
- * it). Survives restarts — порт паттерна старых мониторов «seen
460
- * переживает сбой». Migration is FIRST-SIGHT by construction: an absent
461
- * silentStamps key (pre-detector state file) reads as an empty map — one
462
- * 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. */
463
511
  export type BatchState = {
464
- inbox: VaultSnapshot | null;
465
512
  permanent: VaultSnapshot | null;
466
- humanInbox: VaultSnapshot | null;
467
513
  silentStamps: Map<string, StampRecord> | null;
468
514
  };
469
515
 
@@ -498,22 +544,18 @@ export function loadBatchState(filePath: string): BatchState {
498
544
  return m;
499
545
  };
500
546
  return {
501
- inbox: toMap(raw.inbox),
502
547
  permanent: toMap(raw.permanent),
503
- humanInbox: toMap(raw.humanInbox),
504
548
  silentStamps: toStamps(raw.silentStamps),
505
549
  };
506
550
  } catch {
507
- return { inbox: null, permanent: null, humanInbox: null, silentStamps: null };
551
+ return { permanent: null, silentStamps: null };
508
552
  }
509
553
  }
510
554
 
511
555
  export function persistBatchState(
512
556
  filePath: string,
513
557
  state: {
514
- inbox: VaultSnapshot;
515
558
  permanent: VaultSnapshot;
516
- humanInbox: VaultSnapshot;
517
559
  silentStamps: Map<string, StampRecord>;
518
560
  },
519
561
  ): void {
@@ -522,9 +564,7 @@ export function persistBatchState(
522
564
  guardedWriteFileSync(
523
565
  tmp,
524
566
  JSON.stringify({
525
- inbox: Object.fromEntries(state.inbox),
526
567
  permanent: Object.fromEntries(state.permanent),
527
- humanInbox: Object.fromEntries(state.humanInbox),
528
568
  silentStamps: Object.fromEntries(state.silentStamps),
529
569
  }),
530
570
  "utf-8",
@@ -572,11 +612,11 @@ export type MemorydOptions = {
572
612
  tagsProjectionPath?: string;
573
613
  /** Detect-hash persistence file; default `<db dir>/memoryd.hashes.json`. */
574
614
  hashStatePath?: string;
575
- /** Persisted batch baselines (inbox + permanent + human-inbox snapshots). */
615
+ /** Persisted batch baselines (the permanent-folders snapshot + silent
616
+ * stamps). */
576
617
  batchStatePath?: string;
577
- /** Cadence overrides (tests); default from config.batch. */
578
- permanentBatchMs?: number;
579
- humanInboxBatchMs?: number;
618
+ /** Curator-tick cadence override (tests); default from config.batch. */
619
+ curatorTickMs?: number;
580
620
  /** Periodic hash-persist interval (ms). */
581
621
  persistMs?: number;
582
622
  /** Human owner name; human-edit detection is OFF when absent (⚖7). */
@@ -622,11 +662,24 @@ export type MemorydHandle = {
622
662
  mcpPort: number | null;
623
663
  /** Force one detect pass immediately (used by tests and shutdown flush). */
624
664
  runDetectPass: () => Promise<void>;
625
- /** Force a cadence batch (tests / operator force-tick). */
626
- runBatchPass: (kind: "permanent" | "human-inbox") => void;
665
+ /** Force a curator tick (tests / operator force-tick). */
666
+ runCuratorTick: () => void;
627
667
  close: () => Promise<void>;
628
668
  };
629
669
 
670
+ /**
671
+ * Whether a note's frontmatter already carries a non-empty `author`. The
672
+ * first-sight guard uses it to tell a SETTLED note (has author — never
673
+ * re-stamp it at startup) from a genuinely NEW one (a human's bare-body canon
674
+ * note with no author yet — fill it on its first event, §9). No frontmatter →
675
+ * bare → not settled.
676
+ */
677
+ function hasAuthorField(content: string): boolean {
678
+ const fm = /^---[^\S\n]*\n([\s\S]*?)\n---[^\S\n]*(?:\n|$)/.exec(content);
679
+ if (!fm) return false;
680
+ return /^author\s*:\s*\S/m.test(fm[1]);
681
+ }
682
+
630
683
  export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle> {
631
684
  const { config } = opts;
632
685
  const logger = opts.logger ?? makeLogger("memoryd");
@@ -646,22 +699,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
646
699
  const db = openDatabase(config);
647
700
  await indexAll({ db, config, logger });
648
701
 
649
- // Baselines (каденции — директива Артура 10.06 ~15:31): inbox HASH-diffed
650
- // мгновенно (B-приёмка fix: правка существующего черновика = событие, цикл
651
- // reject→fix→re-review); канон + оперативка копятся и уходят ПАЧКОЙ раз в
652
- // batch.permanentMs (default 6h); human-inbox пачкой раз в
653
- // batch.humanInboxMs (default 4h). Снапшоты ПЕРСИСТЯТСЯ через рестарты
654
- // (порт паттерна старых мониторов: «seen переживает сбой») — рестарт не
655
- // глотает накопленное и не реплеит обработанное.
702
+ // Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
703
+ // копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
704
+ // Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
705
+ // переживает сбой») — рестарт не глотает накопленное и не реплеит
706
+ // обработанное.
656
707
  const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
657
708
  const persistedBatches = loadBatchState(batchStatePath);
658
- let inboxSnapshot: VaultSnapshot =
659
- persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
660
709
  let permanentBaseline: VaultSnapshot =
661
710
  persistedBatches.permanent ?? snapshotVault(config.vaultPath, taxonomy);
662
- const humanInboxDir = path.join(config.vaultPath, taxonomy.folders.inboxHuman);
663
- let humanInboxBaseline: VaultSnapshot =
664
- persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
665
711
  const lastSeenHashes = loadHashState(hashStatePath);
666
712
  /** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
667
713
  * old state file = empty map = first-sight warm-up (design §4). */
@@ -671,16 +717,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
671
717
  function persistBatches(): void {
672
718
  try {
673
719
  persistBatchState(batchStatePath, {
674
- inbox: inboxSnapshot,
675
720
  permanent: permanentBaseline,
676
- humanInbox: humanInboxBaseline,
677
721
  silentStamps,
678
722
  });
679
723
  } catch (err) {
680
724
  logger.error(`batch-state persist failed: ${String(err)}`);
681
725
  }
682
726
  }
683
- if (!persistedBatches.inbox) persistBatches(); // first run: write baselines
727
+ if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
684
728
 
685
729
  /** iCloud-mount guard (порт защиты старых мониторов): корень vault
686
730
  * недоступен → пропускаем проход целиком, baseline не трогаем — после
@@ -862,15 +906,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
862
906
  logger.info("tags projection updated");
863
907
  }
864
908
 
865
- /** Zone map of the unstamped detector (design §3): the agent inbox +
866
- * the six permanent folders (five canonical + agent memory — wider than
867
- * humanEditPass's getZone, which has no agent-memory notion). */
909
+ /** Zone map of the unstamped detector (design §3): the six monitored
910
+ * folders (five canonical + agent memory — wider than humanEditPass's
911
+ * getZone, which has no agent-memory notion). */
868
912
  function silentZoneOf(absPath: string): SilentZone | null {
869
913
  const rel = path.relative(config.vaultPath, absPath);
870
914
  if (rel.startsWith("..")) return null;
871
915
  const first = rel.split(path.sep)[0];
872
916
  const f = taxonomy.folders;
873
- if (first === f.inbox) return "inbox";
874
917
  if (
875
918
  first === f.knowledge ||
876
919
  first === f.decisions ||
@@ -886,13 +929,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
886
929
 
887
930
  /**
888
931
  * Unstamped-write detector (design doc, boris-accepted 11.06). Runs
889
- * BEFORE the inbox emission loop and BEFORE humanEditPass: a re-stamped
890
- * file then reads `last_edited_by: unstamped` — the curator source
891
- * filters pass it into the pipeline, and humanEditPass sees a fresh
892
- * agent stamp (echo-agent skip, no double stamp — order instead of ifs).
893
- * Candidates carry CHANGED files only (fs.watch set inbox diff) the
894
- * semantic-hash precondition of the rule; service-only echoes never
895
- * reach here (smart-hash blindness = echo safety of our own re-stamp).
932
+ * BEFORE humanEditPass: a re-stamped file then reads `last_edited_by:
933
+ * unstamped` — the curator source filters pass it into the curation pass,
934
+ * and humanEditPass sees a fresh agent stamp (echo-agent skip, no double
935
+ * stamp — order instead of ifs). Candidates carry CHANGED files only (the
936
+ * fs.watch set) the semantic-hash precondition of the rule; service-only
937
+ * echoes never reach here (smart-hash blindness = echo safety of our own
938
+ * re-stamp).
896
939
  */
897
940
  function silentEditPass(candidatesAbs: Set<string>): void {
898
941
  for (const abs of candidatesAbs) {
@@ -952,13 +995,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
952
995
  } catch {
953
996
  continue; // deleted mid-debounce
954
997
  }
955
- // FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
956
- // нет в hash-базе — это mv размещения Индексом (Входящие → канон) или
957
- // первое наблюдение после установки, НЕ человеческая правка. Запиши
958
- // baseline и не стампь: фантомные last_edited_by на каждом размещении —
959
- // системный churn. Цена: первая правка человеком never-seen файла
960
- // пройдёт без стампа один раз (поймается следующей).
961
- if (!lastSeenHashes.has(filePath)) {
998
+ // FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3 + §9-фикс 0.3.1):
999
+ // путь, которого нет в hash-базе — это ПЕРВОЕ наблюдение. Если у ноты
1000
+ // УЖЕ есть author она SETTLED (старт демона над населённым vault, или
1001
+ // любая атрибутированная нота): запиши baseline и НЕ стампь (load-bearing
1002
+ // инвариант старт никогда не перештамповывает существующие ноты).
1003
+ // Нота БЕЗ author генуинно новая: голое тело человека прямо в канон —
1004
+ // проваливается в fill ниже и достраивается на ЭТОМ первом событии (§9:
1005
+ // голое тело человека достраивается, не откладывается до второго).
1006
+ // (Размещений Индексом больше нет — инбокс устранён.)
1007
+ if (!lastSeenHashes.has(filePath) && hasAuthorField(content)) {
962
1008
  lastSeenHashes.set(filePath, sha256(content));
963
1009
  continue;
964
1010
  }
@@ -1046,63 +1092,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1046
1092
 
1047
1093
  if (!vaultAvailable()) return;
1048
1094
 
1049
- // INBOX_NEW мгновенно: new OR semantically changed drafts (hash-diff
1050
- // over the WHOLE inbox folder; no dependency on fs.watch filenames).
1051
- const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
1052
- const inboxChanged = diffSnapshots(inboxSnapshot, inboxNext);
1053
-
1054
- // Unstamped detector FIRST (design §3 order): re-stamps land before the
1055
- // curator suppress below reads leb and before humanEditPass judges.
1056
- // Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
1057
- // diff (the diff is semantic — a re-stamp does not invalidate it).
1058
- silentEditPass(
1059
- new Set([
1060
- ...changed,
1061
- ...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
1062
- ]),
1063
- );
1064
-
1065
- for (const name of inboxChanged) {
1066
- const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
1067
- // Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
1068
- // 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
1069
- // повторный INBOX_NEW по тому же черновику — вычитывающая сессия и
1070
- // есть конвейер). Правки автора (reject→fix→re-review) проходят:
1071
- // last_edited_by остаётся автором. Цена документирована: черновик,
1072
- // чья ПОСЛЕДНЯЯ правка до первого анонса — кураторская, не
1073
- // анонсируется; в конвейере не возникает (кураторы трогают черновики
1074
- // только по событиям, т.е. после анонса). Baseline обновляется ниже
1075
- // безусловно — подавленное НЕ реплеится, авторская правка поверх
1076
- // кураторской диффится от свежего снапшота и проходит.
1077
- const leb = lastEditedByOf(abs);
1078
- if (leb && config.curatorSet.includes(leb)) {
1079
- logger.info(`inbox event suppressed (curator ${leb}): ${name}`);
1080
- continue;
1081
- }
1082
- logger.info(`inbox event: ${name}`);
1083
- // ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
1084
- // vault root — a role peer once `find`-guessed a stale copy.
1085
- emit(`INBOX_NEW: ${abs}`);
1086
- }
1087
- if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
1088
- inboxSnapshot = inboxNext;
1089
- persistBatches();
1090
- }
1095
+ // Unstamped detector FIRST (design §3 order): re-stamps land before
1096
+ // humanEditPass judges (a re-stamped file then reads `last_edited_by:
1097
+ // unstamped` and humanEditPass sees a fresh agent stamp → echo-agent
1098
+ // skip, no double stamp — order instead of ifs). Candidates are the
1099
+ // fs.watch changed set; service-only echoes never reach the rule
1100
+ // (smart-hash blindness = echo safety of our own re-stamp).
1101
+ silentEditPass(changed);
1091
1102
 
1092
1103
  humanEditPass(changed);
1093
1104
  archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
1094
1105
  syncTagsMirror();
1095
1106
  await indexAll({ db, config, logger }); // incremental by content hash
1096
1107
  renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
1097
- // PERMANENT правки НЕ эмитятся мгновеннокопятся до batch-прохода
1098
- // (каденция 6h); см. runPermanentBatch.
1108
+ // Canon edits are NOT emitted instantly they accumulate to the curator
1109
+ // tick (cadence 6h); see runCuratorTick.
1099
1110
  }
1100
1111
 
1101
- /** PERMANENT_BATCHодин проход каденции: diff канона+оперативки против
1102
- * baseline пачки, кураторские правки отфильтрованы ИСТОЧНИКОМ, остальное
1103
- * уходит ОДНОЙ строкой (JSON-массив АБСОЛЮТНЫХ путейодна доставка
1104
- * одна ephemeral-сессия Scriber'аодин отчёт). */
1105
- function runPermanentBatch(): void {
1112
+ /** CURATOR_TICKone cadence pass: diff canon + agent memory against the
1113
+ * carried baseline, curator-authored edits filtered BY SOURCE, the rest
1114
+ * goes out as ONE line (a JSON array of ABSOLUTE paths one delivery
1115
+ * one ephemeral curation session one report). In lean the emit is
1116
+ * suppressed (no proactive receiver), but the baseline still advances. */
1117
+ function runCuratorTick(): void {
1106
1118
  if (!vaultAvailable()) return;
1107
1119
  const next = snapshotVault(config.vaultPath, taxonomy);
1108
1120
  const changedRel = diffSnapshots(permanentBaseline, next);
@@ -1115,23 +1127,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1115
1127
  absPaths.push(abs);
1116
1128
  }
1117
1129
  if (absPaths.length) {
1118
- logger.info(`permanent batch: ${absPaths.length} path(s)`);
1119
- emit(`PERMANENT_BATCH: ${JSON.stringify(absPaths)}`);
1120
- }
1121
- persistBatches();
1122
- }
1123
-
1124
- /** HUMAN_INBOX_BATCH — каденция human-inbox (человек пишет долго,
1125
- * мгновенный триггер схватил бы недоделанное). */
1126
- function runHumanInboxBatch(): void {
1127
- if (!vaultAvailable()) return;
1128
- const next = snapshotFlatFolder(humanInboxDir);
1129
- const names = diffSnapshots(humanInboxBaseline, next);
1130
- humanInboxBaseline = next;
1131
- if (names.length) {
1132
- const absPaths = names.map((n) => path.join(humanInboxDir, n));
1133
- logger.info(`human-inbox batch: ${absPaths.length} draft(s)`);
1134
- emit(`HUMAN_INBOX_BATCH: ${JSON.stringify(absPaths)}`);
1130
+ logger.info(`curator tick: ${absPaths.length} path(s)`);
1131
+ emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
1135
1132
  }
1136
1133
  persistBatches();
1137
1134
  }
@@ -1206,42 +1203,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1206
1203
  const persistTimer = setInterval(persistQuiet, persistMs);
1207
1204
  persistTimer.unref?.();
1208
1205
 
1209
- // ── cadence timers (директива ~15:31): первый прогон через полный период
1210
- const permanentBatchMs = opts.permanentBatchMs ?? config.batch.permanentMs;
1211
- const permanentTimer = setInterval(() => {
1206
+ // ── cadence timer (директива ~15:31): первый прогон через полный период
1207
+ const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
1208
+ const curatorTimer = setInterval(() => {
1212
1209
  try {
1213
- runPermanentBatch();
1210
+ runCuratorTick();
1214
1211
  } catch (err) {
1215
- logger.error(`permanent batch failed: ${String(err)}`);
1216
- }
1217
- }, permanentBatchMs);
1218
- permanentTimer.unref?.();
1219
- // HUMAN-INBOX: раз в СУТКИ в config.batch.humanInboxHour локального
1220
- // (подтверждение Артура ~15:4x: ночная партия, как в старом контуре).
1221
- // Тестовый override opts.humanInboxBatchMs — простой интервал.
1222
- let humanInboxTimer: ReturnType<typeof setTimeout> | null = null;
1223
- function scheduleHumanInbox(): void {
1224
- let delay: number;
1225
- if (opts.humanInboxBatchMs !== undefined) {
1226
- delay = opts.humanInboxBatchMs;
1227
- } else {
1228
- const now = new Date();
1229
- const next = new Date(now);
1230
- next.setHours(config.batch.humanInboxHour, 0, 0, 0);
1231
- if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
1232
- delay = next.getTime() - now.getTime();
1212
+ logger.error(`curator tick failed: ${String(err)}`);
1233
1213
  }
1234
- humanInboxTimer = setTimeout(() => {
1235
- try {
1236
- runHumanInboxBatch();
1237
- } catch (err) {
1238
- logger.error(`human-inbox batch failed: ${String(err)}`);
1239
- }
1240
- scheduleHumanInbox();
1241
- }, delay);
1242
- humanInboxTimer.unref?.();
1243
- }
1244
- scheduleHumanInbox();
1214
+ }, curatorTickMs);
1215
+ curatorTimer.unref?.();
1245
1216
 
1246
1217
  // ── MCP http ──
1247
1218
  let mcp: { port: number; close: () => Promise<void> } | null = null;
@@ -1255,10 +1226,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1255
1226
 
1256
1227
  return {
1257
1228
  mcpPort: mcp?.port ?? null,
1258
- runBatchPass: (kind) => {
1259
- if (kind === "permanent") runPermanentBatch();
1260
- else runHumanInboxBatch();
1261
- },
1229
+ runCuratorTick,
1262
1230
  runDetectPass: async () => {
1263
1231
  if (flushTimer) {
1264
1232
  clearTimeout(flushTimer);
@@ -1271,11 +1239,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1271
1239
  watcher?.close();
1272
1240
  if (flushTimer) clearTimeout(flushTimer);
1273
1241
  clearInterval(heartbeatTimer);
1274
- clearInterval(permanentTimer);
1275
- if (humanInboxTimer) clearTimeout(humanInboxTimer);
1242
+ clearInterval(curatorTimer);
1276
1243
  clearInterval(persistTimer);
1277
1244
  await flushing;
1278
1245
  persistQuiet();
1246
+ // Batch state (silentStamps + the permanent baseline) also persists on
1247
+ // shutdown — otherwise silentStamps moved since the last curator tick are
1248
+ // lost, breaking the "survives restarts" invariant (first-sight warm-up
1249
+ // would then re-skip a real edit after a graceful restart).
1250
+ persistBatches();
1279
1251
  if (mcp) await mcp.close();
1280
1252
  try {
1281
1253
  guardedUnlinkSync(heartbeatPath);