@agfpd/iapeer-memory-core 0.2.9 → 0.3.0
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/archive.ts +1 -1
- package/src/config.ts +7 -10
- package/src/db.ts +2 -2
- package/src/embedding.ts +1 -1
- package/src/frontmatter-fill.ts +20 -55
- package/src/graph.ts +1 -1
- package/src/human-edit-detect.ts +42 -47
- package/src/index-render.ts +1 -1
- package/src/index.ts +17 -0
- package/src/indexer.ts +1 -1
- package/src/mcp-tools.ts +17 -13
- package/src/memoryd.ts +156 -198
- package/src/mode.ts +76 -0
- package/src/permanent-detect.ts +4 -82
- package/src/search.ts +34 -10
- package/src/silent-edit-detect.ts +11 -20
- package/src/taxonomy.ts +5 -15
- package/src/utils.ts +1 -1
package/src/memoryd.ts
CHANGED
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* One process owns everything live:
|
|
5
5
|
* - the WRITER role: sole SQLite owner (openDatabase + indexAll), fs.watch
|
|
6
6
|
* over the vault with debounce, incremental re-index by content hash;
|
|
7
|
-
* - the detect subsystems (stage 8 cores): human-edit attribution,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Index as an IAP signal (docs/06-pipelines-and-events.md);
|
|
7
|
+
* - the detect subsystems (stage 8 cores): human-edit attribution, the
|
|
8
|
+
* silent-edit (unstamped) belt, the tags-dictionary mirror, and the
|
|
9
|
+
* permanent smart-hash diff COALESCED into one curation pass;
|
|
10
|
+
* - the event stream: one signal line per curation pass on stdout
|
|
11
|
+
* (`CURATOR_TICK: [<paths…>]`) — a notifier watcher forwards it to the
|
|
12
|
+
* curation receiver as an IAP signal (docs/06-pipelines-and-events.md);
|
|
14
13
|
* - the heartbeat state file (consumer: every peer's SessionStart
|
|
15
14
|
* health-check, ADR-009/010);
|
|
16
15
|
* - the MCP-http endpoint (ADR-012): localhost, port from config, three
|
|
17
|
-
* read-only tools (
|
|
16
|
+
* read-only tools (memory_search / memory_related / memory_map — ADR-008,
|
|
18
17
|
* vault_read is NOT on the surface), caller identity from the
|
|
19
18
|
* `X-IAPeer-Identity` header per request.
|
|
20
19
|
*
|
|
@@ -46,8 +45,6 @@ import { renderTagsProjection, DEFAULT_TAGS_BOUNDARY_MAXLEN } from "./tags-gate.
|
|
|
46
45
|
import { isArchivableZone, shouldArchive, archiveTargetRel } from "./archive.js";
|
|
47
46
|
import {
|
|
48
47
|
snapshotVault,
|
|
49
|
-
snapshotInbox,
|
|
50
|
-
snapshotFlatFolder,
|
|
51
48
|
diffSnapshots,
|
|
52
49
|
type VaultSnapshot,
|
|
53
50
|
} from "./permanent-detect.js";
|
|
@@ -256,81 +253,129 @@ export function createMcpServer(opts: {
|
|
|
256
253
|
const server = new McpServer(
|
|
257
254
|
{ name: MEMORYD_SERVER_NAME, version: readCoreVersion() },
|
|
258
255
|
{
|
|
256
|
+
// Server instructions carry the PURPOSE + when-to-use + the behavioural
|
|
257
|
+
// cautions (Anthropic tool-design canon: tool descriptions must NOT
|
|
258
|
+
// instruct behaviour — those hints live here, injected once).
|
|
259
259
|
instructions:
|
|
260
|
-
"iapeer-memory
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
260
|
+
"iapeer-memory — the team's shared memory: knowledge, decisions, " +
|
|
261
|
+
"context, ideas, lists, project notes. The tools return ranked paths and " +
|
|
262
|
+
"snippets from an index SNAPSHOT, not live bodies — open a note with the " +
|
|
263
|
+
"native Read tool, and re-check it before acting (its status or content " +
|
|
264
|
+
"may have moved on). Stale-status notes are deboosted but still returned: " +
|
|
265
|
+
"history, not current truth.",
|
|
264
266
|
},
|
|
265
267
|
);
|
|
266
268
|
|
|
267
269
|
server.registerTool(
|
|
268
|
-
"
|
|
270
|
+
"memory_search",
|
|
269
271
|
{
|
|
272
|
+
title: "Search team memory (by meaning)",
|
|
270
273
|
description:
|
|
271
|
-
"
|
|
272
|
-
"
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
"
|
|
274
|
+
"Find relevant memory by MEANING — semantic + keyword search, ranked. " +
|
|
275
|
+
"Returns per item: title, path, type, status, score, snippet, and " +
|
|
276
|
+
"`related` neighbours; `pipeline` carries per-component status (BM25 / " +
|
|
277
|
+
"vector / rerank / graph). Does NOT walk a known note's links — use " +
|
|
278
|
+
"memory_related; for the whole-vault landscape use memory_map.",
|
|
276
279
|
inputSchema: {
|
|
277
|
-
query: z
|
|
278
|
-
|
|
280
|
+
query: z
|
|
281
|
+
.string()
|
|
282
|
+
.min(1, "memory_search: query is required")
|
|
283
|
+
.describe("What to search for — keywords or a natural-language description. Quoted phrases supported."),
|
|
284
|
+
forCuration: z
|
|
285
|
+
.boolean()
|
|
286
|
+
.optional()
|
|
287
|
+
.describe("Internal: bias ranking for a curation pass (Index/Scriber). Omit for a normal author search."),
|
|
279
288
|
},
|
|
280
289
|
outputSchema: vaultSearchOutput.shape,
|
|
281
|
-
annotations: {
|
|
290
|
+
annotations: {
|
|
291
|
+
readOnlyHint: true,
|
|
292
|
+
destructiveHint: false,
|
|
293
|
+
idempotentHint: true,
|
|
294
|
+
openWorldHint: true,
|
|
295
|
+
},
|
|
282
296
|
},
|
|
283
297
|
async ({ query, forCuration }) => {
|
|
284
298
|
try {
|
|
285
299
|
return toResult(await runSearch(db, effectiveConfig, { query, forCuration }));
|
|
286
300
|
} catch (err) {
|
|
287
|
-
return toError("
|
|
301
|
+
return toError("memory_search", err, logger);
|
|
288
302
|
}
|
|
289
303
|
},
|
|
290
304
|
);
|
|
291
305
|
|
|
292
306
|
server.registerTool(
|
|
293
|
-
"
|
|
307
|
+
"memory_related",
|
|
294
308
|
{
|
|
309
|
+
title: "Related notes (link graph)",
|
|
295
310
|
description:
|
|
296
|
-
"Walk the wikilink graph
|
|
297
|
-
"
|
|
298
|
-
"
|
|
311
|
+
"Walk the wikilink graph around a note you ALREADY have — 1–3 hops, both " +
|
|
312
|
+
"directions — surfacing its cross-linked neighbours and backlinks. Returns " +
|
|
313
|
+
"the subgraph (nodes, edges); agent-memory backlinks of canon are " +
|
|
314
|
+
"one-way-filtered; an unknown center path → {found:false}. To find notes by " +
|
|
315
|
+
"meaning use memory_search; for the whole-vault landscape use memory_map.",
|
|
299
316
|
inputSchema: {
|
|
300
|
-
path: z
|
|
301
|
-
|
|
317
|
+
path: z
|
|
318
|
+
.string()
|
|
319
|
+
.min(1, "memory_related: path is required")
|
|
320
|
+
.describe("Vault-relative path of the note to center on (e.g. `01_Knowledge/Note.md`) — take it from a memory_search result's `path`."),
|
|
321
|
+
depth: z
|
|
322
|
+
.number()
|
|
323
|
+
.int()
|
|
324
|
+
.min(1)
|
|
325
|
+
.max(3)
|
|
326
|
+
.optional()
|
|
327
|
+
.describe("Hops outward (1–3). Default 1 — the note's immediate neighbours."),
|
|
302
328
|
},
|
|
303
329
|
outputSchema: vaultGraphOutput.shape,
|
|
304
|
-
annotations: {
|
|
330
|
+
annotations: {
|
|
331
|
+
readOnlyHint: true,
|
|
332
|
+
destructiveHint: false,
|
|
333
|
+
idempotentHint: true,
|
|
334
|
+
openWorldHint: false,
|
|
335
|
+
},
|
|
305
336
|
},
|
|
306
337
|
async ({ path: p, depth }) => {
|
|
307
338
|
try {
|
|
308
339
|
return toResult(runGraph(db, effectiveConfig, { path: p, depth }));
|
|
309
340
|
} catch (err) {
|
|
310
|
-
return toError("
|
|
341
|
+
return toError("memory_related", err, logger);
|
|
311
342
|
}
|
|
312
343
|
},
|
|
313
344
|
);
|
|
314
345
|
|
|
315
346
|
server.registerTool(
|
|
316
|
-
"
|
|
347
|
+
"memory_map",
|
|
317
348
|
{
|
|
349
|
+
title: "Vault landscape (map)",
|
|
318
350
|
description:
|
|
319
|
-
"
|
|
320
|
-
"clusters, hubs, bridges, orphans +
|
|
321
|
-
"
|
|
351
|
+
"The holistic LANDSCAPE of the canonical graph (agent memory excluded), " +
|
|
352
|
+
"like the Obsidian graph view: clusters, hubs, bridges, orphans (+ optional " +
|
|
353
|
+
"orphan_wikilinks). For the BIG PICTURE — themes, structure, gaps — not a " +
|
|
354
|
+
"single note. For one note's neighbourhood use memory_related; to find notes " +
|
|
355
|
+
"by meaning use memory_search.",
|
|
322
356
|
inputSchema: {
|
|
323
|
-
detail: z
|
|
324
|
-
|
|
357
|
+
detail: z
|
|
358
|
+
.enum(["summary", "full"])
|
|
359
|
+
.optional()
|
|
360
|
+
.describe("`summary` (counts + top items) or `full` (every cluster/hub). Default summary."),
|
|
361
|
+
parts: z
|
|
362
|
+
.array(mapPart)
|
|
363
|
+
.optional()
|
|
364
|
+
.describe("Which parts to include: clusters, hubs, bridges, orphans, orphan_wikilinks. Default: all but orphan_wikilinks (the opt-in heavy part)."),
|
|
325
365
|
},
|
|
326
366
|
outputSchema: vaultMapOutput.shape,
|
|
327
|
-
annotations: {
|
|
367
|
+
annotations: {
|
|
368
|
+
readOnlyHint: true,
|
|
369
|
+
destructiveHint: false,
|
|
370
|
+
idempotentHint: true,
|
|
371
|
+
openWorldHint: false,
|
|
372
|
+
},
|
|
328
373
|
},
|
|
329
374
|
async ({ detail, parts }) => {
|
|
330
375
|
try {
|
|
331
376
|
return toResult(runMap(db, effectiveConfig, { detail, parts }));
|
|
332
377
|
} catch (err) {
|
|
333
|
-
return toError("
|
|
378
|
+
return toError("memory_map", err, logger);
|
|
334
379
|
}
|
|
335
380
|
},
|
|
336
381
|
);
|
|
@@ -369,6 +414,7 @@ export async function startMcpHttp(opts: {
|
|
|
369
414
|
content?: string;
|
|
370
415
|
threshold?: number;
|
|
371
416
|
limit?: number;
|
|
417
|
+
linkThreshold?: number;
|
|
372
418
|
};
|
|
373
419
|
const content = (body.content ?? "").trim();
|
|
374
420
|
const result = content
|
|
@@ -376,6 +422,7 @@ export async function startMcpHttp(opts: {
|
|
|
376
422
|
content,
|
|
377
423
|
threshold: body.threshold,
|
|
378
424
|
limit: body.limit,
|
|
425
|
+
linkThreshold: body.linkThreshold,
|
|
379
426
|
})
|
|
380
427
|
: { enabled: Boolean(opts.config.embedding), matches: [] };
|
|
381
428
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -453,17 +500,16 @@ export async function startMcpHttp(opts: {
|
|
|
453
500
|
// a sync-storm right after a restart could still mis-attribute — so the
|
|
454
501
|
// baseline survives restarts, atomically (same tmp+rename canon).
|
|
455
502
|
|
|
456
|
-
/** Persisted batch baselines:
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
503
|
+
/** Persisted batch baselines: the permanent-folders snapshot (rel → smart
|
|
504
|
+
* hash) + the silent-edit stamp baseline (rel → {updated, leb}; the
|
|
505
|
+
* unstamped detector judges stamp movement against it). Survives restarts —
|
|
506
|
+
* порт паттерна старых мониторов «seen переживает сбой». Migration is
|
|
507
|
+
* FIRST-SIGHT by construction: an absent silentStamps key (pre-detector
|
|
508
|
+
* state file) reads as an empty map — one warm-up pass records, never
|
|
509
|
+
* judges. Legacy `inbox`/`humanInbox` keys in an old state file are simply
|
|
510
|
+
* ignored on load. */
|
|
463
511
|
export type BatchState = {
|
|
464
|
-
inbox: VaultSnapshot | null;
|
|
465
512
|
permanent: VaultSnapshot | null;
|
|
466
|
-
humanInbox: VaultSnapshot | null;
|
|
467
513
|
silentStamps: Map<string, StampRecord> | null;
|
|
468
514
|
};
|
|
469
515
|
|
|
@@ -498,22 +544,18 @@ export function loadBatchState(filePath: string): BatchState {
|
|
|
498
544
|
return m;
|
|
499
545
|
};
|
|
500
546
|
return {
|
|
501
|
-
inbox: toMap(raw.inbox),
|
|
502
547
|
permanent: toMap(raw.permanent),
|
|
503
|
-
humanInbox: toMap(raw.humanInbox),
|
|
504
548
|
silentStamps: toStamps(raw.silentStamps),
|
|
505
549
|
};
|
|
506
550
|
} catch {
|
|
507
|
-
return {
|
|
551
|
+
return { permanent: null, silentStamps: null };
|
|
508
552
|
}
|
|
509
553
|
}
|
|
510
554
|
|
|
511
555
|
export function persistBatchState(
|
|
512
556
|
filePath: string,
|
|
513
557
|
state: {
|
|
514
|
-
inbox: VaultSnapshot;
|
|
515
558
|
permanent: VaultSnapshot;
|
|
516
|
-
humanInbox: VaultSnapshot;
|
|
517
559
|
silentStamps: Map<string, StampRecord>;
|
|
518
560
|
},
|
|
519
561
|
): void {
|
|
@@ -522,9 +564,7 @@ export function persistBatchState(
|
|
|
522
564
|
guardedWriteFileSync(
|
|
523
565
|
tmp,
|
|
524
566
|
JSON.stringify({
|
|
525
|
-
inbox: Object.fromEntries(state.inbox),
|
|
526
567
|
permanent: Object.fromEntries(state.permanent),
|
|
527
|
-
humanInbox: Object.fromEntries(state.humanInbox),
|
|
528
568
|
silentStamps: Object.fromEntries(state.silentStamps),
|
|
529
569
|
}),
|
|
530
570
|
"utf-8",
|
|
@@ -572,11 +612,11 @@ export type MemorydOptions = {
|
|
|
572
612
|
tagsProjectionPath?: string;
|
|
573
613
|
/** Detect-hash persistence file; default `<db dir>/memoryd.hashes.json`. */
|
|
574
614
|
hashStatePath?: string;
|
|
575
|
-
/** Persisted batch baselines (
|
|
615
|
+
/** Persisted batch baselines (the permanent-folders snapshot + silent
|
|
616
|
+
* stamps). */
|
|
576
617
|
batchStatePath?: string;
|
|
577
|
-
/**
|
|
578
|
-
|
|
579
|
-
humanInboxBatchMs?: number;
|
|
618
|
+
/** Curator-tick cadence override (tests); default from config.batch. */
|
|
619
|
+
curatorTickMs?: number;
|
|
580
620
|
/** Periodic hash-persist interval (ms). */
|
|
581
621
|
persistMs?: number;
|
|
582
622
|
/** Human owner name; human-edit detection is OFF when absent (⚖7). */
|
|
@@ -622,8 +662,8 @@ export type MemorydHandle = {
|
|
|
622
662
|
mcpPort: number | null;
|
|
623
663
|
/** Force one detect pass immediately (used by tests and shutdown flush). */
|
|
624
664
|
runDetectPass: () => Promise<void>;
|
|
625
|
-
/** Force a
|
|
626
|
-
|
|
665
|
+
/** Force a curator tick (tests / operator force-tick). */
|
|
666
|
+
runCuratorTick: () => void;
|
|
627
667
|
close: () => Promise<void>;
|
|
628
668
|
};
|
|
629
669
|
|
|
@@ -646,22 +686,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
646
686
|
const db = openDatabase(config);
|
|
647
687
|
await indexAll({ db, config, logger });
|
|
648
688
|
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
// (порт паттерна старых мониторов: «seen переживает сбой») — рестарт не
|
|
655
|
-
// глотает накопленное и не реплеит обработанное.
|
|
689
|
+
// Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
|
|
690
|
+
// копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
|
|
691
|
+
// Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
|
|
692
|
+
// переживает сбой») — рестарт не глотает накопленное и не реплеит
|
|
693
|
+
// обработанное.
|
|
656
694
|
const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
|
|
657
695
|
const persistedBatches = loadBatchState(batchStatePath);
|
|
658
|
-
let inboxSnapshot: VaultSnapshot =
|
|
659
|
-
persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
|
|
660
696
|
let permanentBaseline: VaultSnapshot =
|
|
661
697
|
persistedBatches.permanent ?? snapshotVault(config.vaultPath, taxonomy);
|
|
662
|
-
const humanInboxDir = path.join(config.vaultPath, taxonomy.folders.inboxHuman);
|
|
663
|
-
let humanInboxBaseline: VaultSnapshot =
|
|
664
|
-
persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
|
|
665
698
|
const lastSeenHashes = loadHashState(hashStatePath);
|
|
666
699
|
/** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
|
|
667
700
|
* old state file = empty map = first-sight warm-up (design §4). */
|
|
@@ -671,16 +704,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
671
704
|
function persistBatches(): void {
|
|
672
705
|
try {
|
|
673
706
|
persistBatchState(batchStatePath, {
|
|
674
|
-
inbox: inboxSnapshot,
|
|
675
707
|
permanent: permanentBaseline,
|
|
676
|
-
humanInbox: humanInboxBaseline,
|
|
677
708
|
silentStamps,
|
|
678
709
|
});
|
|
679
710
|
} catch (err) {
|
|
680
711
|
logger.error(`batch-state persist failed: ${String(err)}`);
|
|
681
712
|
}
|
|
682
713
|
}
|
|
683
|
-
if (!persistedBatches.
|
|
714
|
+
if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
|
|
684
715
|
|
|
685
716
|
/** iCloud-mount guard (порт защиты старых мониторов): корень vault
|
|
686
717
|
* недоступен → пропускаем проход целиком, baseline не трогаем — после
|
|
@@ -862,15 +893,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
862
893
|
logger.info("tags projection updated");
|
|
863
894
|
}
|
|
864
895
|
|
|
865
|
-
/** Zone map of the unstamped detector (design §3): the
|
|
866
|
-
*
|
|
867
|
-
*
|
|
896
|
+
/** Zone map of the unstamped detector (design §3): the six monitored
|
|
897
|
+
* folders (five canonical + agent memory — wider than humanEditPass's
|
|
898
|
+
* getZone, which has no agent-memory notion). */
|
|
868
899
|
function silentZoneOf(absPath: string): SilentZone | null {
|
|
869
900
|
const rel = path.relative(config.vaultPath, absPath);
|
|
870
901
|
if (rel.startsWith("..")) return null;
|
|
871
902
|
const first = rel.split(path.sep)[0];
|
|
872
903
|
const f = taxonomy.folders;
|
|
873
|
-
if (first === f.inbox) return "inbox";
|
|
874
904
|
if (
|
|
875
905
|
first === f.knowledge ||
|
|
876
906
|
first === f.decisions ||
|
|
@@ -886,13 +916,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
886
916
|
|
|
887
917
|
/**
|
|
888
918
|
* Unstamped-write detector (design doc, boris-accepted 11.06). Runs
|
|
889
|
-
* BEFORE
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
919
|
+
* BEFORE humanEditPass: a re-stamped file then reads `last_edited_by:
|
|
920
|
+
* unstamped` — the curator source filters pass it into the curation pass,
|
|
921
|
+
* and humanEditPass sees a fresh agent stamp (echo-agent skip, no double
|
|
922
|
+
* stamp — order instead of ifs). Candidates carry CHANGED files only (the
|
|
923
|
+
* fs.watch set) — the semantic-hash precondition of the rule; service-only
|
|
924
|
+
* echoes never reach here (smart-hash blindness = echo safety of our own
|
|
925
|
+
* re-stamp).
|
|
896
926
|
*/
|
|
897
927
|
function silentEditPass(candidatesAbs: Set<string>): void {
|
|
898
928
|
for (const abs of candidatesAbs) {
|
|
@@ -953,11 +983,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
953
983
|
continue; // deleted mid-debounce
|
|
954
984
|
}
|
|
955
985
|
// FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
|
|
956
|
-
// нет в hash-базе — это
|
|
957
|
-
//
|
|
958
|
-
// baseline и не стампь:
|
|
959
|
-
//
|
|
960
|
-
//
|
|
986
|
+
// нет в hash-базе — это ПЕРВОЕ наблюдение (старт демона над уже
|
|
987
|
+
// населённым vault), НЕ обязательно человеческая правка. Запиши
|
|
988
|
+
// baseline и не стампь: иначе старт массово штампует все существующие
|
|
989
|
+
// ноты как «правки человека». (Размещений Индексом больше нет — инбокс
|
|
990
|
+
// устранён; единственный источник never-seen-путей — старт/новый файл.)
|
|
991
|
+
// ЦЕНА (§9-край, на живой приёмке): свежая bare-body заметка человека
|
|
992
|
+
// достраивается со ВТОРОГО fs-события — первое лишь пишет baseline.
|
|
961
993
|
if (!lastSeenHashes.has(filePath)) {
|
|
962
994
|
lastSeenHashes.set(filePath, sha256(content));
|
|
963
995
|
continue;
|
|
@@ -1046,63 +1078,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1046
1078
|
|
|
1047
1079
|
if (!vaultAvailable()) return;
|
|
1048
1080
|
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
// Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
|
|
1057
|
-
// diff (the diff is semantic — a re-stamp does not invalidate it).
|
|
1058
|
-
silentEditPass(
|
|
1059
|
-
new Set([
|
|
1060
|
-
...changed,
|
|
1061
|
-
...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
|
|
1062
|
-
]),
|
|
1063
|
-
);
|
|
1064
|
-
|
|
1065
|
-
for (const name of inboxChanged) {
|
|
1066
|
-
const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
|
|
1067
|
-
// Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
|
|
1068
|
-
// 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
|
|
1069
|
-
// повторный INBOX_NEW по тому же черновику — вычитывающая сессия и
|
|
1070
|
-
// есть конвейер). Правки автора (reject→fix→re-review) проходят:
|
|
1071
|
-
// last_edited_by остаётся автором. Цена документирована: черновик,
|
|
1072
|
-
// чья ПОСЛЕДНЯЯ правка до первого анонса — кураторская, не
|
|
1073
|
-
// анонсируется; в конвейере не возникает (кураторы трогают черновики
|
|
1074
|
-
// только по событиям, т.е. после анонса). Baseline обновляется ниже
|
|
1075
|
-
// безусловно — подавленное НЕ реплеится, авторская правка поверх
|
|
1076
|
-
// кураторской диффится от свежего снапшота и проходит.
|
|
1077
|
-
const leb = lastEditedByOf(abs);
|
|
1078
|
-
if (leb && config.curatorSet.includes(leb)) {
|
|
1079
|
-
logger.info(`inbox event suppressed (curator ${leb}): ${name}`);
|
|
1080
|
-
continue;
|
|
1081
|
-
}
|
|
1082
|
-
logger.info(`inbox event: ${name}`);
|
|
1083
|
-
// ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
|
|
1084
|
-
// vault root — a role peer once `find`-guessed a stale copy.
|
|
1085
|
-
emit(`INBOX_NEW: ${abs}`);
|
|
1086
|
-
}
|
|
1087
|
-
if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
|
|
1088
|
-
inboxSnapshot = inboxNext;
|
|
1089
|
-
persistBatches();
|
|
1090
|
-
}
|
|
1081
|
+
// Unstamped detector FIRST (design §3 order): re-stamps land before
|
|
1082
|
+
// humanEditPass judges (a re-stamped file then reads `last_edited_by:
|
|
1083
|
+
// unstamped` and humanEditPass sees a fresh agent stamp → echo-agent
|
|
1084
|
+
// skip, no double stamp — order instead of ifs). Candidates are the
|
|
1085
|
+
// fs.watch changed set; service-only echoes never reach the rule
|
|
1086
|
+
// (smart-hash blindness = echo safety of our own re-stamp).
|
|
1087
|
+
silentEditPass(changed);
|
|
1091
1088
|
|
|
1092
1089
|
humanEditPass(changed);
|
|
1093
1090
|
archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
|
|
1094
1091
|
syncTagsMirror();
|
|
1095
1092
|
await indexAll({ db, config, logger }); // incremental by content hash
|
|
1096
1093
|
renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
|
|
1097
|
-
//
|
|
1098
|
-
// (
|
|
1094
|
+
// Canon edits are NOT emitted instantly — they accumulate to the curator
|
|
1095
|
+
// tick (cadence 6h); see runCuratorTick.
|
|
1099
1096
|
}
|
|
1100
1097
|
|
|
1101
|
-
/**
|
|
1102
|
-
* baseline
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
|
|
1098
|
+
/** CURATOR_TICK — one cadence pass: diff canon + agent memory against the
|
|
1099
|
+
* carried baseline, curator-authored edits filtered BY SOURCE, the rest
|
|
1100
|
+
* goes out as ONE line (a JSON array of ABSOLUTE paths → one delivery →
|
|
1101
|
+
* one ephemeral curation session → one report). In lean the emit is
|
|
1102
|
+
* suppressed (no proactive receiver), but the baseline still advances. */
|
|
1103
|
+
function runCuratorTick(): void {
|
|
1106
1104
|
if (!vaultAvailable()) return;
|
|
1107
1105
|
const next = snapshotVault(config.vaultPath, taxonomy);
|
|
1108
1106
|
const changedRel = diffSnapshots(permanentBaseline, next);
|
|
@@ -1115,23 +1113,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1115
1113
|
absPaths.push(abs);
|
|
1116
1114
|
}
|
|
1117
1115
|
if (absPaths.length) {
|
|
1118
|
-
logger.info(`
|
|
1119
|
-
emit(`
|
|
1120
|
-
}
|
|
1121
|
-
persistBatches();
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/** HUMAN_INBOX_BATCH — каденция human-inbox (человек пишет долго,
|
|
1125
|
-
* мгновенный триггер схватил бы недоделанное). */
|
|
1126
|
-
function runHumanInboxBatch(): void {
|
|
1127
|
-
if (!vaultAvailable()) return;
|
|
1128
|
-
const next = snapshotFlatFolder(humanInboxDir);
|
|
1129
|
-
const names = diffSnapshots(humanInboxBaseline, next);
|
|
1130
|
-
humanInboxBaseline = next;
|
|
1131
|
-
if (names.length) {
|
|
1132
|
-
const absPaths = names.map((n) => path.join(humanInboxDir, n));
|
|
1133
|
-
logger.info(`human-inbox batch: ${absPaths.length} draft(s)`);
|
|
1134
|
-
emit(`HUMAN_INBOX_BATCH: ${JSON.stringify(absPaths)}`);
|
|
1116
|
+
logger.info(`curator tick: ${absPaths.length} path(s)`);
|
|
1117
|
+
emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
|
|
1135
1118
|
}
|
|
1136
1119
|
persistBatches();
|
|
1137
1120
|
}
|
|
@@ -1206,42 +1189,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1206
1189
|
const persistTimer = setInterval(persistQuiet, persistMs);
|
|
1207
1190
|
persistTimer.unref?.();
|
|
1208
1191
|
|
|
1209
|
-
// ── cadence
|
|
1210
|
-
const
|
|
1211
|
-
const
|
|
1192
|
+
// ── cadence timer (директива ~15:31): первый прогон через полный период
|
|
1193
|
+
const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
|
|
1194
|
+
const curatorTimer = setInterval(() => {
|
|
1212
1195
|
try {
|
|
1213
|
-
|
|
1196
|
+
runCuratorTick();
|
|
1214
1197
|
} catch (err) {
|
|
1215
|
-
logger.error(`
|
|
1198
|
+
logger.error(`curator tick failed: ${String(err)}`);
|
|
1216
1199
|
}
|
|
1217
|
-
},
|
|
1218
|
-
|
|
1219
|
-
// HUMAN-INBOX: раз в СУТКИ в config.batch.humanInboxHour локального
|
|
1220
|
-
// (подтверждение Артура ~15:4x: ночная партия, как в старом контуре).
|
|
1221
|
-
// Тестовый override opts.humanInboxBatchMs — простой интервал.
|
|
1222
|
-
let humanInboxTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1223
|
-
function scheduleHumanInbox(): void {
|
|
1224
|
-
let delay: number;
|
|
1225
|
-
if (opts.humanInboxBatchMs !== undefined) {
|
|
1226
|
-
delay = opts.humanInboxBatchMs;
|
|
1227
|
-
} else {
|
|
1228
|
-
const now = new Date();
|
|
1229
|
-
const next = new Date(now);
|
|
1230
|
-
next.setHours(config.batch.humanInboxHour, 0, 0, 0);
|
|
1231
|
-
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
|
|
1232
|
-
delay = next.getTime() - now.getTime();
|
|
1233
|
-
}
|
|
1234
|
-
humanInboxTimer = setTimeout(() => {
|
|
1235
|
-
try {
|
|
1236
|
-
runHumanInboxBatch();
|
|
1237
|
-
} catch (err) {
|
|
1238
|
-
logger.error(`human-inbox batch failed: ${String(err)}`);
|
|
1239
|
-
}
|
|
1240
|
-
scheduleHumanInbox();
|
|
1241
|
-
}, delay);
|
|
1242
|
-
humanInboxTimer.unref?.();
|
|
1243
|
-
}
|
|
1244
|
-
scheduleHumanInbox();
|
|
1200
|
+
}, curatorTickMs);
|
|
1201
|
+
curatorTimer.unref?.();
|
|
1245
1202
|
|
|
1246
1203
|
// ── MCP http ──
|
|
1247
1204
|
let mcp: { port: number; close: () => Promise<void> } | null = null;
|
|
@@ -1255,10 +1212,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1255
1212
|
|
|
1256
1213
|
return {
|
|
1257
1214
|
mcpPort: mcp?.port ?? null,
|
|
1258
|
-
|
|
1259
|
-
if (kind === "permanent") runPermanentBatch();
|
|
1260
|
-
else runHumanInboxBatch();
|
|
1261
|
-
},
|
|
1215
|
+
runCuratorTick,
|
|
1262
1216
|
runDetectPass: async () => {
|
|
1263
1217
|
if (flushTimer) {
|
|
1264
1218
|
clearTimeout(flushTimer);
|
|
@@ -1271,11 +1225,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1271
1225
|
watcher?.close();
|
|
1272
1226
|
if (flushTimer) clearTimeout(flushTimer);
|
|
1273
1227
|
clearInterval(heartbeatTimer);
|
|
1274
|
-
clearInterval(
|
|
1275
|
-
if (humanInboxTimer) clearTimeout(humanInboxTimer);
|
|
1228
|
+
clearInterval(curatorTimer);
|
|
1276
1229
|
clearInterval(persistTimer);
|
|
1277
1230
|
await flushing;
|
|
1278
1231
|
persistQuiet();
|
|
1232
|
+
// Batch state (silentStamps + the permanent baseline) also persists on
|
|
1233
|
+
// shutdown — otherwise silentStamps moved since the last curator tick are
|
|
1234
|
+
// lost, breaking the "survives restarts" invariant (first-sight warm-up
|
|
1235
|
+
// would then re-skip a real edit after a graceful restart).
|
|
1236
|
+
persistBatches();
|
|
1279
1237
|
if (mcp) await mcp.close();
|
|
1280
1238
|
try {
|
|
1281
1239
|
guardedUnlinkSync(heartbeatPath);
|