@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
package/src/core/fs.js ADDED
@@ -0,0 +1,137 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ readdirSync,
5
+ statSync,
6
+ mkdirSync,
7
+ existsSync,
8
+ renameSync,
9
+ } from 'node:fs';
10
+ import { join, relative, basename, dirname } from 'node:path';
11
+
12
+ /**
13
+ * Read a file from .context/, returns content or null.
14
+ */
15
+ export function readContextFile(contextDir, relPath) {
16
+ const fullPath = join(contextDir, relPath);
17
+ if (!existsSync(fullPath)) return null;
18
+ return readFileSync(fullPath, 'utf-8');
19
+ }
20
+
21
+ /**
22
+ * Write a file to .context/, creating directories as needed.
23
+ */
24
+ export function writeContextFile(contextDir, relPath, content) {
25
+ const fullPath = join(contextDir, relPath);
26
+ mkdirSync(dirname(fullPath), { recursive: true });
27
+ writeFileSync(fullPath, content, 'utf-8');
28
+ }
29
+
30
+ /**
31
+ * Move a file within .context/.
32
+ */
33
+ export function moveContextFile(contextDir, fromRel, toRel) {
34
+ const fromFull = join(contextDir, fromRel);
35
+ const toFull = join(contextDir, toRel);
36
+ if (!existsSync(fromFull)) throw new Error(`File not found: ${fromRel}`);
37
+ mkdirSync(dirname(toFull), { recursive: true });
38
+ renameSync(fromFull, toFull);
39
+ }
40
+
41
+ /**
42
+ * Parse frontmatter from a markdown file.
43
+ * Returns { description, limit, readOnly, content }.
44
+ */
45
+ export function parseFrontmatter(text) {
46
+ const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
47
+ if (!match) return { description: null, limit: null, readOnly: false, content: text };
48
+
49
+ const frontmatter = match[1];
50
+ const content = match[2];
51
+ const desc = frontmatter.match(/description:\s*['"]?(.*?)['"]?\s*$/m)?.[1] || null;
52
+ const limit = frontmatter.match(/limit:\s*(\d+)/)?.[1]
53
+ ? parseInt(frontmatter.match(/limit:\s*(\d+)/)[1])
54
+ : null;
55
+ const readOnly = /read_only:\s*true/i.test(frontmatter);
56
+
57
+ return { description: desc, limit, readOnly, content };
58
+ }
59
+
60
+ /**
61
+ * Build a tree representation of .context/ directory.
62
+ * Returns array of { path, name, isDir, description, size }.
63
+ */
64
+ export function buildTree(contextDir, subDir = '', depth = 0, maxDepth = 4) {
65
+ const entries = [];
66
+ const fullDir = join(contextDir, subDir);
67
+
68
+ if (!existsSync(fullDir)) return entries;
69
+ if (depth > maxDepth) return entries;
70
+
71
+ const items = readdirSync(fullDir)
72
+ .filter((n) => !n.startsWith('.'))
73
+ .sort();
74
+
75
+ for (const name of items) {
76
+ const relPath = subDir ? `${subDir}/${name}` : name;
77
+ const fullPath = join(fullDir, name);
78
+ const stat = statSync(fullPath);
79
+
80
+ if (stat.isDirectory()) {
81
+ entries.push({ path: relPath, name, isDir: true, depth });
82
+ entries.push(...buildTree(contextDir, relPath, depth + 1, maxDepth));
83
+ } else if (name.endsWith('.md') || name.endsWith('.yaml') || name.endsWith('.yml')) {
84
+ const content = readFileSync(fullPath, 'utf-8');
85
+ const { description } = parseFrontmatter(content);
86
+ entries.push({
87
+ path: relPath,
88
+ name,
89
+ isDir: false,
90
+ depth,
91
+ description: description || summarizeLine(content),
92
+ size: stat.size,
93
+ });
94
+ }
95
+ }
96
+
97
+ return entries;
98
+ }
99
+
100
+ /**
101
+ * Get the first meaningful line of content as a summary.
102
+ */
103
+ function summarizeLine(text) {
104
+ const lines = text
105
+ .split('\n')
106
+ .filter((l) => l.trim() && !l.startsWith('---') && !l.startsWith('#'));
107
+ const first = lines[0]?.trim() || '';
108
+ return first.length > 80 ? first.slice(0, 77) + '...' : first;
109
+ }
110
+
111
+ /**
112
+ * List files in a directory (non-recursive).
113
+ */
114
+ export function listFiles(contextDir, subDir) {
115
+ const fullDir = join(contextDir, subDir);
116
+ if (!existsSync(fullDir)) return [];
117
+ return readdirSync(fullDir).filter((n) => !n.startsWith('.'));
118
+ }
119
+
120
+ /**
121
+ * Count files recursively in a directory.
122
+ */
123
+ export function countFiles(contextDir, subDir) {
124
+ const fullDir = join(contextDir, subDir);
125
+ if (!existsSync(fullDir)) return 0;
126
+ let count = 0;
127
+ const walk = (dir) => {
128
+ for (const name of readdirSync(dir)) {
129
+ if (name.startsWith('.')) continue;
130
+ const full = join(dir, name);
131
+ if (statSync(full).isDirectory()) walk(full);
132
+ else count++;
133
+ }
134
+ };
135
+ walk(fullDir);
136
+ return count;
137
+ }
@@ -0,0 +1,182 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Run a git command in the given directory. Returns stdout as string.
7
+ */
8
+ export function git(args, cwd) {
9
+ try {
10
+ return execSync(`git ${args}`, {
11
+ cwd,
12
+ encoding: 'utf-8',
13
+ stdio: ['pipe', 'pipe', 'pipe'],
14
+ }).trim();
15
+ } catch (err) {
16
+ throw new Error(`git ${args} failed: ${err.stderr?.trim() || err.message}`);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Check if directory is inside a git repo.
22
+ */
23
+ export function isGitRepo(cwd) {
24
+ try {
25
+ git('rev-parse --is-inside-work-tree', cwd);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Initialize git in .context/ if not already in a git repo.
34
+ */
35
+ export function initGit(contextDir) {
36
+ if (!isGitRepo(contextDir)) {
37
+ git('init', contextDir);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Stage and commit all changes in .context/.
43
+ */
44
+ export function commitContext(contextDir, message) {
45
+ git('add -A', contextDir);
46
+
47
+ // Check if there are staged changes
48
+ try {
49
+ git('diff --cached --quiet', contextDir);
50
+ return null; // nothing to commit
51
+ } catch {
52
+ // There are changes — commit them
53
+ }
54
+
55
+ git(`commit -m "${message.replace(/"/g, '\\"')}"`, contextDir);
56
+ return git('rev-parse --short HEAD', contextDir);
57
+ }
58
+
59
+ /**
60
+ * Get commit count in .context/ repo.
61
+ */
62
+ export function commitCount(contextDir) {
63
+ try {
64
+ return parseInt(git('rev-list --count HEAD', contextDir), 10);
65
+ } catch {
66
+ return 0;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get last commit info.
72
+ */
73
+ export function lastCommit(contextDir) {
74
+ try {
75
+ const log = git('log -1 --format="%h|%s|%cr"', contextDir);
76
+ const [hash, message, timeAgo] = log.split('|');
77
+ return { hash, message, timeAgo };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if there are uncommitted changes.
85
+ */
86
+ export function hasChanges(contextDir) {
87
+ try {
88
+ const status = git('status --porcelain', contextDir);
89
+ return status.length > 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get commit log since a ref. Returns array of { hash, message, date }.
97
+ * If sinceRef is null, returns all commits.
98
+ */
99
+ export function gitLog(contextDir, sinceRef, maxCount = 50) {
100
+ try {
101
+ const range = sinceRef ? `${sinceRef}..HEAD` : 'HEAD';
102
+ const raw = git(`log ${range} --format="%h|%s|%ai" -n ${maxCount}`, contextDir);
103
+ if (!raw) return [];
104
+ return raw.split('\n').map((line) => {
105
+ const [hash, message, date] = line.split('|');
106
+ return { hash, message, date };
107
+ });
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Get total commit count since a ref.
115
+ */
116
+ export function commitCountSince(contextDir, sinceRef) {
117
+ try {
118
+ if (!sinceRef) return commitCount(contextDir);
119
+ const raw = git(`rev-list --count ${sinceRef}..HEAD`, contextDir);
120
+ return parseInt(raw, 10);
121
+ } catch {
122
+ return 0;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get diff stats since a ref. Returns array of { file, added, removed }.
128
+ */
129
+ export function gitDiffStat(contextDir, sinceRef) {
130
+ try {
131
+ const range = sinceRef ? `${sinceRef}..HEAD` : `$(git rev-list --max-parents=0 HEAD)..HEAD`;
132
+ const raw = git(`diff --numstat ${range}`, contextDir);
133
+ if (!raw) return [];
134
+ return raw
135
+ .split('\n')
136
+ .filter(Boolean)
137
+ .map((line) => {
138
+ const [added, removed, file] = line.split('\t');
139
+ return {
140
+ file,
141
+ added: added === '-' ? 0 : parseInt(added, 10),
142
+ removed: removed === '-' ? 0 : parseInt(removed, 10),
143
+ };
144
+ });
145
+ } catch {
146
+ return [];
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get full diff for specific path since a ref.
152
+ */
153
+ export function gitDiffFiles(contextDir, sinceRef, path) {
154
+ try {
155
+ const range = sinceRef ? `${sinceRef}..HEAD` : `$(git rev-list --max-parents=0 HEAD)..HEAD`;
156
+ return git(`diff ${range} -- ${path}`, contextDir);
157
+ } catch {
158
+ return '';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get file content at a specific ref.
164
+ */
165
+ export function gitShowFile(contextDir, ref, path) {
166
+ try {
167
+ return git(`show ${ref}:${path}`, contextDir);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Get the first commit hash in the repo.
175
+ */
176
+ export function firstCommit(contextDir) {
177
+ try {
178
+ return git('rev-list --max-parents=0 HEAD', contextDir).split('\n')[0];
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
@@ -0,0 +1,210 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { writeContextFile } from './fs.js';
4
+
5
+ /**
6
+ * Import Claude Code session history into .context/memory/.
7
+ *
8
+ * Claude Code stores sessions at:
9
+ * ~/.claude/projects/<project-hash>/<session-id>.jsonl
10
+ *
11
+ * Project hash = cwd path with slashes replaced by dashes.
12
+ * Each line is a JSON object with { type, message, ... }.
13
+ * We extract assistant messages and tool results as context.
14
+ */
15
+ export function importClaudeHistory(cwd, contextDir) {
16
+ const home = process.env.HOME || process.env.USERPROFILE || '';
17
+ const claudeDir = join(home, '.claude', 'projects');
18
+
19
+ if (!existsSync(claudeDir)) {
20
+ console.log(' â„šī¸ No Claude Code history found (~/.claude/projects/ not found)');
21
+ return 0;
22
+ }
23
+
24
+ // Find matching project directory
25
+ // Claude uses path with slashes → dashes: /home/user/myapp → -home-user-myapp-
26
+ const projectHash = cwd.replace(/\//g, '-');
27
+ const candidates = readdirSync(claudeDir).filter(
28
+ (d) => d.includes(basename(cwd)) || d === projectHash || d.endsWith(`-${basename(cwd)}-`),
29
+ );
30
+
31
+ if (!candidates.length) {
32
+ console.log(` â„šī¸ No Claude Code sessions found for this project`);
33
+ return 0;
34
+ }
35
+
36
+ let totalSessions = 0;
37
+ const summaries = [];
38
+
39
+ for (const candidate of candidates) {
40
+ const projectDir = join(claudeDir, candidate);
41
+ if (!statSync(projectDir).isDirectory()) continue;
42
+
43
+ const jsonlFiles = readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
44
+
45
+ for (const file of jsonlFiles.slice(-5)) {
46
+ // Import last 5 sessions max
47
+ try {
48
+ const content = readFileSync(join(projectDir, file), 'utf-8');
49
+ const messages = content
50
+ .split('\n')
51
+ .filter((l) => l.trim())
52
+ .map((l) => {
53
+ try {
54
+ return JSON.parse(l);
55
+ } catch {
56
+ return null;
57
+ }
58
+ })
59
+ .filter(Boolean);
60
+
61
+ // Extract human messages and assistant summaries
62
+ const humanMsgs = messages
63
+ .filter((m) => m.type === 'human' && m.message?.content)
64
+ .map((m) =>
65
+ typeof m.message.content === 'string'
66
+ ? m.message.content
67
+ : JSON.stringify(m.message.content),
68
+ )
69
+ .slice(0, 10); // First 10 human messages as context
70
+
71
+ if (humanMsgs.length > 0) {
72
+ const sessionId = basename(file, '.jsonl').slice(0, 8);
73
+ summaries.push(
74
+ `### Session ${sessionId}\n${humanMsgs.map((m) => `- ${m.slice(0, 200)}`).join('\n')}`,
75
+ );
76
+ totalSessions++;
77
+ }
78
+ } catch {}
79
+ }
80
+ }
81
+
82
+ if (summaries.length) {
83
+ writeContextFile(
84
+ contextDir,
85
+ 'memory/imported-claude-sessions.md',
86
+ [
87
+ '---',
88
+ 'description: "Imported from Claude Code session history"',
89
+ '---',
90
+ '',
91
+ '# Claude Code Session History',
92
+ '',
93
+ `Imported ${totalSessions} sessions.`,
94
+ '',
95
+ ...summaries,
96
+ '',
97
+ ].join('\n'),
98
+ );
99
+ console.log(
100
+ ` đŸ“Ĩ Imported ${totalSessions} Claude Code sessions → memory/imported-claude-sessions.md`,
101
+ );
102
+ }
103
+
104
+ return totalSessions;
105
+ }
106
+
107
+ /**
108
+ * Import Codex session history into .context/memory/.
109
+ *
110
+ * Codex stores sessions at:
111
+ * ~/.codex/sessions/YYYY/MM/DD/<session-name>.jsonl
112
+ *
113
+ * Each line is a JSON object. We extract the conversation content.
114
+ */
115
+ export function importCodexHistory(cwd, contextDir) {
116
+ const home = process.env.HOME || process.env.USERPROFILE || '';
117
+ const codexDir = join(home, '.codex', 'sessions');
118
+
119
+ if (!existsSync(codexDir)) {
120
+ console.log(' â„šī¸ No Codex history found (~/.codex/sessions/ not found)');
121
+ return 0;
122
+ }
123
+
124
+ // Walk the date-based directory structure, find recent sessions
125
+ const jsonlFiles = [];
126
+ const walkDates = (dir, depth = 0) => {
127
+ if (depth > 4 || !existsSync(dir)) return;
128
+ try {
129
+ for (const entry of readdirSync(dir)) {
130
+ const full = join(dir, entry);
131
+ if (statSync(full).isDirectory()) {
132
+ walkDates(full, depth + 1);
133
+ } else if (entry.endsWith('.jsonl')) {
134
+ jsonlFiles.push({ path: full, name: entry, mtime: statSync(full).mtimeMs });
135
+ }
136
+ }
137
+ } catch {}
138
+ };
139
+
140
+ walkDates(codexDir);
141
+
142
+ if (!jsonlFiles.length) {
143
+ console.log(' â„šī¸ No Codex sessions found');
144
+ return 0;
145
+ }
146
+
147
+ // Sort by most recent, take last 5
148
+ jsonlFiles.sort((a, b) => b.mtime - a.mtime);
149
+ const recent = jsonlFiles.slice(0, 5);
150
+
151
+ const summaries = [];
152
+
153
+ for (const file of recent) {
154
+ try {
155
+ const content = readFileSync(file.path, 'utf-8');
156
+ const lines = content
157
+ .split('\n')
158
+ .filter((l) => l.trim())
159
+ .map((l) => {
160
+ try {
161
+ return JSON.parse(l);
162
+ } catch {
163
+ return null;
164
+ }
165
+ })
166
+ .filter(Boolean);
167
+
168
+ // Extract messages — Codex format varies, look for common patterns
169
+ const msgs = lines
170
+ .filter((m) => m.role === 'user' || m.type === 'message' || m.content)
171
+ .map((m) => {
172
+ const text = m.content || m.message?.content || m.text || '';
173
+ return typeof text === 'string' ? text : JSON.stringify(text);
174
+ })
175
+ .filter((t) => t.length > 0 && t.length < 500)
176
+ .slice(0, 10);
177
+
178
+ if (msgs.length) {
179
+ const sessionId = basename(file.name, '.jsonl').slice(0, 20);
180
+ summaries.push(
181
+ `### Session ${sessionId}\n${msgs.map((m) => `- ${m.slice(0, 200)}`).join('\n')}`,
182
+ );
183
+ }
184
+ } catch {}
185
+ }
186
+
187
+ if (summaries.length) {
188
+ writeContextFile(
189
+ contextDir,
190
+ 'memory/imported-codex-sessions.md',
191
+ [
192
+ '---',
193
+ 'description: "Imported from Codex session history"',
194
+ '---',
195
+ '',
196
+ '# Codex Session History',
197
+ '',
198
+ `Imported ${summaries.length} sessions.`,
199
+ '',
200
+ ...summaries,
201
+ '',
202
+ ].join('\n'),
203
+ );
204
+ console.log(
205
+ ` đŸ“Ĩ Imported ${summaries.length} Codex sessions → memory/imported-codex-sessions.md`,
206
+ );
207
+ }
208
+
209
+ return summaries.length;
210
+ }
@@ -0,0 +1,62 @@
1
+ import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const LOCK_FILE = '.context.lock';
5
+ const LOCK_TIMEOUT_MS = 30_000; // 30 seconds
6
+
7
+ /**
8
+ * Acquire a lock on .context/ for safe concurrent access.
9
+ * Uses a simple lock file with PID and timestamp.
10
+ * Returns a release function.
11
+ */
12
+ export function acquireLock(projectRoot) {
13
+ const lockPath = join(projectRoot, LOCK_FILE);
14
+
15
+ // Check for stale lock
16
+ if (existsSync(lockPath)) {
17
+ try {
18
+ const data = JSON.parse(readFileSync(lockPath, 'utf-8'));
19
+ const age = Date.now() - data.timestamp;
20
+
21
+ if (age > LOCK_TIMEOUT_MS) {
22
+ // Stale lock — remove it
23
+ unlinkSync(lockPath);
24
+ } else {
25
+ // Active lock — wait briefly then fail
26
+ const waitMs = Math.min(age, 2000);
27
+ const start = Date.now();
28
+ while (Date.now() - start < waitMs) {
29
+ // busy wait
30
+ }
31
+
32
+ if (existsSync(lockPath)) {
33
+ console.error(
34
+ `âš ī¸ Context locked by PID ${data.pid} (${Math.round(age / 1000)}s ago). Proceeding anyway.`,
35
+ );
36
+ // Don't block — just warn. Append-only files are safe for concurrent writes.
37
+ }
38
+ }
39
+ } catch {
40
+ // Corrupted lock file — remove
41
+ try {
42
+ unlinkSync(lockPath);
43
+ } catch {}
44
+ }
45
+ }
46
+
47
+ // Write lock
48
+ writeFileSync(
49
+ lockPath,
50
+ JSON.stringify({
51
+ pid: process.pid,
52
+ timestamp: Date.now(),
53
+ }),
54
+ );
55
+
56
+ // Return release function
57
+ return () => {
58
+ try {
59
+ unlinkSync(lockPath);
60
+ } catch {}
61
+ };
62
+ }