@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.
- package/package.json +32 -0
- package/src/config.ts +257 -0
- package/src/context-render.ts +185 -0
- package/src/db.ts +550 -0
- package/src/embedding.ts +174 -0
- package/src/fm-update.ts +352 -0
- package/src/frontmatter-fill.ts +529 -0
- package/src/graph.ts +427 -0
- package/src/http-client.ts +129 -0
- package/src/human-edit-detect.ts +213 -0
- package/src/index-render.ts +876 -0
- package/src/index.ts +65 -0
- package/src/indexer.ts +323 -0
- package/src/log.ts +27 -0
- package/src/mcp-tools.ts +468 -0
- package/src/memoryd.ts +680 -0
- package/src/migrate-auto-memory.ts +289 -0
- package/src/parser.ts +269 -0
- package/src/permanent-detect.ts +110 -0
- package/src/render-doctrine.ts +113 -0
- package/src/reranker.ts +162 -0
- package/src/search.ts +806 -0
- package/src/smart-hash.ts +85 -0
- package/src/sqlite-loader.ts +151 -0
- package/src/tags-mirror.ts +47 -0
- package/src/taxonomy.ts +385 -0
- package/src/utils.ts +69 -0
- package/tsconfig.json +24 -0
|
@@ -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
|
+
}
|