junecoder 1.0.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/README.md +2 -0
- package/agent.mjs +650 -0
- package/checkpoint.mjs +43 -0
- package/cli.js +199 -0
- package/config.mjs +118 -0
- package/context.mjs +160 -0
- package/distill.mjs +178 -0
- package/executor.mjs +207 -0
- package/mcp.mjs +209 -0
- package/memory.mjs +218 -0
- package/metaTools.mjs +523 -0
- package/package.json +26 -0
- package/provider.mjs +145 -0
- package/session.mjs +114 -0
- package/skills.mjs +147 -0
- package/tools/bash.mjs +41 -0
- package/tools/delete.mjs +57 -0
- package/tools/edit.mjs +71 -0
- package/tools/fetch.mjs +55 -0
- package/tools/glob.mjs +83 -0
- package/tools/grep.mjs +58 -0
- package/tools/index.mjs +41 -0
- package/tools/ls.mjs +62 -0
- package/tools/read.mjs +50 -0
- package/tools/websearch.mjs +58 -0
- package/tools/write.mjs +54 -0
- package/tools.mjs +15 -0
- package/tui.mjs +437 -0
package/README.md
ADDED
package/agent.mjs
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative, basename } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { loadSkills, formatSkillListing } from './skills.mjs';
|
|
6
|
+
|
|
7
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MAX_TURNS = 50;
|
|
10
|
+
export const DEFAULT_SUBAGENT_TURNS = 20;
|
|
11
|
+
export const DEFAULT_GOAL_TURNS = 30;
|
|
12
|
+
export const MIN_REPORT_CHARS = 50;
|
|
13
|
+
export const REPORT_CONTINUATION = '... [content truncated]';
|
|
14
|
+
export const TOOL_RESULT_OFFLOAD_LIMIT = 8000;
|
|
15
|
+
export const TOOL_RESULT_PREVIEW = 500;
|
|
16
|
+
export const AUTO_REMINDER =
|
|
17
|
+
'[System reminder: working directory snapshot is provided at session start and after tool executions that may change it.]';
|
|
18
|
+
export const MAX_INSTRUCTION_CHARS = 32_000;
|
|
19
|
+
|
|
20
|
+
// ─── Pure Helpers ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Escape XML special characters. */
|
|
23
|
+
export function escapeXml(s) {
|
|
24
|
+
return String(s)
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Canonicalize a tool call signature for duplication detection.
|
|
32
|
+
* Returns "name:normalized-args" or null on parse failure.
|
|
33
|
+
*/
|
|
34
|
+
export function tryCanonicalize(name, args) {
|
|
35
|
+
if (args === undefined || args === null) return `${name}:`;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
|
|
38
|
+
return `${name}:${JSON.stringify(parsed, Object.keys(parsed).sort())}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Repair history: strip transient messages, ensure system prompt is first,
|
|
46
|
+
* validate message structure.
|
|
47
|
+
*/
|
|
48
|
+
export function repairHistory(history) {
|
|
49
|
+
if (!Array.isArray(history)) return [];
|
|
50
|
+
|
|
51
|
+
const cleaned = [];
|
|
52
|
+
let systemIdx = -1;
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < history.length; i++) {
|
|
55
|
+
const msg = history[i];
|
|
56
|
+
// Skip null/undefined or messages without a valid role
|
|
57
|
+
if (!msg || typeof msg.role !== 'string') continue;
|
|
58
|
+
// Skip transient messages
|
|
59
|
+
if (msg.transient || msg._transient) continue;
|
|
60
|
+
|
|
61
|
+
// Clone to avoid mutating original
|
|
62
|
+
const copy = { role: msg.role, content: msg.content };
|
|
63
|
+
if (msg.tool_calls) copy.tool_calls = msg.tool_calls;
|
|
64
|
+
if (msg.tool_call_id) copy.tool_call_id = msg.tool_call_id;
|
|
65
|
+
if (msg.name) copy.name = msg.name;
|
|
66
|
+
if (msg.reasoning_content) copy.reasoning_content = msg.reasoning_content;
|
|
67
|
+
|
|
68
|
+
if (copy.role === 'system') {
|
|
69
|
+
systemIdx = cleaned.length;
|
|
70
|
+
}
|
|
71
|
+
cleaned.push(copy);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Move first system message to index 0 if it's not already there
|
|
75
|
+
if (systemIdx > 0) {
|
|
76
|
+
const sysMsg = cleaned.splice(systemIdx, 1)[0];
|
|
77
|
+
cleaned.unshift(sysMsg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Drop orphaned tool messages: a "tool" message is only valid as a response
|
|
81
|
+
// to a preceding assistant message carrying the matching tool_calls id.
|
|
82
|
+
// Truncation can cut the parent away, and providers reject the request (400).
|
|
83
|
+
const knownCallIds = new Set();
|
|
84
|
+
const repaired = [];
|
|
85
|
+
for (const msg of cleaned) {
|
|
86
|
+
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) {
|
|
87
|
+
for (const tc of msg.tool_calls) {
|
|
88
|
+
if (tc && tc.id) knownCallIds.add(tc.id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (msg.role === 'tool' && !knownCallIds.has(msg.tool_call_id)) continue;
|
|
92
|
+
repaired.push(msg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return repaired;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── File System Helpers ─────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate a tree-style listing of a working directory.
|
|
102
|
+
* @param {string} cwd - directory to list
|
|
103
|
+
* @param {{ maxDepth?: number, maxEntries?: number }} [opts]
|
|
104
|
+
* @returns {string} tree text
|
|
105
|
+
*/
|
|
106
|
+
export function listWorkDir(cwd, opts = {}) {
|
|
107
|
+
const { maxDepth = 3, maxEntries = 200 } = opts;
|
|
108
|
+
let count = 0;
|
|
109
|
+
|
|
110
|
+
function walk(dir, prefix, depth) {
|
|
111
|
+
if (depth > maxDepth || count >= maxEntries) return '';
|
|
112
|
+
|
|
113
|
+
let result = '';
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Sort: directories first, then files, alphabetically
|
|
122
|
+
entries.sort((a, b) => {
|
|
123
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
124
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
125
|
+
return a.name.localeCompare(b.name);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < entries.length; i++) {
|
|
129
|
+
if (count >= maxEntries) {
|
|
130
|
+
result += `${prefix}... (max entries reached)\n`;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const entry = entries[i];
|
|
134
|
+
// Skip hidden and node_modules
|
|
135
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
136
|
+
|
|
137
|
+
const isLast = i === entries.length - 1;
|
|
138
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
139
|
+
const relPath = relative(cwd, join(dir, entry.name));
|
|
140
|
+
|
|
141
|
+
count++;
|
|
142
|
+
if (entry.isDirectory()) {
|
|
143
|
+
result += `${prefix}${connector}${entry.name}/\n`;
|
|
144
|
+
result += walk(
|
|
145
|
+
join(dir, entry.name),
|
|
146
|
+
prefix + (isLast ? ' ' : '│ '),
|
|
147
|
+
depth + 1,
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
try {
|
|
151
|
+
const st = statSync(join(dir, entry.name));
|
|
152
|
+
result += `${prefix}${connector}${entry.name} (${st.size} bytes)\n`;
|
|
153
|
+
} catch {
|
|
154
|
+
result += `${prefix}${connector}${entry.name}\n`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const header = `Working directory: ${cwd}\n`;
|
|
162
|
+
const tree = walk(cwd, '', 1);
|
|
163
|
+
return header + (tree || '(empty or inaccessible)\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Collect git context from a working directory.
|
|
168
|
+
* @param {string} cwd
|
|
169
|
+
* @returns {string} git status, branch, and recent log
|
|
170
|
+
*/
|
|
171
|
+
export function collectGitContext(cwd) {
|
|
172
|
+
const parts = [];
|
|
173
|
+
const run = (cmd) => {
|
|
174
|
+
try {
|
|
175
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const branch = run('git rev-parse --abbrev-ref HEAD');
|
|
182
|
+
if (branch) parts.push(`Branch: ${branch}`);
|
|
183
|
+
|
|
184
|
+
const status = run('git status --short');
|
|
185
|
+
if (status !== null) {
|
|
186
|
+
parts.push(status ? `Status:\n${status}` : 'Status: clean');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const log = run('git --no-pager log --oneline -5');
|
|
190
|
+
if (log) parts.push(`Recent commits:\n${log}`);
|
|
191
|
+
|
|
192
|
+
const diff = run('git diff --stat');
|
|
193
|
+
if (diff) parts.push(`Unstaged changes:\n${diff}`);
|
|
194
|
+
|
|
195
|
+
return parts.length > 0 ? parts.join('\n\n') : '(not a git repository or git unavailable)';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Project Instructions ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load project instructions from AGENTS.md, PROJECT_RULES.md etc.
|
|
202
|
+
* Reads from both ~/.junecode/ and the local project directory.
|
|
203
|
+
* @param {string} cwd
|
|
204
|
+
* @returns {string} combined instructions
|
|
205
|
+
*/
|
|
206
|
+
export function loadProjectInstructions(cwd) {
|
|
207
|
+
const sources = [
|
|
208
|
+
join(homedir(), '.junecode', 'AGENTS.md'),
|
|
209
|
+
join(cwd, 'AGENTS.md'),
|
|
210
|
+
join(cwd, 'PROJECT_RULES.md'),
|
|
211
|
+
join(cwd, '.cursorrules'),
|
|
212
|
+
join(cwd, '.windsurfrules'),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const contents = [];
|
|
216
|
+
for (const src of sources) {
|
|
217
|
+
if (!existsSync(src)) continue;
|
|
218
|
+
try {
|
|
219
|
+
const text = readFileSync(src, 'utf-8').trim();
|
|
220
|
+
if (text) {
|
|
221
|
+
const label = basename(src);
|
|
222
|
+
contents.push(`--- ${label} ---\n${text}`);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// skip unreadable files
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (contents.length === 0) return '';
|
|
230
|
+
|
|
231
|
+
let combined = contents.join('\n\n');
|
|
232
|
+
if (combined.length > MAX_INSTRUCTION_CHARS) {
|
|
233
|
+
combined =
|
|
234
|
+
combined.slice(0, MAX_INSTRUCTION_CHARS) +
|
|
235
|
+
`\n\n[WARNING: Instructions exceeded ${MAX_INSTRUCTION_CHARS} chars and were truncated.]`;
|
|
236
|
+
}
|
|
237
|
+
return combined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── System Prompt ────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export const DEFAULT_SYSTEM_PROMPT = `You are JuneCode, a coding agent. Thin means sharp: you are a terse, precise engineer who cuts straight to the point—no fluff, no showing off, no filler. You write the most minimal, elegant code that solves the problem, and you say things in as few words as the truth allows.
|
|
243
|
+
|
|
244
|
+
Rules:
|
|
245
|
+
- Prefer tool calls over guessing. Read files before modifying them.
|
|
246
|
+
- When you need multiple independent pieces of information, make all independent tool calls in the SAME response so they can run in parallel.
|
|
247
|
+
- Be concise in your final answers. Report what you did, not what you plan to do.
|
|
248
|
+
- When the user asks a question, answer it. When they describe a task, do it. When unsure which they meant, ask before acting—once. Never guess at ambiguous intent.
|
|
249
|
+
- Never fabricate file contents or command outputs; only trust tool results.
|
|
250
|
+
- Run shell commands non-interactively: git commit -m, git --no-pager, -y/--yes flags where applicable.
|
|
251
|
+
- Make MINIMAL changes: fix the bug, don't refactor the file; ship the feature, don't add configurability nobody asked for.
|
|
252
|
+
- Never run git commit/push unless the user explicitly asks.
|
|
253
|
+
- After changing behavior, sweep comments and docstrings that now describe the old behavior.
|
|
254
|
+
- Before your final reply, re-read the user's latest request and confirm you are answering that one.
|
|
255
|
+
- Before declaring a coding task complete, use the verify tool. If tests exist, run them and confirm they pass.`;
|
|
256
|
+
|
|
257
|
+
// ─── Default Callbacks ────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function defaultCallbacks(overrides = {}) {
|
|
260
|
+
return {
|
|
261
|
+
onToken: () => {},
|
|
262
|
+
onReasoning: () => {},
|
|
263
|
+
onToolCall: () => {},
|
|
264
|
+
onToolResult: () => {},
|
|
265
|
+
onToolOutput: () => {},
|
|
266
|
+
onPermissionRequest: async () => true,
|
|
267
|
+
onCompress: () => {},
|
|
268
|
+
onUsage: () => {},
|
|
269
|
+
onTurnEnd: () => {},
|
|
270
|
+
onTaskUpdate: () => {},
|
|
271
|
+
onQuestion: async () => '',
|
|
272
|
+
...overrides,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── ContinueError ────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
export class ContinueError extends Error {
|
|
279
|
+
constructor(message, turn) {
|
|
280
|
+
super(message);
|
|
281
|
+
this.name = 'ContinueError';
|
|
282
|
+
this.turn = turn;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Agent Factory ────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export function createAgent(opts) {
|
|
289
|
+
return {
|
|
290
|
+
provider: opts.provider || {},
|
|
291
|
+
tools: opts.tools || [],
|
|
292
|
+
config: opts.config || {},
|
|
293
|
+
cwd: opts.cwd || process.cwd(),
|
|
294
|
+
memory: opts.memory || null,
|
|
295
|
+
overlay: opts.overlay || null,
|
|
296
|
+
history: [],
|
|
297
|
+
tasks: [],
|
|
298
|
+
planMode: false,
|
|
299
|
+
goal: null,
|
|
300
|
+
_permQueue: Promise.resolve(),
|
|
301
|
+
_sessionStart: null,
|
|
302
|
+
_turnsSinceTaskUpdate: 0,
|
|
303
|
+
_turnsInPlanMode: 0,
|
|
304
|
+
_mutatedThisRun: false,
|
|
305
|
+
_verifiedThisRun: false,
|
|
306
|
+
_pendingReminders: [],
|
|
307
|
+
_recentCallSigs: [],
|
|
308
|
+
_blockTally: {},
|
|
309
|
+
_mcpProcesses: [],
|
|
310
|
+
_compressFailures: 0,
|
|
311
|
+
_depth: opts.depth || 0,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── runAgent Main Loop ───────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Execute the ReAct agent loop.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} agent - agent instance from createAgent()
|
|
321
|
+
* @param {string} input - user input / task description
|
|
322
|
+
* @param {object} [callbacks] - optional callback overrides
|
|
323
|
+
* @param {object} [options] - runtime options
|
|
324
|
+
* @param {number} [options.depth=0] - nesting depth (0 = top-level)
|
|
325
|
+
* @param {AbortSignal} [options.signal] - abort signal
|
|
326
|
+
* @returns {Promise<string>} final response content
|
|
327
|
+
*/
|
|
328
|
+
export async function runAgent(agent, input, callbacks = {}, options = {}) {
|
|
329
|
+
const { depth = 0, signal } = options;
|
|
330
|
+
agent._depth = depth;
|
|
331
|
+
|
|
332
|
+
const cb = defaultCallbacks(callbacks);
|
|
333
|
+
|
|
334
|
+
// ── Initialization ──────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
const maxTurns = depth > 0
|
|
337
|
+
? (agent.config.agent?.subagentTurns || DEFAULT_SUBAGENT_TURNS)
|
|
338
|
+
: (agent.config.agent?.maxTurns || DEFAULT_MAX_TURNS);
|
|
339
|
+
|
|
340
|
+
const compactThreshold = agent.config.agent?.compactThreshold || 750_000;
|
|
341
|
+
|
|
342
|
+
// Repair history on first run
|
|
343
|
+
if (agent.history.length > 0) {
|
|
344
|
+
agent.history = repairHistory(agent.history);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Set session start timestamp once
|
|
348
|
+
if (!agent._sessionStart) {
|
|
349
|
+
agent._sessionStart = Date.now();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Build system prompt
|
|
353
|
+
const sysPrompt = buildSystemPrompt(agent, depth);
|
|
354
|
+
|
|
355
|
+
// Inject initial messages if history is empty
|
|
356
|
+
if (agent.history.length === 0) {
|
|
357
|
+
agent.history.push({ role: 'system', content: sysPrompt });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Inject environment snapshot + memories + user input
|
|
361
|
+
const envSnapshot = [
|
|
362
|
+
listWorkDir(agent.cwd),
|
|
363
|
+
collectGitContext(agent.cwd),
|
|
364
|
+
].filter(Boolean).join('\n\n');
|
|
365
|
+
|
|
366
|
+
const projInstr = loadProjectInstructions(agent.cwd);
|
|
367
|
+
|
|
368
|
+
const transientBlocks = [];
|
|
369
|
+
transientBlocks.push(`Working directory: ${agent.cwd}\n${envSnapshot}`);
|
|
370
|
+
if (projInstr) transientBlocks.push(`Project instructions:\n${projInstr}`);
|
|
371
|
+
|
|
372
|
+
// Search memory if available
|
|
373
|
+
if (agent.memory) {
|
|
374
|
+
try {
|
|
375
|
+
const { search } = await import('./memory.mjs');
|
|
376
|
+
const memResult = await search(agent.memory, input, { limit: 5 });
|
|
377
|
+
if (memResult) transientBlocks.push(`Relevant memories:\n${memResult}`);
|
|
378
|
+
} catch { /* memory is optional */ }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Inject all transient context as user messages
|
|
382
|
+
if (transientBlocks.length > 0) {
|
|
383
|
+
agent.history.push({
|
|
384
|
+
role: 'user',
|
|
385
|
+
content: `[System context]\n\n${transientBlocks.join('\n\n')}`,
|
|
386
|
+
transient: true,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Push AUTO_REMINDER
|
|
391
|
+
agent.history.push({ role: 'user', content: AUTO_REMINDER, transient: true });
|
|
392
|
+
|
|
393
|
+
// Push the actual user input
|
|
394
|
+
agent.history.push({ role: 'user', content: input });
|
|
395
|
+
|
|
396
|
+
// Assemble tools (base + meta — rebuilt each turn for dynamic tools like MCP)
|
|
397
|
+
const toolsModule = await import('./tools.mjs');
|
|
398
|
+
const { metaTools } = await import('./metaTools.mjs');
|
|
399
|
+
|
|
400
|
+
// ── Main Loop ───────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
403
|
+
// Rebuild tools each turn so dynamically registered tools (e.g. MCP) are visible
|
|
404
|
+
let allTools = [...agent.tools, ...toolsModule.baseTools];
|
|
405
|
+
const filteredMeta = depth >= 2
|
|
406
|
+
? metaTools.filter((t) => t.name !== 'subagent')
|
|
407
|
+
: metaTools;
|
|
408
|
+
allTools = [...allTools, ...filteredMeta];
|
|
409
|
+
|
|
410
|
+
const toolByName = new Map();
|
|
411
|
+
for (const t of allTools) {
|
|
412
|
+
toolByName.set(t.name, t);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Build OpenAI-format tool schemas for the provider
|
|
416
|
+
const toolSchemas = allTools.map((t) => toolsModule.toOpenAISchema(t));
|
|
417
|
+
// Step 1: Increment counters
|
|
418
|
+
agent._turnsSinceTaskUpdate++;
|
|
419
|
+
if (agent.planMode) {
|
|
420
|
+
agent._turnsInPlanMode++;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Step 2: Compress check
|
|
424
|
+
try {
|
|
425
|
+
const { checkAndCompress } = await import('./context.mjs');
|
|
426
|
+
const didCompress = await checkAndCompress(agent, compactThreshold);
|
|
427
|
+
if (didCompress) {
|
|
428
|
+
// Re-inject AUTO_REMINDER after compression
|
|
429
|
+
agent.history.push({ role: 'user', content: AUTO_REMINDER, transient: true });
|
|
430
|
+
}
|
|
431
|
+
} catch { /* compression is non-fatal */ }
|
|
432
|
+
|
|
433
|
+
// Step 3: Build messages (clean transient for LLM)
|
|
434
|
+
const messages = repairHistory(agent.history);
|
|
435
|
+
|
|
436
|
+
// Call LLM
|
|
437
|
+
const { chat } = await import('./provider.mjs');
|
|
438
|
+
const provider = agent.provider;
|
|
439
|
+
|
|
440
|
+
const response = await chat(provider, {
|
|
441
|
+
messages,
|
|
442
|
+
tools: toolSchemas.length > 0 ? toolSchemas : undefined,
|
|
443
|
+
onToken: cb.onToken,
|
|
444
|
+
onReasoning: cb.onReasoning,
|
|
445
|
+
signal,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Report usage
|
|
449
|
+
if (response.usage) {
|
|
450
|
+
try { cb.onUsage(response.usage); } catch { /* ignore */ }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Step 4: No tool_calls — completion guard
|
|
454
|
+
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
455
|
+
// Guard: if files were mutated but not verified, inject reminder
|
|
456
|
+
if (agent._mutatedThisRun && !agent._verifiedThisRun && !agent.planMode) {
|
|
457
|
+
agent._pendingReminders.push(
|
|
458
|
+
'[System reminder: you modified files but have not verified the changes. Call verify before finishing.]',
|
|
459
|
+
);
|
|
460
|
+
agent._mutatedThisRun = false;
|
|
461
|
+
// Push a reminder for the next turn
|
|
462
|
+
if (agent._pendingReminders.length > 0) {
|
|
463
|
+
for (const reminder of agent._pendingReminders) {
|
|
464
|
+
agent.history.push({ role: 'user', content: reminder, transient: true });
|
|
465
|
+
}
|
|
466
|
+
agent._pendingReminders = [];
|
|
467
|
+
}
|
|
468
|
+
continue; // Loop again to let the model respond to the reminder
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Push assistant response to history and return
|
|
472
|
+
const assistantMsg = { role: 'assistant', content: response.content };
|
|
473
|
+
if (response.reasoning) assistantMsg.reasoning_content = response.reasoning;
|
|
474
|
+
agent.history.push(assistantMsg);
|
|
475
|
+
|
|
476
|
+
return response.content || '';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Step 5: Tool calls — push assistant message
|
|
480
|
+
const assistantMsg = {
|
|
481
|
+
role: 'assistant',
|
|
482
|
+
content: response.content || null,
|
|
483
|
+
tool_calls: response.toolCalls,
|
|
484
|
+
};
|
|
485
|
+
if (response.reasoning) assistantMsg.reasoning_content = response.reasoning;
|
|
486
|
+
agent.history.push(assistantMsg);
|
|
487
|
+
|
|
488
|
+
// Step 6: Execute tool calls
|
|
489
|
+
const { executeToolCalls } = await import('./executor.mjs');
|
|
490
|
+
const results = await executeToolCalls(agent, toolByName, response.toolCalls, cb, depth, signal);
|
|
491
|
+
|
|
492
|
+
// Step 7: Process results
|
|
493
|
+
for (let i = 0; i < results.length; i++) {
|
|
494
|
+
const r = results[i];
|
|
495
|
+
const tc = response.toolCalls[i];
|
|
496
|
+
|
|
497
|
+
const toolMsg = {
|
|
498
|
+
role: 'tool',
|
|
499
|
+
tool_call_id: r.id,
|
|
500
|
+
name: r.name,
|
|
501
|
+
content: r.error ? `Error: ${r.error}` : (r.output || ''),
|
|
502
|
+
};
|
|
503
|
+
agent.history.push(toolMsg);
|
|
504
|
+
|
|
505
|
+
// Track mutations
|
|
506
|
+
if (!r.error && !r.denied) {
|
|
507
|
+
const tool = toolByName.get(r.name);
|
|
508
|
+
if (tool && !tool.readonly) {
|
|
509
|
+
agent._mutatedThisRun = true;
|
|
510
|
+
// Reset verified flag when new mutations happen
|
|
511
|
+
agent._verifiedThisRun = false;
|
|
512
|
+
|
|
513
|
+
// Reindex file in memory if applicable
|
|
514
|
+
if (agent.memory && r.name === 'write' && tc?.function?.arguments) {
|
|
515
|
+
try {
|
|
516
|
+
const args = JSON.parse(tc.function.arguments);
|
|
517
|
+
if (args.path) {
|
|
518
|
+
const absPath = args.path.startsWith('/')
|
|
519
|
+
? args.path
|
|
520
|
+
: join(agent.cwd, args.path);
|
|
521
|
+
const { reindexFile } = await import('./memory.mjs');
|
|
522
|
+
await reindexFile(agent.memory, agent.cwd, absPath);
|
|
523
|
+
}
|
|
524
|
+
} catch { /* non-fatal */ }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Track verify
|
|
529
|
+
if (r.name === 'verify') {
|
|
530
|
+
agent._verifiedThisRun = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Report result
|
|
535
|
+
try { cb.onToolResult(r.name, r.output, r.error); } catch { /* ignore */ }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Step 8: Inject pending reminders
|
|
539
|
+
if (agent._pendingReminders.length > 0) {
|
|
540
|
+
for (const reminder of agent._pendingReminders) {
|
|
541
|
+
agent.history.push({ role: 'user', content: reminder, transient: true });
|
|
542
|
+
}
|
|
543
|
+
agent._pendingReminders = [];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Step 9: Stagnation detection
|
|
547
|
+
if (results.length > 0) {
|
|
548
|
+
const sigs = [];
|
|
549
|
+
for (let i = 0; i < results.length; i++) {
|
|
550
|
+
const tc = response.toolCalls[i];
|
|
551
|
+
if (tc) {
|
|
552
|
+
const sig = tryCanonicalize(tc.function?.name, tc.function?.arguments);
|
|
553
|
+
if (sig) sigs.push(sig);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const sigKey = sigs.join(';');
|
|
557
|
+
agent._recentCallSigs.push(sigKey);
|
|
558
|
+
if (agent._recentCallSigs.length > 4) {
|
|
559
|
+
agent._recentCallSigs.shift();
|
|
560
|
+
}
|
|
561
|
+
// Check last 3
|
|
562
|
+
if (
|
|
563
|
+
agent._recentCallSigs.length >= 3 &&
|
|
564
|
+
agent._recentCallSigs[agent._recentCallSigs.length - 1] === sigKey &&
|
|
565
|
+
agent._recentCallSigs[agent._recentCallSigs.length - 2] === sigKey &&
|
|
566
|
+
agent._recentCallSigs[agent._recentCallSigs.length - 3] === sigKey
|
|
567
|
+
) {
|
|
568
|
+
agent.history.push({
|
|
569
|
+
role: 'user',
|
|
570
|
+
content: '[System reminder: you appear to be stuck in a loop — the same tool calls have been made 3 times in a row. Try a different approach or ask for clarification.]',
|
|
571
|
+
transient: true,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Step 10: Goal progress tracking
|
|
577
|
+
if (agent.goal) {
|
|
578
|
+
const goalTurns = agent.config.agent?.goalTurns || DEFAULT_GOAL_TURNS;
|
|
579
|
+
const progress = Math.floor((turn / goalTurns) * 100);
|
|
580
|
+
if (turn > 0 && turn % 10 === 0) {
|
|
581
|
+
agent.history.push({
|
|
582
|
+
role: 'user',
|
|
583
|
+
content: `[Goal progress: turn ${turn}/${goalTurns} (${Math.min(progress, 100)}%). Objective: "${agent.goal.objective}". Criteria: ${agent.goal.criteria || 'none'}]`,
|
|
584
|
+
transient: true,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Step 11: Task list reminder (every 10 turns, top-level only)
|
|
590
|
+
if (depth === 0 && agent._turnsSinceTaskUpdate >= 10 && agent.tasks.length > 0) {
|
|
591
|
+
const incomplete = agent.tasks.filter((t) => t.status !== 'done').length;
|
|
592
|
+
if (incomplete > 0) {
|
|
593
|
+
agent.history.push({
|
|
594
|
+
role: 'user',
|
|
595
|
+
content: `[Task reminder: ${incomplete} tasks still pending. Use task tool to update status.]`,
|
|
596
|
+
transient: true,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Step 12: Plan mode guidance (every 8 turns)
|
|
602
|
+
if (agent.planMode && agent._turnsInPlanMode >= 8) {
|
|
603
|
+
agent.history.push({
|
|
604
|
+
role: 'user',
|
|
605
|
+
content: `[You have been in plan mode for ${agent._turnsInPlanMode} turns. If you have enough information, consider exiting plan mode to implement.]`,
|
|
606
|
+
transient: true,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Step 13: Turn end callback
|
|
611
|
+
try { cb.onTurnEnd(turn, maxTurns); } catch { /* ignore */ }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Loop exhausted without returning
|
|
615
|
+
throw new ContinueError(`Agent exceeded max turns (${maxTurns}) without completing the task.`, maxTurns);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ─── System Prompt Builder ────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
function buildSystemPrompt(agent, depth) {
|
|
621
|
+
let prompt = DEFAULT_SYSTEM_PROMPT;
|
|
622
|
+
|
|
623
|
+
// Apply overlay if present (e.g., sub-agent role instructions)
|
|
624
|
+
if (agent.overlay) {
|
|
625
|
+
const overlayText = Array.isArray(agent.overlay)
|
|
626
|
+
? agent.overlay.join('\n')
|
|
627
|
+
: String(agent.overlay);
|
|
628
|
+
prompt += `\n\n${overlayText}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Sub-agent specific note
|
|
632
|
+
if (depth > 0) {
|
|
633
|
+
prompt += `\n\nYou are a sub-agent at depth ${depth}. Work independently on your assigned task and return a concise report. Do not ask follow-up questions — complete the task yourself.`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Append project instructions
|
|
637
|
+
const projInstr = loadProjectInstructions(agent.cwd);
|
|
638
|
+
if (projInstr) {
|
|
639
|
+
prompt += `\n\n--- Project Instructions ---\n${projInstr}`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Append skills listing
|
|
643
|
+
try {
|
|
644
|
+
const skills = loadSkills(agent.cwd);
|
|
645
|
+
const skillListing = formatSkillListing(skills);
|
|
646
|
+
prompt += `\n\n--- Available Skills ---\n${skillListing}`;
|
|
647
|
+
} catch { /* skills module may not be available */ }
|
|
648
|
+
|
|
649
|
+
return prompt;
|
|
650
|
+
}
|
package/checkpoint.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git checkpoint management — stub implementation.
|
|
3
|
+
*
|
|
4
|
+
* Provides save/restore of git state for before/after tool executions.
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
/** Create a git stash-based checkpoint. Skips if working tree already has uncommitted changes (to avoid hiding user's work). */
|
|
9
|
+
export async function createCheckpoint(cwd) {
|
|
10
|
+
try {
|
|
11
|
+
// Bail if there are pre-existing uncommitted changes — we must not hide user's work
|
|
12
|
+
const status = execSync('git status --porcelain', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
13
|
+
if (status) return;
|
|
14
|
+
execSync('git stash push -m "junecode-checkpoint"', { cwd, stdio: 'ignore' });
|
|
15
|
+
} catch {
|
|
16
|
+
// Not a git repo or no changes to stash
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** List available junecode checkpoints. */
|
|
21
|
+
export async function listCheckpoints(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const out = execSync('git stash list', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
24
|
+
return out.split('\n').filter(Boolean).map((line, i) => ({ id: i, summary: line }));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rewind to a specific checkpoint. */
|
|
31
|
+
export async function rewind(cwd, id) {
|
|
32
|
+
throw new Error('Checkpoint rewind not implemented yet.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check if cwd is inside a git repo. */
|
|
36
|
+
export function isGitRepo(cwd) {
|
|
37
|
+
try {
|
|
38
|
+
execSync('git rev-parse --git-dir', { cwd, stdio: 'ignore' });
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|