@agfpd/iapeer-memory-core 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
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/index.ts CHANGED
@@ -58,6 +58,8 @@ export {
58
58
  MEMORYD_SERVER_NAME,
59
59
  type MemorydHandle,
60
60
  type MemorydOptions,
61
+ type MemorydFragmentsWiring,
62
+ type FleetPeer,
61
63
  } from "./memoryd.js";
62
64
 
63
65
  // auto-memory migration (engine; sources are adapter-scoped)
package/src/memoryd.ts CHANGED
@@ -49,6 +49,15 @@ import {
49
49
  type VaultSnapshot,
50
50
  } from "./permanent-detect.js";
51
51
  import { makeLogger, type Logger } from "./log.js";
52
+ import {
53
+ atomicWrite,
54
+ buildOutput,
55
+ collectNotes,
56
+ filterAgentNotes,
57
+ fullIndexPathFor,
58
+ type RenderContext,
59
+ } from "./index-render.js";
60
+ import { renderPeerFragment, type FragmentEnv } from "./context-render.js";
52
61
 
53
62
  // ── identity ────────────────────────────────────────────────────────────────
54
63
 
@@ -497,8 +506,37 @@ export type MemorydOptions = {
497
506
  * omit to use `config.mcp.port` (the configured default, ADR-012).
498
507
  */
499
508
  mcpPort?: number | null;
509
+ /** Continuous per-peer fragment rendering (docs/05: «триггер регенерации —
510
+ * debounce от FS-изменений vault в memoryd»). Omit → rendering off
511
+ * (host-neutral core: the ecosystem wiring comes from the package). */
512
+ fragments?: MemorydFragmentsWiring;
513
+ };
514
+
515
+ /**
516
+ * Fleet fragment wiring — assembled by the PACKAGE (ADR-009: ecosystem
517
+ * joints live there), consumed by core as plain data. The fleet map is a
518
+ * JSON state file (`{peers: [{personality, cwd}]}`) written by the package
519
+ * from `iapeer list --json` (init/update/verify --repair — the registry cwd
520
+ * is the FACT, iapeer 0.2.14); core only reads it, fail-open: missing or
521
+ * malformed map = rendering quietly off (дыра 10.06: контракт docs/05 был
522
+ * обещан шапкой render.ts и не подключён — пиры после рестарта не знали
523
+ * пути записи vault).
524
+ */
525
+ export type MemorydFragmentsWiring = {
526
+ /** Fleet map JSON path (package state namespace). */
527
+ fleetMapPath: string;
528
+ /** paths-block facts for every fragment (vault/db/config/state/cache/logs). */
529
+ paths: FragmentEnv["paths"];
530
+ /** Author-index target (capped variant) for an agent; `-full` derived. */
531
+ authorIndexPathFor: (agent: string) => string;
532
+ /** Index curator personality (its branch adds the tags dictionary). */
533
+ indexAgent?: string;
534
+ /** ADR-014 projects root for `dir:` resolution. */
535
+ projectsRoot?: string;
500
536
  };
501
537
 
