@agfpd/iapeer-memory-core 0.1.6 → 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 +1 -1
- package/src/config.ts +28 -1
- package/src/index.ts +6 -1
- package/src/memoryd.ts +218 -29
- package/src/permanent-detect.ts +49 -0
- package/src/render-doctrine.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory-core",
|
|
3
|
-
"version": "0.1.
|
|
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,
|
|
@@ -89,7 +99,7 @@ export type CoreConfig = {
|
|
|
89
99
|
* to BM25. Adding the exact endpoint host to NO_PROXY is idempotent and
|
|
90
100
|
* monotonic — it only bypasses the explicitly configured host.
|
|
91
101
|
*/
|
|
92
|
-
function ensureEndpointNotProxied(endpoint: string): void {
|
|
102
|
+
export function ensureEndpointNotProxied(endpoint: string): void {
|
|
93
103
|
let host: string;
|
|
94
104
|
try {
|
|
95
105
|
host = new URL(endpoint).hostname;
|
|
@@ -106,6 +116,19 @@ function ensureEndpointNotProxied(endpoint: string): void {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Loopback NEVER goes through a proxy — fleet-class defect (B-приёмка
|
|
121
|
+
* 10.06, boris): every peer's shell carries HTTP(S)_PROXY (VPN tinyproxy),
|
|
122
|
+
* Bun's fetch honours it for 127.0.0.1 too → the status probe of the LIVE
|
|
123
|
+
* memoryd port detoured to the proxy and lied «nothing listening» (proven:
|
|
124
|
+
* lsof caught tinyproxy SYN_SENT to 8766; `env -u HTTP_PROXY` → truthful).
|
|
125
|
+
* Call before ANY loopback fetch from CLI paths that run in agent shells.
|
|
126
|
+
*/
|
|
127
|
+
export function ensureLoopbackNotProxied(): void {
|
|
128
|
+
ensureEndpointNotProxied("http://127.0.0.1/");
|
|
129
|
+
ensureEndpointNotProxied("http://localhost/");
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
function envString(name: string, fallback = ""): string {
|
|
110
133
|
const value = process.env[name];
|
|
111
134
|
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
@@ -237,6 +260,10 @@ export function configFromEnv(): CoreConfig {
|
|
|
237
260
|
dbPath,
|
|
238
261
|
fullScanOnStartup: envBoolean("IAPEER_MEMORY_FULL_SCAN_ON_STARTUP", true),
|
|
239
262
|
},
|
|
263
|
+
batch: {
|
|
264
|
+
permanentMs: envNumber("IAPEER_MEMORY_PERMANENT_BATCH_SECS", 6 * 3600) * 1000,
|
|
265
|
+
humanInboxHour: envNumber("IAPEER_MEMORY_HUMAN_INBOX_HOUR", 4),
|
|
266
|
+
},
|
|
240
267
|
mcp: {
|
|
241
268
|
port: envNumber("IAPEER_MEMORY_MCP_PORT", 8766),
|
|
242
269
|
},
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
// config
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
configFromEnv,
|
|
13
|
+
ensureEndpointNotProxied,
|
|
14
|
+
ensureLoopbackNotProxied,
|
|
15
|
+
type CoreConfig,
|
|
16
|
+
} from "./config.js";
|
|
12
17
|
|
|
13
18
|
// taxonomy (ADR-002/011)
|
|
14
19
|
export {
|
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
|
-
|
|
47
|
-
|
|
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
|
|
475
|
-
// (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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();
|
package/src/permanent-detect.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/render-doctrine.ts
CHANGED
|
@@ -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;
|