@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,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author vault-index renderer.
|
|
3
|
+
*
|
|
4
|
+
* TS port of the reference `scripts/mergemind-regenerate-vault-index.py`
|
|
5
|
+
* (behavioural parity against `tests/python/test_regenerate_vault_index.py`,
|
|
6
|
+
* 84 fixtures) with the deliberate ADR deviations:
|
|
7
|
+
*
|
|
8
|
+
* - **taxonomy/ranking config instead of constants** (ADR-002/011): folder
|
|
9
|
+
* names, type/subtype tokens, status boost GROUPS and coefficients come
|
|
10
|
+
* from the same config the search pipeline uses — bucket synchronisation
|
|
11
|
+
* with `vault_search` holds BY CONSTRUCTION (one source), where the
|
|
12
|
+
* reference kept two hand-synchronised constant sets.
|
|
13
|
+
* - **personality is a parameter**: identity resolution (PEER_PERSONALITY
|
|
14
|
+
* first) happens at the call level, never inside the renderer.
|
|
15
|
+
* - **projectsRoot is a parameter**: the reference read
|
|
16
|
+
* PM_AGENT_PROJECTS_ROOT from env inside build_output; here the caller
|
|
17
|
+
* resolves it (env IAPEER_MEMORY_PROJECTS_ROOT at the CLI level).
|
|
18
|
+
* - **render strings are locale data** (taxonomy.indexStrings): the RU
|
|
19
|
+
* preset reproduces the reference strings verbatim; EN is the base.
|
|
20
|
+
*
|
|
21
|
+
* The output format, sorting policy and caps are documented in
|
|
22
|
+
* `docs/01-vault-layout.md` §«Индекс заметок автора».
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
import type { RankingConfig, TaxonomyPreset } from "./taxonomy.js";
|
|
30
|
+
import { statusGroup as taxonomyStatusGroup } from "./taxonomy.js";
|
|
31
|
+
|
|
32
|
+
const WIKILINK_RE = /\[\[([^\]|#]+)/g;
|
|
33
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
34
|
+
|
|
35
|
+
export type IndexNote = {
|
|
36
|
+
path: string;
|
|
37
|
+
title: string;
|
|
38
|
+
author: string;
|
|
39
|
+
coauthors: string[];
|
|
40
|
+
type: string;
|
|
41
|
+
status: string;
|
|
42
|
+
tags: string[];
|
|
43
|
+
subtype: string;
|
|
44
|
+
description: string;
|
|
45
|
+
created: string;
|
|
46
|
+
updated: string;
|
|
47
|
+
nOutgoing: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type FilteredNote = IndexNote & { nLinks: number; score: number };
|
|
51
|
+
|
|
52
|
+
export type RenderContext = {
|
|
53
|
+
taxonomy: TaxonomyPreset;
|
|
54
|
+
ranking: RankingConfig;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── frontmatter field parsing (line-based, mirrors the reference) ───────────
|
|
58
|
+
|
|
59
|
+
function escapeRe(s: string): string {
|
|
60
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `key: value` (single line); strips paired quotes and «guillemets». */
|
|
64
|
+
export function scalarField(fm: string, key: string): string | null {
|
|
65
|
+
const m = new RegExp(`^${escapeRe(key)}\\s*:\\s*(.+?)\\s*$`, "m").exec(fm);
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
let v = m[1].trim();
|
|
68
|
+
if (
|
|
69
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
70
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
71
|
+
) {
|
|
72
|
+
v = v.slice(1, -1);
|
|
73
|
+
} else if (v.startsWith("«") && v.endsWith("»")) {
|
|
74
|
+
v = v.slice(1, -1);
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseYamlList(fm: string, key: string): string[] {
|
|
80
|
+
const inline = new RegExp(`^${escapeRe(key)}\\s*:\\s*\\[(.*?)\\]\\s*$`, "m").exec(fm);
|
|
81
|
+
if (inline) {
|
|
82
|
+
return inline[1]
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
const block = new RegExp(
|
|
88
|
+
`^${escapeRe(key)}\\s*:\\s*\\n((?:[ \\t]+-\\s*.+\\n?)+)`,
|
|
89
|
+
"m",
|
|
90
|
+
).exec(fm);
|
|
91
|
+
if (block) {
|
|
92
|
+
const items = [...block[1].matchAll(/^[ \t]+-\s*(.+?)\s*$/gm)].map((m) => m[1]);
|
|
93
|
+
return items.map((i) => i.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `tags:` — inline `[a, b]` or block list. */
|
|
99
|
+
export function parseTags(fm: string): string[] {
|
|
100
|
+
return parseYamlList(fm, "tags");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `coauthors:` — same syntax as tags. */
|
|
104
|
+
export function parseCoauthors(fm: string): string[] {
|
|
105
|
+
return parseYamlList(fm, "coauthors");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── project working-dir resolution ──────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* For a project note at `<vault>/<projectsFolder>/<name>/.../file.md`,
|
|
112
|
+
* return [absDir, name] — the project's working directory (ADR-014):
|
|
113
|
+
*
|
|
114
|
+
* 1. the `dir:` field in the project's Overview frontmatter is the SOURCE
|
|
115
|
+
* OF TRUTH (absolute or `~`-relative; set by the author/Index) —
|
|
116
|
+
* arbitrary layouts and project moves survive;
|
|
117
|
+
* 2. no field (or its path is gone) → the `<projectsRoot>/<name>`
|
|
118
|
+
* convention (backward compatible — existing projects need no
|
|
119
|
+
* migration);
|
|
120
|
+
* 3. neither exists on disk → graceful no-op (null), as before.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveProjectDir(
|
|
123
|
+
notePath: string,
|
|
124
|
+
projectsRoot: string,
|
|
125
|
+
taxonomy: TaxonomyPreset,
|
|
126
|
+
): [string, string] | null {
|
|
127
|
+
if (!notePath) return null;
|
|
128
|
+
const parts = notePath.split(path.sep);
|
|
129
|
+
const i = parts.indexOf(taxonomy.folders.projects);
|
|
130
|
+
if (i === -1) return null;
|
|
131
|
+
// parts[i+1] must be a project subfolder with the note file after it —
|
|
132
|
+
// guard against a note placed directly in the projects folder.
|
|
133
|
+
if (i + 2 >= parts.length) return null;
|
|
134
|
+
const name = parts[i + 1];
|
|
135
|
+
if (!name) return null;
|
|
136
|
+
|
|
137
|
+
// 1. dir: from the Overview frontmatter (ADR-014).
|
|
138
|
+
const overviewPath = path.join(
|
|
139
|
+
parts.slice(0, i + 2).join(path.sep),
|
|
140
|
+
`${taxonomy.projectFiles.overviewPrefix}${name}.md`,
|
|
141
|
+
);
|
|
142
|
+
let declared: string | null = null;
|
|
143
|
+
try {
|
|
144
|
+
const text = fs.readFileSync(overviewPath, "utf-8");
|
|
145
|
+
const m = FRONTMATTER_RE.exec(text);
|
|
146
|
+
declared = m ? scalarField(m[1], "dir") : null;
|
|
147
|
+
} catch {
|
|
148
|
+
declared = null;
|
|
149
|
+
}
|
|
150
|
+
if (declared) {
|
|
151
|
+
const expanded = declared.startsWith("~")
|
|
152
|
+
? path.join(process.env.HOME ?? os.homedir(), declared.slice(1))
|
|
153
|
+
: declared;
|
|
154
|
+
try {
|
|
155
|
+
if (fs.statSync(expanded).isDirectory()) return [expanded, name];
|
|
156
|
+
} catch {
|
|
157
|
+
// declared path gone (project moved without updating dir:) — fall
|
|
158
|
+
// through to the convention rather than silently losing the line.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. convention fallback.
|
|
163
|
+
const pdir = path.join(projectsRoot, name);
|
|
164
|
+
try {
|
|
165
|
+
if (!fs.statSync(pdir).isDirectory()) return null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return [pdir, name];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── status groups & boosts — from the SHARED config (ADR-002) ───────────────
|
|
173
|
+
|
|
174
|
+
export function statusBoost(status: string | null, ctx: RenderContext): number {
|
|
175
|
+
if (status === null || status === undefined) return 1.0;
|
|
176
|
+
const group = taxonomyStatusGroup(ctx.taxonomy, status.toLowerCase());
|
|
177
|
+
if (group === "active") return ctx.ranking.activeBoost;
|
|
178
|
+
if (group === "pending") return ctx.ranking.pendingPenalty;
|
|
179
|
+
if (group === "stale") return ctx.ranking.stalePenalty;
|
|
180
|
+
return 1.0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** active=0, pending=1, stale=2, unknown=3 — ascending sort rank. */
|
|
184
|
+
export function statusGroupRank(status: string | null, ctx: RenderContext): number {
|
|
185
|
+
const group = taxonomyStatusGroup(ctx.taxonomy, (status ?? "").toLowerCase());
|
|
186
|
+
if (group === "active") return 0;
|
|
187
|
+
if (group === "pending") return 1;
|
|
188
|
+
if (group === "stale") return 2;
|
|
189
|
+
return 3;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isStale(status: string | null, ctx: RenderContext): boolean {
|
|
193
|
+
return taxonomyStatusGroup(ctx.taxonomy, (status ?? "").toLowerCase()) === "stale";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Subtype render rank within the agent-memory section. Unknown → 99. */
|
|
197
|
+
export function subtypeRank(subtype: string | null | undefined, ctx: RenderContext): number {
|
|
198
|
+
if (!subtype) return 99;
|
|
199
|
+
const i = ctx.taxonomy.subtypeOrder.indexOf(subtype.trim().toLowerCase());
|
|
200
|
+
return i === -1 ? 99 : i;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Parse `YYYY-MM-DD HH:MM` or `YYYY-MM-DD` → 5-tuple; malformed → zeros. */
|
|
204
|
+
export function datetimeKey(s: string | null | undefined): number[] {
|
|
205
|
+
if (!s) return [0, 0, 0, 0, 0];
|
|
206
|
+
const parts = s.trim().split(/\s+/);
|
|
207
|
+
const dateParts = parts[0].split("-");
|
|
208
|
+
const y = Number(dateParts[0]);
|
|
209
|
+
const mo = Number(dateParts[1]);
|
|
210
|
+
const d = Number(dateParts[2]);
|
|
211
|
+
if (
|
|
212
|
+
dateParts.length < 3 ||
|
|
213
|
+
!Number.isInteger(y) || !Number.isInteger(mo) || !Number.isInteger(d)
|
|
214
|
+
) {
|
|
215
|
+
return [0, 0, 0, 0, 0];
|
|
216
|
+
}
|
|
217
|
+
let h = 0;
|
|
218
|
+
let mi = 0;
|
|
219
|
+
if (parts.length > 1) {
|
|
220
|
+
const t = parts[1].split(":");
|
|
221
|
+
const th = Number(t[0]);
|
|
222
|
+
const tm = Number(t[1]);
|
|
223
|
+
if (Number.isInteger(th) && Number.isInteger(tm)) {
|
|
224
|
+
h = th;
|
|
225
|
+
mi = tm;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return [y, mo, d, h, mi];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── sort keys (python tuples → arrays + lexicographic compare) ──────────────
|
|
232
|
+
|
|
233
|
+
export type SortKey = Array<number | string>;
|
|
234
|
+
|
|
235
|
+
export function compareKeys(a: SortKey, b: SortKey): number {
|
|
236
|
+
const n = Math.min(a.length, b.length);
|
|
237
|
+
for (let i = 0; i < n; i++) {
|
|
238
|
+
const x = a[i];
|
|
239
|
+
const y = b[i];
|
|
240
|
+
if (x < y) return -1;
|
|
241
|
+
if (x > y) return 1;
|
|
242
|
+
}
|
|
243
|
+
return a.length - b.length;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Global sort key: status_group asc → (subtype rank for memory / -score for
|
|
248
|
+
* canon) → updated desc → title asc.
|
|
249
|
+
*/
|
|
250
|
+
export function sortKeyGlobal(
|
|
251
|
+
item: { type: string; status: string; subtype?: string; score?: number; updated?: string; created?: string; title: string },
|
|
252
|
+
ctx: RenderContext,
|
|
253
|
+
): SortKey {
|
|
254
|
+
const dtStr = item.updated || item.created || "";
|
|
255
|
+
const isMemory = item.type === ctx.taxonomy.types.agentMemory;
|
|
256
|
+
return [
|
|
257
|
+
statusGroupRank(item.status, ctx),
|
|
258
|
+
isMemory ? subtypeRank(item.subtype, ctx) : -(item.score ?? 0),
|
|
259
|
+
...datetimeKey(dtStr).map((v) => -v),
|
|
260
|
+
item.title,
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function memoryIntraKey(
|
|
265
|
+
item: { status: string; updated?: string; created?: string; title: string },
|
|
266
|
+
ctx: RenderContext,
|
|
267
|
+
): SortKey {
|
|
268
|
+
const dt = item.updated || item.created || "";
|
|
269
|
+
return [statusGroupRank(item.status, ctx), ...datetimeKey(dt).map((v) => -v), item.title];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function canonIntraKey(
|
|
273
|
+
item: { status: string; score?: number; updated?: string; created?: string; title: string },
|
|
274
|
+
ctx: RenderContext,
|
|
275
|
+
): SortKey {
|
|
276
|
+
const dt = item.updated || item.created || "";
|
|
277
|
+
return [
|
|
278
|
+
statusGroupRank(item.status, ctx),
|
|
279
|
+
-(item.score ?? 0),
|
|
280
|
+
...datetimeKey(dt).map((v) => -v),
|
|
281
|
+
item.title,
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Order within a project group: Overview → Plan → Phases (by the
|
|
287
|
+
* locale's phase status order). Tie-breaker — n_links desc, title asc.
|
|
288
|
+
*/
|
|
289
|
+
export function projectIntraSortKey(
|
|
290
|
+
item: { title: string; status: string; nLinks: number },
|
|
291
|
+
ctx: RenderContext,
|
|
292
|
+
): SortKey {
|
|
293
|
+
const t = ctx.taxonomy.projectFiles;
|
|
294
|
+
let order: number;
|
|
295
|
+
if (item.title.startsWith(t.overviewPrefix.trimEnd())) {
|
|
296
|
+
order = 0.0;
|
|
297
|
+
} else if (item.title.startsWith(t.planPrefix.trimEnd())) {
|
|
298
|
+
order = 1.0;
|
|
299
|
+
} else if (item.title.startsWith(t.phasePrefix.trimEnd())) {
|
|
300
|
+
const rank = ctx.taxonomy.phaseStatusOrder.indexOf((item.status || "").toLowerCase());
|
|
301
|
+
order = 2.0 + (rank === -1 ? 9 : rank) * 0.1;
|
|
302
|
+
} else {
|
|
303
|
+
order = 9.0;
|
|
304
|
+
}
|
|
305
|
+
return [order, -item.nLinks, item.title];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── collection & filtering ───────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export type CollectResult = {
|
|
311
|
+
notes: Map<string, IndexNote>;
|
|
312
|
+
incomingCount: Map<string, number>;
|
|
313
|
+
skipped: Array<[string, string]>;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
function* walkMdFiles(dir: string): Generator<string> {
|
|
317
|
+
let entries: fs.Dirent[];
|
|
318
|
+
try {
|
|
319
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
320
|
+
} catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const e of entries) {
|
|
324
|
+
const full = path.join(dir, e.name);
|
|
325
|
+
if (e.isDirectory()) {
|
|
326
|
+
yield* walkMdFiles(full);
|
|
327
|
+
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
328
|
+
yield full;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Walk the permanent folders, parse frontmatter + wikilinks.
|
|
335
|
+
* `incomingCount[title]` = number of UNIQUE source notes linking to the
|
|
336
|
+
* title (3 mentions in one note = +1 source, not +3). Each note carries
|
|
337
|
+
* `nOutgoing` — unique outgoing targets, self-references excluded.
|
|
338
|
+
*/
|
|
339
|
+
export function collectNotes(vault: string, ctx: RenderContext): CollectResult {
|
|
340
|
+
const f = ctx.taxonomy.folders;
|
|
341
|
+
const permanentDirs = [
|
|
342
|
+
f.knowledge, f.decisions, f.projects, f.ideas, f.lists, f.agentMemory,
|
|
343
|
+
];
|
|
344
|
+
const notes = new Map<string, IndexNote>();
|
|
345
|
+
const incomingSources = new Map<string, Set<string>>();
|
|
346
|
+
const skipped: Array<[string, string]> = [];
|
|
347
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
348
|
+
|
|
349
|
+
for (const d of permanentDirs) {
|
|
350
|
+
const full = path.join(vault, d);
|
|
351
|
+
for (const filePath of walkMdFiles(full)) {
|
|
352
|
+
let text: string;
|
|
353
|
+
try {
|
|
354
|
+
text = decoder.decode(fs.readFileSync(filePath));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
const name =
|
|
357
|
+
err instanceof TypeError ? "UnicodeDecodeError" : ((err as Error).name || "OSError");
|
|
358
|
+
skipped.push([filePath, name]);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const m = FRONTMATTER_RE.exec(text);
|
|
363
|
+
if (!m) continue;
|
|
364
|
+
const fm = m[1];
|
|
365
|
+
|
|
366
|
+
const author = scalarField(fm, "author");
|
|
367
|
+
if (!author) continue;
|
|
368
|
+
|
|
369
|
+
const title = scalarField(fm, "title") || path.basename(filePath, ".md");
|
|
370
|
+
|
|
371
|
+
const outgoing = new Set(
|
|
372
|
+
[...text.matchAll(WIKILINK_RE)].map((w) => w[1].trim()),
|
|
373
|
+
);
|
|
374
|
+
outgoing.delete(title);
|
|
375
|
+
|
|
376
|
+
notes.set(filePath, {
|
|
377
|
+
path: filePath,
|
|
378
|
+
title,
|
|
379
|
+
author,
|
|
380
|
+
coauthors: parseCoauthors(fm),
|
|
381
|
+
type: scalarField(fm, "type") ?? "",
|
|
382
|
+
status: scalarField(fm, "status") ?? "",
|
|
383
|
+
tags: parseTags(fm),
|
|
384
|
+
subtype: scalarField(fm, "subtype") ?? "",
|
|
385
|
+
description: scalarField(fm, "description") ?? "",
|
|
386
|
+
created: scalarField(fm, "created") ?? "",
|
|
387
|
+
updated: scalarField(fm, "updated") ?? "",
|
|
388
|
+
nOutgoing: outgoing.size,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
for (const target of outgoing) {
|
|
392
|
+
if (!incomingSources.has(target)) incomingSources.set(target, new Set());
|
|
393
|
+
incomingSources.get(target)!.add(filePath);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const incomingCount = new Map<string, number>();
|
|
399
|
+
for (const [title, sources] of incomingSources) incomingCount.set(title, sources.size);
|
|
400
|
+
return { notes, incomingCount, skipped };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Keep notes where the agent is author or coauthor; drop STALE; compute
|
|
405
|
+
* `nLinks = incoming(unique sources) + outgoing(unique targets)` and
|
|
406
|
+
* `score = nLinks × statusBoost`.
|
|
407
|
+
*/
|
|
408
|
+
export function filterAgentNotes(
|
|
409
|
+
notes: Map<string, IndexNote>,
|
|
410
|
+
incomingCount: Map<string, number>,
|
|
411
|
+
agent: string,
|
|
412
|
+
ctx: RenderContext,
|
|
413
|
+
): FilteredNote[] {
|
|
414
|
+
const mine: FilteredNote[] = [];
|
|
415
|
+
for (const data of notes.values()) {
|
|
416
|
+
if (data.author !== agent && !data.coauthors.includes(agent)) continue;
|
|
417
|
+
if (isStale(data.status, ctx)) continue;
|
|
418
|
+
const nIn = incomingCount.get(data.title) ?? 0;
|
|
419
|
+
const nLinks = nIn + data.nOutgoing;
|
|
420
|
+
mine.push({ ...data, nLinks, score: nLinks * statusBoost(data.status, ctx) });
|
|
421
|
+
}
|
|
422
|
+
return mine;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── line formatting ──────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
export const DESCRIPTION_MAX_LEN = 120;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Compress a description for the index: first sentence, falling back to a
|
|
431
|
+
* word-boundary cut at maxLen. The `…` marker appears ONLY when content was
|
|
432
|
+
* actually cut — no marker means the description is shown in full.
|
|
433
|
+
*/
|
|
434
|
+
export function truncateDescription(
|
|
435
|
+
desc: string,
|
|
436
|
+
maxLen: number = DESCRIPTION_MAX_LEN,
|
|
437
|
+
): string {
|
|
438
|
+
if (!desc) return desc;
|
|
439
|
+
desc = desc.trim();
|
|
440
|
+
const sentMatch = /[.!?]\s/.exec(desc);
|
|
441
|
+
if (sentMatch) {
|
|
442
|
+
const sentenceEnd = sentMatch.index + 1; // including the punctuation
|
|
443
|
+
if (sentenceEnd <= maxLen) {
|
|
444
|
+
if (sentenceEnd < desc.replace(/[.!? ]+$/, "").length) {
|
|
445
|
+
return desc.slice(0, sentenceEnd) + "…";
|
|
446
|
+
}
|
|
447
|
+
return desc;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (desc.length <= maxLen) return desc;
|
|
451
|
+
let truncated = desc.slice(0, maxLen);
|
|
452
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
453
|
+
if (lastSpace > Math.floor(maxLen / 2)) {
|
|
454
|
+
truncated = truncated.slice(0, lastSpace);
|
|
455
|
+
}
|
|
456
|
+
return truncated.replace(/[,;:\-— ]+$/, "") + "…";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** `- [[Title]] · status · tag(s) · N св. · YYYY-MM-DD` */
|
|
460
|
+
export function fmtCanonical(
|
|
461
|
+
item: { title: string; status: string; tags: string[]; nLinks: number; created?: string; updated?: string },
|
|
462
|
+
ctx: RenderContext,
|
|
463
|
+
): string {
|
|
464
|
+
const parts = [`[[${item.title}]]`];
|
|
465
|
+
if (item.status) parts.push(item.status);
|
|
466
|
+
if (item.tags.length) parts.push(item.tags.join(", "));
|
|
467
|
+
if (item.nLinks > 0) parts.push(`${item.nLinks} ${ctx.taxonomy.indexStrings.linksSuffix}`);
|
|
468
|
+
const upd = item.updated || item.created || "";
|
|
469
|
+
if (upd) parts.push(upd.split(/\s+/)[0]);
|
|
470
|
+
return "- " + parts.join(" · ");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `- [[Title]] · subtype · status · «description» · N св. · YYYY-MM-DD` */
|
|
474
|
+
export function fmtMemory(
|
|
475
|
+
item: { title: string; subtype: string; status: string; description: string; nLinks: number; created?: string; updated?: string },
|
|
476
|
+
ctx: RenderContext,
|
|
477
|
+
): string {
|
|
478
|
+
const parts = [`[[${item.title}]]`];
|
|
479
|
+
if (item.subtype) parts.push(item.subtype);
|
|
480
|
+
if (item.status) parts.push(item.status);
|
|
481
|
+
if (item.description) parts.push(`«${truncateDescription(item.description)}»`);
|
|
482
|
+
if (item.nLinks > 0) parts.push(`${item.nLinks} ${ctx.taxonomy.indexStrings.linksSuffix}`);
|
|
483
|
+
const upd = item.updated || item.created || "";
|
|
484
|
+
if (upd) parts.push(upd.split(/\s+/)[0]);
|
|
485
|
+
return "- " + parts.join(" · ");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── caps & selection ─────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
export const MEMORY_CAP = 50;
|
|
491
|
+
export const CANON_CAP = 50;
|
|
492
|
+
export const PROJECT_SECTION_CAP = 15;
|
|
493
|
+
export const PROJECT_HARD_CAP = 50;
|
|
494
|
+
export const MEMORY_DEFAULT_QUOTA = 10;
|
|
495
|
+
export const CANON_DEFAULT_QUOTA = 10;
|
|
496
|
+
|
|
497
|
+
function memorySubtypeQuotas(ctx: RenderContext): Map<string, number> {
|
|
498
|
+
const s = ctx.taxonomy.subtypes;
|
|
499
|
+
return new Map([
|
|
500
|
+
[s.feedback, 10],
|
|
501
|
+
[s.context, 10],
|
|
502
|
+
[s.reference, 10],
|
|
503
|
+
[s.pitfall, 10],
|
|
504
|
+
[s.personProfile, 1],
|
|
505
|
+
]);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function memorySubtypeCeilings(ctx: RenderContext): Map<string, number> {
|
|
509
|
+
// person_profile: max 3 in the index — facts about the owner are compact,
|
|
510
|
+
// further notes of this genre are likely duplicates or minor context.
|
|
511
|
+
return new Map([[ctx.taxonomy.subtypes.personProfile, 3]]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function canonQuotas(ctx: RenderContext): Map<string, number> {
|
|
515
|
+
const t = ctx.taxonomy.types;
|
|
516
|
+
return new Map([
|
|
517
|
+
[t.knowledge, 10],
|
|
518
|
+
[t.decision, 10],
|
|
519
|
+
[t.idea, 10],
|
|
520
|
+
[t.list, 10],
|
|
521
|
+
]);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Soft-trim PENDING notes of the whole projects section when over cap —
|
|
526
|
+
* across all projects, oldest `updated` first. ACTIVE notes are
|
|
527
|
+
* untouchable. Returns [kept (input order preserved), removedCount].
|
|
528
|
+
*/
|
|
529
|
+
export function trimProjectSectionPending(
|
|
530
|
+
notes: FilteredNote[],
|
|
531
|
+
ctx: RenderContext,
|
|
532
|
+
cap: number = PROJECT_SECTION_CAP,
|
|
533
|
+
): [FilteredNote[], number] {
|
|
534
|
+
if (notes.length <= cap) return [[...notes], 0];
|
|
535
|
+
const excess = notes.length - cap;
|
|
536
|
+
const pendingIndices = notes
|
|
537
|
+
.map((n, i) => ({ n, i }))
|
|
538
|
+
.filter(({ n }) => statusGroupRank(n.status, ctx) === 1)
|
|
539
|
+
.sort((a, b) =>
|
|
540
|
+
compareKeys(
|
|
541
|
+
datetimeKey(a.n.updated || a.n.created || ""),
|
|
542
|
+
datetimeKey(b.n.updated || b.n.created || ""),
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
.map(({ i }) => i);
|
|
546
|
+
const toDrop = new Set(pendingIndices.slice(0, excess));
|
|
547
|
+
const kept = notes.filter((_, i) => !toDrop.has(i));
|
|
548
|
+
return [kept, toDrop.size];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Per-type canon quotas with overflow up to the shared cap.
|
|
553
|
+
* cap=null disables quotas (full index).
|
|
554
|
+
*/
|
|
555
|
+
export function selectCanon(
|
|
556
|
+
canonPool: FilteredNote[],
|
|
557
|
+
ctx: RenderContext,
|
|
558
|
+
cap: number | null = CANON_CAP,
|
|
559
|
+
): FilteredNote[] {
|
|
560
|
+
if (cap === null) return [...canonPool];
|
|
561
|
+
|
|
562
|
+
const quotas = canonQuotas(ctx);
|
|
563
|
+
const byType = new Map<string, FilteredNote[]>();
|
|
564
|
+
for (const n of canonPool) {
|
|
565
|
+
if (!byType.has(n.type)) byType.set(n.type, []);
|
|
566
|
+
byType.get(n.type)!.push(n);
|
|
567
|
+
}
|
|
568
|
+
for (const list of byType.values()) {
|
|
569
|
+
list.sort((a, b) => compareKeys(canonIntraKey(a, ctx), canonIntraKey(b, ctx)));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const selected: FilteredNote[] = [];
|
|
573
|
+
const overflowPool: FilteredNote[] = [];
|
|
574
|
+
for (const [t, list] of byType) {
|
|
575
|
+
const quota = quotas.get(t) ?? CANON_DEFAULT_QUOTA;
|
|
576
|
+
const take = Math.min(quota, list.length);
|
|
577
|
+
selected.push(...list.slice(0, take));
|
|
578
|
+
overflowPool.push(...list.slice(take));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const remaining = cap - selected.length;
|
|
582
|
+
if (remaining > 0 && overflowPool.length) {
|
|
583
|
+
overflowPool.sort((a, b) => compareKeys(canonIntraKey(a, ctx), canonIntraKey(b, ctx)));
|
|
584
|
+
selected.push(...overflowPool.slice(0, remaining));
|
|
585
|
+
}
|
|
586
|
+
return selected;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Per-subtype memory quotas with overflow up to the shared cap; hard
|
|
591
|
+
* ceilings respected (person_profile ≤ 3). cap=null returns everything.
|
|
592
|
+
*/
|
|
593
|
+
export function selectMemory(
|
|
594
|
+
memoryPool: FilteredNote[],
|
|
595
|
+
ctx: RenderContext,
|
|
596
|
+
cap: number | null = MEMORY_CAP,
|
|
597
|
+
): FilteredNote[] {
|
|
598
|
+
if (cap === null) return [...memoryPool];
|
|
599
|
+
|
|
600
|
+
const quotas = memorySubtypeQuotas(ctx);
|
|
601
|
+
const ceilings = memorySubtypeCeilings(ctx);
|
|
602
|
+
const bySubtype = new Map<string, FilteredNote[]>();
|
|
603
|
+
for (const n of memoryPool) {
|
|
604
|
+
const st = (n.subtype || "").trim().toLowerCase() || "unknown";
|
|
605
|
+
if (!bySubtype.has(st)) bySubtype.set(st, []);
|
|
606
|
+
bySubtype.get(st)!.push(n);
|
|
607
|
+
}
|
|
608
|
+
for (const list of bySubtype.values()) {
|
|
609
|
+
list.sort((a, b) => compareKeys(memoryIntraKey(a, ctx), memoryIntraKey(b, ctx)));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const selected: FilteredNote[] = [];
|
|
613
|
+
const overflowPool: FilteredNote[] = [];
|
|
614
|
+
for (const [st, list] of bySubtype) {
|
|
615
|
+
const quota = quotas.get(st) ?? MEMORY_DEFAULT_QUOTA;
|
|
616
|
+
const ceiling = ceilings.get(st);
|
|
617
|
+
const take = Math.min(quota, list.length);
|
|
618
|
+
selected.push(...list.slice(0, take));
|
|
619
|
+
let leftover = list.slice(take);
|
|
620
|
+
if (ceiling !== undefined) {
|
|
621
|
+
const extraAllowed = Math.max(0, ceiling - take);
|
|
622
|
+
leftover = leftover.slice(0, extraAllowed);
|
|
623
|
+
}
|
|
624
|
+
overflowPool.push(...leftover);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const remaining = cap - selected.length;
|
|
628
|
+
if (remaining > 0 && overflowPool.length) {
|
|
629
|
+
overflowPool.sort((a, b) => compareKeys(memoryIntraKey(a, ctx), memoryIntraKey(b, ctx)));
|
|
630
|
+
selected.push(...overflowPool.slice(0, remaining));
|
|
631
|
+
}
|
|
632
|
+
return selected;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── output assembly ──────────────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
function fmtTemplate(tpl: string, vars: Record<string, string | number>): string {
|
|
638
|
+
return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export type BuildOutputOptions = {
|
|
642
|
+
ctx: RenderContext;
|
|
643
|
+
projectsRoot?: string;
|
|
644
|
+
memoryCap?: number | null;
|
|
645
|
+
canonCap?: number | null;
|
|
646
|
+
projectHardCap?: number | null;
|
|
647
|
+
fullIndexPath?: string | null;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Build the final markdown body from filtered notes.
|
|
652
|
+
* Returns [text, total, truncated].
|
|
653
|
+
*/
|
|
654
|
+
export function buildOutput(
|
|
655
|
+
mine: FilteredNote[],
|
|
656
|
+
agent: string,
|
|
657
|
+
opts: BuildOutputOptions,
|
|
658
|
+
): [string, number, boolean] {
|
|
659
|
+
const { ctx } = opts;
|
|
660
|
+
const T = ctx.taxonomy;
|
|
661
|
+
const S = T.indexStrings;
|
|
662
|
+
const projectsRoot = opts.projectsRoot ?? path.join(os.homedir(), "Projects");
|
|
663
|
+
const memoryCap = opts.memoryCap === undefined ? MEMORY_CAP : opts.memoryCap;
|
|
664
|
+
const canonCap = opts.canonCap === undefined ? CANON_CAP : opts.canonCap;
|
|
665
|
+
const projectHardCap =
|
|
666
|
+
opts.projectHardCap === undefined ? PROJECT_HARD_CAP : opts.projectHardCap;
|
|
667
|
+
const fullIndexPath = opts.fullIndexPath ?? null;
|
|
668
|
+
|
|
669
|
+
const memoryPool = mine.filter((n) => n.type === T.types.agentMemory);
|
|
670
|
+
// Projects are immune — no cap, only the STALE filter (already applied
|
|
671
|
+
// in filterAgentNotes). Overview + Plan + Phases land as one group.
|
|
672
|
+
const projectPool = mine.filter((n) => n.type === T.types.project);
|
|
673
|
+
const canonPool = mine.filter(
|
|
674
|
+
(n) => n.type !== T.types.agentMemory && n.type !== T.types.project,
|
|
675
|
+
);
|
|
676
|
+
const memoryTotal = memoryPool.length;
|
|
677
|
+
const canonTotal = canonPool.length;
|
|
678
|
+
const projectTotal = projectPool.length;
|
|
679
|
+
|
|
680
|
+
const memoryKept = selectMemory(memoryPool, ctx, memoryCap);
|
|
681
|
+
// Final render order: subtype grouping for readability, freshness inside.
|
|
682
|
+
memoryKept.sort((a, b) => compareKeys(sortKeyGlobal(a, ctx), sortKeyGlobal(b, ctx)));
|
|
683
|
+
const canonKept = selectCanon(canonPool, ctx, canonCap);
|
|
684
|
+
canonKept.sort((a, b) => compareKeys(sortKeyGlobal(a, ctx), sortKeyGlobal(b, ctx)));
|
|
685
|
+
|
|
686
|
+
const projectSorted = [...projectPool].sort((a, b) =>
|
|
687
|
+
compareKeys(sortKeyGlobal(a, ctx), sortKeyGlobal(b, ctx)),
|
|
688
|
+
);
|
|
689
|
+
let [projectKept, projectsTrimmedCount] = trimProjectSectionPending(projectSorted, ctx);
|
|
690
|
+
// Hard ceiling AFTER the soft-trim: deterministic index ceiling.
|
|
691
|
+
let projectsHardTrimmedCount = 0;
|
|
692
|
+
if (projectHardCap !== null && projectKept.length > projectHardCap) {
|
|
693
|
+
projectsHardTrimmedCount = projectKept.length - projectHardCap;
|
|
694
|
+
projectKept = projectKept.slice(0, projectHardCap);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const memoryTruncated = memoryCap !== null && memoryTotal > memoryKept.length;
|
|
698
|
+
const canonTruncated = canonCap !== null && canonTotal > canonKept.length;
|
|
699
|
+
const projectsTrimmed = projectsTrimmedCount > 0 || projectsHardTrimmedCount > 0;
|
|
700
|
+
|
|
701
|
+
const f = T.folders;
|
|
702
|
+
const sectionOrder: Array<[string, string, string]> = [
|
|
703
|
+
[S.sections.agentMemory, T.types.agentMemory, `${f.agentMemory}/${agent}/`],
|
|
704
|
+
[S.sections.knowledge, T.types.knowledge, `${f.knowledge}/`],
|
|
705
|
+
[S.sections.decisions, T.types.decision, `${f.decisions}/`],
|
|
706
|
+
[S.sections.projects, T.types.project, `${f.projects}/${S.namePlaceholder}/`],
|
|
707
|
+
[S.sections.ideas, T.types.idea, `${f.ideas}/`],
|
|
708
|
+
[S.sections.lists, T.types.list, `${f.lists}/`],
|
|
709
|
+
];
|
|
710
|
+
|
|
711
|
+
const sections = new Map<string, FilteredNote[]>(
|
|
712
|
+
sectionOrder.map(([, key]) => [key, []]),
|
|
713
|
+
);
|
|
714
|
+
for (const item of memoryKept) sections.get(T.types.agentMemory)!.push(item);
|
|
715
|
+
for (const item of canonKept) {
|
|
716
|
+
if (sections.has(item.type)) sections.get(item.type)!.push(item);
|
|
717
|
+
}
|
|
718
|
+
sections.get(T.types.project)!.push(...projectKept);
|
|
719
|
+
|
|
720
|
+
const lines: string[] = [
|
|
721
|
+
`# ${S.header} \`${agent}\``,
|
|
722
|
+
"",
|
|
723
|
+
S.generatedComment,
|
|
724
|
+
"",
|
|
725
|
+
];
|
|
726
|
+
for (const [sectionTitle, typeKey, folder] of sectionOrder) {
|
|
727
|
+
const items = sections.get(typeKey) ?? [];
|
|
728
|
+
lines.push(`## ${sectionTitle} — \`${folder}\``);
|
|
729
|
+
lines.push("");
|
|
730
|
+
if (!items.length) {
|
|
731
|
+
lines.push(S.emptySection);
|
|
732
|
+
lines.push("");
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const fmt =
|
|
736
|
+
typeKey === T.types.agentMemory
|
|
737
|
+
? (it: FilteredNote) => fmtMemory(it, ctx)
|
|
738
|
+
: (it: FilteredNote) => fmtCanonical(it, ctx);
|
|
739
|
+
if (typeKey === T.types.project) {
|
|
740
|
+
// Group by project: one path in the H3 group header, Overview → Plan
|
|
741
|
+
// → Phases inside.
|
|
742
|
+
const byProject = new Map<string, { pdir: string; items: FilteredNote[] }>();
|
|
743
|
+
const noProject: FilteredNote[] = [];
|
|
744
|
+
for (const item of items) {
|
|
745
|
+
const resolved = resolveProjectDir(item.path ?? "", projectsRoot, T);
|
|
746
|
+
if (resolved) {
|
|
747
|
+
const [pdir, pname] = resolved;
|
|
748
|
+
if (!byProject.has(pname)) byProject.set(pname, { pdir, items: [] });
|
|
749
|
+
byProject.get(pname)!.items.push(item);
|
|
750
|
+
} else {
|
|
751
|
+
noProject.push(item);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const sortedProjects = [...byProject.entries()].sort(
|
|
755
|
+
(a, b) =>
|
|
756
|
+
Math.max(...b[1].items.map((it) => it.score)) -
|
|
757
|
+
Math.max(...a[1].items.map((it) => it.score)),
|
|
758
|
+
);
|
|
759
|
+
for (const [pname, group] of sortedProjects) {
|
|
760
|
+
lines.push(`### ${pname} — ${group.pdir}/`);
|
|
761
|
+
const ordered = [...group.items].sort((a, b) =>
|
|
762
|
+
compareKeys(projectIntraSortKey(a, ctx), projectIntraSortKey(b, ctx)),
|
|
763
|
+
);
|
|
764
|
+
for (const it of ordered) lines.push(fmt(it));
|
|
765
|
+
lines.push("");
|
|
766
|
+
}
|
|
767
|
+
if (noProject.length) {
|
|
768
|
+
for (const it of noProject) lines.push(fmt(it));
|
|
769
|
+
lines.push("");
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
for (const item of items) lines.push(fmt(item));
|
|
773
|
+
lines.push("");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const truncated = memoryTruncated || canonTruncated || projectsTrimmed;
|
|
778
|
+
const total = memoryTotal + canonTotal + projectTotal;
|
|
779
|
+
|
|
780
|
+
if (truncated) {
|
|
781
|
+
const home = os.homedir();
|
|
782
|
+
let pathDisplay: string;
|
|
783
|
+
if (fullIndexPath && fullIndexPath.startsWith(home + "/")) {
|
|
784
|
+
pathDisplay = "~" + fullIndexPath.slice(home.length);
|
|
785
|
+
} else {
|
|
786
|
+
pathDisplay = fullIndexPath || "<full index not generated>";
|
|
787
|
+
}
|
|
788
|
+
const bits: string[] = [];
|
|
789
|
+
if (memoryTruncated) bits.push(`${S.memoryLabel} ${memoryKept.length}/${memoryTotal}`);
|
|
790
|
+
if (canonTruncated) bits.push(`${S.canonLabel} ${canonKept.length}/${canonTotal}`);
|
|
791
|
+
if (projectsTrimmed) {
|
|
792
|
+
const partsProj: string[] = [];
|
|
793
|
+
if (projectsTrimmedCount) {
|
|
794
|
+
partsProj.push(
|
|
795
|
+
fmtTemplate(S.pendingPhases, { n: projectsTrimmedCount, cap: PROJECT_SECTION_CAP }),
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (projectsHardTrimmedCount) {
|
|
799
|
+
partsProj.push(
|
|
800
|
+
fmtTemplate(S.overHardCap, { n: projectsHardTrimmedCount, cap: projectHardCap ?? 0 }),
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
bits.push(fmtTemplate(S.projectsTrimmed, { parts: partsProj.join(", ") }));
|
|
804
|
+
}
|
|
805
|
+
lines.push("---");
|
|
806
|
+
lines.push("");
|
|
807
|
+
lines.push(fmtTemplate(S.truncatedMarker, { bits: bits.join(", "), path: pathDisplay }));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return [lines.join("\n") + "\n", total, truncated];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ── files ────────────────────────────────────────────────────────────────────
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* temp file + rename — atomic write. Load-bearing: the out file is written
|
|
817
|
+
* by memoryd while peers' system-prompt assembly reads it concurrently;
|
|
818
|
+
* rename means a reader only ever sees old-complete or new-complete.
|
|
819
|
+
*/
|
|
820
|
+
export function atomicWrite(filePath: string, content: string): void {
|
|
821
|
+
const dir = path.dirname(filePath) || ".";
|
|
822
|
+
const tmp = path.join(dir, `.vault-index-${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
823
|
+
try {
|
|
824
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
825
|
+
fs.renameSync(tmp, filePath);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
try {
|
|
828
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
829
|
+
} catch {
|
|
830
|
+
// best effort
|
|
831
|
+
}
|
|
832
|
+
throw err;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** `boris-vault-index.md` → `boris-vault-index-full.md` (same directory). */
|
|
837
|
+
export function fullIndexPathFor(outFile: string): string {
|
|
838
|
+
const ext = path.extname(outFile);
|
|
839
|
+
const base = outFile.slice(0, outFile.length - ext.length);
|
|
840
|
+
return `${base}-full${ext}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Render both index files (capped + full) for an agent. The personality is
|
|
845
|
+
* a PARAMETER — identity resolution (PEER_PERSONALITY first) is the
|
|
846
|
+
* caller's job (нюанс 10), never guessed here.
|
|
847
|
+
*/
|
|
848
|
+
export function regenerateVaultIndex(opts: {
|
|
849
|
+
vault: string;
|
|
850
|
+
agent: string;
|
|
851
|
+
outFile: string;
|
|
852
|
+
ctx: RenderContext;
|
|
853
|
+
projectsRoot?: string;
|
|
854
|
+
}): { total: number; truncated: boolean; skipped: Array<[string, string]> } {
|
|
855
|
+
const { notes, incomingCount, skipped } = collectNotes(opts.vault, opts.ctx);
|
|
856
|
+
const mine = filterAgentNotes(notes, incomingCount, opts.agent, opts.ctx);
|
|
857
|
+
const fullOut = fullIndexPathFor(opts.outFile);
|
|
858
|
+
|
|
859
|
+
const [text, total, truncated] = buildOutput(mine, opts.agent, {
|
|
860
|
+
ctx: opts.ctx,
|
|
861
|
+
projectsRoot: opts.projectsRoot,
|
|
862
|
+
fullIndexPath: fullOut,
|
|
863
|
+
});
|
|
864
|
+
atomicWrite(opts.outFile, text);
|
|
865
|
+
|
|
866
|
+
const [fullText] = buildOutput(mine, opts.agent, {
|
|
867
|
+
ctx: opts.ctx,
|
|
868
|
+
projectsRoot: opts.projectsRoot,
|
|
869
|
+
memoryCap: null,
|
|
870
|
+
canonCap: null,
|
|
871
|
+
projectHardCap: null,
|
|
872
|
+
});
|
|
873
|
+
atomicWrite(fullOut, fullText);
|
|
874
|
+
|
|
875
|
+
return { total, truncated, skipped };
|
|
876
|
+
}
|