@lmaksym/agent-mem 0.1.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.
Files changed (58) hide show
  1. package/.claude/commands/context.md +24 -0
  2. package/.claude/skills/agent-mem/SKILL.md +66 -0
  3. package/.claude/skills/agent-mem/references/branching-merging.md +34 -0
  4. package/.claude/skills/agent-mem/references/coexistence.md +19 -0
  5. package/.claude/skills/agent-mem/references/collaboration.md +33 -0
  6. package/.claude/skills/agent-mem/references/reflection-compaction.md +104 -0
  7. package/.claude/skills/agent-mem/references/sub-agent-patterns.md +60 -0
  8. package/LICENSE +21 -0
  9. package/README.md +235 -0
  10. package/bin/agent-context.js +95 -0
  11. package/bin/parse-args.js +85 -0
  12. package/package.json +58 -0
  13. package/src/commands/branch.js +57 -0
  14. package/src/commands/branch.test.js +91 -0
  15. package/src/commands/branches.js +34 -0
  16. package/src/commands/commit.js +55 -0
  17. package/src/commands/compact.js +307 -0
  18. package/src/commands/compact.test.js +110 -0
  19. package/src/commands/config.js +47 -0
  20. package/src/commands/core.test.js +166 -0
  21. package/src/commands/diff.js +157 -0
  22. package/src/commands/diff.test.js +64 -0
  23. package/src/commands/forget.js +77 -0
  24. package/src/commands/forget.test.js +68 -0
  25. package/src/commands/help.js +99 -0
  26. package/src/commands/import.js +83 -0
  27. package/src/commands/init.js +269 -0
  28. package/src/commands/init.test.js +80 -0
  29. package/src/commands/lesson.js +95 -0
  30. package/src/commands/lesson.test.js +93 -0
  31. package/src/commands/merge.js +105 -0
  32. package/src/commands/pin.js +34 -0
  33. package/src/commands/pull.js +80 -0
  34. package/src/commands/push.js +80 -0
  35. package/src/commands/read.js +62 -0
  36. package/src/commands/reflect.js +328 -0
  37. package/src/commands/remember.js +95 -0
  38. package/src/commands/resolve.js +230 -0
  39. package/src/commands/resolve.test.js +167 -0
  40. package/src/commands/search.js +70 -0
  41. package/src/commands/share.js +65 -0
  42. package/src/commands/snapshot.js +106 -0
  43. package/src/commands/status.js +37 -0
  44. package/src/commands/switch.js +31 -0
  45. package/src/commands/sync.js +328 -0
  46. package/src/commands/track.js +61 -0
  47. package/src/commands/unpin.js +28 -0
  48. package/src/commands/write.js +58 -0
  49. package/src/core/auto-commit.js +22 -0
  50. package/src/core/config.js +93 -0
  51. package/src/core/context-root.js +28 -0
  52. package/src/core/fs.js +137 -0
  53. package/src/core/git.js +182 -0
  54. package/src/core/importers.js +210 -0
  55. package/src/core/lock.js +62 -0
  56. package/src/core/reflect-defrag.js +287 -0
  57. package/src/core/reflect-gather.js +360 -0
  58. package/src/core/reflect-parse.js +168 -0
