@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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 ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Deterministic archiving — lean §2.2a.
3
+ *
4
+ * In lean, archiving leaves the Index overlay and becomes BASE (0 LLM): a
5
+ * note whose `status` is a FINAL token (`isStale` — устарело/завершён/…; «на
6
+ * паузе» is PENDING, not stale → a resumable note is never archived) is moved
7
+ * to the archive folder by memoryd. The decision is taxonomy, not judgement.
8
+ *
9
+ * Wikilinks resolve by TITLE, so the graph survives the move (edges are
10
+ * reindexed); the archive is NOT excluded from search (it stays findable with
11
+ * the stale boost). The move is flat (`07_Archive/<basename>`), collisions
12
+ * resolved with a numeric suffix.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { isStale, type TaxonomyPreset } from "./taxonomy.js";
17
+
18
+ /** First path segment of a vault-relative path. */
19
+ function firstSegment(relPath: string): string {
20
+ return relPath.split(/[\\/]/)[0] ?? "";
21
+ }
22
+
23
+ /**
24
+ * Folders whose notes are subject to archiving — UNIFIED rule, no exceptions
25
+ * (decision Артур 15.06, §2.2a): the six monitored content folders (five
26
+ * canonical permanent + agent memory + `03_Projects`). NOT the archive
27
+ * itself or the system folder. A completed phase/project
28
+ * (status `completed`/`cancelled`) is stale like any other note and moves to
29
+ * the archive; active `03_Projects` then shows only live work. Wikilinks
30
+ * survive by title; the archive stays searchable.
31
+ */
32
+ export function isArchivableZone(relPath: string, taxonomy: TaxonomyPreset): boolean {
33
+ const f = taxonomy.folders;
34
+ const head = firstSegment(relPath);
35
+ return (
36
+ head === f.knowledge ||
37
+ head === f.decisions ||
38
+ head === f.projects ||
39
+ head === f.ideas ||
40
+ head === f.lists ||
41
+ head === f.agentMemory
42
+ );
43
+ }
44
+
45
+ /** Read `status` from a note's frontmatter (null when absent/no frontmatter). */
46
+ export function statusOf(content: string): string | null {
47
+ const fm = /^---[^\S\n]*\n([\s\S]*?)\n---/.exec(content);
48
+ if (!fm) return null;
49
+ const m = /^status\s*:\s*(.+?)\s*$/m.exec(fm[1]);
50
+ return m ? m[1].trim() : null;
51
+ }
52
+
53
+ /**
54
+ * Should this note be archived? In an archivable content zone AND carrying a
55
+ * final (stale) status. Notes already in the archive are excluded by
56
+ * `isArchivableZone` (the archive folder is not in the set).
57
+ */
58
+ export function shouldArchive(
59
+ relPath: string,
60
+ content: string,
61
+ taxonomy: TaxonomyPreset,
62
+ ): boolean {
63
+ if (!isArchivableZone(relPath, taxonomy)) return false;
64
+ return isStale(taxonomy, statusOf(content));
65
+ }
66
+
67
+ /**
68
+ * Flat archive target (vault-relative): `<archive>/<basename>`, with a numeric
69
+ * suffix on collision (`<stem>-2.md`, `-3.md`, …). `exists` answers whether a
70
+ * vault-relative path is already taken.
71
+ */
72
+ export function archiveTargetRel(
73
+ basename: string,
74
+ taxonomy: TaxonomyPreset,
75
+ exists: (rel: string) => boolean,
76
+ ): string {
77
+ const arch = taxonomy.folders.archive;
78
+ const isMd = basename.endsWith(".md");
79
+ const stem = isMd ? basename.slice(0, -3) : basename;
80
+ const ext = isMd ? ".md" : "";
81
+ let candidate = `${arch}/${basename}`;
82
+ let n = 2;
83
+ while (exists(candidate)) {
84
+ candidate = `${arch}/${stem}-${n}${ext}`;
85
+ n += 1;
86
+ }
87
+ return candidate;
88
+ }
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),
@@ -74,16 +74,20 @@ export type FragmentEnv = {
74
74
  };
75
75
  /** Rendered author index file (capped variant), absolute path. */
76
76
  authorIndexPath: string;
