@agfpd/iapeer-memory-core 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "iapeer-memory core — host-neutral TypeScript memory primitive: vault schema/taxonomy config, search engine, memoryd, context renderer, role contracts. Consumed by the @agfpd/iapeer-memory facade; version kept in lockstep by its release flow (docs/10-distribution.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -50,6 +50,16 @@ export type CoreConfig = {
50
50
  dbPath: string;
51
51
  fullScanOnStartup: boolean;
52
52
  };
53
+ /** Конвейерные каденции (директива Артура 10.06 ~15:31): канон-правки и
54
+ * human-inbox идут ПАЧКАМИ, не событиями — правки должны устаканиться,
55
+ * 10 пишущих агентов не дёргают конвейер постоянно. */
56
+ batch: {
57
+ /** PERMANENT_BATCH period, ms (default 6h). */
58
+ permanentMs: number;
59
+ /** HUMAN_INBOX_BATCH — РАЗ В СУТКИ в этот локальный час (default 04:00,
60
+ * как в старом контуре: человек пишет долго, ночная партия). */
61
+ humanInboxHour: number;
62
+ };
53
63
  /**
54
64
  * MCP-http endpoint of memoryd (ADR-012). The default port 8766 is the
55
65
  * neighbour of the iapeer foundation MCP (8765) — one ecosystem block,
@@ -250,6 +260,10 @@ export function configFromEnv(): CoreConfig {
250
260
  dbPath,
251
261
  fullScanOnStartup: envBoolean("IAPEER_MEMORY_FULL_SCAN_ON_STARTUP", true),
252
262
  },
263
+ batch: {
264
+ permanentMs: envNumber("IAPEER_MEMORY_PERMANENT_BATCH_SECS", 6 * 3600) * 1000,
265
+ humanInboxHour: envNumber("IAPEER_MEMORY_HUMAN_INBOX_HOUR", 4),
266
+ },
253
267
  mcp: {
254
268
  port: envNumber("IAPEER_MEMORY_MCP_PORT", 8766),
255
269
  },
package/src/memoryd.ts CHANGED
@@ -39,12 +39,13 @@ import type { CoreConfig } from "./config.js";
39
39
  import { openDatabase, type CoreDb } from "./db.js";
40
40
  import { indexAll } from "./indexer.js";
41
41
  import { runSearch, runGraph, runMap } from "./mcp-tools.js";
42
- import { decideUpdate, getZone } from "./human-edit-detect.js";
42
+ import { decideUpdate, getZone, sha256 } from "./human-edit-detect.js";
43
43
  import { decideMirror, tagsDictionarySourceRel } from "./tags-mirror.js";
44
44
  import {
45
45
  snapshotVault,
46
- detectPermanentChanges,
47
- formatEventLines,
46
+ snapshotInbox,
47
+ snapshotFlatFolder,
48
+ diffSnapshots,
48
49
  type VaultSnapshot,
49
50
  } from "./permanent-detect.js";
50
51
  import { makeLogger, type Logger } from "./log.js";
@@ -399,6 +400,51 @@ export async function startMcpHttp(opts: {
399
400
  // a sync-storm right after a restart could still mis-attribute — so the
400
401
  // baseline survives restarts, atomically (same tmp+rename canon).
401
402
 
403
+ /** Persisted batch baselines: inbox + permanent + human-inbox snapshots
404
+ * (rel/name → smart hash). Survives restarts — порт паттерна старых
405
+ * мониторов «seen переживает сбой». */
406
+ export type BatchState = {
407
+ inbox: VaultSnapshot | null;
408
+ permanent: VaultSnapshot | null;
409
+ humanInbox: VaultSnapshot | null;
410
+ };
411
+
412
+ export function loadBatchState(filePath: string): BatchState {
413
+ try {
414
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
415
+ string,
416
+ Record<string, string>
417
+ >;
418
+ const toMap = (o: Record<string, string> | undefined): VaultSnapshot | null =>
419
+ o ? new Map(Object.entries(o)) : null;
420
+ return {
421
+ inbox: toMap(raw.inbox),
422
+ permanent: toMap(raw.permanent),
423
+ humanInbox: toMap(raw.humanInbox),
424
+ };
425
+ } catch {
426
+ return { inbox: null, permanent: null, humanInbox: null };
427
+ }
428
+ }
429
+
430
+ export function persistBatchState(
431
+ filePath: string,
432
+ state: { inbox: VaultSnapshot; permanent: VaultSnapshot; humanInbox: VaultSnapshot },
433
+ ): void {
434
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
435
+ const tmp = `${filePath}.tmp`;
436
+ fs.writeFileSync(
437
+ tmp,
438
+ JSON.stringify({
439
+ inbox: Object.fromEntries(state.inbox),
440
+ permanent: Object.fromEntries(state.permanent),
441
+ humanInbox: Object.fromEntries(state.humanInbox),
442
+ }),
443
+ "utf-8",
444
+ );
445
+ fs.renameSync(tmp, filePath);
446
+ }
447
+
402
448
  export function loadHashState(filePath: string): Map<string, string> {
403
449
  const map = new Map<string, string>();
404
450
  try {
@@ -436,6 +482,11 @@ export type MemorydOptions = {
436
482
  tagsMirrorPath?: string;
437
483
  /** Detect-hash persistence file; default `<db dir>/memoryd.hashes.json`. */
438
484
  hashStatePath?: string;
485
+ /** Persisted batch baselines (inbox + permanent + human-inbox snapshots). */
486
+ batchStatePath?: string;
487
+ /** Cadence overrides (tests); default from config.batch. */
488
+ permanentBatchMs?: number;
489
+ humanInboxBatchMs?: number;
439
490
  /** Periodic hash-persist interval (ms). */
440
491
  persistMs?: number;
441
492
  /** Human owner name; human-edit detection is OFF when absent (⚖7). */
@@ -452,6 +503,8 @@ export type MemorydHandle = {
452
503
  mcpPort: number | null;
453
504
  /** Force one detect pass immediately (used by tests and shutdown flush). */
454
505
  runDetectPass: () => Promise<void>;
506
+ /** Force a cadence batch (tests / operator force-tick). */
507
+ runBatchPass: (kind: "permanent" | "human-inbox") => void;
455
508
  close: () => Promise<void>;
456
509
  };
457
510
 
@@ -471,15 +524,65 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
471
524
  const db = openDatabase(config);
472
525
  await indexAll({ db, config, logger });
473
526
 
474
- // Baselines: detect from NOW on; pre-existing inbox files do not replay
475
- // (the Index catches them up at its own start — reference semantics).
476
- let snapshot: VaultSnapshot = snapshotVault(config.vaultPath, taxonomy);
477
- const inboxDir = path.join(config.vaultPath, taxonomy.folders.inbox);
478
- const inboxSeen = new Set<string>(
479
- fs.existsSync(inboxDir) ? fs.readdirSync(inboxDir).filter((f) => f.endsWith(".md")) : [],
480
- );
527
+ // Baselines (каденции директива Артура 10.06 ~15:31): inbox HASH-diffed
528
+ // мгновенно (B-приёмка fix: правка существующего черновика = событие, цикл
529
+ // reject→fix→re-review); канон + оперативка копятся и уходят ПАЧКОЙ раз в
530
+ // batch.permanentMs (default 6h); human-inbox — пачкой раз в
531
+ // batch.humanInboxMs (default 4h). Снапшоты ПЕРСИСТЯТСЯ через рестарты
532
+ // (порт паттерна старых мониторов: «seen переживает сбой») рестарт не
533
+ // глотает накопленное и не реплеит обработанное.
534
+ const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
535
+ const persistedBatches = loadBatchState(batchStatePath);
536
+ let inboxSnapshot: VaultSnapshot =
537
+ persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
538
+ let permanentBaseline: VaultSnapshot =
539
+ persistedBatches.permanent ?? snapshotVault(config.vaultPath, taxonomy);
540
+ const humanInboxDir = path.join(config.vaultPath, taxonomy.folders.inboxHuman);
541
+ let humanInboxBaseline: VaultSnapshot =
542
+ persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
481
543
  const lastSeenHashes = loadHashState(hashStatePath);
482
544
 
545
+ function persistBatches(): void {
546
+ try {
547
+ persistBatchState(batchStatePath, {
548
+ inbox: inboxSnapshot,
549
+ permanent: permanentBaseline,
550
+ humanInbox: humanInboxBaseline,
551
+ });
552
+ } catch (err) {
553
+ logger.error(`batch-state persist failed: ${String(err)}`);
554
+ }
555
+ }
556
+ if (!persistedBatches.inbox) persistBatches(); // first run: write baselines
557
+
558
+ /** iCloud-mount guard (порт защиты старых мониторов): корень vault
559
+ * недоступен → пропускаем проход целиком, baseline не трогаем — после
560
+ * восстановления mount «всё новое» не реплеится штормом. */
561
+ function vaultAvailable(): boolean {
562
+ try {
563
+ return fs.statSync(config.vaultPath).isDirectory();
564
+ } catch {
565
+ logger.warn("vault root unavailable (iCloud unmount?) — pass skipped");
566
+ return false;
567
+ }
568
+ }
569
+
570
+ /** SOURCE-фильтр кураторских правок (директива п.5 + stale-fix): свежий
571
+ * last_edited_by читается ИЗ ФАЙЛА на момент пачки — правки
572
+ * index/scriber/dreamweaver в пачку не попадают (эхо-брейкер механикой,
573
+ * не доктриной). */
574
+ function lastEditedByOf(absPath: string): string | null {
575
+ try {
576
+ const head = fs.readFileSync(absPath, "utf-8").slice(0, 4096);
577
+ const fm = /^---[^\S\n]*\n([\s\S]*?)\n---/.exec(head);
578
+ if (!fm) return null;
579
+ const m = /^last_edited_by\s*:\s*(.+?)\s*$/m.exec(fm[1]);
580
+ return m ? m[1].trim() : null;
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+
483
586
  syncTagsMirror(); // best-effort materialisation at start
484
587
 
485
588
  function syncTagsMirror(): void {
@@ -519,6 +622,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
519
622
  } catch {
520
623
  continue; // deleted mid-debounce
521
624
  }
625
+ // FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
626
+ // нет в hash-базе — это mv размещения Индексом (Входящие → канон) или
627
+ // первое наблюдение после установки, НЕ человеческая правка. Запиши
628
+ // baseline и не стампь: фантомные last_edited_by на каждом размещении —
629
+ // системный churn. Цена: первая правка человеком never-seen файла
630
+ // пройдёт без стампа один раз (поймается следующей).
631
+ if (!lastSeenHashes.has(filePath)) {
632
+ lastSeenHashes.set(filePath, sha256(content));
633
+ continue;
634
+ }
522
635
  const decision = decideUpdate({
523
636
  content,
524
637
  zone,
@@ -561,32 +674,65 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
561
674
  const changed = new Set(pending);
562
675
  pending.clear();
563
676
 
564
- // INBOX_NEW — new inbox files since the baseline.
565
- for (const abs of changed) {
566
- const rel = path.relative(config.vaultPath, abs);
567
- const parts = rel.split(path.sep);
568
- if (parts[0] === taxonomy.folders.inbox && parts.length === 2) {
569
- const name = parts[1];
570
- if (!inboxSeen.has(name) && fs.existsSync(abs)) {
571
- inboxSeen.add(name);
572
- emit(`INBOX_NEW: ${name}`);
573
- }
574
- }
677
+ if (!vaultAvailable()) return;
678
+
679
+ // INBOX_NEW мгновенно: new OR semantically changed drafts (hash-diff
680
+ // over the WHOLE inbox folder; no dependency on fs.watch filenames).
681
+ const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
682
+ for (const name of diffSnapshots(inboxSnapshot, inboxNext)) {
683
+ logger.info(`inbox event: ${name}`);
684
+ // ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
685
+ // vault root — a role peer once `find`-guessed a stale copy.
686
+ emit(`INBOX_NEW: ${path.join(config.vaultPath, taxonomy.folders.inbox, name)}`);
687
+ }
688
+ if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
689
+ inboxSnapshot = inboxNext;
690
+ persistBatches();
575
691
  }
576
692
 
577
693
  humanEditPass(changed);
578
694
  syncTagsMirror();
579
695
  await indexAll({ db, config, logger }); // incremental by content hash
696
+ // PERMANENT правки НЕ эмитятся мгновенно — копятся до batch-прохода
697
+ // (каденция 6h); см. runPermanentBatch.
698
+ }
580
699
 
581
- const { event, next } = detectPermanentChanges({
582
- vault: config.vaultPath,
583
- taxonomy,
584
- prev: snapshot,
585
- });
586
- snapshot = next;
587
- if (event) {
588
- for (const line of formatEventLines(event)) emit(line);
700
+ /** PERMANENT_BATCH один проход каденции: diff канона+оперативки против
701
+ * baseline пачки, кураторские правки отфильтрованы ИСТОЧНИКОМ, остальное
702
+ * уходит ОДНОЙ строкой (JSON-массив АБСОЛЮТНЫХ путей → одна доставка →
703
+ * одна ephemeral-сессия Scriber'а → один отчёт). */
704
+ function runPermanentBatch(): void {
705
+ if (!vaultAvailable()) return;
706
+ const next = snapshotVault(config.vaultPath, taxonomy);
707
+ const changedRel = diffSnapshots(permanentBaseline, next);
708
+ permanentBaseline = next; // кураторские поглощаются, не реплеятся
709
+ const absPaths: string[] = [];
710
+ for (const rel of changedRel) {
711
+ const abs = path.join(config.vaultPath, rel);
712
+ const leb = lastEditedByOf(abs);
713
+ if (leb && config.curatorSet.includes(leb)) continue; // source-фильтр
714
+ absPaths.push(abs);
715
+ }
716
+ if (absPaths.length) {
717
+ logger.info(`permanent batch: ${absPaths.length} path(s)`);
718
+ emit(`PERMANENT_BATCH: ${JSON.stringify(absPaths)}`);
589
719
  }
720
+ persistBatches();
721
+ }
722
+
723
+ /** HUMAN_INBOX_BATCH — каденция human-inbox (человек пишет долго,
724
+ * мгновенный триггер схватил бы недоделанное). */
725
+ function runHumanInboxBatch(): void {
726
+ if (!vaultAvailable()) return;
727
+ const next = snapshotFlatFolder(humanInboxDir);
728
+ const names = diffSnapshots(humanInboxBaseline, next);
729
+ humanInboxBaseline = next;
730
+ if (names.length) {
731
+ const absPaths = names.map((n) => path.join(humanInboxDir, n));
732
+ logger.info(`human-inbox batch: ${absPaths.length} draft(s)`);
733
+ emit(`HUMAN_INBOX_BATCH: ${JSON.stringify(absPaths)}`);
734
+ }
735
+ persistBatches();
590
736
  }
591
737
 
592
738
  function schedule(absPath: string): void {
@@ -641,6 +787,43 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
641
787
  const persistTimer = setInterval(persistQuiet, persistMs);
642
788
  persistTimer.unref?.();
643
789
 
790
+ // ── cadence timers (директива ~15:31): первый прогон через полный период
791
+ const permanentBatchMs = opts.permanentBatchMs ?? config.batch.permanentMs;
792
+ const permanentTimer = setInterval(() => {
793
+ try {
794
+ runPermanentBatch();
795
+ } catch (err) {
796
+ logger.error(`permanent batch failed: ${String(err)}`);
797
+ }
798
+ }, permanentBatchMs);
799
+ permanentTimer.unref?.();
800
+ // HUMAN-INBOX: раз в СУТКИ в config.batch.humanInboxHour локального
801
+ // (подтверждение Артура ~15:4x: ночная партия, как в старом контуре).
802
+ // Тестовый override opts.humanInboxBatchMs — простой интервал.
803
+ let humanInboxTimer: ReturnType<typeof setTimeout> | null = null;
804
+ function scheduleHumanInbox(): void {
805
+ let delay: number;
806
+ if (opts.humanInboxBatchMs !== undefined) {
807
+ delay = opts.humanInboxBatchMs;
808
+ } else {
809
+ const now = new Date();
810
+ const next = new Date(now);
811
+ next.setHours(config.batch.humanInboxHour, 0, 0, 0);
812
+ if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
813
+ delay = next.getTime() - now.getTime();
814
+ }
815
+ humanInboxTimer = setTimeout(() => {
816
+ try {
817
+ runHumanInboxBatch();
818
+ } catch (err) {
819
+ logger.error(`human-inbox batch failed: ${String(err)}`);
820
+ }
821
+ scheduleHumanInbox();
822
+ }, delay);
823
+ humanInboxTimer.unref?.();
824
+ }
825
+ scheduleHumanInbox();
826
+
644
827
  // ── MCP http ──
645
828
  let mcp: { port: number; close: () => Promise<void> } | null = null;
646
829
  if (opts.mcpPort !== null) {
@@ -653,6 +836,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
653
836
 
654
837
  return {
655
838
  mcpPort: mcp?.port ?? null,
839
+ runBatchPass: (kind) => {
840
+ if (kind === "permanent") runPermanentBatch();
841
+ else runHumanInboxBatch();
842
+ },
656
843
  runDetectPass: async () => {
657
844
  if (flushTimer) {
658
845
  clearTimeout(flushTimer);
@@ -665,6 +852,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
665
852
  watcher?.close();
666
853
  if (flushTimer) clearTimeout(flushTimer);
667
854
  clearInterval(heartbeatTimer);
855
+ clearInterval(permanentTimer);
856
+ if (humanInboxTimer) clearTimeout(humanInboxTimer);
668
857
  clearInterval(persistTimer);
669
858
  await flushing;
670
859
  persistQuiet();
@@ -108,3 +108,52 @@ export function detectPermanentChanges(opts: {
108
108
  export function formatEventLines(event: PermanentChangedEvent): string[] {
109
109
  return event.paths.map((p) => `PERMANENT_CHANGED: ${p}`);
110
110
  }
111
+
112
+ /**
113
+ * Inbox snapshot: draft basename → smart hash (top-level `.md` only —
114
+ * drafts live flat). Same smart-hash mechanics as the permanent snapshot:
115
+ * service-field re-stamps are invisible, content edits are events.
116
+ *
117
+ * WHY hash-diff and not a name baseline (B-приёмка 10.06, boris): the
118
+ * original name-set baseline made a draft INVISIBLE FOREVER once it
119
+ * survived a memoryd restart, and silently broke the reject→fix→re-review
120
+ * cycle (an author's fix never re-entered the pipeline). The reference
121
+ * semantics «the Index catches pre-existing drafts up at its own start»
122
+ * died with the inversion — the Index no longer scans the inbox; the
123
+ * fail-open sweep covers pre-existing UNCHANGED drafts, the hash-diff
124
+ * covers everything else.
125
+ */
126
+ export function snapshotFlatFolder(dir: string): VaultSnapshot {
127
+ const snapshot: VaultSnapshot = new Map();
128
+ let entries: fs.Dirent[];
129
+ try {
130
+ entries = fs.readdirSync(dir, { withFileTypes: true });
131
+ } catch {
132
+ return snapshot;
133
+ }
134
+ for (const e of entries) {
135
+ if (!e.isFile() || !e.name.endsWith(".md")) continue;
136
+ const h = hashFile(path.join(dir, e.name));
137
+ if (h) snapshot.set(e.name, h);
138
+ }
139
+ return snapshot;
140
+ }
141
+
142
+ export function snapshotInbox(vault: string, taxonomy: TaxonomyPreset): VaultSnapshot {
143
+ return snapshotFlatFolder(path.join(vault, taxonomy.folders.inbox));
144
+ }
145
+
146
+ /**
147
+ * One inbox detection pass: new or semantically changed drafts since the
148
+ * previous snapshot (deletions ignored — a placement move by the Index).
149
+ * Scans the WHOLE inbox folder, independent of which fs.watch path
150
+ * triggered the flush — no dependency on watch filename fidelity.
151
+ */
152
+ export function detectInboxChanges(opts: {
153
+ vault: string;
154
+ taxonomy: TaxonomyPreset;
155
+ prev: VaultSnapshot;
156
+ }): { names: string[]; next: VaultSnapshot } {
157
+ const next = snapshotInbox(opts.vault, opts.taxonomy);
158
+ return { names: diffSnapshots(opts.prev, next), next };
159
+ }
@@ -55,6 +55,12 @@ export function renderDoctrine(opts: {
55
55
  templatePath: string;
56
56
  peerCwd: string;
57
57
  version: string;
58
+ /** Host vault root — substituted for `{{VAULT_PATH}}` in the template.
59
+ * B-приёмка incident 10.06: a role peer that didn't KNOW the vault root
60
+ * guessed it with `find` and read a STALE git copy of the vault —
61
+ * wrong-world metadata broke the echo-breaker downstream. The doctrine
62
+ * must carry the host fact. */
63
+ vaultPath?: string;
58
64
  }): RenderOutcome {
59
65
  const target = path.join(opts.peerCwd, ".iapeer", "IAPEER.md");
60
66
 
@@ -65,7 +71,10 @@ export function renderDoctrine(opts: {
65
71
  return { action: "missing-template", target };
66
72
  }
67
73
 
68
- const body = stripTemplateFrontmatter(template);
74
+ const body = stripTemplateFrontmatter(template).replaceAll(
75
+ "{{VAULT_PATH}}",
76
+ opts.vaultPath ?? "<unknown — see IAPEER_MEMORY_VAULT_PATH in the package config.env>",
77
+ );
69
78
  const rendered = `${versionMarker(opts.version)}\n${body.startsWith("\n") ? body.slice(1) : body}`;
70
79
 
71
80
  let existing: string | null = null;