@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.
- package/.claude/commands/context.md +24 -0
- package/.claude/skills/agent-mem/SKILL.md +66 -0
- package/.claude/skills/agent-mem/references/branching-merging.md +34 -0
- package/.claude/skills/agent-mem/references/coexistence.md +19 -0
- package/.claude/skills/agent-mem/references/collaboration.md +33 -0
- package/.claude/skills/agent-mem/references/reflection-compaction.md +104 -0
- package/.claude/skills/agent-mem/references/sub-agent-patterns.md +60 -0
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/bin/agent-context.js +95 -0
- package/bin/parse-args.js +85 -0
- package/package.json +58 -0
- package/src/commands/branch.js +57 -0
- package/src/commands/branch.test.js +91 -0
- package/src/commands/branches.js +34 -0
- package/src/commands/commit.js +55 -0
- package/src/commands/compact.js +307 -0
- package/src/commands/compact.test.js +110 -0
- package/src/commands/config.js +47 -0
- package/src/commands/core.test.js +166 -0
- package/src/commands/diff.js +157 -0
- package/src/commands/diff.test.js +64 -0
- package/src/commands/forget.js +77 -0
- package/src/commands/forget.test.js +68 -0
- package/src/commands/help.js +99 -0
- package/src/commands/import.js +83 -0
- package/src/commands/init.js +269 -0
- package/src/commands/init.test.js +80 -0
- package/src/commands/lesson.js +95 -0
- package/src/commands/lesson.test.js +93 -0
- package/src/commands/merge.js +105 -0
- package/src/commands/pin.js +34 -0
- package/src/commands/pull.js +80 -0
- package/src/commands/push.js +80 -0
- package/src/commands/read.js +62 -0
- package/src/commands/reflect.js +328 -0
- package/src/commands/remember.js +95 -0
- package/src/commands/resolve.js +230 -0
- package/src/commands/resolve.test.js +167 -0
- package/src/commands/search.js +70 -0
- package/src/commands/share.js +65 -0
- package/src/commands/snapshot.js +106 -0
- package/src/commands/status.js +37 -0
- package/src/commands/switch.js +31 -0
- package/src/commands/sync.js +328 -0
- package/src/commands/track.js +61 -0
- package/src/commands/unpin.js +28 -0
- package/src/commands/write.js +58 -0
- package/src/core/auto-commit.js +22 -0
- package/src/core/config.js +93 -0
- package/src/core/context-root.js +28 -0
- package/src/core/fs.js +137 -0
- package/src/core/git.js +182 -0
- package/src/core/importers.js +210 -0
- package/src/core/lock.js +62 -0
- package/src/core/reflect-defrag.js +287 -0
- package/src/core/reflect-gather.js +360 -0
- 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
|
+
});
|