@vib795/agent-memory 0.1.15 → 0.3.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
@@ -94,6 +94,74 @@ 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
+
127
+ ## Engagements
128
+
129
+ If you work for more than one client on one machine, knowledge from one of them is
130
+ not the next one's to see. An engagement is a **separate store**, and switching is a
131
+ path change rather than a filter:
132
+
133
+ ```bash
134
+ agent-memory engagement # which one is active, and why
135
+ agent-memory engagement list # all of them, with note counts
136
+ agent-memory engagement use acme # the fallback for every window
137
+ agent-memory engagement purge acme --yes # delete that client's store entirely
138
+ ```
139
+
140
+ Pin a client's whole tree instead of remembering to switch. One file at the top of
141
+ it, and every repository beneath is in that engagement:
142
+
143
+ ```bash
144
+ echo acme > ~/clients/acme/.agent-memory-engagement
145
+ ```
146
+
147
+ Resolution order is `AGENT_MEMORY_ENGAGEMENT`, then the nearest marker file walking
148
+ up from where you are, then the machine-wide pointer, then `default`. `doctor` and
149
+ `engagement` both print which one decided it, because the failure worth preventing is
150
+ believing you are in one client's store while writing to another.
151
+
152
+ **The isolation is structural, not a filter.** Each engagement is its own directory,
153
+ its own markdown and its own index, so `search`, `get` and `tree` cannot reach across
154
+ even by exact id — there is no query to forget. Filtering would have to be remembered
155
+ at fourteen separate read paths, and two of them were already missed before this
156
+ existed.
157
+
158
+ That also makes removal something you can evidence rather than assert: `purge` deletes
159
+ a directory and reports the count, and refuses without `--yes` after telling you
160
+ exactly what would go.
161
+
162
+ Everything already captured stays in `default`, at the same path as before. Nothing
163
+ migrates and nothing changes until you create a second engagement.
164
+
97
165
  ## CLI
98
166
 
99
167
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vib795/agent-memory",
3
- "version": "0.1.15",
3
+ "version": "0.3.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",
package/src/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { loadConfig, saveConfig, paths, NOTE_TYPES } from './config.js';
4
+ import {
5
+ loadConfig, saveConfig, paths, NOTE_TYPES, ENGAGEMENT, ENGAGEMENT_RE, ENGAGEMENT_POINTER,
6
+ ENGAGEMENT_MARKER, DEFAULT_ENGAGEMENT, STORE_BASE, engagementRoot,
7
+ } from './config.js';
5
8
  import { ensureStore, writeNote, normalizeTitle, listNotes } from './store.js';
