@vib795/agent-memory 0.6.5 → 0.7.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/README.md CHANGED
@@ -349,11 +349,12 @@ lives under `~/.agents/memory`, nothing is written into the projects you point i
349
349
  at, and there is no per-repo setup step. `cd` between projects freely: the store
350
350
  does not move, split, or reset.
351
351
 
352
- One exception, and it is this repository rather than yours: if you installed from a
353
- clone, `npm install -g .` symlinks rather than copies, so `compact` regenerating the
354
- skill descriptions lands in your working tree and `skills/recall/SKILL.md` shows as
355
- modified. That is generated state, and [From a clone](#from-a-clone) says so. No
356
- project repository is ever written to.
352
+ One note, and it is about this repository rather than yours: if you installed from a
353
+ clone, `npm install -g .` symlinks rather than copies, so the skill links resolve back
354
+ into your working tree. `compact` detects that and refuses to write there — the files
355
+ are tracked, and one developer's note count committed and published is exactly what
356
+ happened for twenty releases. It reports them as skipped instead. No project repository
357
+ is ever written to.
357
358
 
358
359
  What the working directory changes is *scope*, never location.
359
360
 
@@ -366,6 +367,7 @@ What the working directory changes is *scope*, never location.
366
367
  | `search` | whole store | nothing — full text hits every note in every repo |
367
368
  | `get` | whole store | the staleness line only; the note is found by id either way |
368
369
  | `tree` | whole store | **filters it** — defaults to the current repo |
370
+ | `brief` | whole store | **filters it** — the same scoping as `tree` |
369
371
  | `write` | whole store | **is stamped into the note** — see below |
370
372
 
371
373
  The current repo is `git rev-parse --show-toplevel` reduced to its directory name.
@@ -403,6 +405,7 @@ Only `init` is a once-per-machine command, and `agent-memory setup` already ran
403
405
  | `init` | once, via `setup`. Again only to register extra skill paths |
404
406
  | `write` | every capture |
405
407
  | `tree`, `get`, `search` | every lookup |
408
+ | `brief` | every capture, before `write` — what is already known here |
406
409
  | `index` | repair only — `write` reindexes on every call. Run it after hand-editing or deleting notes, or after deleting `index.db` |
407
410
  | `compact` | occasionally. Nothing schedules it: no daemon, no cron, no hook |
408
411
  | `doctor` | after install, after an upgrade, and whenever something looks wrong |
@@ -550,14 +553,15 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 # Windows
550
553
  Both are thin wrappers over `agent-memory setup`; the linking logic lives in
551
554
  `src/setup.js` so there is one implementation rather than three that drift.
552
555
 
553
- Note that `npm install -g .` from a clone *symlinks* rather than copies, so
554
- `compact` regenerates the description in your working tree and
555
- `skills/recall/SKILL.md` will show as modified. That is expected — the description
556
- is generated state, and the committed value is only a placeholder.
556
+ Note that `npm install -g .` from a clone *symlinks* rather than copies, so the skill
557
+ links point back into your working tree. `compact` will not regenerate a description
558
+ there — `skills/recall/SKILL.md` and `skills/remember/SKILL.md` are tracked files, and
559
+ their committed descriptions are deliberately generic placeholders. `compact` prints
560
+ them as skipped, which is the intended outcome, not a failure.
557
561
 
558
562
  Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old.
559
563
 
560
- Run `npm test` for the suite (91 tests, no dependencies). CI runs it on Linux,
564
+ Run `npm test` for the suite (105 tests, no dependencies). CI runs it on Linux,
561
565
  macOS and Windows across Node 22 and 24, and separately installs the packed tarball
562
566
  and exercises it end to end on all three.
563
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vib795/agent-memory",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.",
5
5
  "keywords": [
6
6
  "github-copilot",
@@ -79,6 +79,32 @@ most turns.
79
79
 
80
80
  ---
81
81
 
82
+ ## Step 0 — Read what is already known (ONE terminal call)
83
+
84
+ ```bash
85
+ agent-memory brief
86
+ ```
87
+
88
+ Read-only, safe on every shell including PowerShell, and it costs no request of its
89
+ own — it rides inside the turn you are already answering.
90
+
91
+ It answers three things you would otherwise guess at:
92
+
93
+ - **What is already here.** Deduplication is by exact content, so the same claim in
94
+ different words becomes a second node. If the brief already lists it, either say
95
+ nothing or update that note by its id.
96
+ - **Which ids are real.** An `edges[].dst` or `supersedes` pointing at an id you
97
+ invented is accepted and then silently never connects. Take targets from the brief.
98
+ - **Which types are empty.** A store with no `constraint` has not recorded what the
99
+ environment forbids, which is the type that saves a future session a wasted retry.
100
+
101
+ If the brief lists an id under "already covered", that juncture was captured minutes
102
+ ago. Do not capture it again.
103
+
104
+ Skip this step only when the user named exactly what to write and it is plainly new.
105
+
106
+ ---
107
+
82
108
  ## Step 1 — Select what is durable
83
109
 
84
110
  <!-- extraction-rules:start -->
package/src/cli.js CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  openDb, reindex, searchNodes, getNodeRow, markAccessed, nodeCount, hasFts,
11
11
  } from './index-db.js';
12
12
  import { neighborhood, applyBudget } from './graph.js';
13
- import { buildTree, renderTree, buildDigest } from './digest.js';
13
+ import {
14
+ buildTree, renderTree, buildDigest, buildCaptureNudge, buildBrief, renderBrief,
15
+ } from './digest.js';
14
16
  import { compact, maybeCompact } from './compact.js';
15
17
  import { staleness, currentRepo, reviewCandidates, captureGap } from './staleness.js';
16
18
  import { setup as runSetup, unlinkSkills, danglingSkillLinks, SKILLS } from './setup.js';
@@ -121,6 +123,7 @@ const USAGE = `agent-memory — durable cross-repo knowledge for coding agents
121
123
  search <terms> [--limit N] full-text fallback when the tree misses
122
124
  write --from-json <file> validated upsert; used by the skills
123
125
  [--source <name>] [--repo <name>]
126
+ brief [--repo <name>] what is already known here, before capturing
124
127
  compact dedup, decay, reindex, regenerate
125
128
  doctor preflight and health report
126
129
  export [--scope global|repo|all] knowledge worth carrying to another machine
@@ -217,7 +220,7 @@ function cmdSetup() {
217
220
  if (r.compactError) {
218
221
  lines.push(
219
222
  '',
220
- ` Skills are installed, but refreshing the /recall description failed: ${r.compactError}`,
223
+ ` Skills are installed, but refreshing the /recall and /remember descriptions failed: ${r.compactError}`,
221
224
  ' Run `agent-memory index` then `agent-memory compact` to retry just that step.',
222
225
  );
223
226
  }
@@ -286,6 +289,23 @@ function cmdTree(opts) {
286
289
  return { ok: true, ...result, gap, text: renderTree({ ...result, gap }) };
287
290
  }
288
291
 
292
+ /**
293
+ * Tier 2 of the capture pipeline: what the store already knows, before writing to it.
294
+ *
295
+ * The counterpart to \`tree\`. Same scoping, same budget, opposite reader: \`tree\` tells an
296
+ * agent which note answers a question, this tells it which note it is about to write
297
+ * twice. One call, in a turn already paid for.
298
+ */
299
+ function cmdBrief(opts) {
300
+ const cfg = loadConfig();
301
+ const db = openDb();
302
+ // \`--repo\` with no value means every repo, matching tree. Anything else names one.
303
+ const repo = opts.repo === true ? null : (opts.repo ?? currentRepo());
304
+ const result = buildBrief(db, { repo, cfg });
305
+ db.close();
306
+ return { ok: true, ...result, text: renderBrief(result) };
307
+ }
308
+
289
309
  function cmdGet(opts) {
290
310
  const cfg = loadConfig();
291
311
  const id = opts._[0];
@@ -481,6 +501,7 @@ function cmdCompact() {
481
501
  ...r.decayed.map((d) => `archived ${d.id}, last seen ${d.lastSeen}`),
482
502
  ...r.malformed.map((m) => `warning: unparseable ${m.path}`),
483
503
  `digest ${r.digestChars} chars`,
504
+ `capture nudge ${r.nudgeChars} chars`,
484
505
  ...r.skills.map((s) => `updated description in ${s}`),
485
506
  ...(r.skipped || []).map(
486
507
  (s) => `skipped ${s}: inside this package's git checkout, so the file is tracked`
@@ -553,6 +574,11 @@ function cmdDoctor() {
553
574
  const digest = buildDigest(db, { cfg });
554
575
  add('digest within cap', digest.length <= cfg.digestChars, `${digest.length}/${cfg.digestChars} chars`);
555
576
 
577
+ // The nudge shares the cap and the silent fallback: over it, Tier 1 quietly becomes
578
+ // the generic string, which reads exactly like a store with nothing to report.
579
+ const nudge = buildCaptureNudge(db, { cfg });
580
+ add('capture nudge within cap', nudge.length <= cfg.digestChars, `${nudge.length}/${cfg.digestChars} chars`);
581
+
556
582
  const registered = cfg.skillPaths || [];
557
583
  const missing = registered.filter((p) => !existsSync(p));
558
584
  add(
@@ -640,7 +666,7 @@ function cmdDoctor() {
640
666
  // Staleness is a report, not a failure. Being told about it is the whole feature.
641
667
  const advisory = new Set(['staleness', 'capture gap', 'skills linked']);
642
668
  const fatal = checks.filter((c) => !c.ok && !advisory.has(c.name));
643
- return { ok: fatal.length === 0, checks, stale, gap, digest, text };
669
+ return { ok: fatal.length === 0, checks, stale, gap, digest, nudge, text };
644
670
  }
645
671
 
646
672
  // --- dispatch ---------------------------------------------------------------
@@ -974,6 +1000,7 @@ const COMMANDS = {
974
1000
  init: cmdInit,
975
1001
  index: cmdIndex,
976
1002
  tree: cmdTree,
1003
+ brief: cmdBrief,
977
1004
  get: cmdGet,
978
1005
  search: cmdSearch,
979
1006
  write: cmdWrite,
package/src/compact.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { join, sep } from 'node:path';
3
+ import { join, sep, basename, dirname } from 'node:path';
4
4
  import { loadConfig, paths } from './config.js';
5
5
  import { listNotes, archiveNote, contentHash, nowIso, serializeNote } from './store.js';
6
6
  import { atomicWrite } from './atomic.js';
7
7
  import { openDb, reindex } from './index-db.js';
8
- import { buildDigest, buildTree, renderTree } from './digest.js';
8
+ import { buildDigest, buildCaptureNudge, buildTree, renderTree } from './digest.js';
9
9
 
10
10
  /**
11
11
  * Compaction is pure code. No model is involved, and none should be.
@@ -203,9 +203,33 @@ export function writeSkillDescription(skillPath, description) {
203
203
  return true;
204
204
  }
205
205
 
206
- /** Regenerate everything derived: ROUTING.md and each installed skill description. */
206
+ /**
207
+ * Which skill a registered path belongs to.
208
+ *
209
+ * Derived from the path rather than stored beside it, because `setup` is what creates
210
+ * these paths and it only ever creates the two shapes below. Keeping `skillPaths` a
211
+ * flat list of strings means no config migration and no second source of truth about
212
+ * which skill is which — the layout already answers it.
213
+ */
214
+ export function skillNameFromPath(p) {
215
+ const file = basename(p);
216
+ if (file === 'SKILL.md') return basename(dirname(p));
217
+ const m = file.match(/^(.+)\.prompt\.md$/);
218
+ return m ? m[1] : null;
219
+ }
220
+
221
+ /**
222
+ * Regenerate everything derived: ROUTING.md and each installed skill description.
223
+ *
224
+ * Two descriptions are generated, not one, and which a path receives is decided by the
225
+ * skill it belongs to. `remember` gets the capture nudge; everything else registered
226
+ * gets the digest. Before this, one text was written to every registered path, which is
227
+ * why `setup` could only ever register `recall` — handing `remember` the digest would
228
+ * have replaced a good description with a description of the wrong thing.
229
+ */
207
230
  function regenerate(db, cfg) {
208
231
  const digest = buildDigest(db, { cfg });
232
+ const nudge = buildCaptureNudge(db, { cfg });
209
233
  const tree = buildTree(db, { all: true, cfg });
210
234
 
211
235
  const routing = [
@@ -227,9 +251,18 @@ function regenerate(db, cfg) {
227
251
  skipped.push(p);
228
252
  continue;
229
253
  }
230
- if (writeSkillDescription(p, digest)) skills.push(p);
254
+ const text = skillNameFromPath(p) === 'remember' ? nudge : digest;
255
+ if (writeSkillDescription(p, text)) skills.push(p);
231
256
  }
232
- return { digest, digestChars: digest.length, routing: paths.routing, skills, skipped };
257
+ return {
258
+ digest,
259
+ digestChars: digest.length,
260
+ nudge,
261
+ nudgeChars: nudge.length,
262
+ routing: paths.routing,
263
+ skills,
264
+ skipped,
265
+ };
233
266
  }
234
267
 
235
268
  /**
package/src/config.js CHANGED
@@ -102,6 +102,7 @@ export const DEFAULTS = {
102
102
  staleReviewCommits: 100, // above this, doctor flags it for review
103
103
  captureGapCommits: 50, // repo movement with no capture at all before it is worth saying
104
104
  compactThreshold: 10, // node-count delta that triggers an automatic compact
105
+ briefRecentMinutes: 120, // window the capture brief calls already covered
105
106
  };
106
107
 
107
108
  // Written by the installer: every SKILL.md whose description compact regenerates.
package/src/digest.js CHANGED
@@ -1,4 +1,5 @@
1
- import { loadConfig } from './config.js';
1
+ import { loadConfig, NOTE_TYPES } from './config.js';
2
+ import { captureGap, currentRepo } from './staleness.js';
2
3
 
3
4
  /**
4
5
  * Two-tier routing.
@@ -12,6 +13,11 @@ import { loadConfig } from './config.js';
12
13
  *
13
14
  * Neither tier costs a premium request. A request is charged per prompt, not per
14
15
  * tool call, so both of these ride inside a turn that was already paid for.
16
+ *
17
+ * Tier 1 has two occupants, not one. `recall`'s description advertises what the store
18
+ * knows; `remember`'s advertises what it is missing. Both are the same mechanism — a
19
+ * line of frontmatter that code regenerates and every conversation loads — pointed at
20
+ * opposite halves of the same problem.
15
21
  */
16
22
 
17
23
  // Never dropped from either tier. A constraint is what stops an agent from burning
@@ -126,6 +132,111 @@ export function buildDigest(db, { cfg = loadConfig() } = {}) {
126
132
  return out;
127
133
  }
128
134
 
135
+ // The routing clause for `remember`, and the mirror of USE_WHEN. It carries both the
136
+ // asked form and the unasked one, because the unasked one is the entire reason this
137
+ // description is regenerated at all: a memory that only grows when someone remembers
138
+ // to grow it stays thin. Never dropped, for the same reason USE_WHEN is never dropped.
139
+ const CAPTURE_WHEN =
140
+ 'Use when the user says remember this, save this or note this down — and invoke it ' +
141
+ 'unasked the moment a decision settles, a constraint surfaces, a root cause is ' +
142
+ 'found or a convention is agreed.';
143
+
144
+ const NUDGE_GENERIC = 'Capture durable project knowledge into a cross-repo memory graph.';
145
+
146
+ /**
147
+ * Notes that speak for this repository, counted the way `captureGap` scopes them.
148
+ *
149
+ * A node with no repos is global and applies everywhere, so it counts here; a node
150
+ * claiming other repos does not. This mirrors `captureGap`'s filter deliberately —
151
+ * two different answers to "does this note cover me" in one description would be a
152
+ * bug the reader could see.
153
+ */
154
+ function repoScopedCount(db, repo, type = null) {
155
+ // Bound in SQL order: the type predicate precedes the repo one in the statement.
156
+ return db
157
+ .prepare(`
158
+ SELECT COUNT(*) AS c
159
+ FROM nodes n
160
+ WHERE n.archived = 0 ${type ? 'AND n.type = ?' : ''}
161
+ AND (NOT EXISTS (SELECT 1 FROM node_repos r WHERE r.node_id = n.id)
162
+ OR EXISTS (SELECT 1 FROM node_repos r WHERE r.node_id = n.id AND r.repo = ?))
163
+ `)
164
+ .get(...(type ? [type, repo] : [repo])).c;
165
+ }
166
+
167
+ /**
168
+ * Tier 1, second occupant: the `remember` skill description.
169
+ *
170
+ * `captureGap` has known since 0.5 how far a repository has moved with nothing written
171
+ * down in it, and that answer went only to `doctor` — a command a person runs on
172
+ * purpose, which is precisely the person who did not need telling. The signal never
173
+ * reached the one reader who could act on it mid-conversation. This routes it to the
174
+ * surface that is already loaded into every turn.
175
+ *
176
+ * Written as a *state*, never as an instruction. "340 commits since anything was
177
+ * captured here" is a fact the model can weigh against what just happened in the
178
+ * conversation; "remember to capture things" is wallpaper it stops seeing by the third
179
+ * turn. The distinction is the whole design: code supplies the timing signal, the model
180
+ * still decides whether anything durable actually happened.
181
+ *
182
+ * It goes quiet on a covered repository. A description that nags at a store which is
183
+ * already current teaches the reader to discount the line, and then it is worth nothing
184
+ * on the day it has something to say.
185
+ *
186
+ * Scoping caveat, stated because it is visible in the output: the gap is per repository
187
+ * and `compact` runs wherever the user happens to be, so this describes the repo where
188
+ * compaction last ran. That is why every variant names the repo out loud — a reader in
189
+ * a different tree can see the mismatch rather than act on a number that is not theirs.
190
+ * `maybeCompact` re-points it on the next write, so it self-corrects with use.
191
+ */
192
+ export function buildCaptureNudge(db, { cfg = loadConfig(), cwd = process.cwd(), repo, gap } = {}) {
193
+ const g = gap !== undefined ? gap : captureGap(db, { cwd, cfg, repo });
194
+
195
+ const compose = (head) => {
196
+ const out = head ? `${head} ${CAPTURE_WHEN}` : `${NUDGE_GENERIC} ${CAPTURE_WHEN}`;
197
+ // Nothing here is a list, so there is no elastic middle to shed one item at a
198
+ // time. Over the cap, drop the whole head rather than truncate mid-sentence: a
199
+ // description that stops in the middle of a number reads as corrupted, and the
200
+ // routing clause is the part that must survive either way.
201
+ return out.length > cfg.digestChars ? `${NUDGE_GENERIC} ${CAPTURE_WHEN}` : out;
202
+ };
203
+
204
+ // Outside a repository there is no gap to report and no repo to name. Say what the
205
+ // skill is for and stop, rather than inventing a number.
206
+ if (!g) return compose(null);
207
+
208
+ // Never captured in this repository. `captureGap` counts only notes carrying a
209
+ // `captured_sha`, so this is its answer to "has anyone written anything down here",
210
+ // and the commit branches below stay on that same definition. The broader count is
211
+ // reserved for the covered branches, where the question is how much knowledge applies
212
+ // here rather than how much of it was captured here.
213
+ if (g.notes === 0) {
214
+ // captureGap withholds its note on a repository too young for the absence to mean
215
+ // anything. Stay silent with it: nagging on commit three is how a signal gets
216
+ // discounted long before the day it matters.
217
+ return compose(
218
+ g.note ? `Nothing has ever been captured for ${g.repo} — ${g.commits} commits of history.` : null,
219
+ );
220
+ }
221
+
222
+ // Captured, but the repository has moved a long way since the most recent one.
223
+ if (g.note) return compose(`${g.commits} commits since anything was captured for ${g.repo}.`);
224
+
225
+ // Current on commits, but blind in the type that matters most. `digest` privileges
226
+ // constraints and never drops them from Tier 1; a store holding none has not recorded
227
+ // the thing most likely to save a future session a wasted retry loop.
228
+ const notes = repoScopedCount(db, g.repo);
229
+ if (repoScopedCount(db, g.repo, PRIVILEGED) === 0) {
230
+ return compose(
231
+ `${notes} note${notes === 1 ? '' : 's'} for ${g.repo} and no constraint recorded — ` +
232
+ 'what this environment forbids has never been written down.',
233
+ );
234
+ }
235
+
236
+ // Covered. Report the state plainly and let the routing clause do the rest.
237
+ return compose(`${notes} note${notes === 1 ? '' : 's'} for ${g.repo}, capture is current.`);
238
+ }
239
+
129
240
  /**
130
241
  * Tier 2: the routing tree, scoped.
131
242
  *
@@ -207,3 +318,167 @@ export function renderTree(result) {
207
318
  if (result.gap?.note) out.push(result.gap.note);
208
319
  return out.join('\n');
209
320
  }
321
+
322
+ /**
323
+ * What each type is for, shown only where one is missing.
324
+ *
325
+ * Taken from the table in the remember skill so there is one wording, not two that
326
+ * drift. Printed against a zero count it answers the question the count raises: not
327
+ * "you have none of these" but "here is what one would have said".
328
+ */
329
+ const TYPE_HINT = {
330
+ constraint: 'what the environment or the org forbids',
331
+ decision: 'what was chosen, why, and what was rejected',
332
+ convention: 'how this codebase does something, and the gotcha',
333
+ system: 'how a thing works, anchored to a path:line',
334
+ };
335
+
336
+ /** Notes per type under the same scoping the tree uses, zeros included. */
337
+ function typeCounts(db, repo) {
338
+ const rows = repo
339
+ ? db
340
+ .prepare(`
341
+ SELECT n.type AS type, COUNT(DISTINCT n.id) AS c
342
+ FROM nodes n
343
+ LEFT JOIN node_repos r ON r.node_id = n.id
344
+ WHERE n.archived = 0 AND (n.scope = 'global' OR r.repo = ?)
345
+ GROUP BY n.type
346
+ `)
347
+ .all(repo)
348
+ : db.prepare('SELECT type, COUNT(*) AS c FROM nodes WHERE archived = 0 GROUP BY type').all();
349
+
350
+ // Seeded from NOTE_TYPES so a type with no rows reports 0 rather than going absent.
351
+ // The absent ones are the entire point of this section.
352
+ const counts = new Map(NOTE_TYPES.map((t) => [t, 0]));
353
+ for (const r of rows) counts.set(r.type, r.c);
354
+ return counts;
355
+ }
356
+
357
+ /**
358
+ * Ids written recently enough that capturing them again would be a duplicate.
359
+ *
360
+ * The skill already forbids re-capturing a juncture that came up twice in one
361
+ * conversation, and that rule currently depends on the model remembering across a
362
+ * long turn. This makes it data instead.
363
+ *
364
+ * Recency is a proxy and is labelled as one: the CLI has no notion of a session, and
365
+ * inventing one would mean tracking state this tool deliberately does not keep. A
366
+ * window wide enough to cover the working session is the honest approximation.
367
+ */
368
+ function recentlyCaptured(db, repo, cfg, now) {
369
+ // Same format store.js writes, so a lexicographic compare is a chronological one.
370
+ const cutoff = new Date(now - cfg.briefRecentMinutes * 60000)
371
+ .toISOString()
372
+ .replace(/\.\d{3}Z$/, 'Z');
373
+ const rows = repo
374
+ ? db
375
+ .prepare(`
376
+ SELECT DISTINCT n.id AS id, n.updated AS updated
377
+ FROM nodes n
378
+ LEFT JOIN node_repos r ON r.node_id = n.id
379
+ WHERE n.archived = 0 AND n.updated >= ? AND (n.scope = 'global' OR r.repo = ?)
380
+ ORDER BY n.updated DESC, n.id
381
+ `)
382
+ .all(cutoff, repo)
383
+ : db
384
+ .prepare(`
385
+ SELECT id, updated FROM nodes
386
+ WHERE archived = 0 AND updated >= ? ORDER BY updated DESC, id
387
+ `)
388
+ .all(cutoff);
389
+ return rows.map((r) => r.id);
390
+ }
391
+
392
+ /**
393
+ * Tier 2 of the capture pipeline: what `remember` reads before it composes.
394
+ *
395
+ * The mirror of `buildTree`, scoped for a writer rather than a reader. Without it the
396
+ * skill composes blind, and blind composition has three failure modes this answers
397
+ * directly: it rewords a note that already exists (dedup is by exact content hash, so
398
+ * a paraphrase becomes a second node), it invents an `edges[].dst` id that never
399
+ * connects because a missing dst is legal, and it cannot see which type the store is
400
+ * missing at the one moment it is about to write something.
401
+ *
402
+ * One call, inside a turn that was already paid for. That economy is why the brief is
403
+ * a command the skill runs rather than anything loaded standing — and it matters more
404
+ * on Copilot, where a second round trip is a second premium request.
405
+ *
406
+ * The list is `buildTree`'s, deliberately: two answers to "which notes speak for this
407
+ * repo" in one tool would be a bug the reader could see.
408
+ */
409
+ export function buildBrief(db, { repo, cwd = process.cwd(), cfg = loadConfig(), gap, now = Date.now() } = {}) {
410
+ const here = repo === undefined ? currentRepo(cwd) : repo;
411
+ const g = gap !== undefined ? gap : here ? captureGap(db, { cwd, cfg, repo: here }) : null;
412
+ const tree = buildTree(db, { repo: here, cfg });
413
+ const counts = typeCounts(db, here);
414
+
415
+ return {
416
+ repo: here,
417
+ gap: g,
418
+ tree,
419
+ counts: Object.fromEntries(counts),
420
+ missing: NOTE_TYPES.filter((t) => counts.get(t) === 0),
421
+ recent: recentlyCaptured(db, here, cfg, now),
422
+ recentMinutes: cfg.briefRecentMinutes,
423
+ total: tree.total,
424
+ };
425
+ }
426
+
427
+ export function renderBrief(result) {
428
+ const { repo, gap, tree, counts, missing, recent, recentMinutes } = result;
429
+ const scope = repo ?? 'all repos';
430
+
431
+ // The header carries the same signal the remember description does, at the same
432
+ // moment it is being acted on. A brief that opens with a note count while the repo
433
+ // has moved 300 commits since anyone wrote anything is burying its own headline.
434
+ // Composed from the gap fields rather than reusing \`gap.note\` verbatim: that string
435
+ // names the repo, and the header already has, so borrowing it stutters.
436
+ const state = !repo
437
+ ? `${tree.total} notes in the store`
438
+ : gap?.note && gap.notes === 0
439
+ ? `nothing captured here yet, over ${gap.commits} commits of history`
440
+ : gap?.note
441
+ ? `${gap.commits} commits since anything was captured here`
442
+ : `${tree.total} note${tree.total === 1 ? '' : 's'} here, capture is current`;
443
+ const out = [`# capture brief: ${scope} — ${state}`];
444
+
445
+ if (tree.lines.length) {
446
+ out.push('', 'already known here — write the gap, not these');
447
+ const width = tree.lines.reduce((w, e) => Math.max(w, e.id.length), 0);
448
+ for (const e of tree.lines) {
449
+ out.push(`${e.type.padEnd(10)} ${e.id.padEnd(width)} ${e.title}${e.archived ? ' (archived)' : ''}`);
450
+ }
451
+ // Never silent. A truncated list that reads as the whole store is how a duplicate
452
+ // gets written against a note that was there the entire time.
453
+ if (tree.omitted.length) {
454
+ out.push(`${tree.omitted.length} more not shown, run agent-memory tree --all`);
455
+ }
456
+ out.push('', 'These ids are real. Use them as an `edges[].dst` or `supersedes` target;');
457
+ out.push('an id you invent is accepted and then never connects to anything.');
458
+ }
459
+
460
+ if (missing.length) {
461
+ out.push('', 'nothing captured yet in these types');
462
+ const width = missing.reduce((w, t) => Math.max(w, t.length), 0);
463
+ for (const t of missing) out.push(` ${t.padEnd(width)} ${TYPE_HINT[t]}`);
464
+ }
465
+
466
+ const present = Object.entries(counts).filter(([, c]) => c > 0);
467
+ if (present.length) {
468
+ out.push('', `counts: ${present.map(([t, c]) => `${t} ${c}`).join(', ')}`);
469
+ }
470
+
471
+ if (recent.length) {
472
+ // Written as the reason rather than the rule, because the rule is already in the
473
+ // skill and the model is being asked to apply it, not to learn it again.
474
+ out.push(
475
+ '',
476
+ `captured in the last ${recentMinutes} minutes, so already covered: ${recent.join(', ')}`,
477
+ );
478
+ }
479
+
480
+ if (!tree.lines.length && !recent.length) {
481
+ out.push('', 'The store holds nothing for this repository yet. Anything durable is new.');
482
+ }
483
+ return out.join('\n');
484
+ }
package/src/setup.js CHANGED
@@ -200,14 +200,27 @@ export function unlinkSkills() {
200
200
  return { removed, kept };
201
201
  }
202
202
 
203
+ /**
204
+ * Skills whose description `compact` regenerates from store state.
205
+ *
206
+ * `recall` advertises what the store knows. `remember` advertises what it is missing,
207
+ * which is the only form of that signal that reaches the model at the moment capture is
208
+ * worth doing — `captureGap` had computed it since 0.5 and sent it only to `doctor`, a
209
+ * command run by the one person who did not need telling.
210
+ *
211
+ * `handoff` is deliberately absent: it describes itself, and there is no store-derived
212
+ * state that would make its description more useful than the one it ships with.
213
+ */
214
+ export const REGENERATED = ['recall', 'remember'];
215
+
203
216
  /**
204
217
  * Install into every agent present on this machine, then build the store.
205
218
  *
206
- * Only `recall` is registered for description regeneration. `compact` overwrites the
207
- * description of every path it is given, and handoff and remember describe
208
- * themselves; registering all three would replace two good descriptions with a third.
209
- * Copies and prompt files register their own path, because rewriting the packaged
210
- * original would never reach them.
219
+ * Only the skills in `REGENERATED` are registered, and `compact` now picks the text per
220
+ * skill rather than writing one digest to every path it is given. That is what makes
221
+ * registering a second skill safe; before it, doing so would have replaced a good
222
+ * description with a description of the wrong thing. Copies and prompt files register
223
+ * their own path, because rewriting the packaged original would never reach them.
211
224
  */
212
225
  export function setup({ compactFn } = {}) {
213
226
  const targets = installableTargets();
@@ -241,9 +254,11 @@ export function setup({ compactFn } = {}) {
241
254
  }
242
255
 
243
256
  const skillPaths = [
244
- join(packagedSkillsDir(), 'recall', 'SKILL.md'),
245
- ...copies.filter((c) => c.name === 'recall').map((c) => join(c.path, 'SKILL.md')),
246
- ...installed.filter((i) => i.mode === 'prompt' && i.name === 'recall').map((i) => i.path),
257
+ ...REGENERATED.map((name) => join(packagedSkillsDir(), name, 'SKILL.md')),
258
+ ...copies.filter((c) => REGENERATED.includes(c.name)).map((c) => join(c.path, 'SKILL.md')),
259
+ ...installed
260
+ .filter((i) => i.mode === 'prompt' && REGENERATED.includes(i.name))
261
+ .map((i) => i.path),
247
262
  ];
248
263
  // Drop registrations whose file is gone before adding the current ones. Renaming
249
264
  // the checkout, moving it, or reinstalling under a different prefix each leave a