@@ -0,0 +1,307 @@
1
+ import { existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { contextDir as getContextDir } from '../core/context-root.js';
4
+ import { readContextFile, writeContextFile, listFiles } from '../core/fs.js';
5
+ import { commitContext, hasChanges } from '../core/git.js';
6
+ import { readConfig } from '../core/config.js';
7
+
8
+ /**
9
+ * Calculate total byte size of all readable files in .context/.
10
+ */
11
+ function contextByteSize(ctxDir) {
12
+ let total = 0;
13
+ const walk = (dir) => {
14
+ if (!existsSync(dir)) return;
15
+ for (const name of readdirSync(dir)) {
16
+ if (name.startsWith('.')) continue;
17
+ const full = join(dir, name);
18
+ const stat = statSync(full);
19
+ if (stat.isDirectory()) walk(full);
20
+ else total += stat.size;
21
+ }
22
+ };
23
+ walk(ctxDir);
24
+ return total;
25
+ }
26
+
27
+ /**
28
+ * Collect all entry lines from a memory file with dates.
29
+ * Handles both bullet entries (- [date]) and block entries (### [date] for lessons).
30
+ * Returns { header: string, entries: Array<{ line: string, date: Date|null, raw: string }> }
31
+ */
32
+ function parseMemoryFile(content) {
33
+ const lines = content.split('\n');
34
+ const headerLines = [];
35
+ const entries = [];
36
+ let inHeader = true;
37
+ let currentBlock = null;
38
+
39
+ for (const line of lines) {
40
+ // Block entry (lessons): ### [2026-02-21 14:30] Title
41
+ const blockMatch = line.match(/^### \[(\d{4}-\d{2}-\d{2})[\s\d:]*\]\s*.+/);
42
+ if (blockMatch) {
43
+ // Push previous block if any
44
+ if (currentBlock) {
45
+ entries.push(currentBlock);
46
+ }
47
+ inHeader = false;
48
+ currentBlock = {
49
+ line: line,
50
+ date: new Date(blockMatch[1]),
51
+ raw: line,
52
+ isStale: false,
53
+ _blockLines: [line],
54
+ };
55
+ continue;
56
+ }
57
+
58
+ // If we're inside a block, accumulate lines
59
+ if (currentBlock) {
60
+ currentBlock._blockLines.push(line);
61
+ currentBlock.line = currentBlock._blockLines.join('\n');
62
+ currentBlock.raw = currentBlock.line;
63
+ continue;
64
+ }
65
+
66
+ const entryMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})[\s\d:]*\]\s*.+/);
67
+ // Also catch stale markers
68
+ const staleMarker = line.match(/^- \[\d{4}-\d{2}-\d{2} STALE\]/);
69
+
70
+ if (entryMatch) {
71
+ inHeader = false;
72
+ entries.push({
73
+ line,
74
+ date: new Date(entryMatch[1]),
75
+ raw: line,
76
+ isStale: false,
77
+ });
78
+ } else if (staleMarker) {
79
+ inHeader = false;
80
+ // Skip stale markers — they'll be dropped in compact
81
+ } else if (inHeader || (!entryMatch && entries.length === 0)) {
82
+ headerLines.push(line);
83
+ }
84
+ // Lines after entries that aren't entries (blank lines between entries, etc.)
85
+ }
86
+
87
+ // Push last block if any
88
+ if (currentBlock) {
89
+ entries.push(currentBlock);
90
+ }
91
+
92
+ return { header: headerLines.join('\n'), entries };
93
+ }
94
+
95
+ /**
96
+ * Determine which memory entries to keep based on mode.
97
+ * Default: keep entries from last 7 days + pinned file entries always kept.
98
+ * Hard: discard all non-pinned memory entries.
99
+ */
100
+ function selectEntries(entries, mode, config) {
101
+ if (mode === 'hard') return []; // Hard mode: archive everything
102
+
103
+ const retainDays = config.compact?.retain_days || 7;
104
+ const cutoff = new Date(Date.now() - retainDays * 24 * 60 * 60 * 1000);
105
+
106
+ return entries.filter((e) => e.date && e.date >= cutoff);
107
+ }
108
+
109
+ export default async function compact({ args, flags }) {
110
+ const root = flags._contextRoot;
111
+ const ctxDir = getContextDir(root);
112
+ const config = readConfig(ctxDir);
113
+
114
+ const isDryRun = flags['dry-run'] === true;
115
+ const isHard = flags.hard === true;
116
+ const mode = isHard ? 'hard' : 'default';
117
+
118
+ // 1. Measure before size
119
+ const beforeBytes = contextByteSize(ctxDir);
120
+
121
+ // 2. Safety checkpoint (auto-commit before compact)
122
+ if (!isDryRun && hasChanges(ctxDir)) {
123
+ const hash = commitContext(ctxDir, 'compact: pre-compact checkpoint');
124
+ if (hash) {
125
+ console.log(`💾 Pre-compact checkpoint: ${hash}`);
126
+ }
127
+ }
128
+
129
+ // 3. Identify what to keep vs archive
130
+ const kept = [];
131
+ const archived = [];
132
+
133
+ // 3a. System/ (pinned) — ALWAYS kept
134
+ const systemFiles = listFiles(ctxDir, 'system');
135
+ for (const name of systemFiles) {
136
+ kept.push({ path: `system/${name}`, reason: 'pinned' });
137
+ }
138
+
139
+ // 3b. Memory files — filter entries by recency (or archive all in hard mode)
140
+ const memDir = join(ctxDir, 'memory');
141
+ if (existsSync(memDir)) {
142
+ const memFiles = readdirSync(memDir).filter((n) => n.endsWith('.md') && !n.startsWith('.'));
143
+
144
+ for (const name of memFiles) {
145
+ const relPath = `memory/${name}`;
146
+ const content = readContextFile(ctxDir, relPath);
147
+ if (!content) continue;
148
+
149
+ const { header, entries } = parseMemoryFile(content);
150
+ const keepEntries = selectEntries(entries, mode, config);
151
+ const dropEntries = entries.filter((e) => !keepEntries.includes(e));
152
+
153
+ if (dropEntries.length > 0) {
154
+ archived.push({
155
+ path: relPath,
156
+ entriesDropped: dropEntries.length,
157
+ entriesKept: keepEntries.length,
158
+ reason: mode === 'hard' ? 'hard mode' : 'stale (older than retain window)',
159
+ });
160
+ }
161
+
162
+ if (keepEntries.length > 0) {
163
+ kept.push({
164
+ path: relPath,
165
+ entries: keepEntries.length,
166
+ reason: mode === 'hard' ? 'n/a' : 'recent',
167
+ });
168
+ }
169
+
170
+ // Apply changes (if not dry run)
171
+ if (!isDryRun && dropEntries.length > 0) {
172
+ // Archive dropped entries
173
+ const archivePath = `archive/compact-${new Date().toISOString().slice(0, 10)}/${name}`;
174
+ const archiveContent = header + '\n' + dropEntries.map((e) => e.line).join('\n') + '\n';
175
+ writeContextFile(ctxDir, archivePath, archiveContent);
176
+
177
+ if (keepEntries.length > 0) {
178
+ // Rewrite memory file with only kept entries
179
+ const newContent = header + '\n' + keepEntries.map((e) => e.line).join('\n') + '\n';
180
+ writeContextFile(ctxDir, relPath, newContent);
181
+ } else {
182
+ // Archive entire file — rewrite as empty with header only
183
+ writeContextFile(ctxDir, relPath, header + '\n');
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // 3c. Reflections — archive all except latest in default, archive all in hard
190
+ const refDir = join(ctxDir, 'reflections');
191
+ if (existsSync(refDir)) {
192
+ const refFiles = readdirSync(refDir)
193
+ .filter((n) => n.endsWith('.md'))
194
+ .sort();
195
+ const keepCount = mode === 'hard' ? 0 : 1;
196
+ const toArchive = refFiles.slice(0, refFiles.length - keepCount);
197
+ const toKeep = refFiles.slice(refFiles.length - keepCount);
198
+
199
+ for (const name of toKeep) {
200
+ kept.push({ path: `reflections/${name}`, reason: 'latest reflection' });
201
+ }
202
+
203
+ for (const name of toArchive) {
204
+ archived.push({ path: `reflections/${name}`, reason: 'older reflection' });
205
+
206
+ if (!isDryRun) {
207
+ const relPath = `reflections/${name}`;
208
+ const content = readContextFile(ctxDir, relPath);
209
+ const archivePath = `archive/compact-${new Date().toISOString().slice(0, 10)}/${relPath}`;
210
+ writeContextFile(ctxDir, archivePath, content);
211
+ // Remove original after archiving
212
+ const fullPath = join(ctxDir, relPath);
213
+ if (existsSync(fullPath)) {
214
+ unlinkSync(fullPath);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // 3d. Branches metadata — always kept (lightweight)
221
+ const branchDir = join(ctxDir, 'branches');
222
+ if (existsSync(branchDir)) {
223
+ const branches = readdirSync(branchDir).filter((n) => {
224
+ const full = join(branchDir, n);
225
+ return existsSync(full) && statSync(full).isDirectory();
226
+ });
227
+ if (branches.length > 0) {
228
+ kept.push({ path: 'branches/', reason: 'branch metadata', count: branches.length });
229
+ }
230
+ }
231
+
232
+ // 3e. Config — always kept
233
+ if (existsSync(join(ctxDir, 'config.yaml'))) {
234
+ kept.push({ path: 'config.yaml', reason: 'configuration' });
235
+ }
236
+
237
+ // 4. Measure after size
238
+ const afterBytes = isDryRun ? beforeBytes : contextByteSize(ctxDir);
239
+
240
+ // 5. Commit compaction
241
+ let commitHash = null;
242
+ if (!isDryRun && archived.length > 0) {
243
+ const msg =
244
+ mode === 'hard'
245
+ ? `compact --hard: archived ${archived.length} items, kept pins only`
246
+ : `compact: archived ${archived.length} items, kept recent + pins`;
247
+ commitHash = commitContext(ctxDir, msg);
248
+ }
249
+
250
+ // 6. Output report
251
+ const marker = isDryRun ? ' (dry run)' : '';
252
+ const modeLabel = mode === 'hard' ? ' --hard' : '';
253
+ console.log(`🗜️ COMPACT${modeLabel}${marker}`);
254
+ console.log('');
255
+
256
+ if (kept.length > 0) {
257
+ console.log('KEPT:');
258
+ for (const k of kept) {
259
+ const extra = k.entries ? ` (${k.entries} entries)` : k.count ? ` (${k.count})` : '';
260
+ console.log(` ✅ ${k.path}${extra} — ${k.reason}`);
261
+ }
262
+ console.log('');
263
+ }
264
+
265
+ if (archived.length > 0) {
266
+ console.log('ARCHIVED:');
267
+ for (const a of archived) {
268
+ const extra = a.entriesDropped
269
+ ? ` (${a.entriesDropped} entries dropped, ${a.entriesKept} kept)`
270
+ : '';
271
+ console.log(` 📦 ${a.path}${extra} — ${a.reason}`);
272
+ }
273
+ console.log('');
274
+ }
275
+
276
+ if (archived.length === 0) {
277
+ console.log('Nothing to compact — context is already lean.');
278
+ console.log('');
279
+ }
280
+
281
+ // Token delta (byte-based approximation)
282
+ const delta = beforeBytes - afterBytes;
283
+ const pct = beforeBytes > 0 ? ((delta / beforeBytes) * 100).toFixed(1) : '0.0';
284
+ console.log(
285
+ `SIZE: ${formatBytes(beforeBytes)} → ${formatBytes(afterBytes)} (${delta > 0 ? '-' : '+'}${formatBytes(Math.abs(delta))}, ${pct}% reduction)`,
286
+ );
287
+
288
+ if (!isDryRun && archived.length > 0) {
289
+ const today = new Date().toISOString().slice(0, 10);
290
+ console.log(`ARCHIVE: .context/archive/compact-${today}/`);
291
+ }
292
+
293
+ if (commitHash) {
294
+ console.log(`COMMIT: ${commitHash}`);
295
+ }
296
+
297
+ if (isDryRun && archived.length > 0) {
298
+ console.log('');
299
+ console.log('Run without --dry-run to apply.');
300
+ }
301
+ }
302
+
303
+ function formatBytes(bytes) {
304
+ if (bytes < 1024) return `${bytes}B`;
305
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
306
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
307
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const CLI = join(import.meta.dirname, '../../bin/agent-context.js');
9
+
10
+ function run(args, cwd) {
11
+ return execSync(`node ${CLI} ${args}`, { cwd, encoding: 'utf-8', timeout: 10000 });
12
+ }
13
+
14
+ describe('compact', () => {
15
+ let dir;
16
+
17
+ before(() => {
18
+ dir = mkdtempSync(join(tmpdir(), 'amem-compact-'));
19
+ execSync('git init', { cwd: dir, stdio: 'ignore' });
20
+ execSync('git config user.name "test"', { cwd: dir, stdio: 'ignore' });
21
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore' });
22
+
23
+ // Init context
24
+ run('init', dir);
25
+
26
+ // Add some memory entries — mix of old and recent
27
+ const ctxDir = join(dir, '.context');
28
+ const memDir = join(ctxDir, 'memory');
29
+
30
+ const oldDate = '2025-01-01';
31
+ const recentDate = new Date().toISOString().slice(0, 10);
32
+
33
+ const decisionsContent = [
34
+ '---',
35
+ 'description: "Decisions"',
36
+ '---',
37
+ '',
38
+ '# Decisions',
39
+ '',
40
+ `- [${oldDate} 10:00] Use PostgreSQL for primary storage`,
41
+ `- [${oldDate} 11:00] Choose FastAPI over Flask`,
42
+ `- [${recentDate} 09:00] Switch to connection pooling`,
43
+ ].join('\n');
44
+
45
+ writeFileSync(join(memDir, 'decisions.md'), decisionsContent);
46
+
47
+ // Add a pinned file
48
+ mkdirSync(join(ctxDir, 'system'), { recursive: true });
49
+ writeFileSync(join(ctxDir, 'system', 'task.md'), '# Current Task\nBuild compact command');
50
+
51
+ // Commit so we have a clean state
52
+ execSync("git add -A && git commit -m 'setup'", { cwd: dir, stdio: 'ignore' });
53
+ });
54
+
55
+ after(() => {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ it('dry-run shows what would be archived without changes', () => {
60
+ const out = run('compact --dry-run', dir);
61
+ assert.match(out, /COMPACT/);
62
+ assert.match(out, /dry run/);
63
+ assert.match(out, /system\/task\.md/);
64
+ assert.match(out, /pinned/);
65
+ // Should not create archive directory
66
+ assert.ok(!existsSync(join(dir, '.context', 'archive')));
67
+ });
68
+
69
+ it('default compact archives old entries, keeps recent + pins', () => {
70
+ const out = run('compact', dir);
71
+ assert.match(out, /COMPACT/);
72
+ assert.match(out, /KEPT/);
73
+ assert.match(out, /ARCHIVED/);
74
+ assert.match(out, /system\/task\.md.*pinned/);
75
+
76
+ // Archive should exist
77
+ assert.ok(existsSync(join(dir, '.context', 'archive')));
78
+
79
+ // Decisions file should still have the recent entry
80
+ const decisions = readFileSync(join(dir, '.context', 'memory', 'decisions.md'), 'utf-8');
81
+ assert.match(decisions, /connection pooling/);
82
+ // Old entries should be gone from the file
83
+ assert.ok(!decisions.includes('Use PostgreSQL'));
84
+ });
85
+
86
+ it('--hard keeps only pins, archives everything else', () => {
87
+ // Re-add some entries
88
+ const ctxDir = join(dir, '.context');
89
+ const recentDate = new Date().toISOString().slice(0, 10);
90
+ const content = [
91
+ '---',
92
+ 'description: "Patterns"',
93
+ '---',
94
+ '',
95
+ '# Patterns',
96
+ '',
97
+ `- [${recentDate} 10:00] Always use typed responses`,
98
+ ].join('\n');
99
+ writeFileSync(join(ctxDir, 'memory', 'patterns.md'), content);
100
+ execSync("git add -A && git commit -m 'add patterns'", { cwd: dir, stdio: 'ignore' });
101
+
102
+ const out = run('compact --hard', dir);
103
+ assert.match(out, /--hard/);
104
+ assert.match(out, /system\/task\.md.*pinned/);
105
+
106
+ // Patterns file should be emptied (entries archived)
107
+ const patterns = readFileSync(join(ctxDir, 'memory', 'patterns.md'), 'utf-8');
108
+ assert.ok(!patterns.includes('Always use typed responses'));
109
+ });
110
+ });
@@ -0,0 +1,47 @@
1
+ import { contextDir as getContextDir } from '../core/context-root.js';
2
+ import { readConfig, writeConfig } from '../core/config.js';
3
+
4
+ export default async function config({ args, flags }) {
5
+ const root = flags._contextRoot;
6
+ const ctxDir = getContextDir(root);
7
+ const cfg = readConfig(ctxDir);
8
+
9
+ // config set <key> <value>
10
+ if (args[0] === 'set' && args.length >= 3) {
11
+ const key = args[1];
12
+ const val = args[2];
13
+
14
+ // Handle nested keys (e.g., reflection.trigger)
15
+ const parts = key.split('.');
16
+ if (parts.length === 2 && cfg[parts[0]] && typeof cfg[parts[0]] === 'object') {
17
+ cfg[parts[0]][parts[1]] = parseConfigVal(val);
18
+ } else {
19
+ cfg[key] = parseConfigVal(val);
20
+ }
21
+
22
+ writeConfig(ctxDir, cfg);
23
+ console.log(`✅ CONFIG: ${key} = ${val}`);
24
+ return;
25
+ }
26
+
27
+ // Show config
28
+ console.log(`⚙️ CONFIG (.context/config.yaml):\n`);
29
+ for (const [key, val] of Object.entries(cfg)) {
30
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
31
+ console.log(` ${key}:`);
32
+ for (const [k, v] of Object.entries(val)) {
33
+ console.log(` ${k}: ${v}`);
34
+ }
35
+ } else {
36
+ console.log(` ${key}: ${val}`);
37
+ }
38
+ }
39
+ }
40
+
41
+ function parseConfigVal(s) {
42
+ if (s === 'true') return true;
43
+ if (s === 'false') return false;
44
+ if (s === 'null') return null;
45
+ if (/^\d+$/.test(s)) return parseInt(s, 10);
46
+ return s;
47
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const CLI = join(import.meta.dirname, '../../bin/agent-context.js');
9
+ const run = (args, cwd) =>
10
+ execSync(`node ${CLI} ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
11
+
12
+ describe('snapshot', () => {
13
+ let dir;
14
+
15
+ before(() => {
16
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-snap-'));
17
+ writeFileSync(
18
+ join(dir, 'package.json'),
19
+ JSON.stringify({ name: 'snap-test', dependencies: { express: '4' } }),
20
+ );
21
+ run('init', dir);
22
+ });
23
+
24
+ after(() => rmSync(dir, { recursive: true, force: true }));
25
+
26
+ it('shows context snapshot', () => {
27
+ const out = run('snapshot', dir);
28
+ assert.ok(out.includes('CONTEXT SNAPSHOT'));
29
+ assert.ok(out.includes('PINNED'));
30
+ });
31
+
32
+ it('shows stack in pinned files', () => {
33
+ const out = run('snapshot', dir);
34
+ assert.ok(out.includes('Express'));
35
+ });
36
+ });
37
+
38
+ describe('read / write', () => {
39
+ let dir;
40
+
41
+ before(() => {
42
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-rw-'));
43
+ writeFileSync(join(dir, 'package.json'), '{}');
44
+ run('init', dir);
45
+ });
46
+
47
+ after(() => rmSync(dir, { recursive: true, force: true }));
48
+
49
+ it('reads existing files', () => {
50
+ const out = run('read system/project.md', dir);
51
+ assert.ok(out.includes('rw-')); // part of temp dir name in project name
52
+ });
53
+
54
+ it('writes new files', () => {
55
+ run('write memory/test.md --content "hello world"', dir);
56
+ const content = readFileSync(join(dir, '.context/memory/test.md'), 'utf-8');
57
+ assert.equal(content, 'hello world');
58
+ });
59
+
60
+ it('fails on nonexistent file', () => {
61
+ assert.throws(() => run('read nonexistent.md', dir));
62
+ });
63
+ });
64
+
65
+ describe('commit', () => {
66
+ let dir;
67
+
68
+ before(() => {
69
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-commit-'));
70
+ writeFileSync(join(dir, 'package.json'), '{}');
71
+ run('init', dir);
72
+ });
73
+
74
+ after(() => rmSync(dir, { recursive: true, force: true }));
75
+
76
+ it('reports nothing to commit when clean', () => {
77
+ const out = run('commit', dir);
78
+ assert.ok(out.includes('No changes'));
79
+ });
80
+
81
+ it('commits after changes', () => {
82
+ run('write memory/test.md --content "test data"', dir);
83
+ const out = run('commit test checkpoint', dir);
84
+ assert.ok(out.includes('COMMITTED'));
85
+ assert.ok(out.includes('test checkpoint'));
86
+ });
87
+ });
88
+
89
+ describe('status', () => {
90
+ let dir;
91
+
92
+ before(() => {
93
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-status-'));
94
+ writeFileSync(join(dir, 'package.json'), '{}');
95
+ run('init', dir);
96
+ });
97
+
98
+ after(() => rmSync(dir, { recursive: true, force: true }));
99
+
100
+ it('shows status', () => {
101
+ const out = run('status', dir);
102
+ assert.ok(out.includes('STATUS'));
103
+ assert.ok(out.includes('Commits: 1'));
104
+ assert.ok(out.includes('pinned'));
105
+ });
106
+ });
107
+
108
+ describe('remember', () => {
109
+ let dir;
110
+
111
+ before(() => {
112
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-rem-'));
113
+ writeFileSync(join(dir, 'package.json'), '{}');
114
+ run('init', dir);
115
+ });
116
+
117
+ after(() => rmSync(dir, { recursive: true, force: true }));
118
+
119
+ it('remembers decisions', () => {
120
+ run('remember --decision "chose X over Y"', dir);
121
+ const content = readFileSync(join(dir, '.context/memory/decisions.md'), 'utf-8');
122
+ assert.ok(content.includes('chose X over Y'));
123
+ });
124
+
125
+ it('remembers patterns', () => {
126
+ run('remember --pattern "always check logs"', dir);
127
+ const content = readFileSync(join(dir, '.context/memory/patterns.md'), 'utf-8');
128
+ assert.ok(content.includes('always check logs'));
129
+ });
130
+
131
+ it('remembers mistakes', () => {
132
+ run('remember --mistake "never force push"', dir);
133
+ const content = readFileSync(join(dir, '.context/memory/mistakes.md'), 'utf-8');
134
+ assert.ok(content.includes('never force push'));
135
+ });
136
+
137
+ it('defaults to notes', () => {
138
+ run('remember just a note', dir);
139
+ const content = readFileSync(join(dir, '.context/memory/notes.md'), 'utf-8');
140
+ assert.ok(content.includes('just a note'));
141
+ });
142
+ });
143
+
144
+ describe('search', () => {
145
+ let dir;
146
+
147
+ before(() => {
148
+ dir = mkdtempSync(join(tmpdir(), 'amem-test-search-'));
149
+ writeFileSync(join(dir, 'package.json'), '{}');
150
+ run('init', dir);
151
+ run('remember --decision "chose PostgreSQL for persistence"', dir);
152
+ });
153
+
154
+ after(() => rmSync(dir, { recursive: true, force: true }));
155
+
156
+ it('finds matching text', () => {
157
+ const out = run('search PostgreSQL', dir);
158
+ assert.ok(out.includes('PostgreSQL'));
159
+ assert.ok(out.includes('decisions.md'));
160
+ });
161
+
162
+ it('reports no results for missing query', () => {
163
+ const out = run('search zzzznonexistent', dir);
164
+ assert.ok(out.includes('No results'));
165
+ });
166
+ });