538
+ export type FleetPeer = { personality: string; cwd: string };
539
+
502
540
  export type MemorydHandle = {
503
541
  mcpPort: number | null;
504
542
  /** Force one detect pass immediately (used by tests and shutdown flush). */
@@ -571,6 +609,94 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
571
609
  * last_edited_by читается ИЗ ФАЙЛА на момент пачки — правки
572
610
  * index/scriber/dreamweaver в пачку не попадают (эхо-брейкер механикой,
573
611
  * не доктриной). */
612
+ // ── per-peer fragments (docs/05: свежесть за секунды от FS-изменений) ──
613
+ const fragments = opts.fragments ?? null;
614
+ let fleetCache: { mtimeMs: number; peers: FleetPeer[] } | null = null;
615
+
616
+ /** Fail-open fleet map reader with an mtime cache: missing/malformed file
617
+ * → empty fleet (rendering quietly off), never throws. */
618
+ function readFleetMap(): FleetPeer[] {
619
+ if (!fragments) return [];
620
+ try {
621
+ const st = fs.statSync(fragments.fleetMapPath);
622
+ if (fleetCache && fleetCache.mtimeMs === st.mtimeMs) return fleetCache.peers;
623
+ const raw = JSON.parse(fs.readFileSync(fragments.fleetMapPath, "utf-8")) as {
624
+ peers?: Array<{ personality?: unknown; cwd?: unknown }>;
625
+ };
626
+ const peers: FleetPeer[] = (Array.isArray(raw?.peers) ? raw.peers : [])
627
+ .filter(
628
+ (p): p is { personality: string; cwd: string } =>
629
+ typeof p?.personality === "string" &&
630
+ p.personality.trim() !== "" &&
631
+ typeof p?.cwd === "string" &&
632
+ p.cwd.trim() !== "",
633
+ )
634
+ .map((p) => ({ personality: p.personality.trim(), cwd: p.cwd.trim() }));
635
+ fleetCache = { mtimeMs: st.mtimeMs, peers };
636
+ return peers;
637
+ } catch {
638
+ fleetCache = null;
639
+ return [];
640
+ }
641
+ }
642
+
643
+ /** One render pass over the whole fleet: collectNotes ONCE (the full
644
+ * vault scan is the expensive part), then filter/build/write per peer.
645
+ * Per-peer failures are isolated; a peer whose cwd is gone is skipped
646
+ * (never scaffold directories for a removed peer). */
647
+ function renderFleetFragments(reason: string): void {
648
+ if (!fragments || !vaultAvailable()) return;
649
+ const peers = readFleetMap();
650
+ if (!peers.length) return;
651
+ const ctx: RenderContext = { taxonomy, ranking: config.ranking };
652
+ let collected: ReturnType<typeof collectNotes>;
653
+ try {
654
+ collected = collectNotes(config.vaultPath, ctx);
655
+ } catch (err) {
656
+ logger.warn(`fragments: vault collect failed (${String(err)}) — pass skipped`);
657
+ return;
658
+ }
659
+ const indexAgent = fragments.indexAgent ?? "index";
660
+ let rendered = 0;
661
+ for (const peer of peers) {
662
+ try {
663
+ if (!fs.existsSync(peer.cwd)) continue;
664
+ const mine = filterAgentNotes(collected.notes, collected.incomingCount, peer.personality, ctx);
665
+ const outFile = fragments.authorIndexPathFor(peer.personality);
666
+ fs.mkdirSync(path.dirname(outFile), { recursive: true }); // atomicWrite не создаёт родителя
667
+ const fullOut = fullIndexPathFor(outFile);
668
+ const [text] = buildOutput(mine, peer.personality, {
669
+ ctx,
670
+ projectsRoot: fragments.projectsRoot,
671
+ fullIndexPath: fullOut,
672
+ });
673
+ atomicWrite(outFile, text);
674
+ const [fullText] = buildOutput(mine, peer.personality, {
675
+ ctx,
676
+ projectsRoot: fragments.projectsRoot,
677
+ memoryCap: null,
678
+ canonCap: null,
679
+ projectHardCap: null,
680
+ });
681
+ atomicWrite(fullOut, fullText);
682
+ renderPeerFragment({
683
+ peerCwd: peer.cwd,
684
+ env: {
685
+ agent: peer.personality,
686
+ indexAgent,
687
+ paths: fragments.paths,
688
+ authorIndexPath: outFile,
689
+ tagsDictionaryPath: peer.personality === indexAgent ? tagsMirrorPath : undefined,
690
+ },
691
+ });
692
+ rendered++;
693
+ } catch (err) {
694
+ logger.warn(`fragments: ${peer.personality} render failed (${String(err)})`);
695
+ }
696
+ }
697
+ if (rendered) logger.info(`fragments: rendered ${rendered} peer fragment(s) (${reason})`);
698
+ }
699
+
574
700
  function lastEditedByOf(absPath: string): string | null {
575
701
  try {
576
702
  const head = fs.readFileSync(absPath, "utf-8").slice(0, 4096);
@@ -680,10 +806,26 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
680
806
  // over the WHOLE inbox folder; no dependency on fs.watch filenames).
681
807
  const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
682
808
  for (const name of diffSnapshots(inboxSnapshot, inboxNext)) {
809
+ const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
810
+ // Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
811
+ // 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
812
+ // повторный INBOX_NEW по тому же черновику — вычитывающая сессия и
813
+ // есть конвейер). Правки автора (reject→fix→re-review) проходят:
814
+ // last_edited_by остаётся автором. Цена документирована: черновик,
815
+ // чья ПОСЛЕДНЯЯ правка до первого анонса — кураторская, не
816
+ // анонсируется; в конвейере не возникает (кураторы трогают черновики
817
+ // только по событиям, т.е. после анонса). Baseline обновляется ниже
818
+ // безусловно — подавленное НЕ реплеится, авторская правка поверх
819
+ // кураторской диффится от свежего снапшота и проходит.
820
+ const leb = lastEditedByOf(abs);
821
+ if (leb && config.curatorSet.includes(leb)) {
822
+ logger.info(`inbox event suppressed (curator ${leb}): ${name}`);
823
+ continue;
824
+ }
683
825
  logger.info(`inbox event: ${name}`);
684
826
  // ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
685
827
  // vault root — a role peer once `find`-guessed a stale copy.
686
- emit(`INBOX_NEW: ${path.join(config.vaultPath, taxonomy.folders.inbox, name)}`);
828
+ emit(`INBOX_NEW: ${abs}`);
687
829
  }
688
830
  if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
689
831
  inboxSnapshot = inboxNext;
@@ -693,6 +835,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
693
835
  humanEditPass(changed);
694
836
  syncTagsMirror();
695
837
  await indexAll({ db, config, logger }); // incremental by content hash
838
+ renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
696
839
  // PERMANENT правки НЕ эмитятся мгновенно — копятся до batch-прохода
697
840
  // (каденция 6h); см. runPermanentBatch.
698
841
  }
@@ -768,9 +911,27 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
768
911
  }
769
912
  }
770
913
  touchHeartbeat();
771
- const heartbeatTimer = setInterval(touchHeartbeat, heartbeatMs);
914
+ const heartbeatTimer = setInterval(() => {
915
+ touchHeartbeat();
916
+ // Fleet-map change without a vault change (a new peer landed in the
917
+ // registry, the package re-wrote the map) → render the newcomers on
918
+ // the next tick; the mtime cache makes the no-change case a stat().
919
+ if (fragments) {
920
+ try {
921
+ const st = fs.statSync(fragments.fleetMapPath);
922
+ if (st.mtimeMs !== fleetCache?.mtimeMs) renderFleetFragments("fleet-map-change");
923
+ } catch {
924
+ // no map — rendering stays off (fail-open)
925
+ }
926
+ }
927
+ }, heartbeatMs);
772
928
  heartbeatTimer.unref?.();
773
929
 
930
+ // Cold-start coverage (дыра 10.06): render the WHOLE fleet at startup —
931
+ // a memoryd restart after install/update populates every peer's fragment
932
+ // before the fleet's own restarts pick them up.
933
+ renderFleetFragments("startup");
934
+
774
935
  // Periodic atomic persist of the detect baseline (only when changed —
775
936
  // no churn); a non-graceful exit loses at most one interval.
776
937
  let lastPersistedJson: string | null = null;