77
- /** Tags-dictionary mirror, absolute path (Index branch only). */
78
- tagsDictionaryPath?: string;
77
+ /** Compact tags-dictionary projection, absolute path injected to EVERY
78
+ * author in lean (§3), not just the Index. */
79
+ tagsProjectionPath?: string;
80
+ /** Layer title for the projection (the dictionary file name, e.g. `Теги.md`);
81
+ * defaults to the projection file basename. */
82
+ tagsTitle?: string;
79
83
  };
80
84
 
81
85
  /**
82
86
  * Assemble the per-peer fragment layers in the reference build_layers
83
- * order. Author branch: paths → author index. Index branch: paths tags
84
- * dictionaryown index (the curator gets no writer guide — its contract
85
- * is the role doctrine; the guide arrives host-wide by layer mechanics).
86
- * Missing/empty sources are skipped gracefully.
87
+ * order: paths → tags-dictionary projection (lean §3: ALL authors now, not
88
+ * only the Index) author index. The curator gets no writer guide — its
89
+ * contract is the role doctrine; the guide arrives host-wide by layer
90
+ * mechanics. Missing/empty sources are skipped gracefully.
87
91
  */
88
92
  export function buildLayers(env: FragmentEnv): ContextLayer[] {
89
93
  const layers: ContextLayer[] = [];
@@ -101,11 +105,11 @@ export function buildLayers(env: FragmentEnv): ContextLayer[] {
101
105
  ].join("\n");
102
106
  layers.push(["iapeer-memory paths", pathsBlock]);
103
107
 
104
- const isIndex = env.agent === (env.indexAgent ?? "index");
105
-
106
- if (isIndex && env.tagsDictionaryPath) {
107
- const tags = readFileOrEmpty(env.tagsDictionaryPath);
108
- if (tags.trim()) layers.push([path.basename(env.tagsDictionaryPath), tags]);
108
+ if (env.tagsProjectionPath) {
109
+ const tags = readFileOrEmpty(env.tagsProjectionPath);
110
+ if (tags.trim()) {
111
+ layers.push([env.tagsTitle || path.basename(env.tagsProjectionPath), tags]);
112
+ }
109
113
  }
110
114
 
111
115
  const idx = readFileOrEmpty(env.authorIndexPath);
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
@@ -42,12 +41,12 @@ import fs from "node:fs";
42
41
  import path from "node:path";
43
42
  import crypto from "node:crypto";
44
43
  import type { TaxonomyPreset } from "./taxonomy.js";
45
- import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
44
+ import { DEFAULT_CURATOR_SET, genreForFolder, linksSectionPattern } from "./taxonomy.js";
46
45
  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
  /**
@@ -96,6 +95,70 @@ export function setIfMissing(block: string, key: string, value: string): string
96
95
  return `${block}${key}: ${value}\n`;
97
96
  }
98
97
 
98
+ /** Read a scalar field value, or null when absent. */
99
+ export function readScalar(block: string, key: string): string | null {
100
+ const m = new RegExp(`^${escapeRe(key)}\\s*:\\s*(.+?)\\s*$`, "m").exec(block);
101
+ return m ? m[1].trim() : null;
102
+ }
103
+
104
+ /** Parse a YAML list field (block-list ` - item` or inline `[a, b]`). */
105
+ export function parseListField(block: string, key: string): string[] {
106
+ const lines = block.split("\n");
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const m = new RegExp(`^${escapeRe(key)}\\s*:\\s*(.*)$`).exec(lines[i]);
109
+ if (!m) continue;
110
+ const inline = m[1].trim();
111
+ if (inline) {
112
+ return inline
113
+ .replace(/^\[/, "")
114
+ .replace(/\]$/, "")
115
+ .split(",")
116
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
117
+ .filter(Boolean);
118
+ }
119
+ const out: string[] = [];
120
+ for (let j = i + 1; j < lines.length; j++) {
121
+ const item = /^\s+-\s+(.*)$/.exec(lines[j]);
122
+ if (!item) break;
123
+ const v = item[1].trim().replace(/^["']|["']$/g, "");
124
+ if (v) out.push(v);
125
+ }
126
+ return out;
127
+ }
128
+ return [];
129
+ }
130
+
131
+ /** Remove a list field entirely (its `key:` line + any ` - item` lines). */
132
+ function removeListField(block: string, key: string): string {
133
+ const lines = block.split("\n");
134
+ const out: string[] = [];
135
+ const head = new RegExp(`^${escapeRe(key)}\\s*:`);
136
+ for (let i = 0; i < lines.length; i++) {
137
+ if (head.test(lines[i])) {
138
+ // skip the key line and the following block-list items
139
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) i++;
140
+ continue;
141
+ }
142
+ out.push(lines[i]);
143
+ }
144
+ return out.join("\n");
145
+ }
146
+
147
+ /**
148
+ * Append `name` to `coauthors` (lean §3a auto-coauthor). No-op if already
149
+ * present. Rewrites the field as a normalised block-list at the end of the
150
+ * frontmatter — idempotent once the name is in the list.
151
+ */
152
+ export function addCoauthor(block: string, name: string): string {
153
+ const existing = parseListField(block, "coauthors");
154
+ if (existing.includes(name)) return block;
155
+ let b = removeListField(block, "coauthors");
156
+ if (b && !b.endsWith("\n")) b += "\n";
157
+ const all = [...existing, name];
158
+ b += "coauthors:\n" + all.map((n) => ` - ${n}`).join("\n") + "\n";
159
+ return b;
160
+ }
161
+
99
162
  /** Returns [fmBlock, rest]. No frontmatter → ["", content]. */
100
163
  export function splitFrontmatter(content: string): [string, string] {
101
164
  const m = FRONTMATTER_RE.exec(content);
@@ -138,8 +201,9 @@ export function parseMemoryAuthor(
138
201
  * Zone of a file by the first path segment relative to the vault — the
139
202
  * single source of truth for zone routing (the reference de-duplicated a
140
203
  * bash `case` into exactly this function). Folder whitelist comes from the
141
- * taxonomy preset (ADR-002): both inboxes, archive and system are NOT in
142
- * 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.)
143
207
  */
144
208
  export function resolveZone(
145
209
  filePath: string,
@@ -151,7 +215,6 @@ export function resolveZone(
151
215
  if (!parts || parts.length === 0) return null;
152
216
  const head = parts[0];
153
217
  const f = taxonomy.folders;
154
- if (head === f.inbox) return "inbox";
155
218
  if (
156
219
  head === f.knowledge ||
157
220
  head === f.decisions ||
@@ -165,46 +228,64 @@ export function resolveZone(
165
228
  return null;
166
229
  }
167
230
 
168
- export function fillInbox(
231
+ /**
232
+ * Full permanent-zone (canon) fill — the lean §2 «страж» core, SHARED between
233
+ * the post-write hook (`processFile`) and the human-edit detector
234
+ * (`decideUpdate`), so an agent's write and a human's external write get
235
+ * identical deterministic frontmatter (mandate §2: «ОБЩАЯ логика из 2 путей»).
236
+ *
237
+ * Before lean the permanent branch was a near-empty stamp (canon frontmatter
238
+ * was supplied by the Index on placement). In lean the author writes only
239
+ * body + tags + organic inline links + a self-describing title; everything
240
+ * here is derived deterministically (0 LLM):
241
+ * - `title` ← file name; `type`/`status` ← the FOLDER's genre (§2.1);
242
+ * `created` ← today; `author` ← the writer (non-curator);
243
+ * - `last_edited_by`/`updated` ← the stamp pair (always upserted).
244
+ */
245
+ export function fillPermanentFull(
169
246
  fmBlock: string,
170
- opts: { path: string; agent: string; today: string; nowStamp: string; ctx: FillContext },
247
+ opts: {
248
+ path: string;
249
+ agent: string;
250
+ vault: string;
251
+ today: string;
252
+ nowStamp: string;
253
+ ctx: FillContext;
254
+ },
171
255
  ): string {
172
256
  const { taxonomy } = opts.ctx;
173
- // STAMP PAIR (дефект-репро boris 11.06, ложный unstamped): upsert как в
174
- // fillPermanent/fillMemory. Без неё у inbox-черновиков пара тождественно
175
- // (null, null) — «штамп не двинулся» детектора немых записей выполняется
176
- // на ЛЮБОЙ легитимной хук-правке, и inbox-ветка вырождается в «hash
177
- // двинулся → unstamped» (7 ложных ре-стампов за день 11.06). Оба поля
178
- // сервисные для smart-hash → семантический hash и INBOX_NEW-диффы не
179
- // двигаются, эхо невозможно. Побочный выигрыш: source-фильтр кураторов
180
- // INBOX_NEW впервые получает живой last_edited_by.
257
+ // Service stamp (always) load-bearing for smart-hash echo-safety and the
258
+ // unstamped detector, symmetric with fillMemory.
181
259
  fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
182
260
  fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
261
+ // Canon frontmatter the author no longer hand-writes (§2.1). setIfMissing —
262
+ // an explicit author value is never clobbered; a re-edit of an existing note
263
+ // is a stamp-only no-op on these.
183
264
  fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
184
- fmBlock = setIfMissing(fmBlock, "status", taxonomy.statusTokens.draft);
265
+ const folder = opts.vault ? relParts(opts.path, opts.vault)?.[0] : undefined;
266
+ const genre = folder ? genreForFolder(taxonomy, folder) : null;
267
+ if (genre) {
268
+ fmBlock = setIfMissing(fmBlock, "type", genre.type);
269
+ fmBlock = setIfMissing(fmBlock, "status", genre.initialStatus);
270
+ }
185
271
  fmBlock = setIfMissing(fmBlock, "created", opts.today);
186
- // AUTHOR GUARD (инвариант Артура, инверсия 10.06): агент curator-set
187
- // НИКОГДА не становится author в inbox-зоне. Курайторы трогают черновики
188
- // только как курирование (rename/правки стиля горячая дорожка
189
- // инвертированного конвейера: копирайтер пишет НОВЫЙ файл при ренейме);
190
- // бесхозный файл от курайтора остаётся без author до явной атрибуции —
191
- // видимая аномалия лучше тихо присвоенного авторства. Существующий
192
- // author и без гарда неприкосновенен (setIfMissing).
272
+ // AUTHOR GUARD (Артур's invariant; fork-1 decision boris 15.06, §3a): a
273
+ // curator (Index/Scriber/DreamWeaver) edits canon STRUCTURE, never authors
274
+ // content it never becomes `author` (nor, L2, `coauthors`). Same guard as
275
+ // the inbox branch. A non-curator writing canon IS the author. `needs_review`
276
+ // is the guard's flag, lifted only by Index/human.
193
277
  if (!isCurator(opts.agent, opts.ctx)) {
194
278
  fmBlock = setIfMissing(fmBlock, "author", opts.agent);
195
- fmBlock = setIfMissing(fmBlock, "needs_review", "true");
196
- }
197
- return fmBlock;
198
- }
199
-
200
- export function fillPermanent(
201
- fmBlock: string,
202
- opts: { agent: string; nowStamp: string; ctx: FillContext },
203
- ): string {
204
- fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
205
- fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
206
- if (!isCurator(opts.agent, opts.ctx)) {
207
279
  fmBlock = upsert(fmBlock, "needs_review", "true");
280
+ // §3a auto-coauthor: a non-curator who edits a canon note authored by
281
+ // SOMEONE ELSE is recorded as a coauthor — content collaboration that
282
+ // kills duplicates. Curators are excluded (fork-1 boris 15.06: they edit
283
+ // STRUCTURE, not content — same guard as the author guard). `author` is
284
+ // immutable; only `coauthors` grows. On a NEW note author===agent → no-op.
285
+ const author = readScalar(fmBlock, "author");
286
+ if (author && author !== opts.agent) {
287
+ fmBlock = addCoauthor(fmBlock, opts.agent);
288
+ }
208
289
  }
209
290
  return fmBlock;
210
291
  }
@@ -417,6 +498,71 @@ export function normalizeFields(
417
498
  return lines.join("\n");
418
499
  }
419
500
 
501
+ /**
502
+ * YAML-safe normalisation of EVERY scalar frontmatter field (lean §2.2). Before
503
+ * lean only `description` was normalised; the «`: ` inside a plain scalar»
504
+ * failure (incident 49/538 unparseable) applies to ANY field — title, status,
505
+ * author, etc. Block-list fields (`tags`, `coauthors`) are EXCLUDED: they use
506
+ * the ` - item` form, and the inline `[..]`/`: ` heuristic would corrupt them.
507
+ * Idempotent (a clean-quoted value is left untouched).
508
+ */
509
+ export function normalizeAllScalars(
510
+ fmBlock: string,
511
+ excludeKeys: readonly string[] = EMPTY_ARRAY_KEYS,
512
+ ): string {
513
+ const lines = fmBlock.split("\n");
514
+ for (let idx = 0; idx < lines.length; idx++) {
515
+ const m = NORMALIZE_LINE_RE.exec(lines[idx]);
516
+ if (!m || excludeKeys.includes(m[1])) continue;
517
+ const newVal = normalizeScalarValue(m[2]);
518
+ if (newVal !== null) lines[idx] = `${m[1]}: ${newVal}`;
519
+ }
520
+ return lines.join("\n");
521
+ }
522
+
523
+ /** Markdown thematic break: `---`, `***`, `___`, optionally spaced. */
524
+ const HR_LINE_RE = /^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
525
+
526
+ /**
527
+ * A line that is a fuzzy match of the links-section heading: any `#` level,
528
+ * any spacing, any case, but the section text EXACTLY (so `## Связанные…`
529
+ * never matches `## Связи`).
530
+ */
531
+ function isFuzzyLinksHeading(line: string, taxonomy: TaxonomyPreset): boolean {
532
+ const sectionText = taxonomy.linksSection.replace(/^#+\s*/, "");
533
+ const esc = sectionText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
534
+ return new RegExp(`^#{1,6}\\s*${esc}\\s*$`, "i").test(line.trim());
535
+ }
536
+
537
+ /**
538
+ * Make a leading links-section block recognisable to the parser's
539
+ * `stripLinksSection` (heading at body start + a `---` divider), so the block
540
+ * is cut from search/embedding content instead of polluting BM25 with
541
+ * popular-target false hits (lean §2.2). CONSERVATIVE and mechanical (§10.2):
542
+ * only the heading line FORM and the block's HR divider are rewritten — never
543
+ * content, nothing inserted or moved. Body without a leading links heading →
544
+ * no-op. Idempotent.
545
+ */
546
+ export function normalizeLinksBlock(body: string, taxonomy: TaxonomyPreset): string {
547
+ const lines = body.split("\n");
548
+ let i = 0;
549
+ while (i < lines.length && lines[i].trim() === "") i++;
550
+ if (i >= lines.length || !isFuzzyLinksHeading(lines[i], taxonomy)) return body;
551
+ if (lines[i] !== taxonomy.linksSection) lines[i] = taxonomy.linksSection;
552
+ // Normalise the block's first HR divider to `---`. Scan only across the link
553
+ // list (`- …` / `* …` items and blanks); a content line means no divider —
554
+ // leave it (the heading fix alone is the safe part).
555
+ for (let j = i + 1; j < lines.length; j++) {
556
+ if (HR_LINE_RE.test(lines[j])) {
557
+ if (lines[j].trim() !== "---") lines[j] = "---";
558
+ break;
559
+ }
560
+ const t = lines[j].trim();
561
+ if (t !== "" && !t.startsWith("-") && !t.startsWith("*")) break;
562
+ }
563
+ return lines.join("\n");
564
+ }
565
+
420
566
  /**
421
567
  * Assemble the new file. A body not starting with a newline gets one, so the
422
568
  * markdown parser sees the frontmatter separately from the first paragraph.
@@ -509,10 +655,17 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
509
655
  const [fmBlock, rest] = splitFrontmatter(content);
510
656
 
511
657
  let newFm: string | null;
512
- if (zone === "inbox") {
513
- newFm = fillInbox(fmBlock, { path: filePath, agent: opts.agent, today, nowStamp, ctx });
514
- } else if (zone === "permanent") {
515
- newFm = fillPermanent(fmBlock, { agent: opts.agent, nowStamp, ctx });
658
+ let newBody = rest;
659
+ if (zone === "permanent") {
660
+ newFm = fillPermanentFull(fmBlock, {
661
+ path: filePath,
662
+ agent: opts.agent,
663
+ vault,
664
+ today,
665
+ nowStamp,
666
+ ctx,
667
+ });
668
+ newBody = normalizeLinksBlock(rest, opts.taxonomy);
516
669
  } else {
517
670
  newFm = fillMemory(fmBlock, {
518
671
  path: filePath,
@@ -523,11 +676,12 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
523
676
  ctx,
524
677
  });
525
678
  if (newFm === null) return false;
679
+ newBody = normalizeLinksBlock(rest, opts.taxonomy);
526
680
  }
527
681
 
528
682
  newFm = stripEmptyArrays(newFm);
529
- newFm = normalizeFields(newFm);
530
- const newContent = assemble(newFm, rest);
683
+ newFm = normalizeAllScalars(newFm);
684
+ const newContent = assemble(newFm, newBody);
531
685
  if (newContent === content) return false;
532
686
  atomicWrite(filePath, newContent);
533
687
  return true;
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