@agfpd/iapeer-memory-core 0.2.8 → 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 +88 -0
- package/src/config.ts +7 -10
- package/src/context-render.ts +15 -11
- package/src/db.ts +2 -2
- package/src/embedding.ts +1 -1
- package/src/frontmatter-fill.ts +210 -56
- package/src/graph.ts +1 -1
- package/src/human-edit-detect.ts +53 -38
- package/src/index-render.ts +1 -1
- package/src/index.ts +53 -1
- package/src/indexer.ts +1 -1
- package/src/mcp-tools.ts +17 -13
- package/src/memoryd.ts +273 -205
- package/src/mode.ts +76 -0
- package/src/permanent-detect.ts +4 -82
- package/src/search.ts +72 -2
- package/src/silent-edit-detect.ts +11 -20
- package/src/tags-gate.ts +174 -0
- package/src/taxonomy.ts +79 -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
|
*
|
|
@@ -39,12 +38,13 @@ import type { CoreConfig } from "./config.js";
|
|
|
39
38
|
import { openDatabase, type CoreDb } from "./db.js";
|
|
40
39
|
import { indexAll } from "./indexer.js";
|
|
41
40
|
import { runSearch, runGraph, runMap } from "./mcp-tools.js";
|
|
41
|
+
import { runDedup } from "./search.js";
|
|
42
42
|
import { decideUpdate, getZone, sha256 } from "./human-edit-detect.js";
|
|
43
43
|
import { decideMirror, tagsDictionarySourceRel } from "./tags-mirror.js";
|
|
44
|
+
import { renderTagsProjection, DEFAULT_TAGS_BOUNDARY_MAXLEN } from "./tags-gate.js";
|
|
45
|
+
import { isArchivableZone, shouldArchive, archiveTargetRel } from "./archive.js";
|
|
44
46
|
import {
|
|
45
47
|
snapshotVault,
|
|
46
|
-
snapshotInbox,
|
|
47
|
-
snapshotFlatFolder,
|
|
48
48
|
diffSnapshots,
|
|
49
49
|
type VaultSnapshot,
|
|
50
50
|
} from "./permanent-detect.js";
|
|
@@ -253,81 +253,129 @@ export function createMcpServer(opts: {
|
|
|
253
253
|
const server = new McpServer(
|
|
254
254
|
{ name: MEMORYD_SERVER_NAME, version: readCoreVersion() },
|
|
255
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).
|
|
256
259
|
instructions:
|
|
257
|
-
"iapeer-memory
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
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.",
|
|
261
266
|
},
|
|
262
267
|
);
|
|
263
268
|
|
|
264
269
|
server.registerTool(
|
|
265
|
-
"
|
|
270
|
+
"memory_search",
|
|
266
271
|
{
|
|
272
|
+
title: "Search team memory (by meaning)",
|
|
267
273
|
description:
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
"
|
|
272
|
-
"
|
|
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.",
|
|
273
279
|
inputSchema: {
|
|
274
|
-
query: z
|
|
275
|
-
|
|
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."),
|
|
276
288
|
},
|
|
277
289
|
outputSchema: vaultSearchOutput.shape,
|
|
278
|
-
annotations: {
|
|
290
|
+
annotations: {
|
|
291
|
+
readOnlyHint: true,
|
|
292
|
+
destructiveHint: false,
|
|
293
|
+
idempotentHint: true,
|
|
294
|
+
openWorldHint: true,
|
|
295
|
+
},
|
|
279
296
|
},
|
|
280
297
|
async ({ query, forCuration }) => {
|
|
281
298
|
try {
|
|
282
299
|
return toResult(await runSearch(db, effectiveConfig, { query, forCuration }));
|
|
283
300
|
} catch (err) {
|
|
284
|
-
return toError("
|
|
301
|
+
return toError("memory_search", err, logger);
|
|
285
302
|
}
|
|
286
303
|
},
|
|
287
304
|
);
|
|
288
305
|
|
|
289
306
|
server.registerTool(
|
|
290
|
-
"
|
|
307
|
+
"memory_related",
|
|
291
308
|
{
|
|
309
|
+
title: "Related notes (link graph)",
|
|
292
310
|
description:
|
|
293
|
-
"Walk the wikilink graph
|
|
294
|
-
"
|
|
295
|
-
"
|
|
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.",
|
|
296
316
|
inputSchema: {
|
|
297
|
-
path: z
|
|
298
|
-
|
|
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."),
|
|
299
328
|
},
|
|
300
329
|
outputSchema: vaultGraphOutput.shape,
|
|
301
|
-
annotations: {
|
|
330
|
+
annotations: {
|
|
331
|
+
readOnlyHint: true,
|
|
332
|
+
destructiveHint: false,
|
|
333
|
+
idempotentHint: true,
|
|
334
|
+
openWorldHint: false,
|
|
335
|
+
},
|
|
302
336
|
},
|
|
303
337
|
async ({ path: p, depth }) => {
|
|
304
338
|
try {
|
|
305
339
|
return toResult(runGraph(db, effectiveConfig, { path: p, depth }));
|
|
306
340
|
} catch (err) {
|
|
307
|
-
return toError("
|
|
341
|
+
return toError("memory_related", err, logger);
|
|
308
342
|
}
|
|
309
343
|
},
|
|
310
344
|
);
|
|
311
345
|
|
|
312
346
|
server.registerTool(
|
|
313
|
-
"
|
|
347
|
+
"memory_map",
|
|
314
348
|
{
|
|
349
|
+
title: "Vault landscape (map)",
|
|
315
350
|
description:
|
|
316
|
-
"
|
|
317
|
-
"clusters, hubs, bridges, orphans +
|
|
318
|
-
"
|
|
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.",
|
|
319
356
|
inputSchema: {
|
|
320
|
-
detail: z
|
|
321
|
-
|
|
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)."),
|
|
322
365
|
},
|
|
323
366
|
outputSchema: vaultMapOutput.shape,
|
|
324
|
-
annotations: {
|
|
367
|
+
annotations: {
|
|
368
|
+
readOnlyHint: true,
|
|
369
|
+
destructiveHint: false,
|
|
370
|
+
idempotentHint: true,
|
|
371
|
+
openWorldHint: false,
|
|
372
|
+
},
|
|
325
373
|
},
|
|
326
374
|
async ({ detail, parts }) => {
|
|
327
375
|
try {
|
|
328
376
|
return toResult(runMap(db, effectiveConfig, { detail, parts }));
|
|
329
377
|
} catch (err) {
|
|
330
|
-
return toError("
|
|
378
|
+
return toError("memory_map", err, logger);
|
|
331
379
|
}
|
|
332
380
|
},
|
|
333
381
|
);
|
|
@@ -353,6 +401,41 @@ export async function startMcpHttp(opts: {
|
|
|
353
401
|
const httpServer = http.createServer((req, res) => {
|
|
354
402
|
void (async () => {
|
|
355
403
|
const url = (req.url ?? "").split("?")[0];
|
|
404
|
+
// Dedup hint (lean §3a) — a memoryd-INTERNAL RPC for the post-write hook,
|
|
405
|
+
// NOT an MCP tool (the MCP surface stays the three read tools). Loopback
|
|
406
|
+
// (host is 127.0.0.1) + read-only. The hook calls it fail-open with a
|
|
407
|
+
// short timeout, so a slow/down memoryd never hangs a write.
|
|
408
|
+
if (url === "/dedup" && req.method === "POST") {
|
|
409
|
+
const chunks: Buffer[] = [];
|
|
410
|
+
req.on("data", (c) => chunks.push(c as Buffer));
|
|
411
|
+
await new Promise<void>((resolve) => req.on("end", () => resolve()));
|
|
412
|
+
try {
|
|
413
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as {
|
|
414
|
+
content?: string;
|
|
415
|
+
threshold?: number;
|
|
416
|
+
limit?: number;
|
|
417
|
+
linkThreshold?: number;
|
|
418
|
+
};
|
|
419
|
+
const content = (body.content ?? "").trim();
|
|
420
|
+
const result = content
|
|
421
|
+
? await runDedup(opts.db, opts.config, {
|
|
422
|
+
content,
|
|
423
|
+
threshold: body.threshold,
|
|
424
|
+
limit: body.limit,
|
|
425
|
+
linkThreshold: body.linkThreshold,
|
|
426
|
+
})
|
|
427
|
+
: { enabled: Boolean(opts.config.embedding), matches: [] };
|
|
428
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
429
|
+
res.end(JSON.stringify(result));
|
|
430
|
+
} catch (err) {
|
|
431
|
+
logger.error(`dedup request failed: ${String(err)}`);
|
|
432
|
+
if (!res.headersSent) {
|
|
433
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
434
|
+
res.end(JSON.stringify({ enabled: false, matches: [], error: "internal error" }));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
356
439
|
if (url !== "/mcp") {
|
|
357
440
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
358
441
|
res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
|
|
@@ -417,17 +500,16 @@ export async function startMcpHttp(opts: {
|
|
|
417
500
|
// a sync-storm right after a restart could still mis-attribute — so the
|
|
418
501
|
// baseline survives restarts, atomically (same tmp+rename canon).
|
|
419
502
|
|
|
420
|
-
/** Persisted batch baselines:
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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. */
|
|
427
511
|
export type BatchState = {
|
|
428
|
-
inbox: VaultSnapshot | null;
|
|
429
512
|
permanent: VaultSnapshot | null;
|
|
430
|
-
humanInbox: VaultSnapshot | null;
|
|
431
513
|
silentStamps: Map<string, StampRecord> | null;
|
|
432
514
|
};
|
|
433
515
|
|
|
@@ -462,22 +544,18 @@ export function loadBatchState(filePath: string): BatchState {
|
|
|
462
544
|
return m;
|
|
463
545
|
};
|
|
464
546
|
return {
|
|
465
|
-
inbox: toMap(raw.inbox),
|
|
466
547
|
permanent: toMap(raw.permanent),
|
|
467
|
-
humanInbox: toMap(raw.humanInbox),
|
|
468
548
|
silentStamps: toStamps(raw.silentStamps),
|
|
469
549
|
};
|
|
470
550
|
} catch {
|
|
471
|
-
return {
|
|
551
|
+
return { permanent: null, silentStamps: null };
|
|
472
552
|
}
|
|
473
553
|
}
|
|
474
554
|
|
|
475
555
|
export function persistBatchState(
|
|
476
556
|
filePath: string,
|
|
477
557
|
state: {
|
|
478
|
-
inbox: VaultSnapshot;
|
|
479
558
|
permanent: VaultSnapshot;
|
|
480
|
-
humanInbox: VaultSnapshot;
|
|
481
559
|
silentStamps: Map<string, StampRecord>;
|
|
482
560
|
},
|
|
483
561
|
): void {
|
|
@@ -486,9 +564,7 @@ export function persistBatchState(
|
|
|
486
564
|
guardedWriteFileSync(
|
|
487
565
|
tmp,
|
|
488
566
|
JSON.stringify({
|
|
489
|
-
inbox: Object.fromEntries(state.inbox),
|
|
490
567
|
permanent: Object.fromEntries(state.permanent),
|
|
491
|
-
humanInbox: Object.fromEntries(state.humanInbox),
|
|
492
568
|
silentStamps: Object.fromEntries(state.silentStamps),
|
|
493
569
|
}),
|
|
494
570
|
"utf-8",
|
|
@@ -531,13 +607,16 @@ export type MemorydOptions = {
|
|
|
531
607
|
heartbeatPath?: string;
|
|
532
608
|
/** Tags mirror target; default `<db dir>/tags-dictionary.md`. */
|
|
533
609
|
tagsMirrorPath?: string;
|
|
610
|
+
/** Compact tags-projection target (injected to all peers, lean §3);
|
|
611
|
+
* default `<db dir>/tags-projection.md`. */
|
|
612
|
+
tagsProjectionPath?: string;
|
|
534
613
|
/** Detect-hash persistence file; default `<db dir>/memoryd.hashes.json`. */
|
|
535
614
|
hashStatePath?: string;
|
|
536
|
-
/** Persisted batch baselines (
|
|
615
|
+
/** Persisted batch baselines (the permanent-folders snapshot + silent
|
|
616
|
+
* stamps). */
|
|
537
617
|
batchStatePath?: string;
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
humanInboxBatchMs?: number;
|
|
618
|
+
/** Curator-tick cadence override (tests); default from config.batch. */
|
|
619
|
+
curatorTickMs?: number;
|
|
541
620
|
/** Periodic hash-persist interval (ms). */
|
|
542
621
|
persistMs?: number;
|
|
543
622
|
/** Human owner name; human-edit detection is OFF when absent (⚖7). */
|
|
@@ -583,8 +662,8 @@ export type MemorydHandle = {
|
|
|
583
662
|
mcpPort: number | null;
|
|
584
663
|
/** Force one detect pass immediately (used by tests and shutdown flush). */
|
|
585
664
|
runDetectPass: () => Promise<void>;
|
|
586
|
-
/** Force a
|
|
587
|
-
|
|
665
|
+
/** Force a curator tick (tests / operator force-tick). */
|
|
666
|
+
runCuratorTick: () => void;
|
|
588
667
|
close: () => Promise<void>;
|
|
589
668
|
};
|
|
590
669
|
|
|
@@ -597,6 +676,9 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
597
676
|
const dbDir = path.dirname(config.index.dbPath);
|
|
598
677
|
const heartbeatPath = opts.heartbeatPath ?? path.join(dbDir, "memoryd.heartbeat");
|
|
599
678
|
const tagsMirrorPath = opts.tagsMirrorPath ?? path.join(dbDir, "tags-dictionary.md");
|
|
679
|
+
const tagsProjectionPath = opts.tagsProjectionPath ?? path.join(dbDir, "tags-projection.md");
|
|
680
|
+
const tagsBoundaryMaxLen =
|
|
681
|
+
Number(process.env.IAPEER_MEMORY_TAGS_BOUNDARY_MAXLEN) || DEFAULT_TAGS_BOUNDARY_MAXLEN;
|
|
600
682
|
const hashStatePath = opts.hashStatePath ?? path.join(dbDir, "memoryd.hashes.json");
|
|
601
683
|
const persistMs = opts.persistMs ?? 60_000;
|
|
602
684
|
const taxonomy = config.taxonomy;
|
|
@@ -604,22 +686,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
604
686
|
const db = openDatabase(config);
|
|
605
687
|
await indexAll({ db, config, logger });
|
|
606
688
|
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
// (порт паттерна старых мониторов: «seen переживает сбой») — рестарт не
|
|
613
|
-
// глотает накопленное и не реплеит обработанное.
|
|
689
|
+
// Baseline (каденция — директива Артура 10.06 ~15:31): канон + оперативка
|
|
690
|
+
// копятся и уходят ПАЧКОЙ раз в batch.curatorMs (default 6h, CURATOR_TICK).
|
|
691
|
+
// Снапшот ПЕРСИСТИТСЯ через рестарты (порт паттерна старых мониторов: «seen
|
|
692
|
+
// переживает сбой») — рестарт не глотает накопленное и не реплеит
|
|
693
|
+
// обработанное.
|
|
614
694
|
const batchStatePath = opts.batchStatePath ?? path.join(dbDir, "memoryd.batches.json");
|
|
615
695
|
const persistedBatches = loadBatchState(batchStatePath);
|
|
616
|
-
let inboxSnapshot: VaultSnapshot =
|
|
617
|
-
persistedBatches.inbox ?? snapshotInbox(config.vaultPath, taxonomy);
|
|
618
696
|
let permanentBaseline: VaultSnapshot =
|
|
619
697
|
persistedBatches.permanent ?? snapshotVault(config.vaultPath, taxonomy);
|
|
620
|
-
const humanInboxDir = path.join(config.vaultPath, taxonomy.folders.inboxHuman);
|
|
621
|
-
let humanInboxBaseline: VaultSnapshot =
|
|
622
|
-
persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
|
|
623
698
|
const lastSeenHashes = loadHashState(hashStatePath);
|
|
624
699
|
/** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
|
|
625
700
|
* old state file = empty map = first-sight warm-up (design §4). */
|
|
@@ -629,16 +704,14 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
629
704
|
function persistBatches(): void {
|
|
630
705
|
try {
|
|
631
706
|
persistBatchState(batchStatePath, {
|
|
632
|
-
inbox: inboxSnapshot,
|
|
633
707
|
permanent: permanentBaseline,
|
|
634
|
-
humanInbox: humanInboxBaseline,
|
|
635
708
|
silentStamps,
|
|
636
709
|
});
|
|
637
710
|
} catch (err) {
|
|
638
711
|
logger.error(`batch-state persist failed: ${String(err)}`);
|
|
639
712
|
}
|
|
640
713
|
}
|
|
641
|
-
if (!persistedBatches.
|
|
714
|
+
if (!persistedBatches.permanent) persistBatches(); // first run: write baseline
|
|
642
715
|
|
|
643
716
|
/** iCloud-mount guard (порт защиты старых мониторов): корень vault
|
|
644
717
|
* недоступен → пропускаем проход целиком, baseline не трогаем — после
|
|
@@ -745,7 +818,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
745
818
|
indexAgent,
|
|
746
819
|
paths: fragments.paths,
|
|
747
820
|
authorIndexPath: outFile,
|
|
748
|
-
|
|
821
|
+
// lean §3: the compact dictionary projection is injected to EVERY
|
|
822
|
+
// author now (pre-lean: only the Index got the full mirror).
|
|
823
|
+
tagsProjectionPath,
|
|
824
|
+
tagsTitle: taxonomy.systemFiles.tagsDictionary,
|
|
749
825
|
},
|
|
750
826
|
});
|
|
751
827
|
rendered++;
|
|
@@ -785,23 +861,46 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
785
861
|
mirrorContent = null;
|
|
786
862
|
}
|
|
787
863
|
const decision = decideMirror({ srcContent, mirrorContent });
|
|
788
|
-
if (decision.action
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
864
|
+
if (decision.action === "write") {
|
|
865
|
+
fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
|
|
866
|
+
const tmp = `${tagsMirrorPath}.tmp`;
|
|
867
|
+
guardedWriteFileSync(tmp, srcContent!, "utf-8");
|
|
868
|
+
fs.renameSync(tmp, tagsMirrorPath);
|
|
869
|
+
mirrorContent = srcContent;
|
|
870
|
+
logger.info(`tags mirror updated (${decision.reason})`);
|
|
871
|
+
}
|
|
872
|
+
// Refresh the compact projection from the (up-to-date) mirror — even when
|
|
873
|
+
// the mirror was unchanged, so the projection materialises on first run
|
|
874
|
+
// after the feature ships (lean §3). Idempotent: writes only on a change.
|
|
875
|
+
syncTagsProjection(mirrorContent);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function syncTagsProjection(mirrorContent: string | null): void {
|
|
879
|
+
if (!mirrorContent || !mirrorContent.trim()) return; // no dict → keep existing
|
|
880
|
+
const proj = renderTagsProjection(mirrorContent, { boundaryMaxLen: tagsBoundaryMaxLen });
|
|
881
|
+
if (!proj.trim()) return;
|
|
882
|
+
let existing: string | null = null;
|
|
883
|
+
try {
|
|
884
|
+
existing = fs.readFileSync(tagsProjectionPath, "utf-8");
|
|
885
|
+
} catch {
|
|
886
|
+
existing = null;
|
|
887
|
+
}
|
|
888
|
+
if (existing === proj) return;
|
|
889
|
+
fs.mkdirSync(path.dirname(tagsProjectionPath), { recursive: true });
|
|
890
|
+
const tmp = `${tagsProjectionPath}.tmp`;
|
|
891
|
+
guardedWriteFileSync(tmp, proj, "utf-8");
|
|
892
|
+
fs.renameSync(tmp, tagsProjectionPath);
|
|
893
|
+
logger.info("tags projection updated");
|
|
794
894
|
}
|
|
795
895
|
|
|
796
|
-
/** Zone map of the unstamped detector (design §3): the
|
|
797
|
-
*
|
|
798
|
-
*
|
|
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). */
|
|
799
899
|
function silentZoneOf(absPath: string): SilentZone | null {
|
|
800
900
|
const rel = path.relative(config.vaultPath, absPath);
|
|
801
901
|
if (rel.startsWith("..")) return null;
|
|
802
902
|
const first = rel.split(path.sep)[0];
|
|
803
903
|
const f = taxonomy.folders;
|
|
804
|
-
if (first === f.inbox) return "inbox";
|
|
805
904
|
if (
|
|
806
905
|
first === f.knowledge ||
|
|
807
906
|
first === f.decisions ||
|
|
@@ -817,13 +916,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
817
916
|
|
|
818
917
|
/**
|
|
819
918
|
* Unstamped-write detector (design doc, boris-accepted 11.06). Runs
|
|
820
|
-
* BEFORE
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
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).
|
|
827
926
|
*/
|
|
828
927
|
function silentEditPass(candidatesAbs: Set<string>): void {
|
|
829
928
|
for (const abs of candidatesAbs) {
|
|
@@ -884,11 +983,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
884
983
|
continue; // deleted mid-debounce
|
|
885
984
|
}
|
|
886
985
|
// FIRST-SIGHT GUARD (churn-дефект B-приёмки, boris п.3): путь, которого
|
|
887
|
-
// нет в hash-базе — это
|
|
888
|
-
//
|
|
889
|
-
// baseline и не стампь:
|
|
890
|
-
//
|
|
891
|
-
//
|
|
986
|
+
// нет в hash-базе — это ПЕРВОЕ наблюдение (старт демона над уже
|
|
987
|
+
// населённым vault), НЕ обязательно человеческая правка. Запиши
|
|
988
|
+
// baseline и не стампь: иначе старт массово штампует все существующие
|
|
989
|
+
// ноты как «правки человека». (Размещений Индексом больше нет — инбокс
|
|
990
|
+
// устранён; единственный источник never-seen-путей — старт/новый файл.)
|
|
991
|
+
// ЦЕНА (§9-край, на живой приёмке): свежая bare-body заметка человека
|
|
992
|
+
// достраивается со ВТОРОГО fs-события — первое лишь пишет baseline.
|
|
892
993
|
if (!lastSeenHashes.has(filePath)) {
|
|
893
994
|
lastSeenHashes.set(filePath, sha256(content));
|
|
894
995
|
continue;
|
|
@@ -901,6 +1002,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
901
1002
|
birthtimeMs: stat.birthtime ? stat.birthtime.getTime() : 0,
|
|
902
1003
|
mtimeMs: stat.mtime.getTime(),
|
|
903
1004
|
basename: path.basename(filePath),
|
|
1005
|
+
path: filePath,
|
|
1006
|
+
vault: config.vaultPath,
|
|
904
1007
|
lastHash: lastSeenHashes.get(filePath) ?? null,
|
|
905
1008
|
taxonomy,
|
|
906
1009
|
freshEditWindowS: opts.freshEditWindowS,
|
|
@@ -926,6 +1029,44 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
926
1029
|
}
|
|
927
1030
|
}
|
|
928
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Deterministic archiving (lean §2.2a): move stale notes among the changed
|
|
1034
|
+
* files to the archive folder. Runs AFTER humanEditPass (a note just marked
|
|
1035
|
+
* stale carries its stamp). The move is invisible to permanent-detect
|
|
1036
|
+
* (deletions are ignored; the archive is outside `monitoredFolders`), and
|
|
1037
|
+
* `indexAll` below reconciles the path change (drops the source, indexes the
|
|
1038
|
+
* archived copy — still searchable with the stale boost). Returns the count.
|
|
1039
|
+
*/
|
|
1040
|
+
function archiveStaleNotes(candidatesAbs: Set<string>): number {
|
|
1041
|
+
let moved = 0;
|
|
1042
|
+
for (const abs of candidatesAbs) {
|
|
1043
|
+
const rel = path.relative(config.vaultPath, abs);
|
|
1044
|
+
if (!isArchivableZone(rel, taxonomy)) continue;
|
|
1045
|
+
let content: string;
|
|
1046
|
+
try {
|
|
1047
|
+
content = fs.readFileSync(abs, "utf-8");
|
|
1048
|
+
} catch {
|
|
1049
|
+
continue; // deleted mid-debounce
|
|
1050
|
+
}
|
|
1051
|
+
if (!shouldArchive(rel, content, taxonomy)) continue;
|
|
1052
|
+
const targetRel = archiveTargetRel(path.basename(abs), taxonomy, (r) =>
|
|
1053
|
+
fs.existsSync(path.join(config.vaultPath, r)),
|
|
1054
|
+
);
|
|
1055
|
+
const targetAbs = path.join(config.vaultPath, targetRel);
|
|
1056
|
+
try {
|
|
1057
|
+
fs.mkdirSync(path.dirname(targetAbs), { recursive: true });
|
|
1058
|
+
fs.renameSync(abs, targetAbs);
|
|
1059
|
+
silentStamps.delete(rel); // baselines follow the move
|
|
1060
|
+
lastSeenHashes.delete(abs);
|
|
1061
|
+
moved += 1;
|
|
1062
|
+
logger.info(`archived (stale): ${rel} → ${targetRel}`);
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
logger.error(`archive failed for ${rel}: ${String(err)}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return moved;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
929
1070
|
// ── fs.watch + debounce ──
|
|
930
1071
|
const pending = new Set<string>();
|
|
931
1072
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -937,62 +1078,29 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
937
1078
|
|
|
938
1079
|
if (!vaultAvailable()) return;
|
|
939
1080
|
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
// Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
|
|
948
|
-
// diff (the diff is semantic — a re-stamp does not invalidate it).
|
|
949
|
-
silentEditPass(
|
|
950
|
-
new Set([
|
|
951
|
-
...changed,
|
|
952
|
-
...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
|
|
953
|
-
]),
|
|
954
|
-
);
|
|
955
|
-
|
|
956
|
-
for (const name of inboxChanged) {
|
|
957
|
-
const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
|
|
958
|
-
// Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
|
|
959
|
-
// 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
|
|
960
|
-
// повторный INBOX_NEW по тому же черновику — вычитывающая сессия и
|
|
961
|
-
// есть конвейер). Правки автора (reject→fix→re-review) проходят:
|
|
962
|
-
// last_edited_by остаётся автором. Цена документирована: черновик,
|
|
963
|
-
// чья ПОСЛЕДНЯЯ правка до первого анонса — кураторская, не
|
|
964
|
-
// анонсируется; в конвейере не возникает (кураторы трогают черновики
|
|
965
|
-
// только по событиям, т.е. после анонса). Baseline обновляется ниже
|
|
966
|
-
// безусловно — подавленное НЕ реплеится, авторская правка поверх
|
|
967
|
-
// кураторской диффится от свежего снапшота и проходит.
|
|
968
|
-
const leb = lastEditedByOf(abs);
|
|
969
|
-
if (leb && config.curatorSet.includes(leb)) {
|
|
970
|
-
logger.info(`inbox event suppressed (curator ${leb}): ${name}`);
|
|
971
|
-
continue;
|
|
972
|
-
}
|
|
973
|
-
logger.info(`inbox event: ${name}`);
|
|
974
|
-
// ABSOLUTE path (B-приёмка 10.06): a consumer must never guess the
|
|
975
|
-
// vault root — a role peer once `find`-guessed a stale copy.
|
|
976
|
-
emit(`INBOX_NEW: ${abs}`);
|
|
977
|
-
}
|
|
978
|
-
if (inboxNext.size > 0 || inboxSnapshot.size === 0) {
|
|
979
|
-
inboxSnapshot = inboxNext;
|
|
980
|
-
persistBatches();
|
|
981
|
-
}
|
|
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);
|
|
982
1088
|
|
|
983
1089
|
humanEditPass(changed);
|
|
1090
|
+
archiveStaleNotes(changed); // lean §2.2a — stale → archive before reindex
|
|
984
1091
|
syncTagsMirror();
|
|
985
1092
|
await indexAll({ db, config, logger }); // incremental by content hash
|
|
986
1093
|
renderFleetFragments("vault-change"); // docs/05: свежесть за секунды
|
|
987
|
-
//
|
|
988
|
-
// (
|
|
1094
|
+
// Canon edits are NOT emitted instantly — they accumulate to the curator
|
|
1095
|
+
// tick (cadence 6h); see runCuratorTick.
|
|
989
1096
|
}
|
|
990
1097
|
|
|
991
|
-
/**
|
|
992
|
-
* baseline
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
|
|
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 {
|
|
996
1104
|
if (!vaultAvailable()) return;
|
|
997
1105
|
const next = snapshotVault(config.vaultPath, taxonomy);
|
|
998
1106
|
const changedRel = diffSnapshots(permanentBaseline, next);
|
|
@@ -1005,23 +1113,8 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1005
1113
|
absPaths.push(abs);
|
|
1006
1114
|
}
|
|
1007
1115
|
if (absPaths.length) {
|
|
1008
|
-
logger.info(`
|
|
1009
|
-
emit(`
|
|
1010
|
-
}
|
|
1011
|
-
persistBatches();
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/** HUMAN_INBOX_BATCH — каденция human-inbox (человек пишет долго,
|
|
1015
|
-
* мгновенный триггер схватил бы недоделанное). */
|
|
1016
|
-
function runHumanInboxBatch(): void {
|
|
1017
|
-
if (!vaultAvailable()) return;
|
|
1018
|
-
const next = snapshotFlatFolder(humanInboxDir);
|
|
1019
|
-
const names = diffSnapshots(humanInboxBaseline, next);
|
|
1020
|
-
humanInboxBaseline = next;
|
|
1021
|
-
if (names.length) {
|
|
1022
|
-
const absPaths = names.map((n) => path.join(humanInboxDir, n));
|
|
1023
|
-
logger.info(`human-inbox batch: ${absPaths.length} draft(s)`);
|
|
1024
|
-
emit(`HUMAN_INBOX_BATCH: ${JSON.stringify(absPaths)}`);
|
|
1116
|
+
logger.info(`curator tick: ${absPaths.length} path(s)`);
|
|
1117
|
+
emit(`CURATOR_TICK: ${JSON.stringify(absPaths)}`);
|
|
1025
1118
|
}
|
|
1026
1119
|
persistBatches();
|
|
1027
1120
|
}
|
|
@@ -1096,42 +1189,16 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1096
1189
|
const persistTimer = setInterval(persistQuiet, persistMs);
|
|
1097
1190
|
persistTimer.unref?.();
|
|
1098
1191
|
|
|
1099
|
-
// ── cadence
|
|
1100
|
-
const
|
|
1101
|
-
const
|
|
1192
|
+
// ── cadence timer (директива ~15:31): первый прогон через полный период
|
|
1193
|
+
const curatorTickMs = opts.curatorTickMs ?? config.batch.curatorMs;
|
|
1194
|
+
const curatorTimer = setInterval(() => {
|
|
1102
1195
|
try {
|
|
1103
|
-
|
|
1196
|
+
runCuratorTick();
|
|
1104
1197
|
} catch (err) {
|
|
1105
|
-
logger.error(`
|
|
1106
|
-
}
|
|
1107
|
-
}, permanentBatchMs);
|
|
1108
|
-
permanentTimer.unref?.();
|
|
1109
|
-
// HUMAN-INBOX: раз в СУТКИ в config.batch.humanInboxHour локального
|
|
1110
|
-
// (подтверждение Артура ~15:4x: ночная партия, как в старом контуре).
|
|
1111
|
-
// Тестовый override opts.humanInboxBatchMs — простой интервал.
|
|
1112
|
-
let humanInboxTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1113
|
-
function scheduleHumanInbox(): void {
|
|
1114
|
-
let delay: number;
|
|
1115
|
-
if (opts.humanInboxBatchMs !== undefined) {
|
|
1116
|
-
delay = opts.humanInboxBatchMs;
|
|
1117
|
-
} else {
|
|
1118
|
-
const now = new Date();
|
|
1119
|
-
const next = new Date(now);
|
|
1120
|
-
next.setHours(config.batch.humanInboxHour, 0, 0, 0);
|
|
1121
|
-
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
|
|
1122
|
-
delay = next.getTime() - now.getTime();
|
|
1198
|
+
logger.error(`curator tick failed: ${String(err)}`);
|
|
1123
1199
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
runHumanInboxBatch();
|
|
1127
|
-
} catch (err) {
|
|
1128
|
-
logger.error(`human-inbox batch failed: ${String(err)}`);
|
|
1129
|
-
}
|
|
1130
|
-
scheduleHumanInbox();
|
|
1131
|
-
}, delay);
|
|
1132
|
-
humanInboxTimer.unref?.();
|
|
1133
|
-
}
|
|
1134
|
-
scheduleHumanInbox();
|
|
1200
|
+
}, curatorTickMs);
|
|
1201
|
+
curatorTimer.unref?.();
|
|
1135
1202
|
|
|
1136
1203
|
// ── MCP http ──
|
|
1137
1204
|
let mcp: { port: number; close: () => Promise<void> } | null = null;
|
|
@@ -1145,10 +1212,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1145
1212
|
|
|
1146
1213
|
return {
|
|
1147
1214
|
mcpPort: mcp?.port ?? null,
|
|
1148
|
-
|
|
1149
|
-
if (kind === "permanent") runPermanentBatch();
|
|
1150
|
-
else runHumanInboxBatch();
|
|
1151
|
-
},
|
|
1215
|
+
runCuratorTick,
|
|
1152
1216
|
runDetectPass: async () => {
|
|
1153
1217
|
if (flushTimer) {
|
|
1154
1218
|
clearTimeout(flushTimer);
|
|
@@ -1161,11 +1225,15 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
1161
1225
|
watcher?.close();
|
|
1162
1226
|
if (flushTimer) clearTimeout(flushTimer);
|
|
1163
1227
|
clearInterval(heartbeatTimer);
|
|
1164
|
-
clearInterval(
|
|
1165
|
-
if (humanInboxTimer) clearTimeout(humanInboxTimer);
|
|
1228
|
+
clearInterval(curatorTimer);
|
|
1166
1229
|
clearInterval(persistTimer);
|
|
1167
1230
|
await flushing;
|
|
1168
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();
|
|
1169
1237
|
if (mcp) await mcp.close();
|
|
1170
1238
|
try {
|
|
1171
1239
|
guardedUnlinkSync(heartbeatPath);
|