@agfpd/iapeer-memory-core 0.2.1 → 0.2.3

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.2.1",
3
+ "version": "0.2.3",
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/memoryd.ts CHANGED
@@ -59,6 +59,13 @@ import {
59
59
  } from "./index-render.js";
60
60
  import { renderPeerFragment, type FragmentEnv } from "./context-render.js";
61
61
  import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
62
+ import {
63
+ isSilentEdit,
64
+ readStampRecord,
65
+ restampUnstamped,
66
+ type SilentZone,
67
+ type StampRecord,
68
+ } from "./silent-edit-detect.js";
62
69
 
63
70
  // ── identity ────────────────────────────────────────────────────────────────
64
71
 
@@ -411,35 +418,68 @@ export async function startMcpHttp(opts: {
411
418
  // baseline survives restarts, atomically (same tmp+rename canon).
412
419
 
413
420
  /** Persisted batch baselines: inbox + permanent + human-inbox snapshots
414
- * (rel/name → smart hash). Survives restarts порт паттерна старых
415
- * мониторов «seen переживает сбой». */
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. */
416
427
  export type BatchState = {
417
428
  inbox: VaultSnapshot | null;
418
429
  permanent: VaultSnapshot | null;
419
430
  humanInbox: VaultSnapshot | null;
431
+ silentStamps: Map<string, StampRecord> | null;
420
432
  };
421
433
 
422
434
  export function loadBatchState(filePath: string): BatchState {
423
435
  try {
424
436
  const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
425
437
  string,
426
- Record<string, string>
438
+ Record<string, unknown>
427
439
  >;
428
- const toMap = (o: Record<string, string> | undefined): VaultSnapshot | null =>
429
- o ? new Map(Object.entries(o)) : null;
440
+ const toMap = (o: Record<string, unknown> | undefined): VaultSnapshot | null =>
441
+ o
442
+ ? new Map(
443
+ Object.entries(o).filter((e): e is [string, string] => typeof e[1] === "string"),
444
+ )
445
+ : null;
446
+ const toStamps = (
447
+ o: Record<string, unknown> | undefined,
448
+ ): Map<string, StampRecord> | null => {
449
+ if (!o) return null;
450
+ const m = new Map<string, StampRecord>();
451
+ for (const [k, v] of Object.entries(o)) {
452
+ if (v && typeof v === "object" && !Array.isArray(v)) {
453
+ const r = v as { hash?: unknown; updated?: unknown; leb?: unknown };
454
+ if (typeof r.hash !== "string") continue; // schema drift → first-sight
455
+ m.set(k, {
456
+ hash: r.hash,
457
+ updated: typeof r.updated === "string" ? r.updated : null,
458
+ leb: typeof r.leb === "string" ? r.leb : null,
459
+ });
460
+ }
461
+ }
462
+ return m;
463
+ };
430
464
  return {
431
465
  inbox: toMap(raw.inbox),
432
466
  permanent: toMap(raw.permanent),
433
467
  humanInbox: toMap(raw.humanInbox),
468
+ silentStamps: toStamps(raw.silentStamps),
434
469
  };
435
470
  } catch {
436
- return { inbox: null, permanent: null, humanInbox: null };
471
+ return { inbox: null, permanent: null, humanInbox: null, silentStamps: null };
437
472
  }
438
473
  }
439
474
 
440
475
  export function persistBatchState(
441
476
  filePath: string,
442
- state: { inbox: VaultSnapshot; permanent: VaultSnapshot; humanInbox: VaultSnapshot },
477
+ state: {
478
+ inbox: VaultSnapshot;
479
+ permanent: VaultSnapshot;
480
+ humanInbox: VaultSnapshot;
481
+ silentStamps: Map<string, StampRecord>;
482
+ },
443
483
  ): void {
444
484
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
445
485
  const tmp = `${filePath}.tmp`;
@@ -449,6 +489,7 @@ export function persistBatchState(
449
489
  inbox: Object.fromEntries(state.inbox),
450
490
  permanent: Object.fromEntries(state.permanent),
451
491
  humanInbox: Object.fromEntries(state.humanInbox),
492
+ silentStamps: Object.fromEntries(state.silentStamps),
452
493
  }),
453
494
  "utf-8",
454
495
  );
@@ -580,6 +621,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
580
621
  let humanInboxBaseline: VaultSnapshot =
581
622
  persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
582
623
  const lastSeenHashes = loadHashState(hashStatePath);
624
+ /** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
625
+ * old state file = empty map = first-sight warm-up (design §4). */
626
+ const silentStamps: Map<string, StampRecord> =
627
+ persistedBatches.silentStamps ?? new Map();
583
628
 
584
629
  function persistBatches(): void {
585
630
  try {
@@ -587,6 +632,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
587
632
  inbox: inboxSnapshot,
588
633
  permanent: permanentBaseline,
589
634
  humanInbox: humanInboxBaseline,
635
+ silentStamps,
590
636
  });
591
637
  } catch (err) {
592
638
  logger.error(`batch-state persist failed: ${String(err)}`);
@@ -735,6 +781,82 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
735
781
  logger.info(`tags mirror updated (${decision.reason})`);
736
782
  }
