@vib795/agent-memory 0.1.15 → 0.2.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,44 @@ 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
+ ## Engagements
98
+
99
+ If you work for more than one client on one machine, knowledge from one of them is
100
+ not the next one's to see. An engagement is a **separate store**, and switching is a
101
+ path change rather than a filter:
102
+
103
+ ```bash
104
+ agent-memory engagement # which one is active, and why
105
+ agent-memory engagement list # all of them, with note counts
106
+ agent-memory engagement use acme # the fallback for every window
107
+ agent-memory engagement purge acme --yes # delete that client's store entirely
108
+ ```
109
+
110
+ Pin a client's whole tree instead of remembering to switch. One file at the top of
111
+ it, and every repository beneath is in that engagement:
112
+
113
+ ```bash
114
+ echo acme > ~/clients/acme/.agent-memory-engagement
115
+ ```
116
+
117
+ Resolution order is `AGENT_MEMORY_ENGAGEMENT`, then the nearest marker file walking
118
+ up from where you are, then the machine-wide pointer, then `default`. `doctor` and
119
+ `engagement` both print which one decided it, because the failure worth preventing is
120
+ believing you are in one client's store while writing to another.
121
+
122
+ **The isolation is structural, not a filter.** Each engagement is its own directory,
123
+ its own markdown and its own index, so `search`, `get` and `tree` cannot reach across
124
+ even by exact id — there is no query to forget. Filtering would have to be remembered
125
+ at fourteen separate read paths, and two of them were already missed before this
126
+ existed.
127
+
128
+ That also makes removal something you can evidence rather than assert: `purge` deletes
129
+ a directory and reports the count, and refuses without `--yes` after telling you
130
+ exactly what would go.
131
+
132
+ Everything already captured stays in `default`, at the same path as before. Nothing
133
+ migrates and nothing changes until you create a second engagement.
134
+
97
135
  ## CLI
98
136
 
99
137
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vib795/agent-memory",
3
- "version": "0.1.15",
3
+ "version": "0.2.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,11 @@ 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
+ engagement [show|list|use <name>] which client store this window writes to
90
+ [purge <name> --yes] delete one engagement's store entirely
85
91
 
86
92
  Add --json to any command for machine-readable output.
