@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,230 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { contextDir as getContextDir } from '../core/context-root.js';
|
|
4
|
+
import { commitContext } from '../core/git.js';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find files with merge conflict markers in .context/.
|
|
9
|
+
*/
|
|
10
|
+
function findConflicted(ctxDir, verbose = false) {
|
|
11
|
+
const conflicted = [];
|
|
12
|
+
const walk = (dir) => {
|
|
13
|
+
if (!existsSync(dir)) return;
|
|
14
|
+
for (const name of readdirSync(dir)) {
|
|
15
|
+
if (name.startsWith('.')) continue;
|
|
16
|
+
const full = join(dir, name);
|
|
17
|
+
try {
|
|
18
|
+
const stat = statSync(full);
|
|
19
|
+
if (stat.isDirectory()) {
|
|
20
|
+
walk(full);
|
|
21
|
+
} else {
|
|
22
|
+
const content = readFileSync(full, 'utf-8');
|
|
23
|
+
// Check for actual conflict block structure (not just substrings)
|
|
24
|
+
if (
|
|
25
|
+
/^<<<<<<<\s/m.test(content) &&
|
|
26
|
+
/^=======/m.test(content) &&
|
|
27
|
+
/^>>>>>>>\s/m.test(content)
|
|
28
|
+
) {
|
|
29
|
+
const relPath = full.slice(ctxDir.length + 1);
|
|
30
|
+
conflicted.push({ path: relPath, fullPath: full, content });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (verbose) console.error(` ⚠️ Could not read ${full}: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
walk(ctxDir);
|
|
39
|
+
return conflicted;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a single file's merge conflicts.
|
|
44
|
+
*
|
|
45
|
+
* Strategy for .context/ files:
|
|
46
|
+
* - Memory files (append-only entries): keep BOTH sides, deduplicate
|
|
47
|
+
* - Config files: prefer "ours" (local) for settings
|
|
48
|
+
* - Other files: keep both sides concatenated with separator
|
|
49
|
+
*/
|
|
50
|
+
function resolveFile(relPath, content) {
|
|
51
|
+
const isMemory = relPath.startsWith('memory/') || relPath.startsWith('archive/');
|
|
52
|
+
const isConfig = relPath === 'config.yaml';
|
|
53
|
+
|
|
54
|
+
if (isConfig) {
|
|
55
|
+
return resolveConfigConflict(content);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isMemory) {
|
|
59
|
+
return resolveAppendOnly(content);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default: keep both sides
|
|
63
|
+
return resolveKeepBoth(content);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve append-only files (memory) by merging both sides.
|
|
68
|
+
* Deduplicates entries that are exact matches (preserves blank lines and whitespace).
|
|
69
|
+
*/
|
|
70
|
+
function resolveAppendOnly(content) {
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
const resolved = [];
|
|
73
|
+
let inConflict = false;
|
|
74
|
+
let oursLines = [];
|
|
75
|
+
let theirsLines = [];
|
|
76
|
+
let side = null;
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.startsWith('<<<<<<<')) {
|
|
80
|
+
inConflict = true;
|
|
81
|
+
side = 'ours';
|
|
82
|
+
oursLines = [];
|
|
83
|
+
theirsLines = [];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (line === '=======' && inConflict) {
|
|
87
|
+
side = 'theirs';
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith('>>>>>>>') && inConflict) {
|
|
91
|
+
// Merge: keep all lines from ours, then unique lines from theirs
|
|
92
|
+
// Exact string match for dedup (not trimmed)
|
|
93
|
+
const seen = new Set(oursLines);
|
|
94
|
+
const merged = [...oursLines];
|
|
95
|
+
|
|
96
|
+
for (const l of theirsLines) {
|
|
97
|
+
if (!seen.has(l)) {
|
|
98
|
+
seen.add(l);
|
|
99
|
+
merged.push(l);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resolved.push(...merged);
|
|
104
|
+
inConflict = false;
|
|
105
|
+
side = null;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (inConflict) {
|
|
110
|
+
if (side === 'ours') oursLines.push(line);
|
|
111
|
+
else theirsLines.push(line);
|
|
112
|
+
} else {
|
|
113
|
+
resolved.push(line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return resolved.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve config conflicts by preferring "ours" (local) values.
|
|
122
|
+
*/
|
|
123
|
+
function resolveConfigConflict(content) {
|
|
124
|
+
const lines = content.split('\n');
|
|
125
|
+
const resolved = [];
|
|
126
|
+
let inConflict = false;
|
|
127
|
+
let side = null;
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
if (line.startsWith('<<<<<<<')) {
|
|
131
|
+
inConflict = true;
|
|
132
|
+
side = 'ours';
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (line === '=======' && inConflict) {
|
|
136
|
+
side = 'theirs';
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (line.startsWith('>>>>>>>') && inConflict) {
|
|
140
|
+
inConflict = false;
|
|
141
|
+
side = null;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (inConflict) {
|
|
146
|
+
// Keep only "ours" lines
|
|
147
|
+
if (side === 'ours') {
|
|
148
|
+
resolved.push(line);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
resolved.push(line);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return resolved.join('\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fallback: keep both sides separated.
|
|
160
|
+
*/
|
|
161
|
+
function resolveKeepBoth(content) {
|
|
162
|
+
// Just strip conflict markers and keep everything
|
|
163
|
+
return content
|
|
164
|
+
.replace(/^<<<<<<<.*\n/gm, '')
|
|
165
|
+
.replace(/^=======\n/gm, '\n--- merged ---\n\n')
|
|
166
|
+
.replace(/^>>>>>>>.*\n/gm, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get resolution strategy label for a file path.
|
|
171
|
+
*/
|
|
172
|
+
function getStrategy(relPath) {
|
|
173
|
+
if (relPath.startsWith('memory/') || relPath.startsWith('archive/')) {
|
|
174
|
+
return 'append-only (merge both, deduplicate)';
|
|
175
|
+
}
|
|
176
|
+
if (relPath === 'config.yaml') {
|
|
177
|
+
return 'prefer-ours (keep local config)';
|
|
178
|
+
}
|
|
179
|
+
return 'keep-both (concatenate)';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default async function resolve({ args, flags }) {
|
|
183
|
+
const root = flags._contextRoot;
|
|
184
|
+
const ctxDir = getContextDir(root);
|
|
185
|
+
const isDryRun = flags['dry-run'] === true;
|
|
186
|
+
|
|
187
|
+
const verbose = flags.verbose === true;
|
|
188
|
+
const conflicted = findConflicted(ctxDir, verbose);
|
|
189
|
+
|
|
190
|
+
if (conflicted.length === 0) {
|
|
191
|
+
console.log('✅ No merge conflicts in .context/');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`🔀 RESOLVE${isDryRun ? ' (dry run)' : ''}`);
|
|
196
|
+
console.log(`Found ${conflicted.length} conflicted file${conflicted.length > 1 ? 's' : ''}:`);
|
|
197
|
+
console.log('');
|
|
198
|
+
|
|
199
|
+
for (const file of conflicted) {
|
|
200
|
+
const strategy = getStrategy(file.path);
|
|
201
|
+
|
|
202
|
+
console.log(` ${file.path} — ${strategy}`);
|
|
203
|
+
|
|
204
|
+
if (!isDryRun) {
|
|
205
|
+
const resolved = resolveFile(file.path, file.content);
|
|
206
|
+
writeFileSync(file.fullPath, resolved, 'utf-8');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isDryRun) {
|
|
211
|
+
// Stage resolved files
|
|
212
|
+
try {
|
|
213
|
+
execSync('git add .', { cwd: ctxDir, stdio: 'ignore' });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (verbose) console.error(` ⚠️ git add failed: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hash = commitContext(
|
|
219
|
+
ctxDir,
|
|
220
|
+
`resolve: auto-resolved ${conflicted.length} conflict${conflicted.length > 1 ? 's' : ''}`,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
console.log('');
|
|
224
|
+
console.log(`✅ Resolved ${conflicted.length} file${conflicted.length > 1 ? 's' : ''}`);
|
|
225
|
+
if (hash) console.log(`Committed: ${hash}`);
|
|
226
|
+
} else {
|
|
227
|
+
console.log('');
|
|
228
|
+
console.log('Run without --dry-run to apply resolutions.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, 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
|
+
function makeConflict(ours, theirs) {
|
|
15
|
+
return `<<<<<<< HEAD\n${ours}\n=======\n${theirs}\n>>>>>>> branch-b`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('resolve', () => {
|
|
19
|
+
let dir;
|
|
20
|
+
|
|
21
|
+
before(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), 'amem-resolve-'));
|
|
23
|
+
execSync('git init', { cwd: dir, stdio: 'ignore' });
|
|
24
|
+
execSync('git config user.name "test"', { cwd: dir, stdio: 'ignore' });
|
|
25
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore' });
|
|
26
|
+
run('init', dir);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(() => {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('reports no conflicts when clean', () => {
|
|
34
|
+
const out = run('resolve', dir);
|
|
35
|
+
assert.match(out, /No merge conflicts/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolves memory file conflicts by keeping both sides deduplicated', () => {
|
|
39
|
+
const memFile = join(dir, '.context', 'memory', 'decisions.md');
|
|
40
|
+
writeFileSync(
|
|
41
|
+
memFile,
|
|
42
|
+
[
|
|
43
|
+
'# Decisions',
|
|
44
|
+
'',
|
|
45
|
+
'- [2026-01-01] Shared decision',
|
|
46
|
+
makeConflict('- [2026-02-01] Our local decision', '- [2026-02-01] Their remote decision'),
|
|
47
|
+
].join('\n'),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const out = run('resolve', dir);
|
|
51
|
+
assert.match(out, /Resolved/);
|
|
52
|
+
assert.match(out, /append-only/);
|
|
53
|
+
|
|
54
|
+
const resolved = readFileSync(memFile, 'utf-8');
|
|
55
|
+
assert.ok(!resolved.includes('<<<<<<<'));
|
|
56
|
+
assert.ok(!resolved.includes('>>>>>>>'));
|
|
57
|
+
assert.match(resolved, /Our local decision/);
|
|
58
|
+
assert.match(resolved, /Their remote decision/);
|
|
59
|
+
assert.match(resolved, /Shared decision/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('deduplicates exact matches in append-only merge', () => {
|
|
63
|
+
const memFile = join(dir, '.context', 'memory', 'patterns.md');
|
|
64
|
+
mkdirSync(join(dir, '.context', 'memory'), { recursive: true });
|
|
65
|
+
writeFileSync(
|
|
66
|
+
memFile,
|
|
67
|
+
[
|
|
68
|
+
'# Patterns',
|
|
69
|
+
makeConflict(
|
|
70
|
+
'- [2026-01-01] Same entry\n- [2026-01-02] Only ours',
|
|
71
|
+
'- [2026-01-01] Same entry\n- [2026-01-03] Only theirs',
|
|
72
|
+
),
|
|
73
|
+
].join('\n'),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
run('resolve', dir);
|
|
77
|
+
const resolved = readFileSync(memFile, 'utf-8');
|
|
78
|
+
|
|
79
|
+
// "Same entry" should appear exactly once
|
|
80
|
+
const count = (resolved.match(/Same entry/g) || []).length;
|
|
81
|
+
assert.equal(count, 1, 'duplicate entry should appear only once');
|
|
82
|
+
assert.match(resolved, /Only ours/);
|
|
83
|
+
assert.match(resolved, /Only theirs/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('preserves whitespace differences (no trim-based dedupe)', () => {
|
|
87
|
+
const memFile = join(dir, '.context', 'memory', 'ws-test.md');
|
|
88
|
+
writeFileSync(
|
|
89
|
+
memFile,
|
|
90
|
+
makeConflict('- [2026-01-01] spaced entry', '- [2026-01-01] spaced entry'),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
run('resolve', dir);
|
|
94
|
+
const resolved = readFileSync(memFile, 'utf-8');
|
|
95
|
+
// Both should be kept — different exact strings
|
|
96
|
+
const count = (resolved.match(/spaced entry/g) || []).length;
|
|
97
|
+
assert.equal(count, 2, 'whitespace-different lines should both be kept');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('resolves config.yaml with prefer-ours strategy', () => {
|
|
101
|
+
const configFile = join(dir, '.context', 'config.yaml');
|
|
102
|
+
writeFileSync(
|
|
103
|
+
configFile,
|
|
104
|
+
[
|
|
105
|
+
'auto_commit: true',
|
|
106
|
+
makeConflict('branch: feature-a', 'branch: feature-b'),
|
|
107
|
+
'system_files_max: 10',
|
|
108
|
+
].join('\n'),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
run('resolve', dir);
|
|
112
|
+
const resolved = readFileSync(configFile, 'utf-8');
|
|
113
|
+
assert.ok(!resolved.includes('<<<<<<<'));
|
|
114
|
+
assert.match(resolved, /feature-a/, 'should keep ours');
|
|
115
|
+
assert.ok(!resolved.includes('feature-b'), 'should drop theirs');
|
|
116
|
+
assert.match(resolved, /auto_commit: true/);
|
|
117
|
+
assert.match(resolved, /system_files_max: 10/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves other files with keep-both strategy', () => {
|
|
121
|
+
const otherFile = join(dir, '.context', 'main.md');
|
|
122
|
+
writeFileSync(
|
|
123
|
+
otherFile,
|
|
124
|
+
['# Project', makeConflict('## Our section', '## Their section')].join('\n'),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
run('resolve', dir);
|
|
128
|
+
const resolved = readFileSync(otherFile, 'utf-8');
|
|
129
|
+
assert.ok(!resolved.includes('<<<<<<<'));
|
|
130
|
+
assert.match(resolved, /Our section/);
|
|
131
|
+
assert.match(resolved, /Their section/);
|
|
132
|
+
assert.match(resolved, /merged/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles multiple conflict blocks in one file', () => {
|
|
136
|
+
const memFile = join(dir, '.context', 'memory', 'multi.md');
|
|
137
|
+
writeFileSync(
|
|
138
|
+
memFile,
|
|
139
|
+
[
|
|
140
|
+
'# Multi',
|
|
141
|
+
makeConflict('- block1-ours', '- block1-theirs'),
|
|
142
|
+
'- shared middle line',
|
|
143
|
+
makeConflict('- block2-ours', '- block2-theirs'),
|
|
144
|
+
].join('\n'),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
run('resolve', dir);
|
|
148
|
+
const resolved = readFileSync(memFile, 'utf-8');
|
|
149
|
+
assert.ok(!resolved.includes('<<<<<<<'));
|
|
150
|
+
assert.match(resolved, /block1-ours/);
|
|
151
|
+
assert.match(resolved, /block1-theirs/);
|
|
152
|
+
assert.match(resolved, /shared middle line/);
|
|
153
|
+
assert.match(resolved, /block2-ours/);
|
|
154
|
+
assert.match(resolved, /block2-theirs/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('dry-run shows strategy without modifying files', () => {
|
|
158
|
+
const memFile = join(dir, '.context', 'memory', 'drytest.md');
|
|
159
|
+
writeFileSync(memFile, makeConflict('ours', 'theirs'));
|
|
160
|
+
|
|
161
|
+
const out = run('resolve --dry-run', dir);
|
|
162
|
+
assert.match(out, /dry run/);
|
|
163
|
+
|
|
164
|
+
const content = readFileSync(memFile, 'utf-8');
|
|
165
|
+
assert.ok(content.includes('<<<<<<<'), 'should not modify file in dry-run');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { contextDir as getContextDir } from '../core/context-root.js';
|
|
2
|
+
import { buildTree, readContextFile } from '../core/fs.js';
|
|
3
|
+
import { readConfig } from '../core/config.js';
|
|
4
|
+
|
|
5
|
+
export default async function search({ args, flags }) {
|
|
6
|
+
const root = flags._contextRoot;
|
|
7
|
+
const ctxDir = getContextDir(root);
|
|
8
|
+
|
|
9
|
+
if (!args.length) {
|
|
10
|
+
console.error('❌ Usage: agent-mem search <query>');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const config = readConfig(ctxDir);
|
|
15
|
+
const branch = config.branch || 'main';
|
|
16
|
+
const query = args.join(' ').toLowerCase();
|
|
17
|
+
const tree = buildTree(ctxDir);
|
|
18
|
+
|
|
19
|
+
// When on a branch, search branch memory first, then system/ and global memory
|
|
20
|
+
// Skip global memory/ duplicates to avoid noise
|
|
21
|
+
const files = tree.filter((e) => !e.isDir);
|
|
22
|
+
const branchMemPrefix = branch !== 'main' ? `branches/${branch}/memory/` : null;
|
|
23
|
+
|
|
24
|
+
const searchFiles = branchMemPrefix
|
|
25
|
+
? files.filter((f) => {
|
|
26
|
+
// Include branch memory, system/, reflections, config — skip global memory/
|
|
27
|
+
if (f.path.startsWith(branchMemPrefix)) return true;
|
|
28
|
+
if (f.path.startsWith('memory/')) return false;
|
|
29
|
+
return true;
|
|
30
|
+
})
|
|
31
|
+
: files;
|
|
32
|
+
|
|
33
|
+
const results = [];
|
|
34
|
+
|
|
35
|
+
for (const file of searchFiles) {
|
|
36
|
+
const content = readContextFile(ctxDir, file.path);
|
|
37
|
+
if (!content) continue;
|
|
38
|
+
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
if (lines[i].toLowerCase().includes(query)) {
|
|
42
|
+
results.push({
|
|
43
|
+
path: file.path,
|
|
44
|
+
line: i + 1,
|
|
45
|
+
text: lines[i].trim(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!results.length) {
|
|
52
|
+
console.log(`🔍 SEARCH: "${args.join(' ')}"\nNo results found.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const branchLabel = branchMemPrefix ? ` [branch: ${branch}]` : '';
|
|
57
|
+
console.log(`🔍 SEARCH${branchLabel}: "${args.join(' ')}" (${results.length} matches)\n`);
|
|
58
|
+
|
|
59
|
+
// Group by file, show top 20
|
|
60
|
+
const shown = results.slice(0, 20);
|
|
61
|
+
for (let i = 0; i < shown.length; i++) {
|
|
62
|
+
const r = shown[i];
|
|
63
|
+
const preview = r.text.length > 100 ? r.text.slice(0, 97) + '...' : r.text;
|
|
64
|
+
console.log(`${i + 1}. ${r.path}:${r.line} — ${preview}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (results.length > 20) {
|
|
68
|
+
console.log(`\n... and ${results.length - 20} more matches`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { contextDir as getContextDir } from '../core/context-root.js';
|
|
5
|
+
import { buildTree, readContextFile } from '../core/fs.js';
|
|
6
|
+
import { readConfig } from '../core/config.js';
|
|
7
|
+
import { commitCount, lastCommit } from '../core/git.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a portable context snapshot that can be shared with anyone.
|
|
11
|
+
*
|
|
12
|
+
* agent-mem share — generate snapshot file
|
|
13
|
+
* agent-mem share --output <path> — custom output path
|
|
14
|
+
* agent-mem import <file-or-url> — import a shared snapshot
|
|
15
|
+
*/
|
|
16
|
+
export default async function share({ args, flags }) {
|
|
17
|
+
const root = flags._contextRoot;
|
|
18
|
+
const ctxDir = getContextDir(root);
|
|
19
|
+
|
|
20
|
+
const config = readConfig(ctxDir);
|
|
21
|
+
const projectName = basename(root);
|
|
22
|
+
const commits = commitCount(ctxDir);
|
|
23
|
+
const last = lastCommit(ctxDir);
|
|
24
|
+
|
|
25
|
+
// Gather all context into a single portable object
|
|
26
|
+
const tree = buildTree(ctxDir);
|
|
27
|
+
const files = {};
|
|
28
|
+
|
|
29
|
+
for (const entry of tree) {
|
|
30
|
+
if (entry.isDir) continue;
|
|
31
|
+
const content = readContextFile(ctxDir, entry.path);
|
|
32
|
+
if (content !== null) {
|
|
33
|
+
files[entry.path] = content;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const snapshot = {
|
|
38
|
+
version: 1,
|
|
39
|
+
tool: 'agent-mem',
|
|
40
|
+
project: projectName,
|
|
41
|
+
branch: config.branch || 'main',
|
|
42
|
+
commits,
|
|
43
|
+
lastCommit: last,
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
files,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const json = JSON.stringify(snapshot, null, 2);
|
|
49
|
+
const hash = createHash('sha256').update(json).digest('hex').slice(0, 8);
|
|
50
|
+
const filename = `context-${projectName}-${hash}.json`;
|
|
51
|
+
|
|
52
|
+
const outputPath = flags.output || join(root, filename);
|
|
53
|
+
writeFileSync(outputPath, json);
|
|
54
|
+
|
|
55
|
+
const fileCount = Object.keys(files).length;
|
|
56
|
+
const sizeKb = (Buffer.byteLength(json) / 1024).toFixed(1);
|
|
57
|
+
|
|
58
|
+
console.log(`✅ SHARED: ${filename}`);
|
|
59
|
+
console.log(` Project: ${projectName}`);
|
|
60
|
+
console.log(` Files: ${fileCount} | Size: ${sizeKb} KB | Commits: ${commits}`);
|
|
61
|
+
console.log(` Path: ${outputPath}`);
|
|
62
|
+
console.log('');
|
|
63
|
+
console.log('To import on another machine:');
|
|
64
|
+
console.log(` agent-mem import ${filename}`);
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { contextDir as getContextDir } from '../core/context-root.js';
|
|
3
|
+
import { buildTree, readContextFile, countFiles } from '../core/fs.js';
|
|
4
|
+
import { readConfig } from '../core/config.js';
|
|
5
|
+
import { commitCount, lastCommit, hasChanges } from '../core/git.js';
|
|
6
|
+
|
|
7
|
+
export default async function snapshot({ args, flags }) {
|
|
8
|
+
const root = flags._contextRoot;
|
|
9
|
+
const ctxDir = getContextDir(root);
|
|
10
|
+
const config = readConfig(ctxDir);
|
|
11
|
+
const projectName = root.split('/').pop();
|
|
12
|
+
|
|
13
|
+
const commits = commitCount(ctxDir);
|
|
14
|
+
const last = lastCommit(ctxDir);
|
|
15
|
+
const dirty = hasChanges(ctxDir);
|
|
16
|
+
const branch = config.branch || 'main';
|
|
17
|
+
|
|
18
|
+
const tree = buildTree(ctxDir);
|
|
19
|
+
|
|
20
|
+
// Header
|
|
21
|
+
const lines = [
|
|
22
|
+
`📋 CONTEXT SNAPSHOT`,
|
|
23
|
+
`Project: ${projectName} | Branch: ${branch} | Commits: ${commits}${dirty ? ' (uncommitted changes)' : ''}`,
|
|
24
|
+
last ? `Last commit: "${last.message}" (${last.timeAgo})` : '',
|
|
25
|
+
'',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// System files (pinned — show full content)
|
|
29
|
+
const systemFiles = tree.filter((e) => e.path.startsWith('system/') && !e.isDir);
|
|
30
|
+
if (systemFiles.length) {
|
|
31
|
+
lines.push('PINNED (system/) — always in agent context:');
|
|
32
|
+
for (const f of systemFiles) {
|
|
33
|
+
const content = readContextFile(ctxDir, f.path);
|
|
34
|
+
if (content) {
|
|
35
|
+
// Strip frontmatter for display
|
|
36
|
+
const stripped = content.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
|
37
|
+
const preview = stripped.length > 500 ? stripped.slice(0, 497) + '...' : stripped;
|
|
38
|
+
lines.push(` --- ${f.path} ---`);
|
|
39
|
+
lines.push(` ${preview.split('\n').join('\n ')}`);
|
|
40
|
+
lines.push('');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Memory files — branch-scoped when on a branch
|
|
46
|
+
const branchMemPrefix = branch !== 'main' ? `branches/${branch}/memory/` : null;
|
|
47
|
+
const branchMemFiles = branchMemPrefix
|
|
48
|
+
? tree.filter((e) => e.path.startsWith(branchMemPrefix) && !e.isDir)
|
|
49
|
+
: [];
|
|
50
|
+
const globalMemFiles = tree.filter((e) => e.path.startsWith('memory/') && !e.isDir);
|
|
51
|
+
|
|
52
|
+
if (branchMemPrefix && branchMemFiles.length) {
|
|
53
|
+
lines.push(`MEMORY [branch: ${branch}] (${branchMemFiles.length} files):`);
|
|
54
|
+
for (const f of branchMemFiles) {
|
|
55
|
+
lines.push(` ${f.name} — ${f.description || '(no description)'}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (branchMemPrefix && globalMemFiles.length) {
|
|
61
|
+
lines.push(
|
|
62
|
+
`MEMORY [main] (${globalMemFiles.length} files — use 'amem read memory/<file>' for global):`,
|
|
63
|
+
);
|
|
64
|
+
for (const f of globalMemFiles) {
|
|
65
|
+
lines.push(` ${f.name} — ${f.description || '(no description)'}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
} else if (!branchMemPrefix && globalMemFiles.length) {
|
|
69
|
+
lines.push(`MEMORY (${globalMemFiles.length} files):`);
|
|
70
|
+
for (const f of globalMemFiles) {
|
|
71
|
+
lines.push(` ${f.name} — ${f.description || '(no description)'}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Branches
|
|
77
|
+
const branchEntries = tree.filter(
|
|
78
|
+
(e) => e.path.startsWith('branches/') && e.isDir && e.depth === 1,
|
|
79
|
+
);
|
|
80
|
+
if (branchEntries.length) {
|
|
81
|
+
lines.push(`BRANCHES (${branchEntries.length}):`);
|
|
82
|
+
for (const b of branchEntries) {
|
|
83
|
+
const purpose = readContextFile(ctxDir, `${b.path}/purpose.md`);
|
|
84
|
+
const purposeLine =
|
|
85
|
+
purpose?.split('\n').find((l) => l.trim() && !l.startsWith('#') && !l.startsWith('---')) ||
|
|
86
|
+
'';
|
|
87
|
+
lines.push(` ${b.name} — ${purposeLine.trim() || '(no purpose set)'}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Reflections
|
|
93
|
+
const reflectionFiles = tree.filter((e) => e.path.startsWith('reflections/') && !e.isDir);
|
|
94
|
+
if (reflectionFiles.length) {
|
|
95
|
+
const latest = reflectionFiles[reflectionFiles.length - 1];
|
|
96
|
+
lines.push(`REFLECTIONS: ${reflectionFiles.length} total, latest: ${latest.name}`);
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Config summary
|
|
101
|
+
lines.push(
|
|
102
|
+
`CONFIG: auto_commit=${config.auto_commit} | reflection=${config.reflection?.trigger || 'manual'}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
console.log(lines.filter((l) => l !== undefined).join('\n'));
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { contextDir as getContextDir } from '../core/context-root.js';
|
|
2
|
+
import { readConfig } from '../core/config.js';
|
|
3
|
+
import { commitCount, lastCommit, hasChanges } from '../core/git.js';
|
|
4
|
+
import { countFiles, listFiles } from '../core/fs.js';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export default async function status({ args, flags }) {
|
|
9
|
+
const root = flags._contextRoot;
|
|
10
|
+
const ctxDir = getContextDir(root);
|
|
11
|
+
const config = readConfig(ctxDir);
|
|
12
|
+
const projectName = root.split('/').pop();
|
|
13
|
+
|
|
14
|
+
const commits = commitCount(ctxDir);
|
|
15
|
+
const last = lastCommit(ctxDir);
|
|
16
|
+
const dirty = hasChanges(ctxDir);
|
|
17
|
+
const systemCount = countFiles(ctxDir, 'system');
|
|
18
|
+
const memoryCount = countFiles(ctxDir, 'memory');
|
|
19
|
+
const reflectionCount = countFiles(ctxDir, 'reflections');
|
|
20
|
+
|
|
21
|
+
// Count branches
|
|
22
|
+
const branchDir = join(ctxDir, 'branches');
|
|
23
|
+
const branchCount = existsSync(branchDir) ? listFiles(ctxDir, 'branches').length : 0;
|
|
24
|
+
|
|
25
|
+
console.log(`📊 STATUS: ${projectName}`);
|
|
26
|
+
console.log(
|
|
27
|
+
`Branch: ${config.branch || 'main'} | Commits: ${commits}${dirty ? ' ⚠️ uncommitted changes' : ''}`,
|
|
28
|
+
);
|
|
29
|
+
if (last) console.log(`Last: "${last.message}" (${last.timeAgo})`);
|
|
30
|
+
console.log(
|
|
31
|
+
`Files: ${systemCount} pinned, ${memoryCount} memory, ${reflectionCount} reflections`,
|
|
32
|
+
);
|
|
33
|
+
console.log(`Branches: ${branchCount}`);
|
|
34
|
+
console.log(
|
|
35
|
+
`Config: auto_commit=${config.auto_commit} | reflection=${config.reflection?.trigger || 'manual'}`,
|
|
36
|
+
);
|
|
37
|
+
}
|