Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "agent-memory",
"source": "./",
"description": "Durable cross-repository memory for coding agents β€” built to survive an IT security review. Zero runtime dependencies, zero dev dependencies, no install script: nothing runs when you install it, and granting it your agents is a separate explicit command. Independently scanned, with a passing verdict. Adds /handoff, /remember and /recall over one local markdown store. Requires the CLI: npm install -g @vib795/agent-memory (Node >= 22.5).",
"version": "0.7.1",
"version": "0.7.2",
"author": {
"name": "Utkarsh Singh",
"url": "https://github.com/vib795"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "agent-memory",
"displayName": "agent-memory",
"version": "0.7.1",
"version": "0.7.2",
"description": "Durable cross-repository memory for coding agents β€” built to survive an IT security review. Zero runtime dependencies, zero dev dependencies, no install script: nothing runs when you install it, and granting it your agents is a separate explicit command. Independently scanned, with a passing verdict. Adds /handoff, /remember and /recall over one local markdown store. Requires the CLI: npm install -g @vib795/agent-memory (Node >= 22.5).",
"author": {
"name": "Utkarsh Singh",
Expand Down
7 changes: 4 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,14 @@ flowchart TB
D["recall description<br/>≀ 400 chars β€” what the store knows<br/>'Durable project knowledge: 5 notes, 1 constraint<br/>across agent-memory. Topics: … Use when …'"]
R["remember description<br/>≀ 400 chars β€” what it is missing<br/>'340 commits since anything was captured<br/>for agent-memory. Use when …'"]
end
subgraph t2["TIER 2 β€” per-invocation, only when recall fires"]
TR["routing tree<br/>≀ 80 lines<br/>type Β· id Β· title, ordered by<br/>constraint-first then degree"]
subgraph t2["TIER 2 β€” per-invocation, when recall or remember fires"]
TR["recall: routing tree<br/>≀ 80 lines, constraint-first then degree"]
BR["remember: capture brief<br/>same list, plus empty types<br/>and what was just captured"]
end
subgraph t3["TIER 3 β€” only what was asked for"]
N["note bodies + neighborhood<br/>≀ 8 KB"]
end
t1 -->|"agent decides to invoke"| t2 -->|"agent picks an id"| t3
t1 -->|"agent decides to invoke"| t2 -->|"agent picks an id, or writes one"| t3

style t1 fill:#12514c,color:#fff
style t2 fill:#2d6a63,color:#fff
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ them as skipped, which is the intended outcome, not a failure.

Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old.

Run `npm test` for the suite (106 tests, no dependencies). CI runs it on Linux,
Run `npm test` for the suite (109 tests, no dependencies). CI runs it on Linux,
macOS and Windows across Node 22 and 24, and separately installs the packed tarball
and exercises it end to end on all three.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vib795/agent-memory",
"version": "0.7.1",
"version": "0.7.2",
"description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.",
"keywords": [
"github-copilot",
Expand Down
8 changes: 6 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const DEFAULTS = {
captureGapCommits: 50, // repo movement with no capture at all before it is worth saying
compactThreshold: 10, // node-count delta that triggers an automatic compact
briefRecentMinutes: 120, // window the capture brief calls already covered
briefRecentIds: 10, // ids printed from that window before the rest are counted
};

// Written by the installer: every SKILL.md whose description compact regenerates.
Expand Down Expand Up @@ -134,7 +135,10 @@ export function loadConfig() {
const merged = { ...DEFAULTS, skillPaths: [] };

for (const [k, v] of Object.entries(readJson(paths.config))) {
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) merged[k] = v;
// Integer, not merely finite. Every cap here counts something, and one of them is
// bound into a SQL `LIMIT`, where a fractional value is a datatype mismatch rather
// than a rounding question.
if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) merged[k] = v;
}
const machine = readJson(paths.machineConfig);
for (const k of LIST_KEYS) {
Expand Down Expand Up @@ -163,7 +167,7 @@ export function saveConfig(patch) {
let capsDirty = false;
let machineDirty = false;
for (const [k, v] of Object.entries(patch || {})) {
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) {
if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) {
caps[k] = v;
capsDirty = true;
}
Expand Down
98 changes: 85 additions & 13 deletions src/digest.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadConfig, NOTE_TYPES } from './config.js';
import { createHash } from 'node:crypto';
import { captureGap, currentRepo } from './staleness.js';

/**
Expand Down Expand Up @@ -31,6 +32,47 @@ const USE_WHEN =
'Use when you need to know how a system works, why a decision was made, ' +
'what convention applies, or what the environment forbids.';

/**
* A repository name safe to put in front of a model.
*
* `currentRepo` is `basename(git rev-parse --show-toplevel)` β€” a directory name. That
* value reaches Tier 1, the one string loaded into every conversation, and
* `writeSkillDescription` only JSON-quotes the line, which keeps the YAML valid and
* does nothing about the content.
*
* The charset filter alone is not enough, and the reason is specific: a GitHub
* repository name is drawn from exactly this charset, so
* `SYSTEM-ignore-previous-instructions` survives it unchanged and arrives by nothing
* more exotic than `git clone`. Hyphens separate words as well as spaces do.
*
* So shape decides. A repository name is one to three segments and short; an
* instruction needs more words than that. Anything outside that shape is rendered as a
* stable non-semantic identifier instead β€” the name is still distinguishable from
* another repository's, and still tells a reader in the wrong tree that the numbers are
* not theirs, which is the only job it had.
*
* The cost is honest: a legitimate four-segment name shows as `repo-<hash>`. That is a
* deliberate trade of some legibility for a Tier-1 string that cannot be authored by
* whoever chose the directory name.
*
* Display only. Every query still matches on the real name, because a repository whose
* notes stopped being found would be a worse bug than the one this closes.
*/
export function safeRepo(name) {
if (typeof name !== 'string' || !name) return 'unnamed';
// Filtering must not be able to *make* a name look ordinary. Stripping the spaces
// out of "Ignore previous instructions" collapses it into one long token that would
// pass the shape test below, so a name that had to be modified at all is already
// outside the shape and goes straight to an identifier.
const untouched = /^[A-Za-z0-9._-]+$/.test(name);
const segments = name.split(/[-._]+/).filter(Boolean);
if (untouched && segments.length <= 3 && name.length <= 32) return name;
// Stable across runs and machines, so the same repository always reads the same and
// two repositories never collide in the description.
return `repo-${createHash('sha256').update(name).digest('hex').slice(0, 8)}`;
}


function typeRank(type) {
const i = TYPE_ORDER.indexOf(type);
return i === -1 ? TYPE_ORDER.length : i;
Expand Down Expand Up @@ -88,7 +130,7 @@ export function buildDigest(db, { cfg = loadConfig() } = {}) {
ORDER BY c DESC, r.repo
`)
.all()
.map((r) => r.repo);
.map((r) => safeRepo(r.repo));

const topics = db
.prepare(`
Expand Down Expand Up @@ -210,31 +252,34 @@ export function buildCaptureNudge(db, { cfg = loadConfig(), cwd = process.cwd(),
// and the commit branches below stay on that same definition. The broader count is
// reserved for the covered branches, where the question is how much knowledge applies
// here rather than how much of it was captured here.
// Display only. `repoScopedCount` below is still given the real name.
const label = safeRepo(g.repo);

if (g.notes === 0) {
// captureGap withholds its note on a repository too young for the absence to mean
// anything. Stay silent with it: nagging on commit three is how a signal gets
// discounted long before the day it matters.
return compose(
g.note ? `Nothing has ever been captured for ${g.repo} β€” ${g.commits} commits of history.` : null,
g.note ? `Nothing has ever been captured for ${label} β€” ${g.commits} commits of history.` : null,
);
}

// Captured, but the repository has moved a long way since the most recent one.
if (g.note) return compose(`${g.commits} commits since anything was captured for ${g.repo}.`);
if (g.note) return compose(`${g.commits} commits since anything was captured for ${label}.`);

// Current on commits, but blind in the type that matters most. `digest` privileges
// constraints and never drops them from Tier 1; a store holding none has not recorded
// the thing most likely to save a future session a wasted retry loop.
const notes = repoScopedCount(db, g.repo);
if (repoScopedCount(db, g.repo, PRIVILEGED) === 0) {
return compose(
`${notes} note${notes === 1 ? '' : 's'} for ${g.repo} and no constraint recorded β€” ` +
`${notes} note${notes === 1 ? '' : 's'} for ${label} and no constraint recorded β€” ` +
'what this environment forbids has never been written down.',
);
}

// Covered. Report the state plainly and let the routing clause do the rest.
return compose(`${notes} note${notes === 1 ? '' : 's'} for ${g.repo}, capture is current.`);
return compose(`${notes} note${notes === 1 ? '' : 's'} for ${label}, capture is current.`);
}

/**
Expand Down Expand Up @@ -301,7 +346,7 @@ export function buildTree(db, { repo = null, all = false, cfg = loadConfig() } =
}

export function renderTree(result) {
const scope = result.repo ? result.repo : 'all repos';
const scope = result.repo ? safeRepo(result.repo) : 'all repos';
const out = [`# memory: ${scope} β€” ${result.total} notes`];
const width = result.lines.reduce((w, e) => Math.max(w, e.id.length), 0);
for (const e of result.lines) {
Expand Down Expand Up @@ -366,10 +411,15 @@ function typeCounts(db, repo) {
* window wide enough to cover the working session is the honest approximation.
*/
function recentlyCaptured(db, repo, cfg, now) {
// Bound straight into SQLite's `LIMIT ?`, which rejects a REAL with `datatype
// mismatch`. Callers may hand in a cfg that never went through loadConfig -- every
// test does -- so the floor lives here as well as there.
const cap = Math.max(1, Math.floor(cfg.briefRecentIds));
// Same format store.js writes, so a lexicographic compare is a chronological one.
const cutoff = new Date(now - cfg.briefRecentMinutes * 60000)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
// One row past the cap is the cheap overflow probe.
const rows = repo
? db
.prepare(`
Expand All @@ -378,15 +428,31 @@ function recentlyCaptured(db, repo, cfg, now) {
LEFT JOIN node_repos r ON r.node_id = n.id
WHERE n.archived = 0 AND n.updated >= ? AND (n.scope = 'global' OR r.repo = ?)
ORDER BY n.updated DESC, n.id
LIMIT ?
`)
.all(cutoff, repo)
.all(cutoff, repo, cap + 1)
: db
.prepare(`
SELECT id, updated FROM nodes
WHERE archived = 0 AND updated >= ? ORDER BY updated DESC, id
LIMIT ?
`)
.all(cutoff, cap + 1);

if (rows.length <= cap) return { ids: rows.map((r) => r.id), omitted: 0 };

// The exact count is only worth a second query when there is something to report.
const total = repo
? db
.prepare(`
SELECT COUNT(DISTINCT n.id) AS c
FROM nodes n
LEFT JOIN node_repos r ON r.node_id = n.id
WHERE n.archived = 0 AND n.updated >= ? AND (n.scope = 'global' OR r.repo = ?)
`)
.all(cutoff);
return rows.map((r) => r.id);
.get(cutoff, repo).c
: db.prepare('SELECT COUNT(*) AS c FROM nodes WHERE archived = 0 AND updated >= ?').get(cutoff).c;
return { ids: rows.slice(0, cap).map((r) => r.id), omitted: total - cap };
}

/**
Expand All @@ -411,22 +477,24 @@ export function buildBrief(db, { repo, cwd = process.cwd(), cfg = loadConfig(),
const g = gap !== undefined ? gap : here ? captureGap(db, { cwd, cfg, repo: here }) : null;
const tree = buildTree(db, { repo: here, cfg });
const counts = typeCounts(db, here);
const recent = recentlyCaptured(db, here, cfg, now);

return {
repo: here,
gap: g,
tree,
counts: Object.fromEntries(counts),
missing: NOTE_TYPES.filter((t) => counts.get(t) === 0),
recent: recentlyCaptured(db, here, cfg, now),
recent: recent.ids,
recentOmitted: recent.omitted,
recentMinutes: cfg.briefRecentMinutes,
total: tree.total,
};
}

export function renderBrief(result) {
const { repo, gap, tree, counts, missing, recent, recentMinutes } = result;
const scope = repo ?? 'all repos';
const { repo, gap, tree, counts, missing, recent, recentOmitted, recentMinutes } = result;
const scope = repo ? safeRepo(repo) : 'all repos';

// The header carries the same signal the remember description does, at the same
// moment it is being acted on. A brief that opens with a note count while the repo
Expand Down Expand Up @@ -471,9 +539,13 @@ export function renderBrief(result) {
if (recent.length) {
// Written as the reason rather than the rule, because the rule is already in the
// skill and the model is being asked to apply it, not to learn it again.
// Bounded, and it says so. A bulk write or import stamps every note with the same
// minute; printing all of them would spend the context this brief exists to
// conserve, while reading as the whole list -- the exact failure the tree refuses.
const more = recentOmitted ? ` (+${recentOmitted} more)` : '';
out.push(
'',
`captured in the last ${recentMinutes} minutes, so already covered: ${recent.join(', ')}`,
`captured in the last ${recentMinutes} minutes, so already covered: ${recent.join(', ')}${more}`,
);
}

Expand Down
Loading
Loading