@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
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
|
+
}
|
package/src/core/git.js
ADDED
|
@@ -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
|
+
}
|
package/src/core/lock.js
ADDED
|
@@ -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
|
+
}
|