@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "iapeer-memory core — host-neutral TypeScript memory primitive: vault schema/taxonomy config, search engine, memoryd, context renderer, role contracts. Consumed by the @agfpd/iapeer-memory facade; version kept in lockstep by its release flow (docs/10-distribution.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/archive.ts CHANGED
@@ -24,7 +24,7 @@ function firstSegment(relPath: string): string {
24
24
  * Folders whose notes are subject to archiving — UNIFIED rule, no exceptions
25
25
  * (decision Артур 15.06, §2.2a): the six monitored content folders (five
26
26
  * canonical permanent + agent memory + `03_Projects`). NOT the archive
27
- * itself, the inboxes, or the system folder. A completed phase/project
27
+ * itself or the system folder. A completed phase/project
28
28
  * (status `completed`/`cancelled`) is stale like any other note and moves to
29
29
  * the archive; active `03_Projects` then shows only live work. Wikilinks
30
30
  * survive by title; the archive stays searchable.
package/src/config.ts CHANGED
@@ -50,15 +50,13 @@ export type CoreConfig = {
50
50
  dbPath: string;
51
51
  fullScanOnStartup: boolean;
52
52
  };
53
- /** Конвейерные каденции (директива Артура 10.06 ~15:31): канон-правки и
54
- * human-inbox идут ПАЧКАМИ, не событиями — правки должны устаканиться,
55
- * 10 пишущих агентов не дёргают конвейер постоянно. */
53
+ /** Каденция курации (директива Артура 10.06 ~15:31): канон-правки уходят
54
+ * ПАЧКОЙ, не событиями — правки должны устаканиться, 10 пишущих агентов
55
+ * не дёргают конвейер постоянно. */
56
56
  batch: {
57
- /** PERMANENT_BATCH period, ms (default 6h). */
58
- permanentMs: number;
59
- /** HUMAN_INBOX_BATCH — РАЗ В СУТКИ в этот локальный час (default 04:00,
60
- * как в старом контуре: человек пишет долго, ночная партия). */
61
- humanInboxHour: number;
57
+ /** CURATOR_TICK period, ms (default 6h) — the curation pass over changed
58
+ * canon (Scriber/Index links + health). */
59
+ curatorMs: number;
62
60
  };
63
61
  /**
64
62
  * MCP-http endpoint of memoryd (ADR-012). The default port 8766 is the
@@ -261,8 +259,7 @@ export function configFromEnv(): CoreConfig {
261
259
  fullScanOnStartup: envBoolean("IAPEER_MEMORY_FULL_SCAN_ON_STARTUP", true),
262
260
  },
263
261
  batch: {
264
- permanentMs: envNumber("IAPEER_MEMORY_PERMANENT_BATCH_SECS", 6 * 3600) * 1000,
265
- humanInboxHour: envNumber("IAPEER_MEMORY_HUMAN_INBOX_HOUR", 4),
262
+ curatorMs: envNumber("IAPEER_MEMORY_CURATOR_TICK_SECS", 6 * 3600) * 1000,
266
263
  },
267
264
  mcp: {
268
265
  port: envNumber("IAPEER_MEMORY_MCP_PORT", 8766),
package/src/db.ts CHANGED
@@ -144,7 +144,7 @@ export function openDatabase(config: CoreConfig, options: OpenDatabaseOptions =
144
144
 
145
145
  -- Wikilinks that could not be resolved to a real note. Kept first-class
146
146
  -- instead of being silently dropped from edges: a missing/ambiguous link
147
- -- is a vault health signal (surfaced via vault_map orphan_wikilinks +
147
+ -- is a vault health signal (surfaced via memory_map orphan_wikilinks +
148
148
  -- the Index nightly health-check). reason ∈ 'missing' | 'ambiguous'.
149
149
  CREATE TABLE IF NOT EXISTS unresolved_links (
150
150
  source_path TEXT NOT NULL,
@@ -536,7 +536,7 @@ export function documentExists(db: CoreDb, docPath: string): boolean {
536
536
 
537
537
  /**
538
538
  * Unresolved wikilinks (missing target / ambiguous basename across folders).
539
- * First-class health signal — surfaced through vault_map's opt-in
539
+ * First-class health signal — surfaced through memory_map's opt-in
540
540
  * `orphan_wikilinks` part and the Index nightly health-check.
541
541
  */
542
542
  export function getUnresolvedLinks(
package/src/embedding.ts CHANGED
@@ -45,7 +45,7 @@ export type EmbeddingConfig = {
45
45
  };
46
46
 
47
47
  /**
48
- * Status of a single embedding call, surfaced up to the vault_search
48
+ * Status of a single embedding call, surfaced up to the memory_search
49
49
  * response (`pipeline.embedding`).
50
50
  *
51
51
  * - `ok` — valid response;
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Fill a vault note's frontmatter according to its zone
3
- * (inbox / permanent / memory).
3
+ * (permanent / memory). The страж is the SHARED fill logic — the same
4
+ * functions run the agent hook path (processFile) and the human path
5
+ * (decideUpdate, human-edit-detect.ts). Inbox zones were removed with the
6
+ * direct-to-canon model: authors write straight into the typed canon folders.
4
7
  *
5
8
  * TS port of the reference `scripts/mergemind-frontmatter-fill.py`
6
9
  * (behavioural parity against `tests/python/test_frontmatter_fill.py`,
@@ -16,20 +19,16 @@
16
19
  * - **identity = peer personality** (нюанс 10): `resolveAgentName` prefers
17
20
  * `PEER_PERSONALITY`, falling back to `IAPEER_MEMORY_AGENT_NAME`.
18
21
  *
19
- * Zone behaviour (parity with the reference, one deliberate deviation):
20
- * - inbox: idempotent fill of the 4-field draft frontmatter +
21
- * needs_review (unless curator) + UPSERT of the stamp pair
22
- * (last_edited_by, updated) a deviation from the reference,
23
- * load-bearing for the unstamped detector: its inbox branch
24
- * discriminates by «the stamp pair did not move», which is
25
- * only a signal when hook edits DO move it (false-positive
26
- * incident 11.06, boris's Write+Edit repro).
27
- * - permanent: upsert of service fields (last_edited_by, updated,
28
- * needs_review).
29
- * - memory: PERMANENT service-field semantics + idempotent fill of the
30
- * constants. `author` is parsed from the subfolder name, NOT
31
- * from the caller identity — load-bearing for DreamWeaver
32
- * writing into a foreign subfolder on an Index task.
22
+ * Zone behaviour:
23
+ * - permanent: FULL canon fill (`fillPermanentFull`) title filename,
24
+ * type/status the folder's genre (§2.1), created, author for
25
+ * non-curators + needs_review, and an UPSERT of the stamp pair
26
+ * (last_edited_by, updated). The author now hand-writes only
27
+ * body + tags + inline links + title; the rest is deterministic.
28
+ * - memory: service-field semantics + idempotent fill of the constants.
29
+ * `author` is parsed from the subfolder name, NOT from the caller
30
+ * identity — load-bearing for DreamWeaver writing into a foreign
31
+ * subfolder on an Index task.
33
32
  *
34
33
  * The YAML-safe normalisation of `description` is load-bearing: typographic
35
34
  * guillemets `«…»` are a display convention, not YAML quotes; any `: ` inside
@@ -47,7 +46,7 @@ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
47
46
 
48
47
  const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
49
48
 
50
- export const VALID_ZONES = ["inbox", "permanent", "memory"] as const;
49
+ export const VALID_ZONES = ["permanent", "memory"] as const;
51
50
  export type Zone = (typeof VALID_ZONES)[number];
52
51
 
53
52
  /**
@@ -202,8 +201,9 @@ export function parseMemoryAuthor(
202
201
  * Zone of a file by the first path segment relative to the vault — the
203
202
  * single source of truth for zone routing (the reference de-duplicated a
204
203
  * bash `case` into exactly this function). Folder whitelist comes from the
205
- * taxonomy preset (ADR-002): both inboxes, archive and system are NOT in
206
- * the whitelist → null → caller no-ops.
204
+ * taxonomy preset (ADR-002): archive and system are NOT in the whitelist →
205
+ * null → caller no-ops. (Inbox zones removed with the direct-to-canon model:
206
+ * authors write straight into the typed canon folders, the страж fills.)
207
207
  */
208
208
  export function resolveZone(
209
209
  filePath: string,
@@ -215,7 +215,6 @@ export function resolveZone(
215
215
  if (!parts || parts.length === 0) return null;
216
216
  const head = parts[0];
217
217
  const f = taxonomy.folders;
218
- if (head === f.inbox) return "inbox";
219
218
  if (
220
219
  head === f.knowledge ||
221
220
  head === f.decisions ||
@@ -229,38 +228,6 @@ export function resolveZone(
229
228
  return null;
230
229
  }
231
230
 
232
- export function fillInbox(
233
- fmBlock: string,
234
- opts: { path: string; agent: string; today: string; nowStamp: string; ctx: FillContext },
235
- ): string {
236
- const { taxonomy } = opts.ctx;
237
- // STAMP PAIR (дефект-репро boris 11.06, ложный unstamped): upsert как в
238
- // fillPermanent/fillMemory. Без неё у inbox-черновиков пара тождественно
239
- // (null, null) — «штамп не двинулся» детектора немых записей выполняется
240
- // на ЛЮБОЙ легитимной хук-правке, и inbox-ветка вырождается в «hash
241
- // двинулся → unstamped» (7 ложных ре-стампов за день 11.06). Оба поля
242
- // сервисные для smart-hash → семантический hash и INBOX_NEW-диффы не
243
- // двигаются, эхо невозможно. Побочный выигрыш: source-фильтр кураторов
244
- // INBOX_NEW впервые получает живой last_edited_by.
245
- fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
246
- fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
247
- fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
248
- fmBlock = setIfMissing(fmBlock, "status", taxonomy.statusTokens.draft);
249
- fmBlock = setIfMissing(fmBlock, "created", opts.today);
250
- // AUTHOR GUARD (инвариант Артура, инверсия 10.06): агент curator-set
251
- // НИКОГДА не становится author в inbox-зоне. Курайторы трогают черновики
252
- // только как курирование (rename/правки стиля — горячая дорожка
253
- // инвертированного конвейера: копирайтер пишет НОВЫЙ файл при ренейме);
254
- // бесхозный файл от курайтора остаётся без author до явной атрибуции —
255
- // видимая аномалия лучше тихо присвоенного авторства. Существующий
256
- // author и без гарда неприкосновенен (setIfMissing).
257
- if (!isCurator(opts.agent, opts.ctx)) {
258
- fmBlock = setIfMissing(fmBlock, "author", opts.agent);
259
- fmBlock = setIfMissing(fmBlock, "needs_review", "true");
260
- }
261
- return fmBlock;
262
- }
263
-
264
231
  /**
265
232
  * Full permanent-zone (canon) fill — the lean §2 «страж» core, SHARED between
266
233
  * the post-write hook (`processFile`) and the human-edit detector
@@ -288,7 +255,7 @@ export function fillPermanentFull(
288
255
  ): string {
289
256
  const { taxonomy } = opts.ctx;
290
257
  // Service stamp (always) — load-bearing for smart-hash echo-safety and the
291
- // unstamped detector, symmetric with fillInbox/fillMemory.
258
+ // unstamped detector, symmetric with fillMemory.
292
259
  fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
293
260
  fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
294
261
  // Canon frontmatter the author no longer hand-writes (§2.1). setIfMissing —
@@ -689,9 +656,7 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
689
656
 
690
657
  let newFm: string | null;
691
658
  let newBody = rest;
692
- if (zone === "inbox") {
693
- newFm = fillInbox(fmBlock, { path: filePath, agent: opts.agent, today, nowStamp, ctx });
694
- } else if (zone === "permanent") {
659
+ if (zone === "permanent") {
695
660
  newFm = fillPermanentFull(fmBlock, {
696
661
  path: filePath,
697
662
  agent: opts.agent,
package/src/graph.ts CHANGED
@@ -54,7 +54,7 @@ function loadGraph(db: CoreDb, agentMemoryMarker: string): { adj: AdjMap; direct
54
54
  target_path: string;
55
55
  }>;
56
56
 
57
- // vault_map is the CANONICAL / shared topology — used by the Index nightly
57
+ // memory_map is the CANONICAL / shared topology — used by the Index nightly
58
58
  // health-check (orphans, hubs, bridges). Agent operative notes are personal
59
59
  // memory: excluding them here keeps that health picture honest (a
60
60
  // canonically-isolated note must read as orphan even if some agent journaled
@@ -10,15 +10,15 @@
10
10
  * hash persistence) arrives with the memoryd stage; everything
11
11
  * load-bearing (the anti-loop decision core) lives here, pure.
12
12
  *
13
- * PERMANENT mode (the five canonical folders):
13
+ * Canon folders (the five typed + project notes) — the human writes there
14
+ * directly from an external editor:
14
15
  * - `last_edited_by == human` AND `updated` fresh → echo of our own
15
16
  * write — skip;
16
17
  * - `last_edited_by` is an agent AND fresh → the post-write hook just
17
18
  * ran — skip (the hook's zone);
18
- * - otherwise → an external-editor edit: stamp
19
- * `last_edited_by: <human>` + `updated` + `needs_review: true`.
20
- *
21
- * HUMAN-INBOX mode: anti-loop as above, then the idempotent 4-field fill.
19
+ * - otherwise → an external-editor edit: the shared `fillPermanentFull`
20
+ * completes a bare-body note and stamps `last_edited_by: <human>` +
21
+ * `updated` + `needs_review: true`.
22
22
  *
23
23
  * `freshEditWindowS` is CONFIG (the stage-7 review note): default 90s —
24
24
  * the reference VALUE IS 90 (bumped from the historical 30: second-
@@ -32,7 +32,12 @@
32
32
  import crypto from "node:crypto";
33
33
  import path from "node:path";
34
34
  import type { TaxonomyPreset } from "./taxonomy.js";
35
- import { fillPermanentFull } from "./frontmatter-fill.js";
35
+ import {
36
+ fillPermanentFull,
37
+ stripEmptyArrays,
38
+ normalizeAllScalars,
39
+ normalizeLinksBlock,
40
+ } from "./frontmatter-fill.js";
36
41
 
37
42
  export const DEFAULT_FRESH_EDIT_WINDOW_S = 90;
38
43
 
@@ -85,9 +90,11 @@ export function setIfMissing(fm: string, key: string, value: string): string {
85
90
  return `${fm}${tail}${key}: ${value}\n`;
86
91
  }
87
92
 
88
- export type HumanEditZone = "permanent" | "human-inbox";
93
+ export type HumanEditZone = "permanent";
89
94
 
90
- /** Zone of a file for the detector: permanent / human-inbox / null. */
95
+ /** Zone of a file for the detector: permanent (the typed canon folders) /
96
+ * null. The human writes straight into canon from Obsidian — the страж
97
+ * completes a bare-body note via fillPermanentFull. */
91
98
  export function getZone(
92
99
  filepath: string,
93
100
  vault: string,
@@ -96,7 +103,6 @@ export function getZone(
96
103
  const rel = path.relative(vault, filepath);
97
104
  const first = rel.split(path.sep)[0];
98
105
  const f = taxonomy.folders;
99
- if (first === f.inboxHuman) return "human-inbox";
100
106
  if (
101
107
  first === f.knowledge ||
102
108
  first === f.decisions ||
@@ -180,54 +186,43 @@ export function decideUpdate(input: DecideUpdateInput): DecideUpdateResult {
180
186
  return { action: "skip", recordHash: currentHash, reason: "echo-agent" };
181
187
  }
182
188
 
183
- // Otherwise — an external-editor edit (or a new human-inbox file).
189
+ // Otherwise — an external-editor edit of a canon note. The SHARED guard
190
+ // fill (mandate §2: identical to the hook path). Existing notes: stamp-only
191
+ // no-op on the constants; a human's bare-body canon note: full frontmatter
192
+ // (title/type-from-folder/status/created/author). `created` ← birthtime
193
+ // (file creation, not edit time).
184
194
  const nowStamp = formatStamp(new Date(input.nowMs));
185
- let newFm = fmBlock;
186
-
187
- if (input.zone === "human-inbox") {
188
- const titleDefault = input.basename.endsWith(".md")
189
- ? input.basename.slice(0, -3)
190
- : input.basename;
191
- // `created` — file creation date. birthtime is the real creation on
192
- // APFS; mtime creeps with edits. birthtime 0 (non-APFS) → mtime.
193
- const createdSource =
194
- input.birthtimeMs > 0 ? new Date(input.birthtimeMs) : new Date(input.mtimeMs);
195
- const createdDate = createdSource.toISOString().slice(0, 10);
196
-
197
- newFm = setIfMissing(newFm, "title", titleDefault);
198
- newFm = setIfMissing(newFm, "status", input.taxonomy.statusTokens.draft);
199
- newFm = setIfMissing(newFm, "created", createdDate);
200
- newFm = setIfMissing(newFm, "author", input.human);
201
- newFm = setIfMissing(newFm, "needs_review", "true");
202
- } else {
203
- // PERMANENT (canon) — the SHARED guard fill (mandate §2: identical to the
204
- // hook path). Existing notes: stamp-only no-op on the constants; a human's
205
- // bare-body canon note: full frontmatter (title/type-from-folder/status/
206
- // created/author). `created` ← birthtime (file creation, not edit time).
207
- const createdSource =
208
- input.birthtimeMs > 0 ? new Date(input.birthtimeMs) : new Date(input.mtimeMs);
209
- const createdDate = createdSource.toISOString().slice(0, 10);
210
- newFm = fillPermanentFull(newFm, {
211
- path: input.path,
212
- agent: input.human,
213
- vault: input.vault,
214
- today: createdDate,
215
- nowStamp,
216
- ctx: { taxonomy: input.taxonomy },
217
- });
218
- }
195
+ const createdSource =
196
+ input.birthtimeMs > 0 ? new Date(input.birthtimeMs) : new Date(input.mtimeMs);
197
+ // LOCAL date (symmetric with the hook path's localDateIso) — `.toISOString()`
198
+ // is UTC and would shift `created` by a day for early-local-time creations.
199
+ const createdDate = `${createdSource.getFullYear()}-${pad(createdSource.getMonth() + 1)}-${pad(createdSource.getDate())}`;
200
+ let newFm = fillPermanentFull(fmBlock, {
201
+ path: input.path,
202
+ agent: input.human,
203
+ vault: input.vault,
204
+ today: createdDate,
205
+ nowStamp,
206
+ ctx: { taxonomy: input.taxonomy },
207
+ });
219
208
 
220
209
  if (newFm === fmBlock) {
221
210
  return { action: "skip", recordHash: currentHash, reason: "noop" };
222
211
  }
223
212
 
213
+ // Mirror the hook path's normalization (mandate §2: the страж is IDENTICAL on
214
+ // both paths) — YAML-safety on every scalar + ## Связи structural validity, so
215
+ // a human's colon-bearing scalar never produces unparseable YAML that silently
216
+ // drops the note from the index.
217
+ newFm = normalizeAllScalars(stripEmptyArrays(newFm));
218
+ const newBody = normalizeLinksBlock(body, input.taxonomy);
224
219
  const fmTail = newFm.endsWith("\n") ? "" : "\n";
225
- const bodyPrefix = body.startsWith("\n") ? "" : "\n";
226
- const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${body}`;
220
+ const bodyPrefix = newBody.startsWith("\n") ? "" : "\n";
221
+ const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${newBody}`;
227
222
  return {
228
223
  action: "write",
229
224
  newContent,
230
225
  recordHash: sha256(newContent),
231
- reason: input.zone === "human-inbox" ? "fill" : "stamp",
226
+ reason: "stamp",
232
227
  };
233
228
  }
@@ -8,7 +8,7 @@
8
8
  * - **taxonomy/ranking config instead of constants** (ADR-002/011): folder
9
9
  * names, type/subtype tokens, status boost GROUPS and coefficients come
10
10
  * from the same config the search pipeline uses — bucket synchronisation
11
- * with `vault_search` holds BY CONSTRUCTION (one source), where the
11
+ * with `memory_search` holds BY CONSTRUCTION (one source), where the
12
12
  * reference kept two hand-synchronised constant sets.
13
13
  * - **personality is a parameter**: identity resolution (PEER_PERSONALITY
14
14
  * first) happens at the call level, never inside the renderer.
package/src/index.ts CHANGED
@@ -42,6 +42,23 @@ export {
42
42
  } from "./frontmatter-fill.js";
43
43
  export { fmUpdate, collectOps, yamlSafeScalar, type FmUpdateOptions, type Op } from "./fm-update.js";
44
44
 
45
+ // mode + per-role proactivity (lean §7/§7.1)
46
+ export {
47
+ resolveMode,
48
+ curationPlan,
49
+ type MemoryMode,
50
+ type RoleSet,
51
+ type CurationPlan,
52
+ } from "./mode.js";
53
+
54
+ // dedup + link-hint bands (lean §3a/§3b)
55
+ export {
56
+ runDedup,
57
+ DEFAULT_DEDUP_THRESHOLD,
58
+ DEFAULT_LINK_HINT_THRESHOLD,
59
+ type DedupMatch,
60
+ } from "./search.js";
61
+
45
62
  // deterministic archiving (lean §2.2a)
46
63
  export {
47
64
  isArchivableZone,
package/src/indexer.ts CHANGED
@@ -70,7 +70,7 @@ export function addTitlePath(
70
70
  * bare basename resolves only when exactly one note has it — never the last
71
71
  * indexed one. Unresolvable links are NOT silently dropped (Audit #5): they
72
72
  * move to `unresolved_links` with a reason (`missing` | `ambiguous`) so the
73
- * vault_map / nightly health-check can see vault rot. The map carries ALL
73
+ * memory_map / nightly health-check can see vault rot. The map carries ALL
74
74
  * indexed files including unchanged ones (Audit #1), so a link to a note that
75
75
  * simply wasn't re-parsed this run still resolves instead of being dropped.
76
76
  *
package/src/mcp-tools.ts CHANGED
@@ -19,7 +19,7 @@ import { agentMemoryFolderMarker } from "./taxonomy.js";
19
19
  // синхронный fetch с iCloud, без таймаута Node event loop замораживается
20
20
  // на минуты. 30 секунд — большой запас, на нормальной сети iCloud
21
21
  // укладывается за 5-15 секунд. При срабатывании пишем warn в stderr и
22
- // возвращаем not-found, чтобы caller мог упасть на vault_search fallback.
22
+ // возвращаем not-found, чтобы caller мог упасть на memory_search fallback.
23
23
  const READ_TIMEOUT_MS = 30000;
24
24
 
25
25
  function isAbortError(err: unknown): boolean {
@@ -28,7 +28,7 @@ function isAbortError(err: unknown): boolean {
28
28
  return e.name === "AbortError" || e.code === "ABORT_ERR";
29
29
  }
30
30
 
31
- // ---- vault_search ----
31
+ // ---- memory_search ----
32
32
 
33
33
  export async function runSearch(
34
34
  db: CoreDb,
@@ -47,12 +47,12 @@ export async function runSearch(
47
47
  /**
48
48
  * Public MCP tool surface (ADR-008): exactly three read-only tools.
49
49
  * `vault_read` is deliberately NOT part of the surface — in-session reading
50
- * is the harness's native Read (after vault_search the path is known), and
51
- * backlinks are covered by vault_graph(depth=1, incoming). `runRead` below
50
+ * is the harness's native Read (after memory_search the path is known), and
51
+ * backlinks are covered by memory_related(depth=1, incoming). `runRead` below
52
52
  * stays a LIBRARY function of core — for memoryd, the Index runtime, CLI and
53
53
  * programmatic consumers outside harness sessions.
54
54
  */
55
- export const MCP_TOOL_SURFACE = ["vault_search", "vault_graph", "vault_map"] as const;
55
+ export const MCP_TOOL_SURFACE = ["memory_search", "memory_related", "memory_map"] as const;
56
56
 
57
57
  // ---- vault_read — library read function (NOT on the MCP surface, ADR-008) ----
58
58
 
@@ -60,7 +60,7 @@ export const MCP_TOOL_SURFACE = ["vault_search", "vault_graph", "vault_map"] as
60
60
  * Validate and resolve a user-supplied vault path before any disk access.
61
61
  *
62
62
  * vault_read is reachable from any agent that loads the reference plugin,
63
- * including ones whose context can be poisoned by inbox drafts (prompt
63
+ * including ones whose context can be poisoned by untrusted note content (prompt
64
64
  * injection). Without containment, an attacker-controlled `path` like
65
65
  * `../../.ssh/id_rsa`, `../../.mergemind/env`, or `/etc/passwd` would
66
66
  * exfiltrate arbitrary files the MCP process can read.
@@ -110,8 +110,8 @@ function validateVaultPath(
110
110
  throw new Error("Path must not contain empty, '.' or '..' segments");
111
111
  }
112
112
 
113
- // Respect excludeFolders even for direct disk reads. Inbox drafts and
114
- // system folders are intentionally hidden from search — leaking them
113
+ // Respect excludeFolders even for direct disk reads. The system folder is
114
+ // intentionally hidden from search — leaking it
115
115
  // through vault_read would defeat the privacy contract excludeFolders
116
116
  // is supposed to provide. Same "Document not found" wording as the
117
117
  // not-in-index branch so we don't reveal whether the path exists.
@@ -145,8 +145,8 @@ export async function runRead(
145
145
  const meta = getDocumentMeta(db, docPath);
146
146
 
147
147
  // Fallback path: the requested document is not in the index (typically
148
- // because it lives in an excluded folder like `99_System/` or `00_Inbox/`,
149
- // or the watcher hasn't picked it up yet). Read it directly from disk and
148
+ // because it lives in an excluded folder like `99_System/`, or the watcher
149
+ // hasn't picked it up yet). Read it directly from disk and
150
150
  // parse it on the fly — without backlinks, since those depend on the index.
151
151
  if (!meta) {
152
152
  let text: string;
@@ -234,7 +234,7 @@ export async function runRead(
234
234
  };
235
235
  }
236
236
 
237
- // ---- vault_graph ----
237
+ // ---- memory_related ----
238
238
 
239
239
  // Oneway-фильтр графа: backlinks из `06_Оперативка_агентов/` **не**
240
240
  // показываются при запросе графа канонической заметки — граф референса не
@@ -257,7 +257,11 @@ export function runGraph(
257
257
  const centerMeta = getDocumentMeta(db, docPath);
258
258
 
259
259
  if (!centerMeta) {
260
- return { found: false, error: `Document not found: ${docPath}` };
260
+ return {
261
+ found: false,
262
+ error: `Document not found: ${docPath}`,
263
+ hint: "If you don't have the exact path, find the note first with memory_search, then pass its `path` here.",
264
+ };
261
265
  }
262
266
 
263
267
  type Node = {
@@ -354,7 +358,7 @@ export function runGraph(
354
358
  };
355
359
  }
356
360
 
357
- // ---- vault_map ----
361
+ // ---- memory_map ----
358
362
 
359
363
  // Summary-mode caps. Full topology of a 300+ note vault crosses 25KB JSON
360
364
  // before it reaches the agent — most of that is per-cluster node lists and