chainlesschain 0.45.10 → 0.45.11
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/bin/chainlesschain.js +0 -0
- package/package.json +1 -1
- package/src/commands/config.js +44 -0
- package/src/constants.js +1 -0
- package/src/lib/background-task-manager.js +305 -0
- package/src/lib/background-task-worker.js +50 -0
- package/src/lib/feature-flags.js +182 -0
- package/src/lib/jsonl-session-store.js +237 -0
- package/src/lib/prompt-compressor.js +351 -0
- package/src/lib/worktree-isolator.js +231 -0
- package/src/repl/agent-repl.js +38 -2
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree Isolator — git worktree-based task isolation.
|
|
3
|
+
*
|
|
4
|
+
* Creates temporary git worktrees for parallel agent tasks,
|
|
5
|
+
* ensuring file operations don't interfere with the main working tree.
|
|
6
|
+
*
|
|
7
|
+
* Feature-flag gated: WORKTREE_ISOLATION
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { existsSync, rmSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { isGitRepo, gitExec } from "./git-integration.js";
|
|
14
|
+
|
|
15
|
+
const WORKTREE_DIR = ".worktrees";
|
|
16
|
+
|
|
17
|
+
// ── Low-level worktree operations ───────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a new git worktree with a new branch.
|
|
21
|
+
* @param {string} repoDir — Root of the git repository
|
|
22
|
+
* @param {string} branchName — Branch name (e.g. "agent/task-123")
|
|
23
|
+
* @param {string} [baseBranch] — Base branch (default: HEAD)
|
|
24
|
+
* @returns {{ path: string, branch: string }}
|
|
25
|
+
*/
|
|
26
|
+
export function createWorktree(repoDir, branchName, baseBranch) {
|
|
27
|
+
if (!isGitRepo(repoDir)) {
|
|
28
|
+
throw new Error("Not a git repository");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const worktreePath = resolve(
|
|
32
|
+
repoDir,
|
|
33
|
+
WORKTREE_DIR,
|
|
34
|
+
branchName.replace(/\//g, "-"),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (existsSync(worktreePath)) {
|
|
38
|
+
throw new Error(`Worktree already exists: ${worktreePath}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const base = baseBranch || "HEAD";
|
|
42
|
+
gitExec(`worktree add "${worktreePath}" -b "${branchName}" ${base}`, repoDir);
|
|
43
|
+
|
|
44
|
+
return { path: worktreePath, branch: branchName };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remove a git worktree and its branch.
|
|
49
|
+
* @param {string} repoDir
|
|
50
|
+
* @param {string} worktreePath — Absolute path to the worktree
|
|
51
|
+
* @param {object} [options]
|
|
52
|
+
* @param {boolean} [options.deleteBranch=true] — Also delete the branch
|
|
53
|
+
*/
|
|
54
|
+
export function removeWorktree(repoDir, worktreePath, options = {}) {
|
|
55
|
+
const deleteBranch = options.deleteBranch !== false;
|
|
56
|
+
|
|
57
|
+
// Get branch name before removal
|
|
58
|
+
let branch = null;
|
|
59
|
+
if (deleteBranch) {
|
|
60
|
+
try {
|
|
61
|
+
branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
62
|
+
cwd: worktreePath,
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
}).trim();
|
|
65
|
+
} catch (_e) {
|
|
66
|
+
// Can't determine branch — skip branch deletion
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
gitExec(`worktree remove "${worktreePath}" --force`, repoDir);
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
// If git worktree remove fails, try manual cleanup
|
|
74
|
+
if (existsSync(worktreePath)) {
|
|
75
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
gitExec("worktree prune", repoDir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Delete the branch
|
|
81
|
+
if (
|
|
82
|
+
deleteBranch &&
|
|
83
|
+
branch &&
|
|
84
|
+
branch !== "HEAD" &&
|
|
85
|
+
!branch.startsWith("main") &&
|
|
86
|
+
!branch.startsWith("master")
|
|
87
|
+
) {
|
|
88
|
+
try {
|
|
89
|
+
gitExec(`branch -D "${branch}"`, repoDir);
|
|
90
|
+
} catch (_e) {
|
|
91
|
+
// Branch might already be deleted
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List all worktrees.
|
|
98
|
+
* @param {string} repoDir
|
|
99
|
+
* @returns {Array<{path: string, branch: string, head: string}>}
|
|
100
|
+
*/
|
|
101
|
+
export function listWorktrees(repoDir) {
|
|
102
|
+
if (!isGitRepo(repoDir)) return [];
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const output = gitExec("worktree list --porcelain", repoDir);
|
|
106
|
+
const worktrees = [];
|
|
107
|
+
let current = {};
|
|
108
|
+
|
|
109
|
+
for (const line of output.split("\n")) {
|
|
110
|
+
if (line.startsWith("worktree ")) {
|
|
111
|
+
if (current.path) worktrees.push(current);
|
|
112
|
+
current = { path: line.slice(9) };
|
|
113
|
+
} else if (line.startsWith("HEAD ")) {
|
|
114
|
+
current.head = line.slice(5);
|
|
115
|
+
} else if (line.startsWith("branch ")) {
|
|
116
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
117
|
+
} else if (line === "bare") {
|
|
118
|
+
current.bare = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (current.path) worktrees.push(current);
|
|
122
|
+
|
|
123
|
+
return worktrees;
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Prune stale worktrees (where directory no longer exists).
|
|
131
|
+
* @param {string} repoDir
|
|
132
|
+
* @returns {number} Number pruned
|
|
133
|
+
*/
|
|
134
|
+
export function pruneWorktrees(repoDir) {
|
|
135
|
+
if (!isGitRepo(repoDir)) return 0;
|
|
136
|
+
|
|
137
|
+
const before = listWorktrees(repoDir).length;
|
|
138
|
+
gitExec("worktree prune", repoDir);
|
|
139
|
+
const after = listWorktrees(repoDir).length;
|
|
140
|
+
return before - after;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── High-level isolation API ────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run a function in an isolated git worktree.
|
|
147
|
+
*
|
|
148
|
+
* Creates a worktree → executes fn(worktreePath) → cleans up.
|
|
149
|
+
* If fn throws, the worktree is still cleaned up.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} repoDir — Root of the git repository
|
|
152
|
+
* @param {string} taskId — Used to generate branch name: agent/{taskId}
|
|
153
|
+
* @param {Function} fn — async (worktreePath) => result
|
|
154
|
+
* @returns {Promise<{result, branch, merged}>}
|
|
155
|
+
*/
|
|
156
|
+
export async function isolateTask(repoDir, taskId, fn) {
|
|
157
|
+
const branchName = `agent/${taskId}`;
|
|
158
|
+
const { path: worktreePath } = createWorktree(repoDir, branchName);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const result = await fn(worktreePath);
|
|
162
|
+
|
|
163
|
+
// Check if the worktree has any changes
|
|
164
|
+
const hasChanges = _hasUncommittedChanges(worktreePath);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
result,
|
|
168
|
+
branch: branchName,
|
|
169
|
+
worktreePath,
|
|
170
|
+
hasChanges,
|
|
171
|
+
};
|
|
172
|
+
} finally {
|
|
173
|
+
// Always clean up the worktree (but keep branch if there are commits)
|
|
174
|
+
try {
|
|
175
|
+
const hasCommits = _hasBranchCommits(repoDir, branchName);
|
|
176
|
+
removeWorktree(repoDir, worktreePath, {
|
|
177
|
+
deleteBranch: !hasCommits,
|
|
178
|
+
});
|
|
179
|
+
} catch (_e) {
|
|
180
|
+
// Best-effort cleanup
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Clean up all agent worktrees (e.g. after crash recovery).
|
|
187
|
+
* @param {string} repoDir
|
|
188
|
+
* @returns {number} Number of worktrees cleaned
|
|
189
|
+
*/
|
|
190
|
+
export function cleanupAgentWorktrees(repoDir) {
|
|
191
|
+
const worktrees = listWorktrees(repoDir);
|
|
192
|
+
let cleaned = 0;
|
|
193
|
+
|
|
194
|
+
for (const wt of worktrees) {
|
|
195
|
+
if (wt.branch && wt.branch.startsWith("agent/")) {
|
|
196
|
+
try {
|
|
197
|
+
removeWorktree(repoDir, wt.path, { deleteBranch: true });
|
|
198
|
+
cleaned++;
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
// Skip if can't clean
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pruneWorktrees(repoDir);
|
|
206
|
+
return cleaned;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Internal helpers ────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function _hasUncommittedChanges(worktreePath) {
|
|
212
|
+
try {
|
|
213
|
+
const output = execSync("git status --porcelain", {
|
|
214
|
+
cwd: worktreePath,
|
|
215
|
+
encoding: "utf-8",
|
|
216
|
+
});
|
|
217
|
+
return output.trim().length > 0;
|
|
218
|
+
} catch (_e) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _hasBranchCommits(repoDir, branchName) {
|
|
224
|
+
try {
|
|
225
|
+
// Check if branch has commits not in HEAD
|
|
226
|
+
const output = gitExec(`log HEAD..${branchName} --oneline`, repoDir);
|
|
227
|
+
return output.trim().length > 0;
|
|
228
|
+
} catch (_e) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/repl/agent-repl.js
CHANGED
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
} from "../lib/task-model-selector.js";
|
|
39
39
|
import { CLIPermanentMemory } from "../lib/permanent-memory.js";
|
|
40
40
|
import { CLIAutonomousAgent, GoalStatus } from "../lib/autonomous-agent.js";
|
|
41
|
+
import { PromptCompressor } from "../lib/prompt-compressor.js";
|
|
42
|
+
import { feature } from "../lib/feature-flags.js";
|
|
41
43
|
import {
|
|
42
44
|
AGENT_TOOLS,
|
|
43
45
|
buildSystemPrompt,
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
* Reference to the runtime DB for hook execution (set during startAgentRepl)
|
|
51
53
|
*/
|
|
52
54
|
let _hookDb = null;
|
|
55
|
+
let _compressor = null;
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* Execute a tool call — delegates to agent-core with REPL's hookDb and cwd.
|
|
@@ -105,6 +108,11 @@ export async function startAgentRepl(options = {}) {
|
|
|
105
108
|
// Continue without DB — static prompt fallback
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
// Initialize prompt compressor
|
|
112
|
+
if (feature("PROMPT_COMPRESSOR")) {
|
|
113
|
+
_compressor = new PromptCompressor({ maxMessages: 20, maxTokens: 8000 });
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
// Initialize permanent memory
|
|
109
117
|
let permanentMemory = null;
|
|
110
118
|
try {
|
|
@@ -373,7 +381,15 @@ export async function startAgentRepl(options = {}) {
|
|
|
373
381
|
}
|
|
374
382
|
|
|
375
383
|
if (trimmed === "/compact") {
|
|
376
|
-
if (
|
|
384
|
+
if (_compressor && messages.length > 3) {
|
|
385
|
+
const { messages: compacted, stats } =
|
|
386
|
+
await _compressor.compress(messages);
|
|
387
|
+
messages.length = 0;
|
|
388
|
+
messages.push(...compacted);
|
|
389
|
+
logger.info(
|
|
390
|
+
`Compacted: ${stats.originalMessages} → ${stats.compressedMessages} messages, saved ${stats.saved} tokens (${stats.strategy})`,
|
|
391
|
+
);
|
|
392
|
+
} else if (contextEngine && messages.length > 5) {
|
|
377
393
|
const compacted = contextEngine.smartCompact(messages);
|
|
378
394
|
messages.length = 0;
|
|
379
395
|
messages.push(...compacted);
|
|
@@ -381,7 +397,6 @@ export async function startAgentRepl(options = {}) {
|
|
|
381
397
|
`Compacted to ${messages.length} messages (importance-based)`,
|
|
382
398
|
);
|
|
383
399
|
} else if (messages.length > 5) {
|
|
384
|
-
// Fallback: original logic
|
|
385
400
|
const systemMsg = messages[0];
|
|
386
401
|
const recent = messages.slice(-4);
|
|
387
402
|
messages.length = 0;
|
|
@@ -1044,6 +1059,27 @@ export async function startAgentRepl(options = {}) {
|
|
|
1044
1059
|
// Non-critical
|
|
1045
1060
|
}
|
|
1046
1061
|
}
|
|
1062
|
+
// Auto-compact when context grows too large
|
|
1063
|
+
if (
|
|
1064
|
+
feature("PROMPT_COMPRESSOR") &&
|
|
1065
|
+
_compressor &&
|
|
1066
|
+
_compressor.shouldAutoCompact(messages)
|
|
1067
|
+
) {
|
|
1068
|
+
try {
|
|
1069
|
+
const { messages: compacted, stats } =
|
|
1070
|
+
await _compressor.compress(messages);
|
|
1071
|
+
messages.length = 0;
|
|
1072
|
+
messages.push(...compacted);
|
|
1073
|
+
if (stats.saved > 0) {
|
|
1074
|
+
logger.verbose(
|
|
1075
|
+
`Auto-compacted: ${stats.strategy} (saved ${stats.saved} tokens)`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
} catch (_e) {
|
|
1079
|
+
// Non-critical — continue with uncompacted messages
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1047
1083
|
// Store as episodic memory
|
|
1048
1084
|
if (db) {
|
|
1049
1085
|
try {
|