93
+ Engagement: ${ENGAGEMENT.name} (${ENGAGEMENT.source})
87
94
  Store: ${paths.root}`;
88
95
 
89
96
  // --- commands ---------------------------------------------------------------
@@ -422,6 +429,10 @@ function cmdDoctor() {
422
429
  const checks = [];
423
430
  const add = (name, ok, detail) => checks.push({ name, ok, detail });
424
431
 
432
+ // First, because everything below it is a fact about one engagement's store and
433
+ // reading it against the wrong client is the mistake this is here to prevent.
434
+ add('engagement', true, `${ENGAGEMENT.name} (${ENGAGEMENT.source})`);
435
+
425
436
  add('node version', nodeVersionOk(), `${process.versions.node} (need >= ${MIN_NODE.join('.')})`);
426
437
  if (!nodeVersionOk()) {
427
438
  return {
@@ -550,6 +561,154 @@ function cmdDoctor() {
550
561
 
551
562
  // --- dispatch ---------------------------------------------------------------
552
563
 
564
+ /**
565
+ * Show, switch and purge engagements.
566
+ *
567
+ * Each engagement is a whole store on disk, so switching is a path change and
568
+ * purging is a directory removal. Nothing here filters anything: the isolation is
569
+ * structural, and a subcommand that got it wrong could not leak across the boundary
570
+ * even if it tried.
571
+ */
572
+ function cmdEngagement(opts) {
573
+ const sub = opts._[0] ?? 'show';
574
+
575
+ if (sub === 'show') {
576
+ return {
577
+ ok: true,
578
+ engagement: ENGAGEMENT.name,
579
+ source: ENGAGEMENT.source,
580
+ store: paths.root,
581
+ text: [
582
+ `engagement: ${ENGAGEMENT.name}`,
583
+ `decided by: ${ENGAGEMENT.source}`,
584
+ `store: ${paths.root}`,
585
+ ].join('\n'),
586
+ };
587
+ }
588
+
589
+ if (sub === 'list') {
590
+ const found = listEngagements();
591
+ return {
592
+ ok: true,
593
+ engagements: found,
594
+ text: found
595
+ .map((e) => `${e.name === ENGAGEMENT.name ? '*' : ' '} ${e.name.padEnd(24)} ${e.notes} notes`)
596
+ .join('\n'),
597
+ };
598
+ }
599
+
600
+ if (sub === 'use') {
601
+ const name = opts._[1];
602
+ if (!name || !ENGAGEMENT_RE.test(name)) return badEngagementName(name);
603
+ mkdirSync(STORE_BASE, { recursive: true });
604
+ atomicWrite(ENGAGEMENT_POINTER, `${name}\n`);
605
+ return {
606
+ ok: true,
607
+ engagement: name,
608
+ store: engagementRoot(name),
609
+ text: [
610
+ `engagement: ${name}`,
611
+ `store: ${engagementRoot(name)}`,
612
+ '',
613
+ 'This is the fallback for every window on this machine. To pin one client tree',
614
+ `instead, put the name in a ${ENGAGEMENT_MARKER} file at the top of it.`,
615
+ ].join('\n'),
616
+ };
617
+ }
618
+
619
+ if (sub === 'purge') {
620
+ const name = opts._[1];
621
+ if (!name || !ENGAGEMENT_RE.test(name)) return badEngagementName(name);
622
+ if (name === DEFAULT_ENGAGEMENT) {
623
+ return {
624
+ ok: false,
625
+ error: 'the default engagement cannot be purged',
626
+ text: 'The default engagement cannot be purged. Delete individual notes instead.',
627
+ };
628
+ }
629
+ const dir = engagementRoot(name);
630
+ if (!existsSync(dir)) {
631
+ return { ok: false, error: `no engagement named ${name}`, text: `No engagement named ${name}.` };
632
+ }
633
+ const notes = countNotes(dir);
634
+ if (!opts.yes) {
635
+ // Deleting a client's captured knowledge is not something to do because a name
636
+ // was typed. Say exactly what goes, and make the caller ask again.
637
+ return {
638
+ ok: false,
639
+ error: 'purge requires --yes',
640
+ engagement: name,
641
+ notes,
642
+ text: [
643
+ `This deletes ${notes} note${notes === 1 ? '' : 's'} and the whole store at:`,
644
+ ` ${dir}`,
645
+ '',
646
+ 'Nothing else is touched, and this cannot be undone. To go ahead:',
647
+ ` agent-memory engagement purge ${name} --yes`,
648
+ ].join('\n'),
649
+ };
650
+ }
651
+ rmSync(dir, { recursive: true, force: true });
652
+ return {
653
+ ok: true,
654
+ engagement: name,
655
+ notes,
656
+ removed: dir,
657
+ // Stated as a fact someone can check, because "we deleted it" is a claim that
658
+ // eventually has to be evidenced rather than asserted.
659
+ text: [
660
+ `Purged engagement ${name}: ${notes} note${notes === 1 ? '' : 's'} removed.`,
661
+ `${dir} no longer exists.`,
662
+ ].join('\n'),
663
+ };
664
+ }
665
+
666
+ return {
667
+ ok: false,
668
+ error: `unknown subcommand ${sub}`,
669
+ text: 'Usage: agent-memory engagement [show|list|use <name>|purge <name> --yes]',
670
+ };
671
+ }
672
+
673
+ function badEngagementName(name) {
674
+ return {
675
+ ok: false,
676
+ error: 'invalid engagement name',
677
+ text: [
678
+ name ? `"${name}" is not a usable engagement name.` : 'An engagement name is required.',
679
+ 'Names become directory names: lower case, digits and hyphens, up to 64 characters.',
680
+ ].join('\n'),
681
+ };
682
+ }
683
+
684
+ /** Markdown files under a store, counted without opening its index. */
685
+ function countNotes(root) {
686
+ const dir = join(root, 'notes');
687
+ if (!existsSync(dir)) return 0;
688
+ let n = 0;
689
+ const walk = (d) => {
690
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
691
+ if (entry.isDirectory()) walk(join(d, entry.name));
692
+ else if (entry.name.endsWith('.md')) n += 1;
693
+ }
694
+ };
695
+ walk(dir);
696
+ return n;
697
+ }
698
+
699
+ function listEngagements() {
700
+ const out = [{ name: DEFAULT_ENGAGEMENT, notes: countNotes(engagementRoot(DEFAULT_ENGAGEMENT)) }];
701
+ const dir = join(STORE_BASE, 'engagements');
702
+ if (existsSync(dir)) {
703
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
704
+ if (entry.isDirectory()) out.push({ name: entry.name, notes: countNotes(join(dir, entry.name)) });
705
+ }
706
+ }
707
+ // The active one may not exist on disk yet, and omitting it would read as absent.
708
+ if (!out.some((e) => e.name === ENGAGEMENT.name)) out.push({ name: ENGAGEMENT.name, notes: 0 });
709
+ return out.sort((a, b) => a.name.localeCompare(b.name));
710
+ }
711
+
553
712
  const COMMANDS = {
554
713
  setup: cmdSetup,
555
714
  uninstall: cmdUninstall,
@@ -561,6 +720,7 @@ const COMMANDS = {
561
720
  write: cmdWrite,
562
721
  compact: cmdCompact,
563
722
  doctor: cmdDoctor,
723
+ engagement: cmdEngagement,
564
724
  };
565
725
 
566
726
  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 = {