@vib795/agent-memory 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +1 -1
- package/src/cli.js +156 -1
- package/src/config.js +61 -29
package/README.md
CHANGED
|
@@ -94,6 +94,36 @@ recursive CTE *is* the graph engine, and `UNION` plus a depth bound is what keep
|
|
|
94
94
|
- **Constraints are never dropped.** At any cap, in either tier. A constraint is what
|
|
95
95
|
stops an agent burning a retry loop on an approach that was never going to ship.
|
|
96
96
|
|
|
97
|
+
## Carrying knowledge between machines
|
|
98
|
+
|
|
99
|
+
Client work usually lives in its own environment, and when the engagement ends the
|
|
100
|
+
environment goes with it. What should survive is the part that was never the
|
|
101
|
+
client's — the constraints your org imposes, the conventions you follow, what you
|
|
102
|
+
have learned about working under them. What must not survive is their architecture.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
agent-memory export --out carry.json # global scope only, by default
|
|
106
|
+
agent-memory import carry.json --dry-run # see what would land
|
|
107
|
+
agent-memory import carry.json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**The default is the safe one.** `export` emits `scope: global` notes and nothing
|
|
111
|
+
else, so a note tied to a client repository cannot leave by forgetting a flag.
|
|
112
|
+
Taking one is possible — `--scope all` — but it takes saying so.
|
|
113
|
+
|
|
114
|
+
Two things are deliberately dropped on the way through:
|
|
115
|
+
|
|
116
|
+
- **`captured_sha` is not carried.** It names a commit that exists in one repository
|
|
117
|
+
on one machine. Carried across, it would either read as current forever or claim
|
|
118
|
+
the history was rewritten; a staleness signal you cannot check is worse than none.
|
|
119
|
+
- **`source` becomes `import`.** How a note was originally captured describes an
|
|
120
|
+
environment that no longer exists. Here the honest answer to where it came from is
|
|
121
|
+
that someone brought it in, and an audit should be able to tell which notes those
|
|
122
|
+
are.
|
|
123
|
+
|
|
124
|
+
Importing the same file twice updates rather than duplicates, so re-running after a
|
|
125
|
+
change is safe.
|
|
126
|
+
|
|
97
127
|
## Engagements
|
|
98
128
|
|
|
99
129
|
If you work for more than one client on one machine, knowledge from one of them is
|
|
@@ -132,6 +162,17 @@ exactly what would go.
|
|
|
132
162
|
Everything already captured stays in `default`, at the same path as before. Nothing
|
|
133
163
|
migrates and nothing changes until you create a second engagement.
|
|
134
164
|
|
|
165
|
+
**Two things sit outside the boundary, deliberately.** Skill installation is shared,
|
|
166
|
+
because every engagement reads the same three skill files and registering them per
|
|
167
|
+
engagement would leave the description stale after a switch. Handoff files are shared
|
|
168
|
+
too — they live in `~/.agents/handoffs`, not in a store — so `purge` does not remove
|
|
169
|
+
them and says so every time rather than letting "the store is gone" be mistaken for
|
|
170
|
+
"their context is gone".
|
|
171
|
+
|
|
172
|
+
Most people need none of this. If each client already gives you a separate machine,
|
|
173
|
+
that boundary is stronger than anything here, and the default engagement is all you
|
|
174
|
+
will ever touch.
|
|
175
|
+
|
|
135
176
|
## CLI
|
|
136
177
|
|
|
137
178
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vib795/agent-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
package/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { compact, maybeCompact } from './compact.js';
|
|
|
15
15
|
import { staleness, currentRepo, reviewCandidates, captureGap } from './staleness.js';
|
|
16
16
|
import { setup as runSetup, unlinkSkills, danglingSkillLinks, SKILLS } from './setup.js';
|
|
17
17
|
import { detectTargets, installableTargets } from './targets.js';
|
|
18
|
-
import { join } from 'node:path';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
19
|
import { atomicWrite } from './atomic.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -86,6 +86,9 @@ const USAGE = `agent-memory — durable cross-repo knowledge for coding agents
|
|
|
86
86
|
[--source <name>] [--repo <name>]
|
|
87
87
|
compact dedup, decay, reindex, regenerate
|
|
88
88
|
doctor preflight and health report
|
|
89
|
+
export [--scope global|repo|all] knowledge worth carrying to another machine
|
|
90
|
+
[--out <file>] defaults to global scope only
|
|
91
|
+
import <file> [--dry-run] bring an export in, provenance intact
|
|
89
92
|
engagement [show|list|use <name>] which client store this window writes to
|
|
90
93
|
[purge <name> --yes] delete one engagement's store entirely
|
|
91
94
|
|
|
@@ -561,6 +564,151 @@ function cmdDoctor() {
|
|
|
561
564
|
|
|
562
565
|
// --- dispatch ---------------------------------------------------------------
|
|
563
566
|
|
|
567
|
+
const EXPORT_SCOPES = ['global', 'repo', 'all'];
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Take the knowledge that is yours to take.
|
|
571
|
+
*
|
|
572
|
+
* Client work tends to live in its own environment, and when the engagement ends the
|
|
573
|
+
* environment goes with it. What should survive is what was never the client's: your
|
|
574
|
+
* conventions, the constraints an org imposes, the way you have learned to work.
|
|
575
|
+
* What must not survive is their architecture.
|
|
576
|
+
*
|
|
577
|
+
* So this defaults to global scope and nothing else. Exporting a client's notes is
|
|
578
|
+
* possible, because sometimes it is legitimately yours to move, but it takes saying
|
|
579
|
+
* so out loud rather than forgetting a flag.
|
|
580
|
+
*
|
|
581
|
+
* `captured_sha` is deliberately dropped. It names a commit that does not exist
|
|
582
|
+
* anywhere else, and a staleness signal that cannot be checked is worse than none:
|
|
583
|
+
* it would either read as current forever or claim the history was rewritten.
|
|
584
|
+
*/
|
|
585
|
+
function cmdExport(opts) {
|
|
586
|
+
const scope = opts.scope === true ? 'global' : (opts.scope ?? 'global');
|
|
587
|
+
if (!EXPORT_SCOPES.includes(scope)) {
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
error: `scope must be one of ${EXPORT_SCOPES.join('|')}`,
|
|
591
|
+
text: `--scope must be one of ${EXPORT_SCOPES.join(', ')}. Default is global.`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const all = listNotes().filter((n) => !n.__error && n.id);
|
|
596
|
+
const active = opts['include-archived'] ? all : all.filter((n) => !n.archived);
|
|
597
|
+
const picked = active.filter((n) => {
|
|
598
|
+
const s = n.scope || (n.repos?.length ? 'repo' : 'global');
|
|
599
|
+
return scope === 'all' || s === scope;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const nodes = picked.map((n) => ({
|
|
603
|
+
id: n.id,
|
|
604
|
+
type: n.type,
|
|
605
|
+
title: n.title,
|
|
606
|
+
body: n.body,
|
|
607
|
+
scope: n.scope || (n.repos?.length ? 'repo' : 'global'),
|
|
608
|
+
repos: n.repos ?? [],
|
|
609
|
+
confidence: n.confidence ?? 'observed',
|
|
610
|
+
supersedes: n.supersedes ?? null,
|
|
611
|
+
edges: n.edges ?? [],
|
|
612
|
+
source: n.source ?? 'manual',
|
|
613
|
+
}));
|
|
614
|
+
|
|
615
|
+
const payload = `${JSON.stringify({ nodes }, null, 2)}\n`;
|
|
616
|
+
if (typeof opts.out === 'string') {
|
|
617
|
+
atomicWrite(opts.out, payload);
|
|
618
|
+
return {
|
|
619
|
+
ok: true,
|
|
620
|
+
exported: nodes.length,
|
|
621
|
+
scope,
|
|
622
|
+
out: opts.out,
|
|
623
|
+
text: `Exported ${nodes.length} ${scope}-scope note${nodes.length === 1 ? '' : 's'} to ${opts.out}.`,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// Straight to stdout so it pipes, with the count on stderr where it will not
|
|
627
|
+
// corrupt the document.
|
|
628
|
+
process.stderr.write(`${nodes.length} ${scope}-scope notes\n`);
|
|
629
|
+
return { ok: true, exported: nodes.length, scope, nodes, text: payload.trimEnd() };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Bring exported knowledge into this environment.
|
|
634
|
+
*
|
|
635
|
+
* Separate from `write` because `write` stamps the current repository and commit onto
|
|
636
|
+
* whatever it is given, which is right for capture and wrong for this: it would
|
|
637
|
+
* relabel another environment's knowledge as having been observed here.
|
|
638
|
+
*/
|
|
639
|
+
function cmdImport(opts) {
|
|
640
|
+
const file = opts._[0] ?? opts.from;
|
|
641
|
+
if (!file || file === true || (file !== '-' && !existsSync(file))) {
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
error: 'import requires a file, or - to read stdin',
|
|
645
|
+
text: 'Usage: agent-memory import <file> (or - to read stdin)',
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let incoming;
|
|
650
|
+
try {
|
|
651
|
+
incoming = readNodesFrom(file);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
return { ok: false, error: `unreadable JSON: ${err.message}`, text: `Unreadable JSON: ${err.message}` };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const db = openDb();
|
|
657
|
+
const existing = new Set(db.prepare('SELECT id FROM nodes').all().map((r) => r.id));
|
|
658
|
+
|
|
659
|
+
if (opts['dry-run']) {
|
|
660
|
+
db.close();
|
|
661
|
+
const lines = incoming.map(
|
|
662
|
+
(n) => `${existing.has(n.id) ? 'would update' : 'would create'} ${n.id} [${n.type}]`,
|
|
663
|
+
);
|
|
664
|
+
return {
|
|
665
|
+
ok: true,
|
|
666
|
+
dryRun: true,
|
|
667
|
+
count: incoming.length,
|
|
668
|
+
text: [...lines, '', 'Nothing written. Re-run without --dry-run to apply.'].join('\n'),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const written = [];
|
|
673
|
+
const failed = [];
|
|
674
|
+
const warnings = [];
|
|
675
|
+
for (const raw of incoming) {
|
|
676
|
+
// Nothing from this environment is stamped on: no repo, no commit. An imported
|
|
677
|
+
// note claims only what it claimed where it was written.
|
|
678
|
+
// Stamped `import`, not the source it carried. How it was captured describes an
|
|
679
|
+
// environment that no longer exists; here, the honest answer to where this came
|
|
680
|
+
// from is that someone brought it in, and an audit should be able to see which.
|
|
681
|
+
const node = { ...raw, source: 'import' };
|
|
682
|
+
delete node.captured_sha;
|
|
683
|
+
try {
|
|
684
|
+
const res = writeNote(node);
|
|
685
|
+
written.push({ id: res.node.id, type: res.node.type, created: res.created });
|
|
686
|
+
if (res.findings.length) {
|
|
687
|
+
warnings.push(
|
|
688
|
+
`${res.node.id}: redacted ${res.findings.map((f) => `${f.count}x ${f.kind}`).join(', ')}`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
failed.push({ id: raw?.id ?? null, errors: err.errors ?? [err.message] });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
reindex(db);
|
|
697
|
+
db.close();
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
ok: failed.length === 0,
|
|
701
|
+
imported: written.length,
|
|
702
|
+
failed,
|
|
703
|
+
warnings,
|
|
704
|
+
text: [
|
|
705
|
+
...written.map((w) => `${w.created ? 'created' : 'updated'} ${w.id} [${w.type}]`),
|
|
706
|
+
...warnings.map((w) => `warning: ${w}`),
|
|
707
|
+
...failed.map((f) => `failed ${f.id}: ${f.errors.join('; ')}`),
|
|
708
|
+
].join('\n'),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
564
712
|
/**
|
|
565
713
|
* Show, switch and purge engagements.
|
|
566
714
|
*
|
|
@@ -659,6 +807,11 @@ function cmdEngagement(opts) {
|
|
|
659
807
|
text: [
|
|
660
808
|
`Purged engagement ${name}: ${notes} note${notes === 1 ? '' : 's'} removed.`,
|
|
661
809
|
`${dir} no longer exists.`,
|
|
810
|
+
'',
|
|
811
|
+
// Said every time, because the gap between "the store is gone" and "their
|
|
812
|
+
// knowledge is gone" is exactly the assumption someone would make here.
|
|
813
|
+
'Handoff files are not part of an engagement store and were not touched.',
|
|
814
|
+
`Review ${join(dirname(STORE_BASE), 'handoffs')} yourself.`,
|
|
662
815
|
].join('\n'),
|
|
663
816
|
};
|
|
664
817
|
}
|
|
@@ -721,6 +874,8 @@ const COMMANDS = {
|
|
|
721
874
|
compact: cmdCompact,
|
|
722
875
|
doctor: cmdDoctor,
|
|
723
876
|
engagement: cmdEngagement,
|
|
877
|
+
export: cmdExport,
|
|
878
|
+
import: cmdImport,
|
|
724
879
|
};
|
|
725
880
|
|
|
726
881
|
function main(argv) {
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, parse as parsePath } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { atomicWrite } from './atomic.js';
|
|
@@ -85,6 +85,8 @@ export const paths = {
|
|
|
85
85
|
db: join(STORE_ROOT, 'index.db'),
|
|
86
86
|
routing: join(STORE_ROOT, 'ROUTING.md'),
|
|
87
87
|
config: join(STORE_ROOT, 'config.json'),
|
|
88
|
+
// Machine-scoped keys live beside every engagement rather than inside one.
|
|
89
|
+
machineConfig: join(STORE_BASE, 'config.json'),
|
|
88
90
|
typeDir: (type) => join(STORE_ROOT, 'notes', type),
|
|
89
91
|
archiveTypeDir: (type) => join(STORE_ROOT, 'notes', 'archive', type),
|
|
90
92
|
};
|
|
@@ -107,24 +109,41 @@ export const DEFAULTS = {
|
|
|
107
109
|
// from a checkout or copied into place, and both are legitimate installs.
|
|
108
110
|
export const LIST_KEYS = ['skillPaths'];
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
/** A malformed config must not take the store down. Absent and unreadable are equal. */
|
|
113
|
+
function readJson(file) {
|
|
114
|
+
if (!existsSync(file)) return {};
|
|
112
115
|
try {
|
|
113
|
-
|
|
114
|
-
const merged = { ...DEFAULTS, skillPaths: [] };
|
|
115
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
116
|
-
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) merged[k] = v;
|
|
117
|
-
if (LIST_KEYS.includes(k) && Array.isArray(v)) {
|
|
118
|
-
merged[k] = v.filter((s) => typeof s === 'string' && s.trim());
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return merged;
|
|
116
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
122
117
|
} catch {
|
|
123
|
-
|
|
124
|
-
return { ...DEFAULTS, skillPaths: [] };
|
|
118
|
+
return {};
|
|
125
119
|
}
|
|
126
120
|
}
|
|
127
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Config comes from two files, split by what each key actually describes.
|
|
124
|
+
*
|
|
125
|
+
* Where the skills are installed is a fact about this machine. Every engagement
|
|
126
|
+
* shares one set of skill files, so registering them per engagement meant `compact`
|
|
127
|
+
* had nothing to regenerate after a switch and the recall description kept
|
|
128
|
+
* advertising the previous engagement's topics — into the agent's context, which is
|
|
129
|
+
* the worst place for it. Caps like the digest size really are per-store, and stay
|
|
130
|
+
* there. For the default engagement both files are the same path and nothing moves.
|
|
131
|
+
*/
|
|
132
|
+
export function loadConfig() {
|
|
133
|
+
const merged = { ...DEFAULTS, skillPaths: [] };
|
|
134
|
+
|
|
135
|
+
for (const [k, v] of Object.entries(readJson(paths.config))) {
|
|
136
|
+
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) merged[k] = v;
|
|
137
|
+
}
|
|
138
|
+
const machine = readJson(paths.machineConfig);
|
|
139
|
+
for (const k of LIST_KEYS) {
|
|
140
|
+
if (Array.isArray(machine[k])) {
|
|
141
|
+
merged[k] = machine[k].filter((s) => typeof s === 'string' && s.trim());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return merged;
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
/**
|
|
129
148
|
* Merge a patch into config.json.
|
|
130
149
|
*
|
|
@@ -136,24 +155,37 @@ export function loadConfig() {
|
|
|
136
155
|
* cannot quietly become permanent state.
|
|
137
156
|
*/
|
|
138
157
|
export function saveConfig(patch) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
})()
|
|
147
|
-
: {};
|
|
148
|
-
|
|
149
|
-
const next = { ...current };
|
|
158
|
+
const sameFile = paths.config === paths.machineConfig;
|
|
159
|
+
const caps = readJson(paths.config);
|
|
160
|
+
const machine = sameFile ? caps : readJson(paths.machineConfig);
|
|
161
|
+
|
|
162
|
+
let capsDirty = false;
|
|
163
|
+
let machineDirty = false;
|
|
150
164
|
for (const [k, v] of Object.entries(patch || {})) {
|
|
151
|
-
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
165
|
+
if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) {
|
|
166
|
+
caps[k] = v;
|
|
167
|
+
capsDirty = true;
|
|
168
|
+
}
|
|
152
169
|
if (LIST_KEYS.includes(k) && Array.isArray(v)) {
|
|
153
|
-
|
|
170
|
+
machine[k] = [...new Set(v.filter((s) => typeof s === 'string' && s.trim()))].sort();
|
|
171
|
+
machineDirty = true;
|
|
154
172
|
}
|
|
155
173
|
}
|
|
156
174
|
|
|
157
|
-
|
|
158
|
-
|
|
175
|
+
if (sameFile) {
|
|
176
|
+
if (capsDirty || machineDirty) {
|
|
177
|
+
mkdirSync(dirname(paths.config), { recursive: true });
|
|
178
|
+
atomicWrite(paths.config, `${JSON.stringify(caps, null, 2)}\n`);
|
|
179
|
+
}
|
|
180
|
+
return { ...caps };
|
|
181
|
+
}
|
|
182
|
+
if (capsDirty) {
|
|
183
|
+
mkdirSync(dirname(paths.config), { recursive: true });
|
|
184
|
+
atomicWrite(paths.config, `${JSON.stringify(caps, null, 2)}\n`);
|
|
185
|
+
}
|
|
186
|
+
if (machineDirty) {
|
|
187
|
+
mkdirSync(dirname(paths.machineConfig), { recursive: true });
|
|
188
|
+
atomicWrite(paths.machineConfig, `${JSON.stringify(machine, null, 2)}\n`);
|
|
189
|
+
}
|
|
190
|
+
return { ...caps, ...Object.fromEntries(LIST_KEYS.map((k) => [k, machine[k]]).filter(([, v]) => v)) };
|
|
159
191
|
}
|