@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/HOWTO.md +368 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/install.ps1 +54 -0
- package/install.sh +36 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +45 -0
- package/skills/handoff/SKILL.md +344 -0
- package/skills/recall/SKILL.md +125 -0
- package/skills/remember/SKILL.md +155 -0
- package/src/atomic.js +57 -0
- package/src/cli.js +558 -0
- package/src/compact.js +242 -0
- package/src/config.js +91 -0
- package/src/digest.js +199 -0
- package/src/graph.js +99 -0
- package/src/index-db.js +317 -0
- package/src/promptfile.js +78 -0
- package/src/redact.js +97 -0
- package/src/schema.js +98 -0
- package/src/setup.js +211 -0
- package/src/staleness.js +151 -0
- package/src/store.js +0 -0
- package/src/targets.js +139 -0
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
|
+
}
|