@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 +1 -1
- package/src/memoryd.ts +143 -8
- package/src/silent-edit-detect.ts +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory-core",
|
|
3
|
-
"version": "0.2.
|
|
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)
|
|
415
|
-
*
|
|
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,
|
|
438
|
+
Record<string, unknown>
|
|
427
439
|
>;
|
|
428
|
-
const toMap = (o: Record<string,
|
|
429
|
-
o
|
|
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: {
|
|
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
|
-
|
|
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
|
+
}
|