@agfpd/iapeer-memory-core 0.1.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.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Fill a vault note's frontmatter according to its zone
3
+ * (inbox / permanent / memory).
4
+ *
5
+ * TS port of the reference `scripts/mergemind-frontmatter-fill.py`
6
+ * (behavioural parity against `tests/python/test_frontmatter_fill.py`,
7
+ * 73 fixtures) with the deliberate ADR deviations:
8
+ *
9
+ * - **curator-set instead of hard-coded `index`** (ADR-006): `needs_review`
10
+ * is NOT stamped when the writing agent belongs to the configured curator
11
+ * set (`index`, `copywriter`, `dreamweaver` by default) — curators'
12
+ * edits are sanctioned curation, not author edits awaiting review.
13
+ * - **taxonomy-driven zone routing** (ADR-002/011): folder names, the
14
+ * agent-memory type token and emitted status tokens come from the locale
15
+ * preset, not constants — the same logic runs the RU vault and the EN base.
16
+ * - **identity = peer personality** (нюанс 10): `resolveAgentName` prefers
17
+ * `PEER_PERSONALITY`, falling back to `IAPEER_MEMORY_AGENT_NAME`.
18
+ *
19
+ * Zone behaviour (parity with the reference):
20
+ * - inbox: idempotent fill of the 4-field draft frontmatter +
21
+ * needs_review (unless curator).
22
+ * - permanent: upsert of service fields (last_edited_by, updated,
23
+ * needs_review).
24
+ * - memory: PERMANENT service-field semantics + idempotent fill of the
25
+ * constants. `author` is parsed from the subfolder name, NOT
26
+ * from the caller identity — load-bearing for DreamWeaver
27
+ * writing into a foreign subfolder on an Index task.
28
+ *
29
+ * The YAML-safe normalisation of `description` is load-bearing: typographic
30
+ * guillemets `«…»` are a display convention, not YAML quotes; any `: ` inside
31
+ * such a plain scalar breaks every real YAML parser downstream (incident
32
+ * scan 2026-06-01: 49/538 notes unparseable on exactly this field; `sed`
33
+ * editing of frontmatter is banned since incident 2026-05-20).
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import crypto from "node:crypto";
39
+ import type { TaxonomyPreset } from "./taxonomy.js";
40
+ import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
41
+
42
+ const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
43
+
44
+ export const VALID_ZONES = ["inbox", "permanent", "memory"] as const;
45
+ export type Zone = (typeof VALID_ZONES)[number];
46
+
47
+ /**
48
+ * Array fields for which an empty value is meaningless and gets sanitised.
49
+ * Source of emptiness: the Index may have edited `coauthors:` without a
50
+ * following ` - <name>` item (bug phase 2026-05-20), or a draft arrived
51
+ * with a truncated value. The hook removes such keys idempotently.
52
+ */
53
+ export const EMPTY_ARRAY_KEYS = ["tags", "coauthors"] as const;
54
+
55
+ /** Fields rewritten into valid YAML by `normalizeFields`. */
56
+ export const NORMALIZE_KEYS = ["description"] as const;
57
+
58
+ export type FillContext = {
59
+ taxonomy: TaxonomyPreset;
60
+ /** ADR-006 curator set; defaults to DEFAULT_CURATOR_SET. */
61
+ curatorSet?: readonly string[];
62
+ };
63
+
64
+ function isCurator(agent: string, ctx: FillContext): boolean {
65
+ return (ctx.curatorSet ?? DEFAULT_CURATOR_SET).includes(agent);
66
+ }
67
+
68
+ function escapeRe(s: string): string {
69
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ }
71
+
72
+ export function hasField(block: string, key: string): boolean {
73
+ return new RegExp(`^${escapeRe(key)}\\s*:`, "m").test(block);
74
+ }
75
+
76
+ /** Update or insert — always sets to value. */
77
+ export function upsert(block: string, key: string, value: string): string {
78
+ const pattern = new RegExp(`^${escapeRe(key)}\\s*:.*$`, "m");
79
+ if (pattern.test(block)) {
80
+ return block.replace(pattern, () => `${key}: ${value}`);
81
+ }
82
+ if (block && !block.endsWith("\n")) block += "\n";
83
+ return `${block}${key}: ${value}\n`;
84
+ }
85
+
86
+ /** Set only if the field is absent — preserves the author's explicit override. */
87
+ export function setIfMissing(block: string, key: string, value: string): string {
88
+ if (hasField(block, key)) return block;
89
+ if (block && !block.endsWith("\n")) block += "\n";
90
+ return `${block}${key}: ${value}\n`;
91
+ }
92
+
93
+ /** Returns [fmBlock, rest]. No frontmatter → ["", content]. */
94
+ export function splitFrontmatter(content: string): [string, string] {
95
+ const m = FRONTMATTER_RE.exec(content);
96
+ if (m) {
97
+ return [m[1], content.slice(m[0].length)];
98
+ }
99
+ return ["", content];
100
+ }
101
+
102
+ export function basenameNoExt(filePath: string): string {
103
+ const base = path.basename(filePath);
104
+ return base.endsWith(".md") ? base.slice(0, -3) : base;
105
+ }
106
+
107
+ function relParts(filePath: string, vault: string): string[] | null {
108
+ const rel = path.relative(vault, filePath);
109
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
110
+ return rel.split(path.sep);
111
+ }
112
+
113
+ /**
114
+ * Extract the owner subfolder name from a path of the form
115
+ * `<vault>/<agentMemoryFolder>/<owner>/...`. Returns null when the path is
116
+ * outside the expected structure — the caller must abort (not our zone).
117
+ */
118
+ export function parseMemoryAuthor(
119
+ filePath: string,
120
+ vault: string,
121
+ taxonomy: TaxonomyPreset,
122
+ ): string | null {
123
+ const parts = relParts(filePath, vault);
124
+ if (!parts || parts.length < 3 || parts[0] !== taxonomy.folders.agentMemory) {
125
+ return null;
126
+ }
127
+ const owner = parts[1].trim();
128
+ return owner || null;
129
+ }
130
+
131
+ /**
132
+ * Zone of a file by the first path segment relative to the vault — the
133
+ * single source of truth for zone routing (the reference de-duplicated a
134
+ * bash `case` into exactly this function). Folder whitelist comes from the
135
+ * taxonomy preset (ADR-002): both inboxes, archive and system are NOT in
136
+ * the whitelist → null → caller no-ops.
137
+ */
138
+ export function resolveZone(
139
+ filePath: string,
140
+ vault: string,
141
+ taxonomy: TaxonomyPreset,
142
+ ): Zone | null {
143
+ if (!vault) return null;
144
+ const parts = relParts(filePath, vault);
145
+ if (!parts || parts.length === 0) return null;
146
+ const head = parts[0];
147
+ const f = taxonomy.folders;
148
+ if (head === f.inbox) return "inbox";
149
+ if (
150
+ head === f.knowledge ||
151
+ head === f.decisions ||
152
+ head === f.projects ||
153
+ head === f.ideas ||
154
+ head === f.lists
155
+ ) {
156
+ return "permanent";
157
+ }
158
+ if (head === f.agentMemory) return "memory";
159
+ return null;
160
+ }
161
+
162
+ export function fillInbox(
163
+ fmBlock: string,
164
+ opts: { path: string; agent: string; today: string; ctx: FillContext },
165
+ ): string {
166
+ const { taxonomy } = opts.ctx;
167
+ fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
168
+ fmBlock = setIfMissing(fmBlock, "status", taxonomy.statusTokens.draft);
169
+ fmBlock = setIfMissing(fmBlock, "created", opts.today);
170
+ fmBlock = setIfMissing(fmBlock, "author", opts.agent);
171
+ if (!isCurator(opts.agent, opts.ctx)) {
172
+ fmBlock = setIfMissing(fmBlock, "needs_review", "true");
173
+ }
174
+ return fmBlock;
175
+ }
176
+
177
+ export function fillPermanent(
178
+ fmBlock: string,
179
+ opts: { agent: string; nowStamp: string; ctx: FillContext },
180
+ ): string {
181
+ fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
182
+ fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
183
+ if (!isCurator(opts.agent, opts.ctx)) {
184
+ fmBlock = upsert(fmBlock, "needs_review", "true");
185
+ }
186
+ return fmBlock;
187
+ }
188
+
189
+ /**
190
+ * Returns the updated block, or null when the path is outside the expected
191
+ * `<agentMemoryFolder>/<owner>/...` structure while a vault is provided
192
+ * (caller aborts processing).
193
+ */
194
+ export function fillMemory(
195
+ fmBlock: string,
196
+ opts: {
197
+ path: string;
198
+ agent: string;
199
+ vault: string;
200
+ today: string;
201
+ nowStamp: string;
202
+ ctx: FillContext;
203
+ },
204
+ ): string | null {
205
+ const { taxonomy } = opts.ctx;
206
+ fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
207
+ fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
208
+ if (!isCurator(opts.agent, opts.ctx)) {
209
+ fmBlock = upsert(fmBlock, "needs_review", "true");
210
+ }
211
+
212
+ let authorForConstants: string;
213
+ if (opts.vault) {
214
+ const parsed = parseMemoryAuthor(opts.path, opts.vault, taxonomy);
215
+ if (parsed === null) return null;
216
+ authorForConstants = parsed;
217
+ } else {
218
+ authorForConstants = opts.agent;
219
+ }
220
+
221
+ fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
222
+ fmBlock = setIfMissing(fmBlock, "type", taxonomy.types.agentMemory);
223
+ fmBlock = setIfMissing(fmBlock, "status", taxonomy.statusTokens.current);
224
+ fmBlock = setIfMissing(fmBlock, "created", opts.today);
225
+ fmBlock = setIfMissing(fmBlock, "author", authorForConstants);
226
+ return fmBlock;
227
+ }
228
+
229
+ /**
230
+ * Remove keys whose value is an empty YAML array. Recognises three forms of
231
+ * emptiness, leaves everything else intact:
232
+ * - `key:` with no value and no following ` - item` lines (empty block form);
233
+ * - `key: []` — inline empty array;
234
+ * - `key: null` / `key: ~` — explicit null.
235
+ */
236
+ export function stripEmptyArrays(
237
+ fmBlock: string,
238
+ keys: readonly string[] = EMPTY_ARRAY_KEYS,
239
+ ): string {
240
+ const lines = fmBlock.split("\n");
241
+ const out: string[] = [];
242
+ let i = 0;
243
+ while (i < lines.length) {
244
+ const line = lines[i];
245
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/.exec(line);
246
+ if (m && keys.includes(m[1])) {
247
+ const value = m[2];
248
+ if (value === "[]" || value === "null" || value === "~") {
249
+ i += 1;
250
+ continue;
251
+ }
252
+ if (value === "") {
253
+ const nxt = i + 1 < lines.length ? lines[i + 1] : "";
254
+ if (!/^\s+-\s/.test(nxt)) {
255
+ i += 1;
256
+ continue;
257
+ }
258
+ }
259
+ }
260
+ out.push(line);
261
+ i += 1;
262
+ }
263
+ return out.join("\n");
264
+ }
265
+
266
+ // --- YAML-safe scalar normalisation (load-bearing) --------------------------
267
+
268
+ /** Leading characters that make a plain scalar invalid/ambiguous in YAML. */
269
+ const YAML_INDICATORS = new Set([..."!&*?|>%@`\"'#,[]{}"]);
270
+
271
+ /** v is a well-formed, terminated double-quoted YAML scalar. */
272
+ export function isCleanDoubleQuoted(v: string): boolean {
273
+ if (v.length < 2 || v[0] !== '"') return false;
274
+ let i = 1;
275
+ const n = v.length;
276
+ while (i < n) {
277
+ if (v[i] === "\\") {
278
+ i += 2;
279
+ continue;
280
+ }
281
+ if (v[i] === '"') return i === n - 1;
282
+ i += 1;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ /** v is a well-formed, terminated single-quoted YAML scalar (escape is `''`). */
288
+ export function isCleanSingleQuoted(v: string): boolean {
289
+ if (v.length < 2 || v[0] !== "'") return false;
290
+ let i = 1;
291
+ const n = v.length;
292
+ while (i < n) {
293
+ if (v[i] === "'") {
294
+ if (i + 1 < n && v[i + 1] === "'") {
295
+ i += 2;
296
+ continue;
297
+ }
298
+ return i === n - 1;
299
+ }
300
+ i += 1;
301
+ }
302
+ return false;
303
+ }
304
+
305
+ /**
306
+ * Plain scalar `v` is unsafe (would parse as something other than a string
307
+ * literal, or crash the parser). Empty and block scalars (`|`/`>`) never
308
+ * reach this — filtered earlier.
309
+ */
310
+ export function yamlNeedsQuoting(v: string): boolean {
311
+ if (!v) return false;
312
+ if (YAML_INDICATORS.has(v[0]) || v[0] === ":") return true;
313
+ if (v[0] === "-" && (v.length === 1 || v[1] === " " || v[1] === "\t")) return true;
314
+ if (v.includes(": ") || v.endsWith(":")) return true;
315
+ if (v.includes(" #")) return true;
316
+ if (v.includes("\t")) return true;
317
+ return false;
318
+ }
319
+
320
+ /** Serialise string `s` as a valid double-quoted YAML scalar. */
321
+ export function yamlDoubleQuote(s: string): string {
322
+ const out: string[] = ['"'];
323
+ for (const ch of s) {
324
+ if (ch === "\\") out.push("\\\\");
325
+ else if (ch === '"') out.push('\\"');
326
+ else if (ch === "\n") out.push("\\n");
327
+ else if (ch === "\t") out.push("\\t");
328
+ else if (ch === "\r") out.push("\\r");
329
+ else if (ch.codePointAt(0)! < 0x20) {
330
+ out.push(`\\x${ch.codePointAt(0)!.toString(16).padStart(2, "0")}`);
331
+ } else out.push(ch);
332
+ }
333
+ out.push('"');
334
+ return out.join("");
335
+ }
336
+
337
+ /**
338
+ * Strip convention delimiters that YAML does not recognise as quotes,
339
+ * keeping the logical content. `«…»` — the description convention pair.
340
+ * A dangling (unterminated) leading `'`/`"` is an artefact of a truncated
341
+ * quoted value: strip the leading one and the paired trailing one if present.
342
+ */
343
+ export function stripBrokenDelims(v: string): string {
344
+ v = v.trim();
345
+ if (v.length >= 2 && v[0] === "«" && v[v.length - 1] === "»") {
346
+ return v.slice(1, -1).trim();
347
+ }
348
+ if (v.startsWith("'")) {
349
+ v = v.slice(1);
350
+ if (v.endsWith("'")) v = v.slice(0, -1);
351
+ return v.replaceAll("''", "'").trim();
352
+ }
353
+ if (v.startsWith('"')) {
354
+ v = v.slice(1);
355
+ if (v.endsWith('"')) v = v.slice(0, -1);
356
+ return v.trim();
357
+ }
358
+ return v;
359
+ }
360
+
361
+ /**
362
+ * Return the YAML-safe representation of a scalar value, or null when no
363
+ * edit is needed (already valid / empty / block scalar). `raw` is the text
364
+ * after `key:`.
365
+ */
366
+ export function normalizeScalarValue(raw: string): string | null {
367
+ const v = raw.trim();
368
+ if (!v || v[0] === "|" || v[0] === ">") return null;
369
+ if (isCleanDoubleQuoted(v) || isCleanSingleQuoted(v)) return null;
370
+ if (!yamlNeedsQuoting(v)) return null;
371
+ return yamlDoubleQuote(stripBrokenDelims(v));
372
+ }
373
+
374
+ const NORMALIZE_LINE_RE = /^([A-Za-z_][\w-]*):[ \t]?(.*)$/;
375
+
376
+ /**
377
+ * Rewrite the values of `keys` fields into valid YAML. Line-based: vault
378
+ * frontmatter is flat (one `key: value` per line); block-list fields
379
+ * (tags/coauthors) are not in `keys`. Idempotent.
380
+ */
381
+ export function normalizeFields(
382
+ fmBlock: string,
383
+ keys: readonly string[] = NORMALIZE_KEYS,
384
+ ): string {
385
+ const lines = fmBlock.split("\n");
386
+ for (let idx = 0; idx < lines.length; idx++) {
387
+ const m = NORMALIZE_LINE_RE.exec(lines[idx]);
388
+ if (!m || !keys.includes(m[1])) continue;
389
+ const newVal = normalizeScalarValue(m[2]);
390
+ if (newVal !== null) {
391
+ lines[idx] = `${m[1]}: ${newVal}`;
392
+ }
393
+ }
394
+ return lines.join("\n");
395
+ }
396
+
397
+ /**
398
+ * Assemble the new file. A body not starting with a newline gets one, so the
399
+ * markdown parser sees the frontmatter separately from the first paragraph.
400
+ */
401
+ export function assemble(fmBlock: string, rest: string): string {
402
+ if (rest && !rest.startsWith("\n")) rest = "\n" + rest;
403
+ return "---\n" + fmBlock + "---\n" + rest;
404
+ }
405
+
406
+ /** temp file + rename — atomic write on POSIX. */
407
+ export function atomicWrite(filePath: string, content: string): void {
408
+ const tmp = path.join(
409
+ path.dirname(filePath),
410
+ `.fm-${crypto.randomBytes(6).toString("hex")}.tmp`,
411
+ );
412
+ try {
413
+ fs.writeFileSync(tmp, content, "utf-8");
414
+ fs.renameSync(tmp, filePath);
415
+ } catch (err) {
416
+ try {
417
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
418
+ } catch {
419
+ // best effort
420
+ }
421
+ throw err;
422
+ }
423
+ }
424
+
425
+ function pad(n: number): string {
426
+ return String(n).padStart(2, "0");
427
+ }
428
+
429
+ function localDateIso(d: Date): string {
430
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
431
+ }
432
+
433
+ /**
434
+ * `YYYY-MM-DD HH:MM:SS` — second precision is deliberate: the human-edit
435
+ * detector tells an agent edit (hook just ran) from an external-editor edit
436
+ * by the freshness of `updated` relative to a window; minute truncation ate
437
+ * up to 59s of that window and caused mis-attribution loops. Parsers accept
438
+ * the legacy `YYYY-MM-DD HH:MM` too.
439
+ */
440
+ function localStamp(d: Date): string {
441
+ return `${localDateIso(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
442
+ }
443
+
444
+ export type ProcessOptions = {
445
+ zone: Zone | "auto";
446
+ agent: string;
447
+ vault?: string;
448
+ /** Injectable for tests; defaults to new Date(). */
449
+ now?: Date;
450
+ taxonomy: TaxonomyPreset;
451
+ curatorSet?: readonly string[];
452
+ };
453
+
454
+ /**
455
+ * Main entry. Returns true when the file was changed, false on no-op.
456
+ * zone === "auto" resolves the zone from the path (whitelist of canonical
457
+ * folders); a path outside the whitelist is a no-op.
458
+ */
459
+ export function processFile(filePath: string, opts: ProcessOptions): boolean {
460
+ const ctx: FillContext = { taxonomy: opts.taxonomy, curatorSet: opts.curatorSet };
461
+ const vault = opts.vault ?? "";
462
+
463
+ let zone: Zone;
464
+ if (opts.zone === "auto") {
465
+ const resolved = resolveZone(filePath, vault, opts.taxonomy);
466
+ if (resolved === null) return false;
467
+ zone = resolved;
468
+ } else {
469
+ zone = opts.zone;
470
+ }
471
+ if (!VALID_ZONES.includes(zone)) return false;
472
+ if (!opts.agent) return false;
473
+ let stat: fs.Stats;
474
+ try {
475
+ stat = fs.statSync(filePath);
476
+ } catch {
477
+ return false;
478
+ }
479
+ if (!stat.isFile()) return false;
480
+
481
+ const now = opts.now ?? new Date();
482
+ const today = localDateIso(now);
483
+ const nowStamp = localStamp(now);
484
+
485
+ const content = fs.readFileSync(filePath, "utf-8");
486
+ const [fmBlock, rest] = splitFrontmatter(content);
487
+
488
+ let newFm: string | null;
489
+ if (zone === "inbox") {
490
+ newFm = fillInbox(fmBlock, { path: filePath, agent: opts.agent, today, ctx });
491
+ } else if (zone === "permanent") {
492
+ newFm = fillPermanent(fmBlock, { agent: opts.agent, nowStamp, ctx });
493
+ } else {
494
+ newFm = fillMemory(fmBlock, {
495
+ path: filePath,
496
+ agent: opts.agent,
497
+ vault,
498
+ today,
499
+ nowStamp,
500
+ ctx,
501
+ });
502
+ if (newFm === null) return false;
503
+ }
504
+
505
+ newFm = stripEmptyArrays(newFm);
506
+ newFm = normalizeFields(newFm);
507
+ const newContent = assemble(newFm, rest);
508
+ if (newContent === content) return false;
509
+ atomicWrite(filePath, newContent);
510
+ return true;
511
+ }
512
+
513
+ /**
514
+ * Resolve the writing identity (нюанс 10): explicit value first, then the
515
+ * stable iapeer identity `PEER_PERSONALITY`, then the namespace fallback
516
+ * `IAPEER_MEMORY_AGENT_NAME` (non-peer sessions). Null when nothing is set —
517
+ * the caller must no-op, never guess from cwd.
518
+ */
519
+ export function resolveAgentName(
520
+ explicit?: string | null,
521
+ env: Record<string, string | undefined> = process.env,
522
+ ): string | null {
523
+ const candidates = [explicit, env.PEER_PERSONALITY, env.IAPEER_MEMORY_AGENT_NAME];
524
+ for (const c of candidates) {
525
+ const v = (c ?? "").trim();
526
+ if (v) return v;
527
+ }
528
+ return null;
529
+ }