6
9
  import {
7
10
  openDb, reindex, searchNodes, getNodeRow, markAccessed, nodeCount, hasFts,
@@ -13,6 +16,7 @@ import { staleness, currentRepo, reviewCandidates, captureGap } from './stalenes
13
16
  import { setup as runSetup, unlinkSkills, danglingSkillLinks, SKILLS } from './setup.js';
14
17
  import { detectTargets, installableTargets } from './targets.js';
15
18
  import { join } from 'node:path';
19
+ import { atomicWrite } from './atomic.js';
16
20
 
17
21
  /**
18
22
  * One process, one answer.
@@ -82,8 +86,14 @@ const USAGE = `agent-memory — durable cross-repo knowledge for coding agents
82
86
  [--source <name>] [--repo <name>]
83
87
  compact dedup, decay, reindex, regenerate
84
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
92
+ engagement [show|list|use <name>] which client store this window writes to
93
+ [purge <name> --yes] delete one engagement's store entirely
85
94
 
86
95
  Add --json to any command for machine-readable output.
96
+ Engagement: ${ENGAGEMENT.name} (${ENGAGEMENT.source})
87
97
  Store: ${paths.root}`;
88
98
 
89
99
  // --- commands ---------------------------------------------------------------
@@ -422,6 +432,10 @@ function cmdDoctor() {
422
432
  const checks = [];
423
433
  const add = (name, ok, detail) => checks.push({ name, ok, detail });
424
434
 
435
+ // First, because everything below it is a fact about one engagement's store and
436
+ // reading it against the wrong client is the mistake this is here to prevent.
437
+ add('engagement', true, `${ENGAGEMENT.name} (${ENGAGEMENT.source})`);
438
+
425
439
  add('node version', nodeVersionOk(), `${process.versions.node} (need >= ${MIN_NODE.join('.')})`);
426
440
  if (!nodeVersionOk()) {
427
441
  return {
@@ -550,6 +564,299 @@ function cmdDoctor() {
550
564
 
551
565
  // --- dispatch ---------------------------------------------------------------
552
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
+
712
+ /**
713
+ * Show, switch and purge engagements.
714
+ *
715
+ * Each engagement is a whole store on disk, so switching is a path change and
716
+ * purging is a directory removal. Nothing here filters anything: the isolation is
717
+ * structural, and a subcommand that got it wrong could not leak across the boundary
718
+ * even if it tried.
719
+ */
720
+ function cmdEngagement(opts) {
721
+ const sub = opts._[0] ?? 'show';
722
+
723
+ if (sub === 'show') {
724
+ return {
725
+ ok: true,
726
+ engagement: ENGAGEMENT.name,
727
+ source: ENGAGEMENT.source,
728
+ store: paths.root,
729
+ text: [
730
+ `engagement: ${ENGAGEMENT.name}`,
731
+ `decided by: ${ENGAGEMENT.source}`,
732
+ `store: ${paths.root}`,
733
+ ].join('\n'),
734
+ };
735
+ }
736
+
737
+ if (sub === 'list') {
738
+ const found = listEngagements();
739
+ return {
740
+ ok: true,
741
+ engagements: found,
742
+ text: found
743
+ .map((e) => `${e.name === ENGAGEMENT.name ? '*' : ' '} ${e.name.padEnd(24)} ${e.notes} notes`)
744
+ .join('\n'),
745
+ };
746
+ }
747
+
748
+ if (sub === 'use') {
749
+ const name = opts._[1];
750
+ if (!name || !ENGAGEMENT_RE.test(name)) return badEngagementName(name);
751
+ mkdirSync(STORE_BASE, { recursive: true });
752
+ atomicWrite(ENGAGEMENT_POINTER, `${name}\n`);
753
+ return {
754
+ ok: true,
755
+ engagement: name,
756
+ store: engagementRoot(name),
757
+ text: [
758
+ `engagement: ${name}`,
759
+ `store: ${engagementRoot(name)}`,
760
+ '',
761
+ 'This is the fallback for every window on this machine. To pin one client tree',
762
+ `instead, put the name in a ${ENGAGEMENT_MARKER} file at the top of it.`,
763
+ ].join('\n'),
764
+ };
765
+ }
766
+
767
+ if (sub === 'purge') {
768
+ const name = opts._[1];
769
+ if (!name || !ENGAGEMENT_RE.test(name)) return badEngagementName(name);
770
+ if (name === DEFAULT_ENGAGEMENT) {
771
+ return {
772
+ ok: false,
773
+ error: 'the default engagement cannot be purged',
774
+ text: 'The default engagement cannot be purged. Delete individual notes instead.',
775
+ };
776
+ }
777
+ const dir = engagementRoot(name);
778
+ if (!existsSync(dir)) {
779
+ return { ok: false, error: `no engagement named ${name}`, text: `No engagement named ${name}.` };
780
+ }
781
+ const notes = countNotes(dir);
782
+ if (!opts.yes) {
783
+ // Deleting a client's captured knowledge is not something to do because a name
784
+ // was typed. Say exactly what goes, and make the caller ask again.
785
+ return {
786
+ ok: false,
787
+ error: 'purge requires --yes',
788
+ engagement: name,
789
+ notes,
790
+ text: [
791
+ `This deletes ${notes} note${notes === 1 ? '' : 's'} and the whole store at:`,
792
+ ` ${dir}`,
793
+ '',
794
+ 'Nothing else is touched, and this cannot be undone. To go ahead:',
795
+ ` agent-memory engagement purge ${name} --yes`,
796
+ ].join('\n'),
797
+ };
798
+ }
799
+ rmSync(dir, { recursive: true, force: true });
800
+ return {
801
+ ok: true,
802
+ engagement: name,
803
+ notes,
804
+ removed: dir,
805
+ // Stated as a fact someone can check, because "we deleted it" is a claim that
806
+ // eventually has to be evidenced rather than asserted.
807
+ text: [
808
+ `Purged engagement ${name}: ${notes} note${notes === 1 ? '' : 's'} removed.`,
809
+ `${dir} no longer exists.`,
810
+ ].join('\n'),
811
+ };
812
+ }
813
+
814
+ return {
815
+ ok: false,
816
+ error: `unknown subcommand ${sub}`,
817
+ text: 'Usage: agent-memory engagement [show|list|use <name>|purge <name> --yes]',
818
+ };
819
+ }
820
+
821
+ function badEngagementName(name) {
822
+ return {
823
+ ok: false,
824
+ error: 'invalid engagement name',
825
+ text: [
826
+ name ? `"${name}" is not a usable engagement name.` : 'An engagement name is required.',
827
+ 'Names become directory names: lower case, digits and hyphens, up to 64 characters.',
828
+ ].join('\n'),
829
+ };
830
+ }
831
+
832
+ /** Markdown files under a store, counted without opening its index. */
833
+ function countNotes(root) {
834
+ const dir = join(root, 'notes');
835
+ if (!existsSync(dir)) return 0;
836
+ let n = 0;
837
+ const walk = (d) => {
838
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
839
+ if (entry.isDirectory()) walk(join(d, entry.name));
840
+ else if (entry.name.endsWith('.md')) n += 1;
841
+ }
842
+ };
843
+ walk(dir);
844
+ return n;
845
+ }
846
+
847
+ function listEngagements() {
848
+ const out = [{ name: DEFAULT_ENGAGEMENT, notes: countNotes(engagementRoot(DEFAULT_ENGAGEMENT)) }];
849
+ const dir = join(STORE_BASE, 'engagements');
850
+ if (existsSync(dir)) {
851
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
852
+ if (entry.isDirectory()) out.push({ name: entry.name, notes: countNotes(join(dir, entry.name)) });
853
+ }
854
+ }
855
+ // The active one may not exist on disk yet, and omitting it would read as absent.
856
+ if (!out.some((e) => e.name === ENGAGEMENT.name)) out.push({ name: ENGAGEMENT.name, notes: 0 });
857
+ return out.sort((a, b) => a.name.localeCompare(b.name));
858
+ }
859
+
553
860
  const COMMANDS = {
554
861
  setup: cmdSetup,
555
862
  uninstall: cmdUninstall,
@@ -561,6 +868,9 @@ const COMMANDS = {
561
868
  write: cmdWrite,
562
869
  compact: cmdCompact,
563
870
  doctor: cmdDoctor,
871
+ engagement: cmdEngagement,
872
+ export: cmdExport,
873
+ import: cmdImport,
564
874
  };
565
875
 
566
876
  function main(argv) {
package/src/config.js CHANGED
@@ -1,14 +1,81 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { join, dirname, parse as parsePath } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { atomicWrite } from './atomic.js';
5
5
 
6
6
  // The store lives outside every repository on purpose. That is what makes a note
7
7
  // written in one window readable from a window opened on a different project.
8
- export const STORE_ROOT =
8
+ export const STORE_BASE =
9
9
  process.env.AGENT_MEMORY_HOME ||
10
10
  join(process.env.USERPROFILE || homedir(), '.agents', 'memory');
11
11
 
12
+ export const DEFAULT_ENGAGEMENT = 'default';
13
+
14
+ /** Where the pointer to the active engagement lives, outside every engagement. */
15
+ export const ENGAGEMENT_POINTER = join(STORE_BASE, '.engagement');
16
+
17
+ /** Engagement names become directory names, so they have to be safe as one. */
18
+ export const ENGAGEMENT_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
19
+
20
+ /** The file a client tree carries to pin itself to an engagement. */
21
+ export const ENGAGEMENT_MARKER = '.agent-memory-engagement';
22
+
23
+ /**
24
+ * Which engagement this process is working in, and how that was decided.
25
+ *
26
+ * A consultant runs several clients through one machine, and knowledge from one is
27
+ * not the next one's to see. The boundary is a separate store per engagement rather
28
+ * than a column to filter on, because filtering has to be remembered at every read
29
+ * and there are fourteen of them; two were already missed. A separate directory
30
+ * cannot be forgotten, and purging one is a directory removal that can be shown to
31
+ * have happened.
32
+ *
33
+ * The marker file is what makes this survive real use: drop one at the root of a
34
+ * client's tree and every repository beneath it is in that engagement, with nothing
35
+ * to remember when switching windows.
36
+ */
37
+ export function resolveEngagement(cwd = process.cwd()) {
38
+ const env = process.env.AGENT_MEMORY_ENGAGEMENT?.trim();
39
+ if (env) return { name: env, source: 'AGENT_MEMORY_ENGAGEMENT' };
40
+
41
+ let dir = cwd;
42
+ for (;;) {
43
+ const marker = join(dir, ENGAGEMENT_MARKER);
44
+ if (existsSync(marker)) {
45
+ try {
46
+ const name = readFileSync(marker, 'utf8').trim().split('\n')[0].trim();
47
+ if (name) return { name, source: marker };
48
+ } catch {
49
+ // An unreadable marker is not a reason to fall back silently to another
50
+ // client's store. Treat it as unset and let the pointer or default decide.
51
+ }
52
+ }
53
+ const parent = dirname(dir);
54
+ if (parent === dir || dir === parsePath(dir).root) break;
55
+ dir = parent;
56
+ }
57
+
58
+ if (existsSync(ENGAGEMENT_POINTER)) {
59
+ try {
60
+ const name = readFileSync(ENGAGEMENT_POINTER, 'utf8').trim();
61
+ if (name) return { name, source: ENGAGEMENT_POINTER };
62
+ } catch {
63
+ /* fall through to the default */
64
+ }
65
+ }
66
+ return { name: DEFAULT_ENGAGEMENT, source: 'default' };
67
+ }
68
+
69
+ /** The store directory for an engagement. The default one is the base itself. */
70
+ export function engagementRoot(name) {
71
+ return name === DEFAULT_ENGAGEMENT ? STORE_BASE : join(STORE_BASE, 'engagements', name);
72
+ }
73
+
74
+ export const ENGAGEMENT = resolveEngagement();
75
+
76
+ // Resolved once per process, like everything else here. One process, one answer.
77
+ export const STORE_ROOT = engagementRoot(ENGAGEMENT.name);
78
+
12
79
  export const NOTE_TYPES = ['system', 'decision', 'convention', 'constraint'];
13
80
 
14
81
  export const paths = {