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.
@@ -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
+ }
@@ -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 (contextEngine && messages.length > 5) {
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 {