@vib795/agent-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compact.js ADDED
@@ -0,0 +1,242 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { loadConfig, paths } from './config.js';
3
+ import { listNotes, archiveNote, contentHash, nowIso, serializeNote } from './store.js';
4
+ import { atomicWrite } from './atomic.js';
5
+ import { openDb, reindex } from './index-db.js';
6
+ import { buildDigest, buildTree, renderTree } from './digest.js';
7
+
8
+ /**
9
+ * Compaction is pure code. No model is involved, and none should be.
10
+ *
11
+ * Everything here is a rule an operator can predict and check by hand: identical
12
+ * content merges, superseded knowledge steps aside, unread and unreferenced notes
13
+ * move to the archive. Because it needs no model, it never needs a schedule and
14
+ * never costs a premium request.
15
+ */
16
+
17
+ function ageDays(iso, now) {
18
+ if (!iso) return Infinity;
19
+ const t = Date.parse(iso);
20
+ return Number.isFinite(t) ? (now - t) / 86400000 : Infinity;
21
+ }
22
+
23
+ function unionEdges(...lists) {
24
+ const seen = new Map();
25
+ for (const list of lists) {
26
+ for (const e of list || []) {
27
+ if (e?.rel && e?.dst) seen.set(`${e.rel} ${e.dst}`, { rel: e.rel, dst: e.dst });
28
+ }
29
+ }
30
+ return [...seen.values()].sort((a, b) =>
31
+ a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : a.dst < b.dst ? -1 : 1,
32
+ );
33
+ }
34
+
35
+ /** Rewrite a note in place, preserving `updated`. Graph repair, not an edit. */
36
+ function rewrite(node) {
37
+ atomicWrite(node.path, serializeNote(node));
38
+ }
39
+
40
+ /**
41
+ * Merge notes whose content is identical.
42
+ *
43
+ * The keeper is the earliest `created`, because the first capture is the one other
44
+ * notes are most likely to already point at. Timestamps are second-precision, so two
45
+ * notes captured in the same turn tie routinely; the tie goes to whichever node more
46
+ * things already reference, which is the same rationale stated directly rather than
47
+ * approximated by a timestamp. Id is the last resort, purely for determinism.
48
+ *
49
+ * Repos and edges are unioned so nothing a duplicate knew is lost, and inbound edges
50
+ * are repointed at the keeper so the merge leaves no dangling reference behind.
51
+ */
52
+ function dedupe(active) {
53
+ const inbound = new Map();
54
+ for (const n of active) {
55
+ for (const e of n.edges || []) inbound.set(e.dst, (inbound.get(e.dst) || 0) + 1);
56
+ }
57
+
58
+ const groups = new Map();
59
+ for (const n of active) {
60
+ const h = contentHash(n);
61
+ if (!groups.has(h)) groups.set(h, []);
62
+ groups.get(h).push(n);
63
+ }
64
+
65
+ const merged = [];
66
+ const remap = new Map();
67
+ for (const group of groups.values()) {
68
+ if (group.length < 2) continue;
69
+ group.sort(
70
+ (a, b) =>
71
+ (a.created || '').localeCompare(b.created || '') ||
72
+ (inbound.get(b.id) || 0) - (inbound.get(a.id) || 0) ||
73
+ a.id.localeCompare(b.id),
74
+ );
75
+ const [keeper, ...dups] = group;
76
+ keeper.repos = [
77
+ ...new Set([...(keeper.repos || []), ...dups.flatMap((d) => d.repos || [])]),
78
+ ].sort();
79
+ keeper.edges = unionEdges(keeper.edges, ...dups.map((d) => d.edges))
80
+ // A merged node must not end up pointing at an id it just absorbed.
81
+ .filter((e) => !dups.some((d) => d.id === e.dst));
82
+ rewrite(keeper);
83
+
84
+ for (const d of dups) {
85
+ archiveNote(d.type, d.id);
86
+ remap.set(d.id, keeper.id);
87
+ merged.push({ id: d.id, into: keeper.id });
88
+ }
89
+ }
90
+
91
+ // Repoint every edge that referenced an absorbed id.
92
+ if (remap.size) {
93
+ for (const n of active) {
94
+ if (remap.has(n.id)) continue;
95
+ const before = JSON.stringify(n.edges || []);
96
+ n.edges = unionEdges(
97
+ (n.edges || []).map((e) => ({ rel: e.rel, dst: remap.get(e.dst) || e.dst })),
98
+ ).filter((e) => e.dst !== n.id);
99
+ if (JSON.stringify(n.edges) !== before) rewrite(n);
100
+ }
101
+ }
102
+ return merged;
103
+ }
104
+
105
+ /**
106
+ * A node that supersedes another archives it.
107
+ *
108
+ * The superseded note keeps its edges and stays reachable through
109
+ * `get --include-archived`, because "we used to do it this way and stopped" is
110
+ * often the exact thing someone needs six months later.
111
+ */
112
+ function collapseSupersedes(active) {
113
+ const byId = new Map(active.map((n) => [n.id, n]));
114
+ const archived = [];
115
+ for (const n of active) {
116
+ if (!n.supersedes) continue;
117
+ const old = byId.get(n.supersedes);
118
+ if (!old || old.archived) continue;
119
+ if (archiveNote(old.type, old.id)) {
120
+ old.archived = 1;
121
+ archived.push({ id: old.id, by: n.id });
122
+ }
123
+ }
124
+ return archived;
125
+ }
126
+
127
+ /**
128
+ * Archive notes nobody points at and nobody has read in `decayDays`.
129
+ *
130
+ * Inbound edges are the exemption: a note other notes depend on is load-bearing
131
+ * whether or not anyone has opened it recently.
132
+ */
133
+ function decay(active, cfg, now) {
134
+ const referenced = new Set(active.flatMap((n) => (n.edges || []).map((e) => e.dst)));
135
+ const archived = [];
136
+ for (const n of active) {
137
+ if (n.archived || referenced.has(n.id)) continue;
138
+ const last = n.accessed || n.updated || n.created;
139
+ if (ageDays(last, now) < cfg.decayDays) continue;
140
+ if (archiveNote(n.type, n.id)) archived.push({ id: n.id, lastSeen: last });
141
+ }
142
+ return archived;
143
+ }
144
+
145
+ /**
146
+ * Rewrite the `description:` line of an installed skill.
147
+ *
148
+ * This is the whole Tier-1 mechanism. An agent decides whether to invoke a skill by
149
+ * reading its description, so regenerating that line is how the store advertises
150
+ * what it now knows without costing anything at chat time.
151
+ */
152
+ export function writeSkillDescription(skillPath, description) {
153
+ if (!existsSync(skillPath)) return false;
154
+ const src = readFileSync(skillPath, 'utf8');
155
+ if (!src.startsWith('---')) return false;
156
+ const end = src.indexOf('\n---', 3);
157
+ if (end === -1) return false;
158
+
159
+ const head = src.slice(0, end);
160
+ const rest = src.slice(end);
161
+ // JSON quoting is valid YAML double-quoting, and the digest contains colons and
162
+ // commas that would otherwise break the frontmatter.
163
+ const line = `description: ${JSON.stringify(description)}`;
164
+ const updated = /^description:.*$/m.test(head)
165
+ ? head.replace(/^description:.*$/m, line)
166
+ : `${head}\n${line}`;
167
+
168
+ atomicWrite(skillPath, updated + rest);
169
+ return true;
170
+ }
171
+
172
+ /** Regenerate everything derived: ROUTING.md and each installed skill description. */
173
+ function regenerate(db, cfg) {
174
+ const digest = buildDigest(db, { cfg });
175
+ const tree = buildTree(db, { all: true, cfg });
176
+
177
+ const routing = [
178
+ '<!-- Generated by `agent-memory compact`. Edits here are overwritten. -->',
179
+ '',
180
+ digest,
181
+ '',
182
+ renderTree(tree),
183
+ '',
184
+ ].join('\n');
185
+ atomicWrite(paths.routing, routing);
186
+
187
+ const skills = [];
188
+ for (const p of cfg.skillPaths || []) {
189
+ if (writeSkillDescription(p, digest)) skills.push(p);
190
+ }
191
+ return { digest, digestChars: digest.length, routing: paths.routing, skills };
192
+ }
193
+
194
+ /**
195
+ * Dedup, collapse, decay, reindex, regenerate.
196
+ *
197
+ * Order matters: dedup before supersede collapse, because a merge can be what makes
198
+ * two supersede chains agree; decay last, because both steps before it change which
199
+ * nodes have inbound edges.
200
+ */
201
+ export function compact({ cfg = loadConfig(), now = Date.now(), db: existing = null } = {}) {
202
+ const db = existing || openDb({ reindexOnCreate: false });
203
+ const all = listNotes();
204
+ const malformed = all.filter((n) => n.__error).map((n) => ({ path: n.path, error: n.__error }));
205
+ const active = all.filter((n) => !n.__error && !n.archived && n.id);
206
+
207
+ const merged = dedupe(active);
208
+ const mergedIds = new Set(merged.map((m) => m.id));
209
+ const remaining = active.filter((n) => !mergedIds.has(n.id));
210
+
211
+ const superseded = collapseSupersedes(remaining);
212
+ const supersededIds = new Set(superseded.map((s) => s.id));
213
+
214
+ const decayed = decay(remaining.filter((n) => !supersededIds.has(n.id)), cfg, now);
215
+
216
+ const indexed = reindex(db);
217
+ const derived = regenerate(db, cfg);
218
+ if (!existing) db.close();
219
+
220
+ return {
221
+ at: nowIso(),
222
+ merged,
223
+ superseded,
224
+ decayed,
225
+ malformed,
226
+ indexed: indexed.indexed,
227
+ duplicateIds: indexed.duplicates,
228
+ ...derived,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Compact automatically when the store has changed enough to be worth it.
234
+ *
235
+ * Runs on install and after a write that moves the node count past the threshold,
236
+ * so compaction never needs a scheduler, a daemon, or a model. Anything that needed
237
+ * one of those would not survive a locked-down desktop.
238
+ */
239
+ export function maybeCompact(db, before, after, cfg = loadConfig()) {
240
+ if (Math.abs(after - before) < cfg.compactThreshold) return null;
241
+ return compact({ cfg, db });
242
+ }
package/src/config.js ADDED
@@ -0,0 +1,91 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { atomicWrite } from './atomic.js';
5
+
6
+ // The store lives outside every repository on purpose. That is what makes a note
7
+ // written in one window readable from a window opened on a different project.
8
+ export const STORE_ROOT =
9
+ process.env.AGENT_MEMORY_HOME ||
10
+ join(process.env.USERPROFILE || homedir(), '.agents', 'memory');
11
+
12
+ export const NOTE_TYPES = ['system', 'decision', 'convention', 'constraint'];
13
+
14
+ export const paths = {
15
+ root: STORE_ROOT,
16
+ notes: join(STORE_ROOT, 'notes'),
17
+ archive: join(STORE_ROOT, 'notes', 'archive'),
18
+ db: join(STORE_ROOT, 'index.db'),
19
+ routing: join(STORE_ROOT, 'ROUTING.md'),
20
+ config: join(STORE_ROOT, 'config.json'),
21
+ typeDir: (type) => join(STORE_ROOT, 'notes', type),
22
+ archiveTypeDir: (type) => join(STORE_ROOT, 'notes', 'archive', type),
23
+ };
24
+
25
+ // Every cap in the spec is tunable. These defaults are judgment calls, not limits
26
+ // derived from anything, so they belong in config rather than frozen in code.
27
+ export const DEFAULTS = {
28
+ digestChars: 400, // standing context cost, loaded on every chat
29
+ treeLines: 80, // per-invocation cost, paid only when recall fires
30
+ getBudgetBytes: 8192, // ceiling on note bodies returned by a single get
31
+ decayDays: 90, // archive threshold for unreferenced, unread notes
32
+ staleAnnotateCommits: 10, // below this, staleness is not worth mentioning
33
+ staleReviewCommits: 100, // above this, doctor flags it for review
34
+ compactThreshold: 10, // node-count delta that triggers an automatic compact
35
+ };
36
+
37
+ // Written by the installer: every SKILL.md whose description compact regenerates.
38
+ // The store cannot discover these on its own, because a skill may be symlinked
39
+ // from a checkout or copied into place, and both are legitimate installs.
40
+ export const LIST_KEYS = ['skillPaths'];
41
+
42
+ export function loadConfig() {
43
+ if (!existsSync(paths.config)) return { ...DEFAULTS, skillPaths: [] };
44
+ try {
45
+ const raw = JSON.parse(readFileSync(paths.config, 'utf8'));
46
+ const merged = { ...DEFAULTS, skillPaths: [] };
47
+ for (const [k, v] of Object.entries(raw)) {
48
+ if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) merged[k] = v;
49
+ if (LIST_KEYS.includes(k) && Array.isArray(v)) {
50
+ merged[k] = v.filter((s) => typeof s === 'string' && s.trim());
51
+ }
52
+ }
53
+ return merged;
54
+ } catch {
55
+ // A malformed config must not take the store down. Defaults are always valid.
56
+ return { ...DEFAULTS, skillPaths: [] };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Merge a patch into config.json.
62
+ *
63
+ * Lives here rather than in the install scripts so that bash and PowerShell do not
64
+ * each grow their own JSON merge, which is exactly the kind of duplication that
65
+ * drifts and only shows up on the machine you cannot test from.
66
+ *
67
+ * Only keys that already mean something are persisted, so a typo in an installer
68
+ * cannot quietly become permanent state.
69
+ */
70
+ export function saveConfig(patch) {
71
+ const current = existsSync(paths.config)
72
+ ? (() => {
73
+ try {
74
+ return JSON.parse(readFileSync(paths.config, 'utf8'));
75
+ } catch {
76
+ return {};
77
+ }
78
+ })()
79
+ : {};
80
+
81
+ const next = { ...current };
82
+ for (const [k, v] of Object.entries(patch || {})) {
83
+ if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) next[k] = v;
84
+ if (LIST_KEYS.includes(k) && Array.isArray(v)) {
85
+ next[k] = [...new Set(v.filter((s) => typeof s === 'string' && s.trim()))].sort();
86
+ }
87
+ }
88
+
89
+ atomicWrite(paths.config, `${JSON.stringify(next, null, 2)}\n`);
90
+ return next;
91
+ }
package/src/digest.js ADDED
@@ -0,0 +1,199 @@
1
+ import { loadConfig } from './config.js';
2
+
3
+ /**
4
+ * Two-tier routing.
5
+ *
6
+ * Tier 1 is the `recall` skill's description, which is loaded into every chat
7
+ * whether or not memory is ever used. It is standing cost, so it has to read like
8
+ * a description rather than a document.
9
+ *
10
+ * Tier 2 is the tree, printed only when `recall` actually fires. Per-invocation
11
+ * cost, paid once, and only when someone is already looking something up.
12
+ *
13
+ * Neither tier costs a premium request. A request is charged per prompt, not per
14
+ * tool call, so both of these ride inside a turn that was already paid for.
15
+ */
16
+
17
+ // Never dropped from either tier. A constraint is what stops an agent from burning
18
+ // a retry loop on an approach the org forbids, which is where requests actually go.
19
+ // Everything else here is negotiable; this is not.
20
+ const PRIVILEGED = 'constraint';
21
+
22
+ const TYPE_ORDER = ['constraint', 'decision', 'convention', 'system'];
23
+
24
+ const USE_WHEN =
25
+ 'Use when you need to know how a system works, why a decision was made, ' +
26
+ 'what convention applies, or what the environment forbids.';
27
+
28
+ function typeRank(type) {
29
+ const i = TYPE_ORDER.indexOf(type);
30
+ return i === -1 ? TYPE_ORDER.length : i;
31
+ }
32
+
33
+ /** id -> total edges touching it, in one query rather than one per node. */
34
+ function degreeMap(db) {
35
+ const map = new Map();
36
+ for (const row of db
37
+ .prepare(`
38
+ SELECT n.id AS id, COUNT(e.rowid) AS degree
39
+ FROM nodes n
40
+ LEFT JOIN edges e ON e.src = n.id OR e.dst = n.id
41
+ GROUP BY n.id
42
+ `)
43
+ .all()) {
44
+ map.set(row.id, row.degree);
45
+ }
46
+ return map;
47
+ }
48
+
49
+ /**
50
+ * Tier 1: the skill description.
51
+ *
52
+ * Composition order is constraint count, then repos by note count, then topics by
53
+ * edge degree. On overflow the lowest-degree topics go first, then repos. Two
54
+ * pieces are structural and never dropped: the constraint count, and the closing
55
+ * "use when" clause, which is the entire reason an agent decides to invoke at all.
56
+ * Cutting that to save characters would save the cost of a feature by deleting it.
57
+ */
58
+ export function buildDigest(db, { cfg = loadConfig() } = {}) {
59
+ const cap = cfg.digestChars;
60
+ const total = db.prepare('SELECT COUNT(*) AS c FROM nodes WHERE archived = 0').get().c;
61
+ if (!total) {
62
+ return 'Durable project knowledge store, currently empty. Run /remember to capture the first note.';
63
+ }
64
+
65
+ const constraints = db
66
+ .prepare("SELECT COUNT(*) AS c FROM nodes WHERE archived = 0 AND type = 'constraint'")
67
+ .get().c;
68
+
69
+ const repos = db
70
+ .prepare(`
71
+ SELECT r.repo AS repo, COUNT(*) AS c
72
+ FROM node_repos r JOIN nodes n ON n.id = r.node_id
73
+ WHERE n.archived = 0
74
+ GROUP BY r.repo
75
+ ORDER BY c DESC, r.repo
76
+ `)
77
+ .all()
78
+ .map((r) => r.repo);
79
+
80
+ const topics = db
81
+ .prepare(`
82
+ SELECT n.title AS title, COUNT(e.rowid) AS degree
83
+ FROM nodes n
84
+ LEFT JOIN edges e ON e.src = n.id OR e.dst = n.id
85
+ WHERE n.archived = 0
86
+ GROUP BY n.id
87
+ ORDER BY degree DESC, n.title
88
+ `)
89
+ .all()
90
+ .map((r) => r.title.toLowerCase());
91
+
92
+ const head =
93
+ `Durable project knowledge: ${total} note${total === 1 ? '' : 's'}` +
94
+ (constraints ? `, ${constraints} constraint${constraints === 1 ? '' : 's'}` : '');
95
+
96
+ // Shed the elastic middle one whole item at a time. Cutting mid-word would leave
97
+ // the description looking corrupted, which is worse than saying less.
98
+ const useRepos = repos.slice();
99
+ const useTopics = topics.slice();
100
+ const compose = () => {
101
+ let s = head;
102
+ if (useRepos.length) s += ` across ${useRepos.join(', ')}`;
103
+ s += '.';
104
+ if (useTopics.length) s += ` Topics: ${useTopics.join(', ')}.`;
105
+ return `${s} ${USE_WHEN}`;
106
+ };
107
+
108
+ let out = compose();
109
+ while (out.length > cap && useTopics.length) {
110
+ useTopics.pop();
111
+ out = compose();
112
+ }
113
+ while (out.length > cap && useRepos.length) {
114
+ useRepos.pop();
115
+ out = compose();
116
+ }
117
+ // If the head plus the use-when clause alone exceed the cap, the cap is too small
118
+ // for a usable description. Return the structural minimum; `doctor` reports it.
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Tier 2: the routing tree, scoped.
124
+ *
125
+ * A repo view returns that repo's nodes plus every `scope: global` node, because a
126
+ * global constraint applies here too and hiding it is exactly the failure this
127
+ * design cares about.
128
+ */
129
+ export function buildTree(db, { repo = null, all = false, cfg = loadConfig() } = {}) {
130
+ const rows = repo
131
+ ? db
132
+ .prepare(`
133
+ SELECT DISTINCT n.id AS id, n.type AS type, n.title AS title,
134
+ n.archived AS archived, n.scope AS scope
135
+ FROM nodes n
136
+ LEFT JOIN node_repos r ON r.node_id = n.id
137
+ WHERE n.scope = 'global' OR r.repo = ?
138
+ `)
139
+ .all(repo)
140
+ : db.prepare('SELECT id, type, title, archived, scope FROM nodes').all();
141
+
142
+ const degrees = degreeMap(db);
143
+ const entries = rows.map((r) => ({ ...r, degree: degrees.get(r.id) ?? 0 }));
144
+
145
+ entries.sort(
146
+ (a, b) =>
147
+ a.archived - b.archived ||
148
+ typeRank(a.type) - typeRank(b.type) ||
149
+ b.degree - a.degree ||
150
+ (a.id < b.id ? -1 : 1),
151
+ );
152
+
153
+ const total = entries.length;
154
+ // One line for the header, one for the truncation notice.
155
+ const cap = Math.max(1, cfg.treeLines - 2);
156
+ if (all || total <= cap) {
157
+ return { repo, total, shown: total, omitted: [], lines: entries, truncated: false };
158
+ }
159
+
160
+ // Drop archived first, then by ascending degree. Least connected means least
161
+ // likely to be the hub anyone needed. Constraints are exempt at any cap.
162
+ const droppable = entries
163
+ .filter((e) => e.type !== PRIVILEGED)
164
+ .sort((a, b) => b.archived - a.archived || a.degree - b.degree || (a.id < b.id ? 1 : -1));
165
+
166
+ const dropped = new Set();
167
+ for (const e of droppable) {
168
+ if (total - dropped.size <= cap) break;
169
+ dropped.add(e.id);
170
+ }
171
+
172
+ const kept = entries.filter((e) => !dropped.has(e.id));
173
+ return {
174
+ repo,
175
+ total,
176
+ shown: kept.length,
177
+ omitted: entries.filter((e) => dropped.has(e.id)).map((e) => ({ id: e.id, type: e.type })),
178
+ lines: kept,
179
+ truncated: true,
180
+ // True when constraints alone exceed the cap. Reported, not silently fixed.
181
+ overCap: kept.length > cap,
182
+ };
183
+ }
184
+
185
+ export function renderTree(result) {
186
+ const scope = result.repo ? result.repo : 'all repos';
187
+ const out = [`# memory: ${scope} — ${result.total} notes`];
188
+ const width = result.lines.reduce((w, e) => Math.max(w, e.id.length), 0);
189
+ for (const e of result.lines) {
190
+ const mark = e.archived ? ' (archived)' : '';
191
+ out.push(`${e.type.padEnd(10)} ${e.id.padEnd(width)} ${e.title}${mark}`);
192
+ }
193
+ if (result.omitted.length) {
194
+ // Always printed. Silent truncation that reads as full coverage is the single
195
+ // most dangerous thing this tool could do, because it looks like an answer.
196
+ out.push(`${result.omitted.length} nodes not shown, run agent-memory tree --all`);
197
+ }
198
+ return out.join('\n');
199
+ }
package/src/graph.js ADDED
@@ -0,0 +1,99 @@
1
+ import { hydrate } from './index-db.js';
2
+
3
+ /**
4
+ * SQLite's recursive CTE is the graph engine.
5
+ *
6
+ * Nothing is superimposed on top of it: no adjacency cache, no in-memory graph, no
7
+ * second traversal in JavaScript. One prepared statement returns the neighborhood.
8
+ */
9
+
10
+ /**
11
+ * Every node within `depth` edges of the root, in one query.
12
+ *
13
+ * `UNION` rather than `UNION ALL` collapses repeated visits, and the depth bound
14
+ * terminates the recursion. Both matter: `contradicts` edges make cycles inevitable
15
+ * and a cycle without either guard would recurse until SQLite gave up.
16
+ *
17
+ * Edges are followed in both directions. A note that depends on the auth service is
18
+ * part of the auth service's neighborhood, and an operator asking about auth wants
19
+ * to hear about it.
20
+ */
21
+ export function neighborhood(db, rootId, { depth = 1, includeArchived = false } = {}) {
22
+ const rows = db
23
+ .prepare(`
24
+ WITH RECURSIVE hood(id, depth) AS (
25
+ SELECT ?, 0
26
+ UNION
27
+ SELECT CASE WHEN e.src = h.id THEN e.dst ELSE e.src END, h.depth + 1
28
+ FROM edges e
29
+ JOIN hood h ON e.src = h.id OR e.dst = h.id
30
+ WHERE h.depth < ?
31
+ )
32
+ SELECT n.*,
33
+ MIN(hood.depth) AS depth,
34
+ (SELECT COUNT(*) FROM edges e2 WHERE e2.src = n.id OR e2.dst = n.id) AS degree
35
+ FROM nodes n
36
+ JOIN hood ON n.id = hood.id
37
+ WHERE (? = 1 OR n.archived = 0)
38
+ GROUP BY n.id
39
+ ORDER BY depth, n.type, n.id
40
+ `)
41
+ .all(rootId, depth, includeArchived ? 1 : 0);
42
+
43
+ // A node reachable at two depths appears once, at the shorter one, because MIN
44
+ // plus GROUP BY collapses it. Ordering by id last keeps output byte-stable across
45
+ // rebuilds, which is what makes the reindex-determinism check meaningful.
46
+ return rows.map((r) => hydrate(db, r));
47
+ }
48
+
49
+ /** Bytes of body text, which is what actually lands in the model's context. */
50
+ function bodyBytes(node) {
51
+ return Buffer.byteLength(String(node.body ?? ''), 'utf8');
52
+ }
53
+
54
+ /**
55
+ * Enforce the retrieval budget.
56
+ *
57
+ * `get --depth 2` on a well-connected node can return fifty notes and blow the
58
+ * context this whole design exists to protect. Prune order: deepest first, then
59
+ * least connected, because depth is distance from what was asked about and low
60
+ * degree means the node is unlikely to be the hub anyone needed.
61
+ *
62
+ * Two things are never dropped: the root, and any `constraint`. A constraint is what
63
+ * stops an agent from spending a dozen requests on an approach the org forbids, so
64
+ * dropping one to save bytes trades the cheap thing away for the expensive one.
65
+ *
66
+ * Omitted ids are always returned. Silent truncation that reads as full coverage is
67
+ * the failure this budget is most likely to cause and the one worth guarding.
68
+ */
69
+ export function applyBudget(nodes, budgetBytes) {
70
+ let total = nodes.reduce((sum, n) => sum + bodyBytes(n), 0);
71
+ if (total <= budgetBytes) {
72
+ return { kept: nodes, omitted: [], overBudget: false, bytes: total };
73
+ }
74
+
75
+ const removable = nodes
76
+ .filter((n) => n.depth > 0 && n.type !== 'constraint')
77
+ .sort(
78
+ (a, b) =>
79
+ b.depth - a.depth || a.degree - b.degree || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0),
80
+ );
81
+
82
+ const dropped = new Set();
83
+ for (const n of removable) {
84
+ if (total <= budgetBytes) break;
85
+ dropped.add(n.id);
86
+ total -= bodyBytes(n);
87
+ }
88
+
89
+ return {
90
+ kept: nodes.filter((n) => !dropped.has(n.id)),
91
+ omitted: nodes
92
+ .filter((n) => dropped.has(n.id))
93
+ .map((n) => ({ id: n.id, type: n.type, title: n.title, depth: n.depth })),
94
+ // True when the root and the constraints alone exceed the budget. Reported
95
+ // rather than fixed, because the fix would be dropping what is worth keeping.
96
+ overBudget: total > budgetBytes,
97
+ bytes: total,
98
+ };
99
+ }