@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,85 @@
1
+ /**
2
+ * sha256 of the SEMANTIC content of a vault .md file.
3
+ *
4
+ * TS port of the reference `scripts/mergemind-frontmatter-hash.py`
5
+ * (behavioural parity against `tests/python/test_frontmatter_hash.py`).
6
+ *
7
+ * The "semantic" part is the frontmatter without service fields plus the
8
+ * body. Service fields (`last_edited_by`, `updated`, `needs_review`) are
9
+ * rewritten by hooks after every edit and carry no meaning for the Index —
10
+ * noisy edits of those fields produce the SAME hash, the change detector
11
+ * emits no event, and the echo-loop is impossible below the instruction
12
+ * level (ADR-004/005: smart-hash is the idempotency primitive of the
13
+ * pipeline).
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import crypto from "node:crypto";
18
+
19
+ /**
20
+ * Frontmatter fields ignored when hashing. Field NAMES are locale-independent
21
+ * (EN in both presets); the set is exported for detector configuration.
22
+ */
23
+ export const SERVICE_FIELDS: ReadonlySet<string> = new Set([
24
+ "last_edited_by",
25
+ "updated",
26
+ "needs_review",
27
+ ]);
28
+
29
+ const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
30
+ const KEY_RE = /^([a-zA-Z_][\w-]*)\s*:/;
31
+
32
+ function sha256Hex(data: Uint8Array | string): string {
33
+ return crypto.createHash("sha256").update(data).digest("hex");
34
+ }
35
+
36
+ /**
37
+ * Smart hash of raw file bytes. Invalid UTF-8 → raw-bytes hash (binary
38
+ * fallback). No frontmatter block → hash of the content as-is.
39
+ */
40
+ export function smartHash(content: Uint8Array): string {
41
+ let text: string;
42
+ try {
43
+ text = new TextDecoder("utf-8", { fatal: true }).decode(content);
44
+ } catch {
45
+ // Binary / non-utf-8: hash as-is, no cleanup.
46
+ return sha256Hex(content);
47
+ }
48
+
49
+ const m = FRONTMATTER_RE.exec(text);
50
+ if (!m) {
51
+ // No frontmatter block — hash the body as-is.
52
+ return sha256Hex(content);
53
+ }
54
+
55
+ const fmBlock = m[1];
56
+ const body = text.slice(m[0].length);
57
+
58
+ // Drop service-field lines. The parser is simple — multi-line YAML values
59
+ // are not supported (our service fields are always single-line).
60
+ const cleanedLines: string[] = [];
61
+ for (const line of fmBlock.split("\n")) {
62
+ const keyMatch = KEY_RE.exec(line);
63
+ if (keyMatch && SERVICE_FIELDS.has(keyMatch[1])) continue;
64
+ cleanedLines.push(line);
65
+ }
66
+ const cleanedFm = cleanedLines.join("\n");
67
+
68
+ // Hash cleaned frontmatter + body. The `---` separator keeps frontmatter
69
+ // and body from merging (theoretical collision guard).
70
+ return sha256Hex(`${cleanedFm}\n---\n${body}`);
71
+ }
72
+
73
+ /**
74
+ * Smart hash of a file. Returns "" when the file is unreadable
75
+ * (silent skip — mirrors the reference CLI contract).
76
+ */
77
+ export function hashFile(filePath: string): string {
78
+ let content: Buffer;
79
+ try {
80
+ content = fs.readFileSync(filePath);
81
+ } catch {
82
+ return "";
83
+ }
84
+ return smartHash(content);
85
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * SQLite runtime preparation for `bun:sqlite` + `sqlite-vec`.
3
+ *
4
+ * Why this file exists: bun's bundled SQLite is compiled with
5
+ * `SQLITE_OMIT_LOAD_EXTENSION`, which makes `Database.prototype.loadExtension`
6
+ * fail at runtime ("This build of sqlite3 does not support dynamic extension
7
+ * loading"). bun:sqlite exports a static escape hatch — `Database.setCustomSQLite(path)`
8
+ * — that swaps in any libsqlite3.dylib at process startup. Most non-bundled
9
+ * builds (homebrew, system Linux distro packages) compile with extension
10
+ * loading enabled, so we can use them as the runtime SQLite and load
11
+ * `sqlite-vec` on top.
12
+ *
13
+ * Call exactly once per process, BEFORE constructing any `Database` — the
14
+ * choice is process-wide. On failure (no non-stripped libsqlite3 found, or
15
+ * `setCustomSQLite` itself throws), the function does NOT crash the process:
16
+ * it returns `{ available: false, reason }` and the caller falls back to the
17
+ * bundled bun-sqlite (without vec). That keeps the plugin working in degraded
18
+ * BM25-only mode on machines that don't have homebrew sqlite.
19
+ */
20
+
21
+ import fs from "node:fs";
22
+ import { Database } from "bun:sqlite";
23
+
24
+ export type SqliteRuntime = {
25
+ available: boolean;
26
+ dylibPath: string | null;
27
+ reason: string;
28
+ };
29
+
30
+ const DEFAULT_DYLIB_CANDIDATES = [
31
+ // macOS — Homebrew (preferred: stays up to date, compiled without
32
+ // OMIT_LOAD_EXTENSION).
33
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
34
+ "/opt/homebrew/lib/libsqlite3.dylib",
35
+ "/usr/local/opt/sqlite/lib/libsqlite3.dylib",
36
+ "/usr/local/lib/libsqlite3.dylib",
37
+ // Linux — typical package paths. Both Debian/Ubuntu and Fedora ship
38
+ // libsqlite3 with extension loading enabled by default.
39
+ "/usr/lib/x86_64-linux-gnu/libsqlite3.so",
40
+ "/usr/lib/x86_64-linux-gnu/libsqlite3.so.0",
41
+ "/usr/lib/aarch64-linux-gnu/libsqlite3.so",
42
+ "/usr/lib/aarch64-linux-gnu/libsqlite3.so.0",
43
+ "/usr/lib64/libsqlite3.so",
44
+ "/usr/lib64/libsqlite3.so.0",
45
+ "/usr/lib/libsqlite3.so",
46
+ "/usr/lib/libsqlite3.so.0",
47
+ ];
48
+
49
+ // Module-level guard: setCustomSQLite is global per process. Calling it twice
50
+ // either silently no-ops or throws depending on bun version — easier to track
51
+ // the first decision ourselves and surface the result on subsequent calls.
52
+ let cached: SqliteRuntime | null = null;
53
+
54
+ export function prepareSqliteRuntime(
55
+ logger?: { info: (msg: string) => void; warn: (msg: string) => void },
56
+ ): SqliteRuntime {
57
+ if (cached) return cached;
58
+
59
+ // Explicit env override wins. Lets operators point at a bundled or
60
+ // custom-built libsqlite3 without code changes.
61
+ const envOverride = process.env.IAPEER_MEMORY_SQLITE_DYLIB;
62
+ const candidates = envOverride
63
+ ? [envOverride, ...DEFAULT_DYLIB_CANDIDATES]
64
+ : DEFAULT_DYLIB_CANDIDATES;
65
+
66
+ let chosen: string | null = null;
67
+ for (const path of candidates) {
68
+ try {
69
+ if (fs.existsSync(path)) {
70
+ chosen = path;
71
+ break;
72
+ }
73
+ } catch {
74
+ // unreadable path — skip
75
+ }
76
+ }
77
+
78
+ if (!chosen) {
79
+ cached = {
80
+ available: false,
81
+ dylibPath: null,
82
+ reason:
83
+ "non-stripped libsqlite3 not found; install homebrew sqlite or set IAPEER_MEMORY_SQLITE_DYLIB",
84
+ };
85
+ logger?.warn(
86
+ `sqlite-loader: ${cached.reason} — falling back to bundled bun-sqlite (no vec)`,
87
+ );
88
+ return cached;
89
+ }
90
+
91
+ try {
92
+ Database.setCustomSQLite(chosen);
93
+ } catch (err) {
94
+ cached = {
95
+ available: false,
96
+ dylibPath: chosen,
97
+ reason: `setCustomSQLite(${chosen}) failed: ${String(err)}`,
98
+ };
99
+ logger?.warn(`sqlite-loader: ${cached.reason}`);
100
+ return cached;
101
+ }
102
+
103
+ // Verify the chosen sqlite supports extension loading. A `.dylib` whose
104
+ // build has OMIT_LOAD_EXTENSION compiled in would still let setCustomSQLite
105
+ // succeed but block sqliteVec.load() later — better to catch it here so the
106
+ // reason is reported once at startup, not on every search.
107
+ try {
108
+ const probe = new Database(":memory:");
109
+ const opts = probe.prepare("SELECT * FROM pragma_compile_options()").all() as Array<{
110
+ compile_options: string;
111
+ }>;
112
+ probe.close();
113
+ const stripped = opts.some((o) =>
114
+ /OMIT_LOAD_EXTENSION/i.test(String(o.compile_options ?? "")),
115
+ );
116
+ if (stripped) {
117
+ cached = {
118
+ available: false,
119
+ dylibPath: chosen,
120
+ reason: `${chosen} is built with SQLITE_OMIT_LOAD_EXTENSION`,
121
+ };
122
+ logger?.warn(`sqlite-loader: ${cached.reason} — falling back to BM25-only`);
123
+ return cached;
124
+ }
125
+ } catch (err) {
126
+ cached = {
127
+ available: false,
128
+ dylibPath: chosen,
129
+ reason: `compile-options probe failed: ${String(err)}`,
130
+ };
131
+ logger?.warn(`sqlite-loader: ${cached.reason}`);
132
+ return cached;
133
+ }
134
+
135
+ cached = {
136
+ available: true,
137
+ dylibPath: chosen,
138
+ reason: "ok",
139
+ };
140
+ logger?.info(`sqlite-loader: using ${chosen} (extension loading enabled)`);
141
+ return cached;
142
+ }
143
+
144
+ /**
145
+ * Test-only escape hatch: reset the module-level cache so a unit test can
146
+ * exercise the detection logic against a synthetic env or candidate list.
147
+ * Production code never calls this.
148
+ */
149
+ export function _resetSqliteRuntimeCacheForTests(): void {
150
+ cached = null;
151
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Tags-dictionary mirror decision core — a memoryd subsystem (ADR-004,
3
+ * нюанс 2: the standalone tags-mirror daemon of the reference merges into
4
+ * memoryd; the mirror feeds the Index's per-peer fragment).
5
+ *
6
+ * TS port of the pure core of `scripts/mergemind-tags-mirror.cjs`
7
+ * (behavioural parity against `tests/tags-mirror.test.ts`, 6 fixtures).
8
+ *
9
+ * Why a mirror at all: the dictionary lives in the vault (possibly on
10
+ * iCloud) — reading it directly at prompt-assembly time risks a dataless
11
+ * source → an EMPTY dictionary for the critical curator. The mirror in the
12
+ * local cache namespace is always materialised; the fragment assembly
13
+ * reads the mirror, never the iCloud source.
14
+ */
15
+
16
+ import type { TaxonomyPreset } from "./taxonomy.js";
17
+
18
+ export type MirrorDecision = { action: "write" | "skip"; reason: string };
19
+
20
+ /**
21
+ * Decide whether to overwrite the mirror. No I/O.
22
+ *
23
+ * LOAD-BEARING invariant: NEVER clobber a populated mirror with an empty
24
+ * source — a dataless/broken read must not zero out the working dictionary.
25
+ * Degraded (stale slice) beats empty.
26
+ */
27
+ export function decideMirror(args: {
28
+ srcContent: string | null;
29
+ mirrorContent: string | null;
30
+ }): MirrorDecision {
31
+ const { srcContent, mirrorContent } = args;
32
+ if (srcContent === null) {
33
+ return { action: "skip", reason: "source-unreadable" };
34
+ }
35
+ if (srcContent.trim() === "") {
36
+ return { action: "skip", reason: "source-empty-keep-mirror" };
37
+ }
38
+ if (mirrorContent !== null && srcContent === mirrorContent) {
39
+ return { action: "skip", reason: "identical" };
40
+ }
41
+ return { action: "write", reason: mirrorContent === null ? "mirror-absent" : "changed" };
42
+ }
43
+
44
+ /** Vault-relative path of the dictionary source for a locale preset. */
45
+ export function tagsDictionarySourceRel(taxonomy: TaxonomyPreset): string {
46
+ return `${taxonomy.folders.system}/${taxonomy.systemFiles.tagsDictionary}`;
47
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Vault taxonomy as configuration — ADR-002 (taxonomy is config, not code
3
+ * constants) and ADR-011 (EN base nomenclature, RU as the first locale
4
+ * preset).
5
+ *
6
+ * Every folder name, enum value (type / status / subtype), the links-section
7
+ * heading, project file-name prefixes and system file names live here.
8
+ * i18n = presets of this same configuration: the EN base plus the RU preset
9
+ * (the live MergeMind vault, used for migration parity).
10
+ *
11
+ * The canonical RU↔EN mapping is the table in `docs/01-vault-layout.md`
12
+ * §«Таксономия» — this module encodes that table; tests guard shape parity
13
+ * between presets.
14
+ *
15
+ * Ranking coefficients are locale-independent and live in DEFAULT_RANKING —
16
+ * same ADR-002 rule (config, not constants), different axis (tuning, not
17
+ * language).
18
+ */
19
+
20
+ export type LocaleId = "en" | "ru";
21
+
22
+ export type StatusGroup = "active" | "pending" | "stale";
23
+
24
+ export type TaxonomyFolders = {
25
+ inbox: string;
26
+ inboxHuman: string;
27
+ knowledge: string;
28
+ decisions: string;
29
+ projects: string;
30
+ ideas: string;
31
+ lists: string;
32
+ agentMemory: string;
33
+ archive: string;
34
+ system: string;
35
+ };
36
+
37
+ export type TaxonomyTypes = {
38
+ knowledge: string;
39
+ decision: string;
40
+ idea: string;
41
+ project: string;
42
+ list: string;
43
+ agentMemory: string;
44
+ };
45
+
46
+ export type TaxonomySubtypes = {
47
+ feedback: string;
48
+ context: string;
49
+ reference: string;
50
+ personProfile: string;
51
+ pitfall: string;
52
+ };
53
+
54
+ /** Individual status tokens the WRITER side must emit (hook fills). */
55
+ export type TaxonomyStatusTokens = {
56
+ /** Initial status of a new inbox draft. */
57
+ draft: string;
58
+ /** Initial status of a new agent-memory note. */
59
+ current: string;
60
+ };
61
+
62
+ export type TaxonomyStatuses = {
63
+ /** Current/live lifecycle states — search boost ×activeBoost. */
64
+ active: string[];
65
+ /** Draft/deferred states — search boost ×pendingPenalty. */
66
+ pending: string[];
67
+ /** Final/closed states — search boost ×stalePenalty; trigger archiving
68
+ * and are filtered out of author indexes. */
69
+ stale: string[];
70
+ };
71
+
72
+ export type TaxonomyProjectFiles = {
73
+ /** `Overview <name>.md` / `Описание <имя>.md` */
74
+ overviewPrefix: string;
75
+ /** `Plan <name>.md` / `План <имя>.md` */
76
+ planPrefix: string;
77
+ /** `Phase — <title>.md` / `Фаза — <название>.md` */
78
+ phasePrefix: string;
79
+ };
80
+
81
+ export type TaxonomySystemFiles = {
82
+ draftTemplate: string;
83
+ tagsDictionary: string;
84
+ };
85
+
86
+ /** Locale strings for the author-index renderer (display, not schema). */
87
+ export type TaxonomyIndexStrings = {
88
+ /** `# {header} \`agent\`` */
89
+ header: string;
90
+ generatedComment: string;
91
+ sections: {
92
+ agentMemory: string;
93
+ knowledge: string;
94
+ decisions: string;
95
+ projects: string;
96
+ ideas: string;
97
+ lists: string;
98
+ };
99
+ emptySection: string;
100
+ /** Suffix after the link count: `3 св.` / `3 links`. */
101
+ linksSuffix: string;
102
+ /** Placeholder in the projects folder display: `<имя>` / `<name>`. */
103
+ namePlaceholder: string;
104
+ /** `{bits}` and `{path}` placeholders. */
105
+ truncatedMarker: string;
106
+ memoryLabel: string;
107
+ canonLabel: string;
108
+ /** `{parts}` placeholder. */
109
+ projectsTrimmed: string;
110
+ /** `{n}` and `{cap}` placeholders. */
111
+ pendingPhases: string;
112
+ /** `{n}` and `{cap}` placeholders. */
113
+ overHardCap: string;
114
+ };
115
+
116
+ export type TaxonomyPreset = {
117
+ locale: LocaleId;
118
+ folders: TaxonomyFolders;
119
+ types: TaxonomyTypes;
120
+ subtypes: TaxonomySubtypes;
121
+ /** Subtype render order in author indexes (visual grouping). */
122
+ subtypeOrder: string[];
123
+ statuses: TaxonomyStatuses;
124
+ statusTokens: TaxonomyStatusTokens;
125
+ /** Phase status tokens in their project-group render order:
126
+ * planned → active → paused → completed → cancelled. */
127
+ phaseStatusOrder: string[];
128
+ indexStrings: TaxonomyIndexStrings;
129
+ /** Links-section heading, e.g. `## Links` / `## Связи`. */
130
+ linksSection: string;
131
+ projectFiles: TaxonomyProjectFiles;
132
+ systemFiles: TaxonomySystemFiles;
133
+ };
134
+
135
+ /** EN base — public nomenclature accepted by ADR-011. */
136
+ export const TAXONOMY_EN: TaxonomyPreset = {
137
+ locale: "en",
138
+ folders: {
139
+ inbox: "00_Inbox",
140
+ inboxHuman: "00_Inbox_Human",
141
+ knowledge: "01_Knowledge",
142
+ decisions: "02_Decisions",
143
+ projects: "03_Projects",
144
+ ideas: "04_Ideas",
145
+ lists: "05_Lists",
146
+ agentMemory: "06_Agent_Memory",
147
+ archive: "07_Archive",
148
+ system: "99_System",
149
+ },
150
+ types: {
151
+ knowledge: "knowledge",
152
+ decision: "decision",
153
+ idea: "idea",
154
+ project: "project",
155
+ list: "list",
156
+ agentMemory: "agent_memory",
157
+ },
158
+ subtypes: {
159
+ feedback: "feedback",
160
+ context: "context",
161
+ reference: "reference",
162
+ personProfile: "person_profile",
163
+ pitfall: "pitfall",
164
+ },
165
+ subtypeOrder: ["feedback", "context", "reference", "person_profile", "pitfall"],
166
+ statuses: {
167
+ active: ["current", "active", "accepted", "new", "in_progress"],
168
+ pending: ["draft", "planned", "paused"],
169
+ stale: ["outdated", "superseded", "dropped", "completed", "cancelled"],
170
+ },
171
+ statusTokens: { draft: "draft", current: "current" },
172
+ phaseStatusOrder: ["planned", "active", "paused", "completed", "cancelled"],
173
+ indexStrings: {
174
+ header: "Vault index of notes by",
175
+ generatedComment: "<!-- Generated by iapeer-memory index-render. Do not edit manually. -->",
176
+ sections: {
177
+ agentMemory: "Agent memory",
178
+ knowledge: "Knowledge",
179
+ decisions: "Decisions",
180
+ projects: "Projects",
181
+ ideas: "Ideas",
182
+ lists: "Lists",
183
+ },
184
+ emptySection: "_(no notes of this type yet)_",
185
+ linksSuffix: "links",
186
+ namePlaceholder: "<name>",
187
+ truncatedMarker: "_Index truncated: {bits}. Full uncapped list — `Read {path}`._",
188
+ memoryLabel: "memory",
189
+ canonLabel: "canon",
190
+ projectsTrimmed: "projects: trimmed {parts}",
191
+ pendingPhases: "{n} PENDING phases (section > {cap})",
192
+ overHardCap: "{n} over the {cap} limit",
193
+ },
194
+ linksSection: "## Links",
195
+ projectFiles: {
196
+ overviewPrefix: "Overview ",
197
+ planPrefix: "Plan ",
198
+ phasePrefix: "Phase — ",
199
+ },
200
+ systemFiles: {
201
+ draftTemplate: "Templates/Draft.md",
202
+ tagsDictionary: "Tags.md",
203
+ },
204
+ };
205
+
206
+ /**
207
+ * RU preset — the live MergeMind vault taxonomy (frozen reference, ADR-002).
208
+ * Values are verbatim from the reference `src/` constants and
209
+ * `docs/01-vault-layout.md`; migration parity runs against this preset.
210
+ */
211
+ export const TAXONOMY_RU: TaxonomyPreset = {
212
+ locale: "ru",
213
+ folders: {
214
+ inbox: "00_Входящие",
215
+ inboxHuman: "00_Входящие_человек",
216
+ knowledge: "01_Знания",
217
+ decisions: "02_Решения",
218
+ projects: "03_Проекты",
219
+ ideas: "04_Идеи",
220
+ lists: "05_Списки",
221
+ agentMemory: "06_Оперативка_агентов",
222
+ archive: "07_Архив",
223
+ system: "99_Система",
224
+ },
225
+ types: {
226
+ knowledge: "знание",
227
+ decision: "решение",
228
+ idea: "идея",
229
+ project: "проект",
230
+ list: "список",
231
+ agentMemory: "оперативка агентов",
232
+ },
233
+ subtypes: {
234
+ feedback: "обратная_связь",
235
+ context: "контекст",
236
+ reference: "справка",
237
+ personProfile: "профиль_человека",
238
+ pitfall: "грабли",
239
+ },
240
+ subtypeOrder: ["обратная_связь", "контекст", "справка", "профиль_человека", "грабли"],
241
+ statuses: {
242
+ // RU carries gendered/inflected duplicates the EN base collapses
243
+ // (активный/активная → active; завершён/завершена → completed).
244
+ active: ["актуально", "активный", "активная", "принято", "новая", "реализуется"],
245
+ pending: ["черновик", "запланирована", "на паузе"],
246
+ stale: ["устарело", "заменено", "отброшена", "завершён", "завершена", "отменена"],
247
+ },
248
+ statusTokens: { draft: "черновик", current: "актуально" },
249
+ phaseStatusOrder: ["запланирована", "активная", "на паузе", "завершена", "отменена"],
250
+ indexStrings: {
251
+ header: "Vault-индекс заметок автора",
252
+ generatedComment: "<!-- Сгенерировано iapeer-memory index-render. Не править вручную. -->",
253
+ sections: {
254
+ agentMemory: "Оперативка агентов",
255
+ knowledge: "Знания",
256
+ decisions: "Решения",
257
+ projects: "Проекты",
258
+ ideas: "Идеи",
259
+ lists: "Списки",
260
+ },
261
+ emptySection: "_(пока нет твоих заметок этого типа)_",
262
+ linksSuffix: "св.",
263
+ namePlaceholder: "<имя>",
264
+ truncatedMarker: "_Индекс обрезан: {bits}. Полный список заметок (без cap'а) — `Read {path}`._",
265
+ memoryLabel: "оперативка",
266
+ canonLabel: "канон",
267
+ projectsTrimmed: "проекты: отрезано {parts}",
268
+ pendingPhases: "{n} PENDING-фаз (секция > {cap})",
269
+ overHardCap: "{n} сверх лимита {cap}",
270
+ },
271
+ linksSection: "## Связи",
272
+ projectFiles: {
273
+ overviewPrefix: "Описание ",
274
+ planPrefix: "План ",
275
+ phasePrefix: "Фаза — ",
276
+ },
277
+ systemFiles: {
278
+ draftTemplate: "Шаблоны/Черновик.md",
279
+ tagsDictionary: "Теги.md",
280
+ },
281
+ };
282
+
283
+ const PRESETS: Record<LocaleId, TaxonomyPreset> = {
284
+ en: TAXONOMY_EN,
285
+ ru: TAXONOMY_RU,
286
+ };
287
+
288
+ export function getTaxonomy(locale: LocaleId): TaxonomyPreset {
289
+ return PRESETS[locale];
290
+ }
291
+
292
+ export function isLocaleId(value: string): value is LocaleId {
293
+ return value === "en" || value === "ru";
294
+ }
295
+
296
+ /**
297
+ * Path prefix for the agent-memory zone (`06_Agent_Memory/<name>/...`).
298
+ * Used by both the foreign-memory score penalty (search) and the one-way
299
+ * graph filter (mcp-tools). One derived value for both call sites.
300
+ *
301
+ * DB paths are stored without a leading slash, so the marker has none.
302
+ * The trailing slash is load-bearing: a note named
303
+ * `06_Agent_Memory_README.md` must NOT be classified as agent memory.
304
+ */
305
+ export function agentMemoryFolderMarker(taxonomy: TaxonomyPreset): string {
306
+ return `${taxonomy.folders.agentMemory}/`;
307
+ }
308
+
309
+ /**
310
+ * Status → lifecycle group lookup. Returns null for unknown statuses
311
+ * (no boost / no penalty — neutral, mirrors the reference behaviour where
312
+ * an unknown status falls through all three Set checks).
313
+ */
314
+ export function statusGroup(
315
+ taxonomy: TaxonomyPreset,
316
+ status: string,
317
+ ): StatusGroup | null {
318
+ if (taxonomy.statuses.active.includes(status)) return "active";
319
+ if (taxonomy.statuses.pending.includes(status)) return "pending";
320
+ if (taxonomy.statuses.stale.includes(status)) return "stale";
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * Default search-index exclusions: both inboxes (raw drafts, possibly without
326
+ * frontmatter) and the system folder (templates/dictionary, not content).
327
+ * The archive is NOT excluded — it stays searchable with the stale boost.
328
+ */
329
+ export function defaultExcludeFolders(taxonomy: TaxonomyPreset): string[] {
330
+ return [
331
+ taxonomy.folders.inbox,
332
+ taxonomy.folders.inboxHuman,
333
+ taxonomy.folders.system,
334
+ ];
335
+ }
336
+
337
+ /**
338
+ * Regex matching a body that STARTS with the links section, mirroring the
339
+ * reference parser semantics: `\b` is ASCII-only in JS, useless after a
340
+ * cyrillic heading, so the pattern uses `(?:\s|$)` explicitly.
341
+ */
342
+ export function linksSectionPattern(taxonomy: TaxonomyPreset): RegExp {
343
+ const escaped = taxonomy.linksSection.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
344
+ return new RegExp(`^${escaped}(?:\\s|$)`);
345
+ }
346
+
347
+ /**
348
+ * Ranking coefficients — locale-independent tuning config (ADR-002).
349
+ * Defaults are verbatim from the reference `search.ts`.
350
+ */
351
+ export type RankingConfig = {
352
+ activeBoost: number;
353
+ pendingPenalty: number;
354
+ stalePenalty: number;
355
+ /** Foreign agent-memory penalty (×0.7) — `docs/03-operatives.md`. */
356
+ foreignAgentMemoryPenalty: number;
357
+ /** 1-hop graph-expand neighbour penalty. */
358
+ graphExpandPenalty: number;
359
+ /** Incoming-wikilink count from which a note counts as a hub. */
360
+ backlinkHubThreshold: number;
361
+ backlinkHubBoost: number;
362
+ };
363
+
364
+ export const DEFAULT_RANKING: RankingConfig = {
365
+ activeBoost: 1.2,
366
+ pendingPenalty: 0.8,
367
+ stalePenalty: 0.5,
368
+ foreignAgentMemoryPenalty: 0.7,
369
+ graphExpandPenalty: 0.4,
370
+ backlinkHubThreshold: 5,
371
+ backlinkHubBoost: 1.15,
372
+ };
373
+
374
+ /**
375
+ * Curator set (ADR-006): personalities whose edits are sanctioned curation —
376
+ * the post-write hook does NOT stamp `needs_review` for them, and zone
377
+ * validation accepts their edits on foreign notes within a task. Replaces the
378
+ * reference hard-coded `agent != "index"` rule. Config, not a constant: the
379
+ * set is extendable per deployment.
380
+ */
381
+ export const DEFAULT_CURATOR_SET: readonly string[] = [
382
+ "index",
383
+ "copywriter",
384
+ "dreamweaver",
385
+ ];