737
783
 
784
+ /** Zone map of the unstamped detector (design §3): the agent inbox +
785
+ * the six permanent folders (five canonical + agent memory — wider than
786
+ * humanEditPass's getZone, which has no agent-memory notion). */
787
+ function silentZoneOf(absPath: string): SilentZone | null {
788
+ const rel = path.relative(config.vaultPath, absPath);
789
+ if (rel.startsWith("..")) return null;
790
+ const first = rel.split(path.sep)[0];
791
+ const f = taxonomy.folders;
792
+ if (first === f.inbox) return "inbox";
793
+ if (
794
+ first === f.knowledge ||
795
+ first === f.decisions ||
796
+ first === f.projects ||
797
+ first === f.ideas ||
798
+ first === f.lists ||
799
+ first === f.agentMemory
800
+ ) {
801
+ return "permanent";
802
+ }
803
+ return null;
804
+ }
805
+
806
+ /**
807
+ * Unstamped-write detector (design doc, boris-accepted 11.06). Runs
808
+ * BEFORE the inbox emission loop and BEFORE humanEditPass: a re-stamped
809
+ * file then reads `last_edited_by: unstamped` — the curator source
810
+ * filters pass it into the pipeline, and humanEditPass sees a fresh
811
+ * agent stamp (echo-agent skip, no double stamp — order instead of ifs).
812
+ * Candidates carry CHANGED files only (fs.watch set ∪ inbox diff) — the
813
+ * semantic-hash precondition of the rule; service-only echoes never
814
+ * reach here (smart-hash blindness = echo safety of our own re-stamp).
815
+ */
816
+ function silentEditPass(candidatesAbs: Set<string>): void {
817
+ for (const abs of candidatesAbs) {
818
+ const zone = silentZoneOf(abs);
819
+ if (!zone) continue;
820
+ const rel = path.relative(config.vaultPath, abs);
821
+ let content: string;
822
+ try {
823
+ content = fs.readFileSync(abs, "utf-8");
824
+ } catch {
825
+ silentStamps.delete(rel); // deleted mid-debounce — drop the record
826
+ continue;
827
+ }
828
+ const curr = readStampRecord(content);
829
+ const prev = silentStamps.get(rel);
830
+ if (prev === undefined) {
831
+ silentStamps.set(rel, curr); // first sight: record, never judge
832
+ continue;
833
+ }
834
+ if (!isSilentEdit({ prev, curr, zone, nowMs: Date.now(), freshEditWindowS: opts.freshEditWindowS })) {
835
+ silentStamps.set(rel, curr);
836
+ continue;
837
+ }
838
+ const restamped = restampUnstamped(content, Date.now());
839
+ if (restamped === null) {
840
+ silentStamps.set(rel, curr); // bare draft — the fill machinery's job
841
+ continue;
842
+ }
843
+ const tmp = `${abs}.memoryd.tmp`;
844
+ try {
845
+ guardedWriteFileSync(tmp, restamped, "utf-8");
846
+ fs.renameSync(tmp, abs);
847
+ silentStamps.set(rel, readStampRecord(restamped));
848
+ logger.info(`silent edit re-stamped (${zone}): ${rel}`);
849
+ } catch (err) {
850
+ try {
851
+ guardedUnlinkSync(tmp);
852
+ } catch {
853
+ // best effort
854
+ }
855
+ logger.error(`silent-edit re-stamp failed for ${abs}: ${String(err)}`);
856
+ }
857
+ }
858
+ }
859
+
738
860
  function humanEditPass(changedAbs: Set<string>): void {
739
861
  const human = opts.humanName ?? null;
740
862
  if (!human) return; // ⚖7: no human role — detection off
@@ -806,7 +928,20 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
806
928
  // INBOX_NEW — мгновенно: new OR semantically changed drafts (hash-diff
807
929
  // over the WHOLE inbox folder; no dependency on fs.watch filenames).
808
930
  const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
809
- for (const name of diffSnapshots(inboxSnapshot, inboxNext)) {
931
+ const inboxChanged = diffSnapshots(inboxSnapshot, inboxNext);
932
+
933
+ // Unstamped detector FIRST (design §3 order): re-stamps land before the
934
+ // curator suppress below reads leb and before humanEditPass judges.
935
+ // Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
936
+ // diff (the diff is semantic — a re-stamp does not invalidate it).
937
+ silentEditPass(
938
+ new Set([
939
+ ...changed,
940
+ ...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
941
+ ]),
942
+ );
943
+
944
+ for (const name of inboxChanged) {
810
945
  const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
811
946
  // Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
812
947
  // 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Silent-edit detector — the unstamped-write belt
3
+ * (docs/_planning/UNSTAMPED_EDIT_DETECTOR_DESIGN.md, accepted by boris
4
+ * 11.06; precedent: the Index's catch — a Bash-heredoc edit of a canon
5
+ * note 52 s after a curator stamp was swallowed FOREVER by the human-edit
6
+ * echo window, masquerading as the curator for the source filters).
7
+ *
8
+ * A vault write that bypasses the PostToolUse hook (Bash, python-heredoc,
9
+ * any non-Write/Edit tool) leaves the stamp untouched. The discriminator
10
+ * is the SEMANTIC hash (smart-hash: frontmatter minus service fields +
11
+ * body) — the battle-proven anti-echo of permanent-detect: a hook echo
12
+ * changes ONLY service fields (semantic hash still), a silent edit moves
13
+ * the semantic hash while the stamp stands still. The same property makes
14
+ * our response re-stamp echo-safe BY CONSTRUCTION: a re-stamp touches only
15
+ * service fields, so it never re-triggers the detector or the batch diffs.
16
+ *
17
+ * ZONED rule (the humanEditPass intersection, §3a of the design):
18
+ * permanent — ONLY the fresh-window case (stamp ≤ FRESH_EDIT_WINDOW_S):
19
+ * that is exactly the echo-window swallow; STALE cases stay with
20
+ * humanEditPass (human attribution — the Obsidian main case; a wider
21
+ * rule would regress «Артур правит кураторскую заметку»);
22
+ * inbox — UNCONDITIONAL: humanEditPass does not cover the agent inbox at
23
+ * all, human edits there are marginal, and the curator-masquerade
24
+ * (memoryd's 822-suppress) plus the silent author edit are both closed
25
+ * by one branch.
26
+ *
27
+ * Attribution token: `unstamped` — NEUTRAL on purpose (the mechanism
28
+ * cannot tell a Bash agent from a human outside Obsidian; a false «agent»
29
+ * on the human's edit is worse than an honest «don't know» — the
30
+ * author-guard symmetry: a visible anomaly beats a silently assigned
31
+ * identity). The Index resolves it by context; needs_review rides along.
32
+ *
33
+ * Pure decision core — no I/O; the memoryd shell owns reading, the atomic
34
+ * write and the persisted stamp baseline (first-sight warm-up: a file
35
+ * without a baseline record is recorded, never judged — the 0.1.8 guard).
36
+ */
37
+
38
+ import {
39
+ formatStamp,
40
+ parseUpdated,
41
+ upsertField,
42
+ DEFAULT_FRESH_EDIT_WINDOW_S,
43
+ } from "./human-edit-detect.js";
44
+ import { smartHash } from "./smart-hash.js";
45
+
46
+ export const UNSTAMPED_TOKEN = "unstamped";
47
+
48
+ export type SilentZone = "permanent" | "inbox";
49
+
50
+ /** The per-file baseline record (design §4): the SEMANTIC hash (the BASE
51
+ * precondition — without it an iCloud mtime-echo inside the fresh window
52
+ * would false-trigger a re-stamp on identical content) + the stamp pair
53
+ * compared verbatim between passes. */
54
+ export type StampRecord = {
55
+ hash: string;
56
+ updated: string | null;
57
+ leb: string | null;
58
+ };
59
+
60
+ const FM_RE = /^---[^\S\n]*\n([\s\S]*?)\n---[^\S\n]*(?:\n|$)/;
61
+
62
+ /** Read the baseline record from note content. No frontmatter → null stamps. */
63
+ export function readStampRecord(content: string): StampRecord {
64
+ const fm = FM_RE.exec(content);
65
+ const hash = smartHash(new TextEncoder().encode(content));
66
+ if (!fm) return { hash, updated: null, leb: null };
67
+ const upd = /^updated\s*:\s*(.+?)\s*$/m.exec(fm[1]);
68
+ const leb = /^last_edited_by\s*:\s*(.+?)\s*$/m.exec(fm[1]);
69
+ return {
70
+ hash,
71
+ updated: upd ? upd[1].trim() : null,
72
+ leb: leb ? leb[1].trim() : null,
73
+ };
74
+ }
75
+
76
+ export type DecideSilentInput = {
77
+ /** Baseline record (the PREVIOUS pass). */
78
+ prev: StampRecord;
79
+ curr: StampRecord;
80
+ zone: SilentZone;
81
+ nowMs: number;
82
+ freshEditWindowS?: number;
83
+ };
84
+
85
+ /**
86
+ * BASE: the semantic hash MOVED ∧ the stamp pair did not move verbatim.
87
+ * Zone branch: permanent → only when the standing stamp is FRESH (the
88
+ * echo-window swallow); inbox → unconditional. A service-only change
89
+ * (hook echo, our own re-stamp) keeps the semantic hash still → never
90
+ * silent; an mtime-only event keeps content identical → never silent.
91
+ */
92
+ export function isSilentEdit(input: DecideSilentInput): boolean {
93
+ if (input.prev.hash === input.curr.hash) return false; // BASE: no semantic move
94
+ const stampUnmoved =
95
+ input.prev.updated === input.curr.updated && input.prev.leb === input.curr.leb;
96
+ if (!stampUnmoved) return false;
97
+ if (input.zone === "inbox") return true;
98
+ const windowS = input.freshEditWindowS ?? DEFAULT_FRESH_EDIT_WINDOW_S;
99
+ const editAt = parseUpdated(input.curr.updated);
100
+ return editAt !== null && (input.nowMs - editAt) / 1000 < windowS;
101
+ }
102
+
103
+ /**
104
+ * The response re-stamp: `last_edited_by: unstamped` + fresh `updated` +
105
+ * `needs_review: true`. Content without frontmatter → null (a bare draft
106
+ * is the fill machinery's job, not ours).
107
+ *
108
+ * SURGICAL splice: only the captured frontmatter block is replaced — the
109
+ * rest of the file (the `---` fences, the blank line after them, the body)
110
+ * stays byte-exact. That is what makes the re-stamp service-fields-only
111
+ * and therefore echo-safe (a reassembly once ate the post-fence blank
112
+ * line, moved the semantic hash and broke the no-loop property — caught
113
+ * by the test, fixed by construction).
114
+ */
115
+ export function restampUnstamped(content: string, nowMs: number): string | null {
116
+ const fm = FM_RE.exec(content);
117
+ if (!fm) return null;
118
+ let block = fm[1];
119
+ block = upsertField(block, "last_edited_by", UNSTAMPED_TOKEN);
120
+ block = upsertField(block, "updated", formatStamp(new Date(nowMs)));
121
+ block = upsertField(block, "needs_review", "true");
122
+ block = block.replace(/\n$/, ""); // the capture carries no trailing \n
123
+ const blockStart = fm[0].indexOf("\n") + 1; // right after the opening fence line
124
+ const blockEnd = blockStart + fm[1].length;
125
+ return content.slice(0, blockStart) + block + content.slice(blockEnd);
126
+ }