@agfpd/iapeer-memory-core 0.2.0 → 0.2.2
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/context-render.ts +3 -2
- package/src/frontmatter-fill.ts +3 -2
- package/src/fs-guard.ts +92 -0
- package/src/index-render.ts +3 -2
- package/src/index.ts +9 -0
- package/src/memoryd.ts +151 -15
- package/src/migrate-auto-memory.ts +5 -4
- package/src/render-doctrine.ts +3 -2
- 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.2",
|
|
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/context-render.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
|
+
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
30
31
|
|
|
31
32
|
export const FRAGMENT_STEM = "iapeer-memory.md";
|
|
32
33
|
|
|
@@ -140,11 +141,11 @@ export function writeFragmentAtomic(
|
|
|
140
141
|
`.${stem}.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
141
142
|
);
|
|
142
143
|
try {
|
|
143
|
-
|
|
144
|
+
guardedWriteFileSync(tmp, text, "utf-8");
|
|
144
145
|
fs.renameSync(tmp, target);
|
|
145
146
|
} catch (err) {
|
|
146
147
|
try {
|
|
147
|
-
if (fs.existsSync(tmp))
|
|
148
|
+
if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
|
|
148
149
|
} catch {
|
|
149
150
|
// best effort
|
|
150
151
|
}
|
package/src/frontmatter-fill.ts
CHANGED
|
@@ -38,6 +38,7 @@ import path from "node:path";
|
|
|
38
38
|
import crypto from "node:crypto";
|
|
39
39
|
import type { TaxonomyPreset } from "./taxonomy.js";
|
|
40
40
|
import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
|
|
41
|
+
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
41
42
|
|
|
42
43
|
const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
|
|
43
44
|
|
|
@@ -417,11 +418,11 @@ export function atomicWrite(filePath: string, content: string): void {
|
|
|
417
418
|
`.fm-${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
418
419
|
);
|
|
419
420
|
try {
|
|
420
|
-
|
|
421
|
+
guardedWriteFileSync(tmp, content, "utf-8");
|
|
421
422
|
fs.renameSync(tmp, filePath);
|
|
422
423
|
} catch (err) {
|
|
423
424
|
try {
|
|
424
|
-
if (fs.existsSync(tmp))
|
|
425
|
+
if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
|
|
425
426
|
} catch {
|
|
426
427
|
// best effort
|
|
427
428
|
}
|
package/src/fs-guard.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FS belt — the file-system half of deny-by-default
|
|
3
|
+
* (iapeer-memory docs/_planning/DENY_BY_DEFAULT_DESIGN.md §4 П4, accepted
|
|
4
|
+
* by boris 11.06).
|
|
5
|
+
*
|
|
6
|
+
* Incident class №2 («лестница рассинхронилась»): the db path ladder lived
|
|
7
|
+
* in TWO copies (core config + package paths) and a drift wrote a sandbox
|
|
8
|
+
* SQLite into the PROD `~/.iapeer/cache`. Path conventions cannot be the
|
|
9
|
+
* only belt — so every raw write/unlink/rm in BOTH src trees goes through
|
|
10
|
+
* the wrappers below, and under an armed test-sandbox env they REFUSE any
|
|
11
|
+
* path under a production anchor, no matter what the ladder computed:
|
|
12
|
+
*
|
|
13
|
+
* ~/.iapeer — ecosystem state/cache/config of the live host
|
|
14
|
+
* ~/.claude, ~/.codex — harness config surfaces of the live fleet
|
|
15
|
+
* ~/Library/Mobile Documents — the iCloud root (the live vault lives there)
|
|
16
|
+
*
|
|
17
|
+
* Outside the sandbox env the wrappers are pass-through: live init/update/
|
|
18
|
+
* migrate write exactly where they always did. The grep invariant (И3) pins
|
|
19
|
+
* the funnel: no raw `fs.writeFileSync`/`Bun.write`/`fs.rmSync`/
|
|
20
|
+
* `fs.unlinkSync` outside this file in either src tree.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT guarded in v1: `mkdirSync` (creates empty dirs — no data
|
|
23
|
+
* loss / no content leak; the write that would fill them refuses) and reads.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Both test belts — the ecosystem-wide var survives generic
|
|
31
|
+
* IAPEER_MEMORY_* env-stripping (incident 10.06 №3). The ONE definition
|
|
32
|
+
* for both packages: the package egress hub imports this. */
|
|
33
|
+
export function sandboxEnvArmed(): boolean {
|
|
34
|
+
return (
|
|
35
|
+
process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
|
|
36
|
+
process.env.IAPEER_TEST_SANDBOX === "1"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function prodAnchors(): string[] {
|
|
41
|
+
const home = os.homedir();
|
|
42
|
+
return [
|
|
43
|
+
path.join(home, ".iapeer"),
|
|
44
|
+
path.join(home, ".claude"),
|
|
45
|
+
path.join(home, ".codex"),
|
|
46
|
+
path.join(home, "Library", "Mobile Documents"),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when the resolved path sits under a production anchor. Exported for
|
|
51
|
+
* tests: the predicate is checkable without arming any write. */
|
|
52
|
+
export function isUnderProdAnchor(filePath: string): boolean {
|
|
53
|
+
const resolved = path.resolve(filePath);
|
|
54
|
+
return prodAnchors().some(
|
|
55
|
+
(a) => resolved === a || resolved.startsWith(a + path.sep),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Throws under an armed sandbox env when the path targets a prod anchor.
|
|
60
|
+
* The op name makes the refusal teach: WHICH write was stopped. */
|
|
61
|
+
export function assertSandboxWritablePath(filePath: string, op: string): void {
|
|
62
|
+
if (!sandboxEnvArmed()) return;
|
|
63
|
+
if (isUnderProdAnchor(filePath)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`fs-guard: ${op} refused under the test sandbox — "${filePath}" is under a production anchor ` +
|
|
66
|
+
"(~/.iapeer, ~/.claude, ~/.codex or the iCloud root). A test must write " +
|
|
67
|
+
"inside its own tmp root; if the path came from the env ladder, the ladder drifted.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function guardedWriteFileSync(
|
|
73
|
+
filePath: string,
|
|
74
|
+
data: string | NodeJS.ArrayBufferView,
|
|
75
|
+
options?: Parameters<typeof fs.writeFileSync>[2],
|
|
76
|
+
): void {
|
|
77
|
+
assertSandboxWritablePath(filePath, "write");
|
|
78
|
+
fs.writeFileSync(filePath, data, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function guardedUnlinkSync(filePath: string): void {
|
|
82
|
+
assertSandboxWritablePath(filePath, "unlink");
|
|
83
|
+
fs.unlinkSync(filePath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function guardedRmSync(
|
|
87
|
+
filePath: string,
|
|
88
|
+
options?: Parameters<typeof fs.rmSync>[1],
|
|
89
|
+
): void {
|
|
90
|
+
assertSandboxWritablePath(filePath, "rm");
|
|
91
|
+
fs.rmSync(filePath, options);
|
|
92
|
+
}
|
package/src/index-render.ts
CHANGED
|
@@ -28,6 +28,7 @@ import path from "node:path";
|
|
|
28
28
|
import crypto from "node:crypto";
|
|
29
29
|
import type { RankingConfig, TaxonomyPreset } from "./taxonomy.js";
|
|
30
30
|
import { statusGroup as taxonomyStatusGroup } from "./taxonomy.js";
|
|
31
|
+
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
31
32
|
|
|
32
33
|
const WIKILINK_RE = /\[\[([^\]|#]+)/g;
|
|
33
34
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
@@ -821,11 +822,11 @@ export function atomicWrite(filePath: string, content: string): void {
|
|
|
821
822
|
const dir = path.dirname(filePath) || ".";
|
|
822
823
|
const tmp = path.join(dir, `.vault-index-${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
823
824
|
try {
|
|
824
|
-
|
|
825
|
+
guardedWriteFileSync(tmp, content, "utf-8");
|
|
825
826
|
fs.renameSync(tmp, filePath);
|
|
826
827
|
} catch (err) {
|
|
827
828
|
try {
|
|
828
|
-
if (fs.existsSync(tmp))
|
|
829
|
+
if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
|
|
829
830
|
} catch {
|
|
830
831
|
// best effort
|
|
831
832
|
}
|
package/src/index.ts
CHANGED
|
@@ -70,3 +70,12 @@ export { prepareSqliteRuntime, type SqliteRuntime } from "./sqlite-loader.js";
|
|
|
70
70
|
|
|
71
71
|
// logging
|
|
72
72
|
export { makeLogger, type Logger } from "./log.js";
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
sandboxEnvArmed,
|
|
76
|
+
isUnderProdAnchor,
|
|
77
|
+
assertSandboxWritablePath,
|
|
78
|
+
guardedWriteFileSync,
|
|
79
|
+
guardedUnlinkSync,
|
|
80
|
+
guardedRmSync,
|
|
81
|
+
} from "./fs-guard.js";
|
package/src/memoryd.ts
CHANGED
|
@@ -58,6 +58,14 @@ import {
|
|
|
58
58
|
type RenderContext,
|
|
59
59
|
} from "./index-render.js";
|
|
60
60
|
import { renderPeerFragment, type FragmentEnv } from "./context-render.js";
|
|
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";
|
|
61
69
|
|
|
62
70
|
// ── identity ────────────────────────────────────────────────────────────────
|
|
63
71
|
|
|
@@ -410,44 +418,78 @@ export async function startMcpHttp(opts: {
|
|
|
410
418
|
// baseline survives restarts, atomically (same tmp+rename canon).
|
|
411
419
|
|
|
412
420
|
/** Persisted batch baselines: inbox + permanent + human-inbox snapshots
|
|
413
|
-
* (rel/name → smart hash)
|
|
414
|
-
*
|
|
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. */
|
|
415
427
|
export type BatchState = {
|
|
416
428
|
inbox: VaultSnapshot | null;
|
|
417
429
|
permanent: VaultSnapshot | null;
|
|
418
430
|
humanInbox: VaultSnapshot | null;
|
|
431
|
+
silentStamps: Map<string, StampRecord> | null;
|
|
419
432
|
};
|
|
420
433
|
|
|
421
434
|
export function loadBatchState(filePath: string): BatchState {
|
|
422
435
|
try {
|
|
423
436
|
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
|
|
424
437
|
string,
|
|
425
|
-
Record<string,
|
|
438
|
+
Record<string, unknown>
|
|
426
439
|
>;
|
|
427
|
-
const toMap = (o: Record<string,
|
|
428
|
-
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
|
+
};
|
|
429
464
|
return {
|
|
430
465
|
inbox: toMap(raw.inbox),
|
|
431
466
|
permanent: toMap(raw.permanent),
|
|
432
467
|
humanInbox: toMap(raw.humanInbox),
|
|
468
|
+
silentStamps: toStamps(raw.silentStamps),
|
|
433
469
|
};
|
|
434
470
|
} catch {
|
|
435
|
-
return { inbox: null, permanent: null, humanInbox: null };
|
|
471
|
+
return { inbox: null, permanent: null, humanInbox: null, silentStamps: null };
|
|
436
472
|
}
|
|
437
473
|
}
|
|
438
474
|
|
|
439
475
|
export function persistBatchState(
|
|
440
476
|
filePath: string,
|
|
441
|
-
state: {
|
|
477
|
+
state: {
|
|
478
|
+
inbox: VaultSnapshot;
|
|
479
|
+
permanent: VaultSnapshot;
|
|
480
|
+
humanInbox: VaultSnapshot;
|
|
481
|
+
silentStamps: Map<string, StampRecord>;
|
|
482
|
+
},
|
|
442
483
|
): void {
|
|
443
484
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
444
485
|
const tmp = `${filePath}.tmp`;
|
|
445
|
-
|
|
486
|
+
guardedWriteFileSync(
|
|
446
487
|
tmp,
|
|
447
488
|
JSON.stringify({
|
|
448
489
|
inbox: Object.fromEntries(state.inbox),
|
|
449
490
|
permanent: Object.fromEntries(state.permanent),
|
|
450
491
|
humanInbox: Object.fromEntries(state.humanInbox),
|
|
492
|
+
silentStamps: Object.fromEntries(state.silentStamps),
|
|
451
493
|
}),
|
|
452
494
|
"utf-8",
|
|
453
495
|
);
|
|
@@ -470,7 +512,7 @@ export function loadHashState(filePath: string): Map<string, string> {
|
|
|
470
512
|
export function persistHashState(filePath: string, map: Map<string, string>): void {
|
|
471
513
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
472
514
|
const tmp = `${filePath}.tmp`;
|
|
473
|
-
|
|
515
|
+
guardedWriteFileSync(tmp, JSON.stringify(Object.fromEntries(map)), "utf-8");
|
|
474
516
|
fs.renameSync(tmp, filePath);
|
|
475
517
|
}
|
|
476
518
|
|
|
@@ -579,6 +621,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
579
621
|
let humanInboxBaseline: VaultSnapshot =
|
|
580
622
|
persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
|
|
581
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();
|
|
582
628
|
|
|
583
629
|
function persistBatches(): void {
|
|
584
630
|
try {
|
|
@@ -586,6 +632,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
586
632
|
inbox: inboxSnapshot,
|
|
587
633
|
permanent: permanentBaseline,
|
|
588
634
|
humanInbox: humanInboxBaseline,
|
|
635
|
+
silentStamps,
|
|
589
636
|
});
|
|
590
637
|
} catch (err) {
|
|
591
638
|
logger.error(`batch-state persist failed: ${String(err)}`);
|
|
@@ -729,11 +776,87 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
729
776
|
if (decision.action !== "write") return;
|
|
730
777
|
fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
|
|
731
778
|
const tmp = `${tagsMirrorPath}.tmp`;
|
|
732
|
-
|
|
779
|
+
guardedWriteFileSync(tmp, srcContent!, "utf-8");
|
|
733
780
|
fs.renameSync(tmp, tagsMirrorPath);
|
|
734
781
|
logger.info(`tags mirror updated (${decision.reason})`);
|
|
735
782
|
}
|
|
736
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
|
+
|
|
737
860
|
function humanEditPass(changedAbs: Set<string>): void {
|
|
738
861
|
const human = opts.humanName ?? null;
|
|
739
862
|
if (!human) return; // ⚖7: no human role — detection off
|
|
@@ -776,13 +899,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
776
899
|
}
|
|
777
900
|
const tmp = `${filePath}.memoryd.tmp`;
|
|
778
901
|
try {
|
|
779
|
-
|
|
902
|
+
guardedWriteFileSync(tmp, decision.newContent, "utf-8");
|
|
780
903
|
fs.renameSync(tmp, filePath);
|
|
781
904
|
lastSeenHashes.set(filePath, decision.recordHash);
|
|
782
905
|
logger.info(`human-edit ${decision.reason}: ${path.relative(config.vaultPath, filePath)}`);
|
|
783
906
|
} catch (err) {
|
|
784
907
|
try {
|
|
785
|
-
|
|
908
|
+
guardedUnlinkSync(tmp);
|
|
786
909
|
} catch {
|
|
787
910
|
// best effort
|
|
788
911
|
}
|
|
@@ -805,7 +928,20 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
805
928
|
// INBOX_NEW — мгновенно: new OR semantically changed drafts (hash-diff
|
|
806
929
|
// over the WHOLE inbox folder; no dependency on fs.watch filenames).
|
|
807
930
|
const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
|
|
808
|
-
|
|
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) {
|
|
809
945
|
const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
|
|
810
946
|
// Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
|
|
811
947
|
// 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
|
|
@@ -905,7 +1041,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
905
1041
|
function touchHeartbeat(): void {
|
|
906
1042
|
try {
|
|
907
1043
|
fs.mkdirSync(path.dirname(heartbeatPath), { recursive: true });
|
|
908
|
-
|
|
1044
|
+
guardedWriteFileSync(heartbeatPath, `${new Date().toISOString()} ${os.hostname()}\n`);
|
|
909
1045
|
} catch (err) {
|
|
910
1046
|
logger.error(`heartbeat write failed: ${String(err)}`);
|
|
911
1047
|
}
|
|
@@ -1020,7 +1156,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1020
1156
|
persistQuiet();
|
|
1021
1157
|
if (mcp) await mcp.close();
|
|
1022
1158
|
try {
|
|
1023
|
-
|
|
1159
|
+
guardedUnlinkSync(heartbeatPath);
|
|
1024
1160
|
} catch {
|
|
1025
1161
|
// best effort
|
|
1026
1162
|
}
|
|
@@ -32,6 +32,7 @@ import fs from "node:fs";
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import type { TaxonomyPreset } from "./taxonomy.js";
|
|
34
34
|
import { yamlSafeScalar } from "./fm-update.js";
|
|
35
|
+
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
35
36
|
|
|
36
37
|
/** Source files that are backed up but never copied into the vault. */
|
|
37
38
|
export const SKIP_FILES: ReadonlySet<string> = new Set(["MEMORY.md"]);
|
|
@@ -217,7 +218,7 @@ export function applyMigration(opts: {
|
|
|
217
218
|
// 2a. Non-md and SKIP_FILES: backup-only, removed from the source.
|
|
218
219
|
if (!name.endsWith(".md") || SKIP_FILES.has(name)) {
|
|
219
220
|
try {
|
|
220
|
-
|
|
221
|
+
guardedUnlinkSync(srcPath);
|
|
221
222
|
} catch (err) {
|
|
222
223
|
errors.push(`${name}: unlink after backup failed — ${String(err)}`);
|
|
223
224
|
}
|
|
@@ -229,7 +230,7 @@ export function applyMigration(opts: {
|
|
|
229
230
|
if (fs.existsSync(targetFile)) {
|
|
230
231
|
skipped.push(name);
|
|
231
232
|
try {
|
|
232
|
-
|
|
233
|
+
guardedUnlinkSync(srcPath);
|
|
233
234
|
} catch (err) {
|
|
234
235
|
errors.push(`${name}: unlink (already migrated) failed — ${String(err)}`);
|
|
235
236
|
}
|
|
@@ -261,7 +262,7 @@ export function applyMigration(opts: {
|
|
|
261
262
|
|
|
262
263
|
try {
|
|
263
264
|
const tmp = `${targetFile}.tmp`;
|
|
264
|
-
|
|
265
|
+
guardedWriteFileSync(tmp, newText, "utf-8");
|
|
265
266
|
fs.renameSync(tmp, targetFile);
|
|
266
267
|
} catch (err) {
|
|
267
268
|
errors.push(`${name}: write failed — ${String(err)}`);
|
|
@@ -269,7 +270,7 @@ export function applyMigration(opts: {
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
try {
|
|
272
|
-
|
|
273
|
+
guardedUnlinkSync(srcPath);
|
|
273
274
|
migrated.push(name);
|
|
274
275
|
} catch (err) {
|
|
275
276
|
errors.push(`${name}: written to target but source unlink failed — ${String(err)}`);
|
package/src/render-doctrine.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import crypto from "node:crypto";
|
|
27
|
+
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
27
28
|
|
|
28
29
|
/** `<!-- iapeer-memory doctrine v<version> -->` — machine-checkable. */
|
|
29
30
|
export function versionMarker(version: string): string {
|
|
@@ -93,11 +94,11 @@ export function renderDoctrine(opts: {
|
|
|
93
94
|
`.IAPEER.md.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
94
95
|
);
|
|
95
96
|
try {
|
|
96
|
-
|
|
97
|
+
guardedWriteFileSync(tmp, rendered, "utf-8");
|
|
97
98
|
fs.renameSync(tmp, target);
|
|
98
99
|
} catch (err) {
|
|
99
100
|
try {
|
|
100
|
-
if (fs.existsSync(tmp))
|
|
101
|
+
if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
|
|
101
102
|
} catch {
|
|
102
103
|
// best effort
|
|
103
104
|
}
|
|
@@ -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
|
+
}
|