@agfpd/iapeer-memory-core 0.2.9 → 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
  *
@@ -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,8 +662,8 @@ 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
 
@@ -646,22 +686,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
646
686
  const db = openDatabase(config);
647
687
  await indexAll({ db, config, logger });
648
688
 
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
- // глотает накопленное и не реплеит обработанное.
689
+ // Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
690
+ // копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
691
+ // Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
692
+ // переживает сбой») — рестарт не глотает накопленное и не реплеит
693
+ // обработанное.
656
694
  const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
657
695
  const persistedBatches = loadBatchState(batchStatePath);
658
- let inboxSnapshot: VaultSnapshot =
659
- persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
660
696
  let permanentBaseline: VaultSnapshot =
661
697
  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
698
  const lastSeenHashes = loadHashState(hashStatePath);
666
699
  /** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
667
700
  * old state file = empty map = first-sight warm-up (design §4). */
@@ -671,16 +704,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
671
704
  function persistBatches(): void {
672
705
  try {
673
706
  persistBatchState(batchStatePath, {
674
- inbox: inboxSnapshot,
675
707
  permanent: permanentBaseline,
676
- humanInbox: humanInboxBaseline,
677
708
  silentStamps,
678
709
  });
679
710
  } catch (err) {
680
711
  logger.error(`batch-state persist failed: ${String(err)}`);
681
712
  }
682
713
  }
683
- if (!persistedBatches.inbox) persistBatches(); // first run: write baselines
714
+ if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
684
715
 
685
716
  /** iCloud-mount guard (порт защиты старых мониторов): корень vault
686
717
  * недоступен → пропускаем проход целиком, baseline не трогаем — после
@@ -862,15 +893,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
862
893
  logger.info("tags projection updated");
863
894
  }
864
895
 
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). */
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). */
868
899
  function silentZoneOf(absPath: string): SilentZone | null {
869
900
  const rel = path.relative(config.vaultPath, absPath);
870
901
  if (rel.startsWith("..")) return null;
871
902
  const first = rel.split(path.sep)[0];
872
903
  const f = taxonomy.folders;
873
- if (first === f.inbox) return "inbox";
874
904
  if (
875
905
  first === f.knowledge ||
876
906
  first === f.decisions ||
@@ -886,13 +916,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
886
916
 
887
917
  /**
888
918
  * 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).
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).
896
926
  */
897
927
  function silentEditPass(candidatesAbs: Set<string>): void {
898
928
  for (const abs of candidatesAbs) {
@@ -953,11 +983,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
953
983
  continue; // deleted mid-debounce
954
984
  }
955
985
  // FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
956
- // нет в hash-базе — это mv размещения Индексом (Входящие канон) или
957
- // первое наблюдение после установки, НЕ человеческая правка. Запиши
958
- // baseline и не стампь: фантомные last_edited_by на каждом размещении
959
- // системный churn. Цена: первая правка человеком never-seen файла
960
- // пройдёт без стампа один раз (поймается следующей).
986
+ // нет в hash-базе — это ПЕРВОЕ наблюдение (старт демона над уже
987
+ // населённым vault), НЕ обязательно человеческая правка. Запиши
988
+ // baseline и не стампь: иначе старт массово штампует все существующие
989
+ // ноты как «правки человека». (Размещений Индексом больше нет — инбокс
990
+ // устранён; единственный источник never-seen-путей старт/новый файл.)
991
+ // ЦЕНА (§9-край, на живой приёмке): свежая bare-body заметка человека
992
+ // достраивается со ВТОРОГО fs-события — первое лишь пишет baseline.
961
993
  if (!lastSeenHashes.has(filePath)) {
962
994
  lastSeenHashes.set(filePath, sha256(content));
963
995
  continue;
@@ -1046,63 +1078,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1046
1078
 
1047
1079
  if (!vaultAvailable()) return;
1048
1080
 
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
- }
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);
1091
1088
 
1092
1089
  humanEditPass(changed);
1093
1090
  archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
1094
1091
  syncTagsMirror();
1095
1092
  await indexAll({ db, config, logger }); // incremental by content hash
1096
1093
  renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
1097
- // PERMANENT правки НЕ эмитятся мгновеннокопятся до batch-прохода
1098
- // (каденция 6h); см. runPermanentBatch.
1094
+ // Canon edits are NOT emitted instantly they accumulate to the curator
1095
+ // tick (cadence 6h); see runCuratorTick.
1099
1096
  }
1100
1097
 
1101
- /** PERMANENT_BATCHодин проход каденции: diff канона+оперативки против
1102
- * baseline пачки, кураторские правки отфильтрованы ИСТОЧНИКОМ, остальное
1103
- * уходит ОДНОЙ строкой (JSON-массив АБСОЛЮТНЫХ путейодна доставка
1104
- * одна ephemeral-сессия Scriber'аодин отчёт). */
1105
- 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 {
1106
1104
  if (!vaultAvailable()) return;
1107
1105
  const next = snapshotVault(config.vaultPath, taxonomy);
1108
1106
  const changedRel = diffSnapshots(permanentBaseline, next);
@@ -1115,23 +1113,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1115
1113
  absPaths.push(abs);
1116
1114
  }
1117
1115
  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)}`);
1116
+ logger.info(`curator tick: ${absPaths.length} path(s)`);
1117
+ emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
1135
1118
  }
1136
1119
  persistBatches();
1137
1120
  }
@@ -1206,42 +1189,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1206
1189
  const persistTimer = setInterval(persistQuiet, persistMs);
1207
1190
  persistTimer.unref?.();
1208
1191
 
1209
- // ── cadence timers (директива ~15:31): первый прогон через полный период
1210
- const permanentBatchMs = opts.permanentBatchMs ?? config.batch.permanentMs;
1211
- const permanentTimer = setInterval(() => {
1192
+ // ── cadence timer (директива ~15:31): первый прогон через полный период
1193
+ const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
1194
+ const curatorTimer = setInterval(() => {
1212
1195
  try {
1213
- runPermanentBatch();
1196
+ runCuratorTick();
1214
1197
  } catch (err) {
1215
- logger.error(`permanent batch failed: ${String(err)}`);
1198
+ logger.error(`curator tick failed: ${String(err)}`);
1216
1199
  }
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();
1233
- }
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();
1200
+ }, curatorTickMs);
1201
+ curatorTimer.unref?.();
1245
1202
 
1246
1203
  // ── MCP http ──
1247
1204
  let mcp: { port: number; close: () => Promise<void> } | null = null;
@@ -1255,10 +1212,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1255
1212
 
1256
1213
  return {
1257
1214
  mcpPort: mcp?.port ?? null,
1258
- runBatchPass: (kind) => {
1259
- if (kind === "permanent") runPermanentBatch();
1260
- else runHumanInboxBatch();
1261
- },
1215
+ runCuratorTick,
1262
1216
  runDetectPass: async () => {
1263
1217
  if (flushTimer) {
1264
1218
  clearTimeout(flushTimer);
@@ -1271,11 +1225,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1271
1225
  watcher?.close();
1272
1226
  if (flushTimer) clearTimeout(flushTimer);
1273
1227
  clearInterval(heartbeatTimer);
1274
- clearInterval(permanentTimer);
1275
- if (humanInboxTimer) clearTimeout(humanInboxTimer);
1228
+ clearInterval(curatorTimer);
1276
1229
  clearInterval(persistTimer);
1277
1230
  await flushing;
1278
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();
1279
1237
  if (mcp) await mcp.close();
1280
1238
  try {
1281
1239
  guardedUnlinkSync(heartbeatPath);