@agfpd/iapeer-memory-core 0.2.9 → 0.3.1
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 +172 -200
- 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,11 +662,24 @@ 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
|
|
|
670
|
+
/**
|
|
671
|
+
* Whether a note's frontmatter already carries a non-empty `author`. The
|
|
672
|
+
* first-sight guard uses it to tell a SETTLED note (has author — never
|
|
673
|
+
* re-stamp it at startup) from a genuinely NEW one (a human's bare-body canon
|
|
674
|
+
* note with no author yet — fill it on its first event, §9). No frontmatter →
|
|
675
|
+
* bare → not settled.
|
|
676
|
+
*/
|
|
677
|
+
function hasAuthorField(content: string): boolean {
|
|
678
|
+
const fm = /^---[^\S\n]*\n([\s\S]*?)\n---[^\S\n]*(?:\n|$)/.exec(content);
|
|
679
|
+
if (!fm) return false;
|
|
680
|
+
return /^author\s*:\s*\S/m.test(fm[1]);
|
|
681
|
+
}
|
|
682
|
+
|
|
630
683
|
export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle> {
|
|
631
684
|
const { config } = opts;
|
|
632
685
|
const logger = opts.logger ?? makeLogger("memoryd");
|
|
@@ -646,22 +699,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
646
699
|
const db = openDatabase(config);
|
|
647
700
|
await indexAll({ db, config, logger });
|
|
648
701
|
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
// (порт паттерна старых мониторов: «seen переживает сбой») — рестарт не
|
|
655
|
-
// глотает накопленное и не реплеит обработанное.
|
|
702
|
+
// Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
|
|
703
|
+
// копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
|
|
704
|
+
// Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
|
|
705
|
+
// переживает сбой») — рестарт не глотает накопленное и не реплеит
|
|
706
|
+
// обработанное.
|
|
656
707
|
const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
|
|
657
708
|
const persistedBatches = loadBatchState(batchStatePath);
|
|
658
|
-
let inboxSnapshot: VaultSnapshot =
|
|
659
|
-
persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
|
|
660
709
|
let permanentBaseline: VaultSnapshot =
|
|
661
710
|
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
711
|
const lastSeenHashes = loadHashState(hashStatePath);
|
|
666
712
|
/** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
|
|
667
713
|
* old state file = empty map = first-sight warm-up (design §4). */
|
|
@@ -671,16 +717,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
671
717
|
function persistBatches(): void {
|
|
672
718
|
try {
|
|
673
719
|
persistBatchState(batchStatePath, {
|
|
674
|
-
inbox: inboxSnapshot,
|
|
675
720
|
permanent: permanentBaseline,
|
|
676
|
-
humanInbox: humanInboxBaseline,
|
|
677
721
|
silentStamps,
|
|
678
722
|
});
|
|
679
723
|
} catch (err) {
|
|
680
724
|
logger.error(`batch-state persist failed: ${String(err)}`);
|
|
681
725
|
}
|
|
682
726
|
}
|
|
683
|
-
if (!persistedBatches.
|
|
727
|
+
if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
|
|
684
728
|
|
|
685
729
|
/** iCloud-mount guard (порт защиты старых мониторов): корень vault
|
|
686
730
|
* недоступен → пропускаем проход целиком, baseline не трогаем — после
|
|
@@ -862,15 +906,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
862
906
|
logger.info("tags projection updated");
|
|
863
907
|
}
|
|
864
908
|
|
|
865
|
-
/** Zone map of the unstamped detector (design §3): the
|
|
866
|
-
*
|
|
867
|
-
*
|
|
909
|
+
/** Zone map of the unstamped detector (design §3): the six monitored
|
|
910
|
+
* folders (five canonical + agent memory — wider than humanEditPass's
|
|
911
|
+
* getZone, which has no agent-memory notion). */
|
|
868
912
|
function silentZoneOf(absPath: string): SilentZone | null {
|
|
869
913
|
const rel = path.relative(config.vaultPath, absPath);
|
|
870
914
|
if (rel.startsWith("..")) return null;
|
|
871
915
|
const first = rel.split(path.sep)[0];
|
|
872
916
|
const f = taxonomy.folders;
|
|
873
|
-
if (first === f.inbox) return "inbox";
|
|
874
917
|
if (
|
|
875
918
|
first === f.knowledge ||
|
|
876
919
|
first === f.decisions ||
|
|
@@ -886,13 +929,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
886
929
|
|
|
887
930
|
/**
|
|
888
931
|
* Unstamped-write detector (design doc, boris-accepted 11.06). Runs
|
|
889
|
-
* BEFORE
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
932
|
+
* BEFORE humanEditPass: a re-stamped file then reads `last_edited_by:
|
|
933
|
+
* unstamped` — the curator source filters pass it into the curation pass,
|
|
934
|
+
* and humanEditPass sees a fresh agent stamp (echo-agent skip, no double
|
|
935
|
+
* stamp — order instead of ifs). Candidates carry CHANGED files only (the
|
|
936
|
+
* fs.watch set) — the semantic-hash precondition of the rule; service-only
|
|
937
|
+
* echoes never reach here (smart-hash blindness = echo safety of our own
|
|
938
|
+
* re-stamp).
|
|
896
939
|
*/
|
|
897
940
|
function silentEditPass(candidatesAbs: Set<string>): void {
|
|
898
941
|
for (const abs of candidatesAbs) {
|
|
@@ -952,13 +995,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
952
995
|
} catch {
|
|
953
996
|
continue; // deleted mid-debounce
|
|
954
997
|
}
|
|
955
|
-
// FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3):
|
|
956
|
-
// нет в hash-базе — это
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
|
|
998
|
+
// FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3 + §9-фикс 0.3.1):
|
|
999
|
+
// путь, которого нет в hash-базе — это ПЕРВОЕ наблюдение. Если у ноты
|
|
1000
|
+
// УЖЕ есть author — она SETTLED (старт демона над населённым vault, или
|
|
1001
|
+
// любая атрибутированная нота): запиши baseline и НЕ стампь (load-bearing
|
|
1002
|
+
// инвариант — старт никогда не перештамповывает существующие ноты).
|
|
1003
|
+
// Нота БЕЗ author — генуинно новая: голое тело человека прямо в канон —
|
|
1004
|
+
// проваливается в fill ниже и достраивается на ЭТОМ первом событии (§9:
|
|
1005
|
+
// голое тело человека достраивается, не откладывается до второго).
|
|
1006
|
+
// (Размещений Индексом больше нет — инбокс устранён.)
|
|
1007
|
+
if (!lastSeenHashes.has(filePath) && hasAuthorField(content)) {
|
|
962
1008
|
lastSeenHashes.set(filePath, sha256(content));
|
|
963
1009
|
continue;
|
|
964
1010
|
}
|
|
@@ -1046,63 +1092,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1046
1092
|
|
|
1047
1093
|
if (!vaultAvailable()) return;
|
|
1048
1094
|
|
|
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
|
-
}
|
|
1095
|
+
// Unstamped detector FIRST (design §3 order): re-stamps land before
|
|
1096
|
+
// humanEditPass judges (a re-stamped file then reads `last_edited_by:
|
|
1097
|
+
// unstamped` and humanEditPass sees a fresh agent stamp → echo-agent
|
|
1098
|
+
// skip, no double stamp — order instead of ifs). Candidates are the
|
|
1099
|
+
// fs.watch changed set; service-only echoes never reach the rule
|
|
1100
|
+
// (smart-hash blindness = echo safety of our own re-stamp).
|
|
1101
|
+
silentEditPass(changed);
|
|
1091
1102
|
|
|
1092
1103
|
humanEditPass(changed);
|
|
1093
1104
|
archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
|
|
1094
1105
|
syncTagsMirror();
|
|
1095
1106
|
await indexAll({ db, config, logger }); // incremental by content hash
|
|
1096
1107
|
renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
|
|
1097
|
-
//
|
|
1098
|
-
// (
|
|
1108
|
+
// Canon edits are NOT emitted instantly — they accumulate to the curator
|
|
1109
|
+
// tick (cadence 6h); see runCuratorTick.
|
|
1099
1110
|
}
|
|
1100
1111
|
|
|
1101
|
-
/**
|
|
1102
|
-
* baseline
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
|
|
1112
|
+
/** CURATOR_TICK — one cadence pass: diff canon + agent memory against the
|
|
1113
|
+
* carried baseline, curator-authored edits filtered BY SOURCE, the rest
|
|
1114
|
+
* goes out as ONE line (a JSON array of ABSOLUTE paths → one delivery →
|
|
1115
|
+
* one ephemeral curation session → one report). In lean the emit is
|
|
1116
|
+
* suppressed (no proactive receiver), but the baseline still advances. */
|
|
1117
|
+
function runCuratorTick(): void {
|
|
1106
1118
|
if (!vaultAvailable()) return;
|
|
1107
1119
|
const next = snapshotVault(config.vaultPath, taxonomy);
|
|
1108
1120
|
const changedRel = diffSnapshots(permanentBaseline, next);
|
|
@@ -1115,23 +1127,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1115
1127
|
absPaths.push(abs);
|
|
1116
1128
|
}
|
|
1117
1129
|
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)}`);
|
|
1130
|
+
logger.info(`curator tick: ${absPaths.length} path(s)`);
|
|
1131
|
+
emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
|
|
1135
1132
|
}
|
|
1136
1133
|
persistBatches();
|
|
1137
1134
|
}
|
|
@@ -1206,42 +1203,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1206
1203
|
const persistTimer = setInterval(persistQuiet, persistMs);
|
|
1207
1204
|
persistTimer.unref?.();
|
|
1208
1205
|
|
|
1209
|
-
// ── cadence
|
|
1210
|
-
const
|
|
1211
|
-
const
|
|
1206
|
+
// ── cadence timer (директива ~15:31): первый прогон через полный период
|
|
1207
|
+
const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
|
|
1208
|
+
const curatorTimer = setInterval(() => {
|
|
1212
1209
|
try {
|
|
1213
|
-
|
|
1210
|
+
runCuratorTick();
|
|
1214
1211
|
} catch (err) {
|
|
1215
|
-
logger.error(`
|
|
1216
|
-
}
|
|
1217
|
-
}, permanentBatchMs);
|
|
1218
|
-
permanentTimer.unref?.();
|
|
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();
|
|
1212
|
+
logger.error(`curator tick failed: ${String(err)}`);
|
|
1233
1213
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
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();
|
|
1214
|
+
}, curatorTickMs);
|
|
1215
|
+
curatorTimer.unref?.();
|
|
1245
1216
|
|
|
1246
1217
|
// ── MCP http ──
|
|
1247
1218
|
let mcp: { port: number; close: () => Promise<void> } | null = null;
|
|
@@ -1255,10 +1226,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1255
1226
|
|
|
1256
1227
|
return {
|
|
1257
1228
|
mcpPort: mcp?.port ?? null,
|
|
1258
|
-
|
|
1259
|
-
if (kind === "permanent") runPermanentBatch();
|
|
1260
|
-
else runHumanInboxBatch();
|
|
1261
|
-
},
|
|
1229
|
+
runCuratorTick,
|
|
1262
1230
|
runDetectPass: async () => {
|
|
1263
1231
|
if (flushTimer) {
|
|
1264
1232
|
clearTimeout(flushTimer);
|
|
@@ -1271,11 +1239,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1271
1239
|
watcher?.close();
|
|
1272
1240
|
if (flushTimer) clearTimeout(flushTimer);
|
|
1273
1241
|
clearInterval(heartbeatTimer);
|
|
1274
|
-
clearInterval(
|
|
1275
|
-
if (humanInboxTimer) clearTimeout(humanInboxTimer);
|
|
1242
|
+
clearInterval(curatorTimer);
|
|
1276
1243
|
clearInterval(persistTimer);
|
|
1277
1244
|
await flushing;
|
|
1278
1245
|
persistQuiet();
|
|
1246
|
+
// Batch state (silentStamps + the permanent baseline) also persists on
|
|
1247
|
+
// shutdown — otherwise silentStamps moved since the last curator tick are
|
|
1248
|
+
// lost, breaking the "survives restarts" invariant (first-sight warm-up
|
|
1249
|
+
// would then re-skip a real edit after a graceful restart).
|
|
1250
|
+
persistBatches();
|
|
1279
1251
|
if (mcp) await mcp.close();
|
|
1280
1252
|
try {
|
|
1281
1253
|
guardedUnlinkSync(heartbeatPath);
|