ineedcodes 1.5.0 → 1.6.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/package.json +1 -1
- package/src/agent.js +39 -13
- package/src/config.js +1 -0
- package/src/provider.js +7 -1
- package/src/session.js +68 -16
- package/src/sessions.js +9 -2
- package/src/tools.js +95 -0
- package/src/ui.js +12 -1
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -29,8 +29,11 @@ const SYSTEM = `You are ineed, an autonomous terminal agent on the user's machin
|
|
|
29
29
|
Rules:
|
|
30
30
|
- Use the tools to do real work. Never invent output. Every success claim needs evidence from a tool result.
|
|
31
31
|
- Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
|
|
32
|
+
- Scope discipline: never explore outside the working folder (no listing the home directory, no scanning drives, no cloning repos) unless the user explicitly names those paths in the current objective. If the task needs it, ask first.
|
|
33
|
+
- Prefer answering from what you already know: questions like "udah?", "done?", or status checks get a direct answer from the conversation. Only call tools when new facts are genuinely needed.
|
|
32
34
|
- Never push to remotes or delete data without being asked.
|
|
33
35
|
- Destructive commands are always blocked. Ask the user to run those themselves.
|
|
36
|
+
- Your replies go straight to a terminal: never use markdown formatting (no **bold**, no ## headers, no tables, no emojis as decoration). Plain sentences and simple "- " bullets only.
|
|
34
37
|
- Explain to match the user's depth preference (short: results only; normal: what changed and why; deep: also the reasoning and trade-offs).
|
|
35
38
|
- Suggest "boost" (isolated git-worktree run) when a task involves major refactoring, repeated failed fixes, or architecture changes, by telling the user to run /boost. Do not start it yourself.
|
|
36
39
|
- Some actions need user approval. A tool result starting with "Denied" means the user said no: do not retry the same call, explain what you wanted instead.
|
|
@@ -121,7 +124,7 @@ const SPAWN_TOOL = {
|
|
|
121
124
|
};
|
|
122
125
|
|
|
123
126
|
export async function runObjective(cfg, objective, cwd, history, hooks = {}, extra = {}) {
|
|
124
|
-
|
|
127
|
+
let ctrl = new AbortController();
|
|
125
128
|
hooks.onRunStart?.(ctrl);
|
|
126
129
|
const plan = cfg.mode === 'plan';
|
|
127
130
|
const depth = extra.depth ?? 0;
|
|
@@ -188,19 +191,27 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
188
191
|
const usage = { input: 0, output: 0 };
|
|
189
192
|
let answer = '';
|
|
190
193
|
let lastShown = '';
|
|
194
|
+
// notes typed mid-task, injected at safe points; a steer can also interrupt an in-flight call
|
|
195
|
+
const drainSteerInto = () => {
|
|
196
|
+
const steer = hooks.drainSteer?.() ?? [];
|
|
197
|
+
for (const s of steer) {
|
|
198
|
+
messages.push({ role: 'user', content: `[steer from the user, newer than the objective] ${s}` });
|
|
199
|
+
}
|
|
200
|
+
if (steer.length) hooks.onSteer?.(steer);
|
|
201
|
+
return steer;
|
|
202
|
+
};
|
|
203
|
+
let steerRestarts = 0;
|
|
191
204
|
try {
|
|
192
205
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
messages.push({ role: 'user', content: `[steer from the user, newer than the objective] ${s}` });
|
|
200
|
-
}
|
|
201
|
-
hooks.onSteer?.(steer);
|
|
202
|
-
}
|
|
206
|
+
// a steer abort must not kill the task: restart the call with the note included
|
|
207
|
+
if (ctrl.signal.aborted && ctrl.signal.reason === 'steer') {
|
|
208
|
+
drainSteerInto();
|
|
209
|
+
hooks.onNote?.('applying your steer, restarting the call');
|
|
210
|
+
ctrl = new AbortController();
|
|
211
|
+
hooks.onRunStart?.(ctrl);
|
|
203
212
|
}
|
|
213
|
+
if (ctrl.signal.aborted) break;
|
|
214
|
+
drainSteerInto();
|
|
204
215
|
let msg;
|
|
205
216
|
try {
|
|
206
217
|
hooks.onThinkingStart?.();
|
|
@@ -208,6 +219,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
208
219
|
hooks.onThinkingEnd?.();
|
|
209
220
|
} catch (err) {
|
|
210
221
|
hooks.onThinkingEnd?.();
|
|
222
|
+
const steerInterrupt = ctrl.signal.aborted && (ctrl.signal.reason === 'steer' || err.reason === 'steer');
|
|
223
|
+
if (steerInterrupt && steerRestarts < 20) {
|
|
224
|
+
steerRestarts++;
|
|
225
|
+
drainSteerInto();
|
|
226
|
+
hooks.onNote?.('applying your steer, restarting the call');
|
|
227
|
+
ctrl = new AbortController();
|
|
228
|
+
hooks.onRunStart?.(ctrl);
|
|
229
|
+
step--; // redo this step with the steer included
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
211
232
|
if (ctrl.signal.aborted) break;
|
|
212
233
|
throw err;
|
|
213
234
|
}
|
|
@@ -253,11 +274,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
253
274
|
}
|
|
254
275
|
|
|
255
276
|
for (const call of calls) {
|
|
277
|
+
// a stop must cut through: never execute queued tools after an abort
|
|
278
|
+
if (ctrl.signal.aborted) break;
|
|
256
279
|
let input = {};
|
|
257
280
|
try { input = JSON.parse(call.function?.arguments || '{}'); } catch {}
|
|
258
281
|
hooks.onTool?.(call.function?.name, input);
|
|
259
282
|
let result;
|
|
260
|
-
|
|
283
|
+
// enforce the role's tool allowlist at execution time, not just listing time
|
|
284
|
+
if (extra.toolFilter && !extra.toolFilter.includes(call.function?.name)) {
|
|
285
|
+
result = { output: `Refused: your role is not allowed to use ${call.function?.name}. Report what you need instead.` };
|
|
286
|
+
} else if (spawnResults.has(call.id)) {
|
|
261
287
|
result = { output: workerResultText(spawnResults.get(call.id)) };
|
|
262
288
|
} else if (call.function?.name === 'spawn_agent') {
|
|
263
289
|
result = { output: 'Refused: workers cannot spawn more agents.' };
|
|
@@ -334,7 +360,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
334
360
|
result = { output: `Todo list updated (${todos.filter(t => t.status === 'completed').length}/${todos.length} done).` };
|
|
335
361
|
} else {
|
|
336
362
|
const name = call.function?.name;
|
|
337
|
-
const isEdit = ['write_file', 'edit_file', 'delete_file'].includes(name);
|
|
363
|
+
const isEdit = ['write_file', 'edit_file', 'delete_file', 'copy_file', 'move_file'].includes(name);
|
|
338
364
|
let allowedNow = true;
|
|
339
365
|
if (isEdit && cfg.permEdit !== 'allow' && !hooks.approved?.has('edit')) {
|
|
340
366
|
const verdict = canAsk ? await hooks.onApprove('edit', name, input) : true; // cannot ask: CI-style allow
|
package/src/config.js
CHANGED
|
@@ -26,6 +26,7 @@ export function normalize(c) {
|
|
|
26
26
|
mode: c.mode === 'plan' ? 'plan' : 'build',
|
|
27
27
|
memory: c.memory !== false,
|
|
28
28
|
mcp: c.mcp !== false,
|
|
29
|
+
tui: c.tui === true ? true : c.tui === false ? false : null,
|
|
29
30
|
humanize: c.humanize !== false,
|
|
30
31
|
stream: c.stream === true,
|
|
31
32
|
searchUrl: c.searchUrl ? String(c.searchUrl) : '',
|
package/src/provider.js
CHANGED
|
@@ -11,7 +11,13 @@ async function request(url, opts, signal) {
|
|
|
11
11
|
try {
|
|
12
12
|
return await fetch(url, { ...opts, signal: ctrl.signal });
|
|
13
13
|
} catch (err) {
|
|
14
|
-
if (signal?.aborted) {
|
|
14
|
+
if (signal?.aborted) {
|
|
15
|
+
// signal.reason can be a string (abort('steer')) that undici rethrows as-is
|
|
16
|
+
const e = new Error('stopped by user');
|
|
17
|
+
e.stopped = true;
|
|
18
|
+
e.reason = signal.reason;
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
15
21
|
if (timedOut) throw new Error('request timed out after 120s');
|
|
16
22
|
throw new Error(`cannot reach ${url}: ${err.message}`);
|
|
17
23
|
} finally {
|
package/src/session.js
CHANGED
|
@@ -77,15 +77,28 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
77
77
|
const pendingLines = [];
|
|
78
78
|
const steerQueue = []; // notes typed while a task runs, injected mid-task
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// Full-screen TUI: great in ANSI terminals (Linux/macOS/Windows Terminal), but
|
|
81
|
+
// legacy Windows consoles garble alt-screen sequences. Auto: on everywhere except
|
|
82
|
+
// win32; force with config "tui": true, disable with "tui": false.
|
|
83
|
+
const TUI = process.stdout.isTTY && !process.env.NO_COLOR
|
|
84
|
+
&& (state.tui === true || (state.tui === null && process.platform !== 'win32'));
|
|
81
85
|
let sessionId = null;
|
|
82
86
|
let lastBoost = null;
|
|
83
87
|
let usage = { input: 0, output: 0 };
|
|
88
|
+
var tuiReady = false;
|
|
84
89
|
|
|
85
90
|
// ONE readline, ONE line dispatcher for the whole session
|
|
86
91
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
87
92
|
let handleRef = null;
|
|
88
93
|
const ask = makeInput(rl, l => handleRef?.(l));
|
|
94
|
+
// EOF (Ctrl+D or closed pipe): exit cleanly, unless a task is still running
|
|
95
|
+
rl.on('close', () => {
|
|
96
|
+
if (!busy) doExit();
|
|
97
|
+
else {
|
|
98
|
+
const wait = setInterval(() => { if (!busy) { clearInterval(wait); doExit(); } }, 200);
|
|
99
|
+
setTimeout(() => { clearInterval(wait); doExit(); }, 30_000);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
89
102
|
|
|
90
103
|
const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
|
|
91
104
|
|
|
@@ -116,6 +129,18 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
let lastStreamedForHooks = '';
|
|
132
|
+
let streamFlushTimer = null;
|
|
133
|
+
let streamFlushedCount = 0;
|
|
134
|
+
let streamBaseLines = null;
|
|
135
|
+
const chatLines = [];
|
|
136
|
+
|
|
137
|
+
function flushStreamed() {
|
|
138
|
+
if (!TUI || !lastStreamedForHooks) return;
|
|
139
|
+
if (streamBaseLines === null) streamBaseLines = chatLines.length;
|
|
140
|
+
chatLines.length = streamBaseLines; // re-render the growing answer in place
|
|
141
|
+
for (const l of wrapLines(lastStreamedForHooks, Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
|
|
142
|
+
redrawChat();
|
|
143
|
+
}
|
|
119
144
|
|
|
120
145
|
function hooksForRun(stopSpinner) {
|
|
121
146
|
let spinner = null;
|
|
@@ -132,7 +157,11 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
132
157
|
onResult: out => { say(gray(' ' + trunc(out, 110))); },
|
|
133
158
|
onText: t => { stop(); },
|
|
134
159
|
onDelta: chunk => {
|
|
135
|
-
|
|
160
|
+
// stream into the chat buffer; the full text lands on flushStreamed()
|
|
161
|
+
lastStreamedForHooks += chunk;
|
|
162
|
+
if (TUI && !streamFlushTimer) {
|
|
163
|
+
streamFlushTimer = setTimeout(() => { streamFlushTimer = null; flushStreamed(); }, 120);
|
|
164
|
+
}
|
|
136
165
|
},
|
|
137
166
|
onTodos: list => {
|
|
138
167
|
stop();
|
|
@@ -143,7 +172,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
143
172
|
onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
144
173
|
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
145
174
|
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
146
|
-
onNote: note => { stop(); say(dim(' ◇ ' + note)); },
|
|
175
|
+
onNote: note => { stop(); if (note.includes('applying your steer')) { lastStreamedForHooks = ''; streamFlushedCount = 0; streamBaseLines = null; if (typeof tuiReady !== 'undefined' && tuiReady) { chatLines.length = 0; redrawChat(); } } say(dim(' ◇ ' + note)); },
|
|
147
176
|
onUsage: u => { usage = u; if (TUI) drawStatus(); },
|
|
148
177
|
drainSteer: () => steerQueue.splice(0),
|
|
149
178
|
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
@@ -174,6 +203,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
174
203
|
async function runTask(input) {
|
|
175
204
|
busy = true;
|
|
176
205
|
lastStreamedForHooks = '';
|
|
206
|
+
streamFlushedCount = 0;
|
|
177
207
|
if (TUI) tuiUserLine(input);
|
|
178
208
|
const hooks = hooksForRun();
|
|
179
209
|
const stopSpinner = hooks.spinnerStop;
|
|
@@ -234,7 +264,8 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
234
264
|
say(' ' + cyan('/status') + ' everything about this session at a glance');
|
|
235
265
|
say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
|
|
236
266
|
say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
|
|
237
|
-
say(' ' + cyan('/
|
|
267
|
+
say(' ' + cyan('/new') + ' start a fresh session, keep the old saved');
|
|
268
|
+
say(' ' + cyan('/resume') + ' list sessions, /resume <code> like 1425-0609');
|
|
238
269
|
say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
|
|
239
270
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
240
271
|
say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
|
|
@@ -264,9 +295,12 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
264
295
|
async function handle(input) {
|
|
265
296
|
if (!input) return;
|
|
266
297
|
if (busy) {
|
|
267
|
-
if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
298
|
+
if (input === '/stop' || input.startsWith('/stop ') || input === 'stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
268
299
|
if (input.startsWith('/')) { pendingLines.push(input); return; }
|
|
269
300
|
steerQueue.push(input);
|
|
301
|
+
// interrupt the in-flight provider call so the steer applies immediately
|
|
302
|
+
if (activeRun) activeRun.abort('steer');
|
|
303
|
+
say(dim(' ↳ steer noted, applying it now...'));
|
|
270
304
|
return;
|
|
271
305
|
}
|
|
272
306
|
if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
|
|
@@ -453,22 +487,41 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
453
487
|
say(all.length ? all.map(s => ` ${cyan(s.name)} ${dim('(' + s.scope + ')')} ${s.description}`).join('\n') : yellow('No skills installed.'));
|
|
454
488
|
return;
|
|
455
489
|
}
|
|
456
|
-
if (input === '/
|
|
490
|
+
if (input === '/new') {
|
|
491
|
+
if (sessionId) saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history });
|
|
492
|
+
history = [];
|
|
493
|
+
sessionId = null;
|
|
494
|
+
say(green('New session started.') + dim(' The old one is saved, /resume brings it back.'));
|
|
495
|
+
if (TUI) { chatLines.length = 0; redrawChat(); drawStatus(); }
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (input === '/resume' || input.startsWith('/resume ')) {
|
|
499
|
+
const codeArg = input === '/resume' ? '' : input.slice(8).trim();
|
|
457
500
|
busy = true;
|
|
458
501
|
const list = listSessions();
|
|
459
502
|
if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
|
|
460
|
-
|
|
503
|
+
if (codeArg) {
|
|
504
|
+
const s = loadSession(codeArg);
|
|
505
|
+
if (s?.history?.length) {
|
|
506
|
+
history = s.history;
|
|
507
|
+
sessionId = s.id;
|
|
508
|
+
say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
|
|
509
|
+
} else say(red(`No session with code ${codeArg}. Check /resume for the list of codes.`));
|
|
510
|
+
busy = false;
|
|
511
|
+
return afterTask();
|
|
512
|
+
}
|
|
513
|
+
list.slice(0, 8).forEach((s, i) => {
|
|
461
514
|
const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
|
|
462
|
-
say(` ${
|
|
515
|
+
say(` ${s.id} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
|
|
463
516
|
});
|
|
464
|
-
const pick = await ask(' Resume which?
|
|
465
|
-
const
|
|
466
|
-
const s = loadSession(list[n - 1]?.id);
|
|
517
|
+
const pick = await ask(' Resume which? (code, e.g. 1425-0609, empty = newest): ');
|
|
518
|
+
const s = pick ? loadSession(pick.trim()) : list[0];
|
|
467
519
|
if (s?.history?.length) {
|
|
468
520
|
history = s.history;
|
|
469
521
|
sessionId = s.id;
|
|
470
|
-
say(green(`Resumed ${Math.floor(s.history.length / 2)} turns. Continue where we left off.`));
|
|
471
|
-
|
|
522
|
+
say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
|
|
523
|
+
if (TUI) redrawChat();
|
|
524
|
+
} else say(red(`No session with code ${pick}. Check the list above.`));
|
|
472
525
|
busy = false;
|
|
473
526
|
return afterTask();
|
|
474
527
|
}
|
|
@@ -556,7 +609,6 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
556
609
|
}
|
|
557
610
|
|
|
558
611
|
// ── full TUI mode ──
|
|
559
|
-
const chatLines = []; // completed chat lines (ANSI strings)
|
|
560
612
|
let chatTop = 0, chatBot = 0; // scroll region rows
|
|
561
613
|
let statusRow = 0;
|
|
562
614
|
|
|
@@ -609,10 +661,10 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
609
661
|
redrawChat();
|
|
610
662
|
}
|
|
611
663
|
|
|
612
|
-
|
|
664
|
+
const tuiUserLine = input => {
|
|
613
665
|
for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
|
|
614
666
|
redrawChat();
|
|
615
|
-
}
|
|
667
|
+
};
|
|
616
668
|
|
|
617
669
|
const layout = () => {
|
|
618
670
|
const rows = process.stdout.rows || 24;
|
package/src/sessions.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
// sessions.js: session persistence. Conversation checkpoints live outside the repo,
|
|
2
2
|
// under the config dir, so users can leave and resume work (master prompt #34).
|
|
3
|
+
// IDs are HHMM-DDMM codes so /resume 1425-0609 just works.
|
|
3
4
|
|
|
4
5
|
import * as fs from 'node:fs';
|
|
5
6
|
import * as path from 'node:path';
|
|
6
7
|
import { CONFIG_DIR as configDirPath } from './config.js';
|
|
8
|
+
|
|
7
9
|
const DIR = path.join(configDirPath, 'sessions');
|
|
8
10
|
|
|
11
|
+
const pad = n => String(n).padStart(2, '0');
|
|
12
|
+
|
|
13
|
+
export function newSessionId(d = new Date()) {
|
|
14
|
+
return `${pad(d.getHours())}${pad(d.getMinutes())}-${pad(d.getDate())}${pad(d.getMonth() + 1)}`;
|
|
15
|
+
}
|
|
9
16
|
|
|
10
17
|
export function saveSession(data) {
|
|
11
18
|
fs.mkdirSync(DIR, { recursive: true });
|
|
12
|
-
const id = data.id ||
|
|
19
|
+
const id = data.id || newSessionId();
|
|
13
20
|
fs.writeFileSync(path.join(DIR, id + '.json'), JSON.stringify({ ...data, id, time: Date.now() }, null, 2), { mode: 0o600 });
|
|
14
21
|
return id;
|
|
15
22
|
}
|
|
@@ -19,7 +26,7 @@ export function listSessions() {
|
|
|
19
26
|
return fs.readdirSync(DIR)
|
|
20
27
|
.filter(f => f.endsWith('.json'))
|
|
21
28
|
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')); } catch { return null; } })
|
|
22
|
-
.filter(Boolean)
|
|
29
|
+
.filter(Boolean).filter(s => (s.history?.length ?? 0) > 0)
|
|
23
30
|
.sort((a, b) => b.time - a.time)
|
|
24
31
|
.slice(0, 20);
|
|
25
32
|
} catch { return []; }
|
package/src/tools.js
CHANGED
|
@@ -129,6 +129,48 @@ export const TOOLS = [
|
|
|
129
129
|
allowedInPlan: true,
|
|
130
130
|
web: true
|
|
131
131
|
},
|
|
132
|
+
{
|
|
133
|
+
name: 'read_file_range',
|
|
134
|
+
description: 'Read part of a file by line numbers (1-based). For big files.',
|
|
135
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number', description: 'first line, default 1' }, limit: { type: 'number', description: 'lines to read, default 200' } }, required: ['path'] },
|
|
136
|
+
allowedInPlan: true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'file_exists',
|
|
140
|
+
description: 'Check whether a path exists.',
|
|
141
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
142
|
+
allowedInPlan: true
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'file_metadata',
|
|
146
|
+
description: 'Size, modified time, and type of a file.',
|
|
147
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
148
|
+
allowedInPlan: true
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'copy_file',
|
|
152
|
+
description: 'Copy a file to a new path.',
|
|
153
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } }, required: ['path', 'to'] },
|
|
154
|
+
allowedInPlan: false
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'move_file',
|
|
158
|
+
description: 'Move or rename a file.',
|
|
159
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } }, required: ['path', 'to'] },
|
|
160
|
+
allowedInPlan: false
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'search_files',
|
|
164
|
+
description: 'Find files whose NAME contains a string (recursive).',
|
|
165
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] },
|
|
166
|
+
allowedInPlan: true
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'list_tracked_files',
|
|
170
|
+
description: 'List files tracked by git in this repository.',
|
|
171
|
+
parameters: { type: 'object', properties: {} },
|
|
172
|
+
allowedInPlan: true
|
|
173
|
+
},
|
|
132
174
|
{
|
|
133
175
|
name: 'shell',
|
|
134
176
|
description: 'Run a shell command in the working directory. Returns exit code with stdout and stderr.',
|
|
@@ -208,6 +250,59 @@ export function runTool(name, input, cwd) {
|
|
|
208
250
|
fs.unlinkSync(abs);
|
|
209
251
|
return { output: `Deleted ${path.relative(cwd, abs)}.` };
|
|
210
252
|
}
|
|
253
|
+
if (name === 'read_file_range') {
|
|
254
|
+
if (isSecret(abs)) return { output: 'Refused: that looks like a secret file, and secrets never enter the model context.' };
|
|
255
|
+
const all = fs.readFileSync(abs, 'utf8').split('\n');
|
|
256
|
+
const offset = Math.max(1, Number(input.offset ?? 1));
|
|
257
|
+
const limit = Math.max(1, Math.min(2_000, Number(input.limit ?? 200)));
|
|
258
|
+
const slice = all.slice(offset - 1, offset - 1 + limit);
|
|
259
|
+
const numbered = slice.map((l, i) => `${offset + i}: ${l}`).join('\n');
|
|
260
|
+
return { output: `${abs} lines ${offset}-${offset + slice.length - 1} of ${all.length}\n${numbered.slice(0, 60_000)}` };
|
|
261
|
+
}
|
|
262
|
+
if (name === 'file_exists') {
|
|
263
|
+
return { output: fs.existsSync(abs) ? `yes: ${path.relative(cwd, abs)}` : `no: ${path.relative(cwd, abs)}` };
|
|
264
|
+
}
|
|
265
|
+
if (name === 'file_metadata') {
|
|
266
|
+
const st = fs.statSync(abs);
|
|
267
|
+
return { output: `${path.relative(cwd, abs)}\nsize: ${st.size} bytes\n${st.isDirectory() ? 'directory' : 'file'}\nmodified: ${st.mtime.toISOString()}` };
|
|
268
|
+
}
|
|
269
|
+
if (name === 'copy_file') {
|
|
270
|
+
const dest = path.resolve(cwd, String(input.to ?? ''));
|
|
271
|
+
if (!underRoot(dest, cwd)) return { output: 'Refused: destination is outside the working directory.' };
|
|
272
|
+
fs.copyFileSync(abs, dest);
|
|
273
|
+
return { output: `Copied ${path.relative(cwd, abs)} -> ${path.relative(cwd, dest)}.` };
|
|
274
|
+
}
|
|
275
|
+
if (name === 'move_file') {
|
|
276
|
+
const dest = path.resolve(cwd, String(input.to ?? ''));
|
|
277
|
+
if (!underRoot(dest, cwd)) return { output: 'Refused: destination is outside the working directory.' };
|
|
278
|
+
fs.renameSync(abs, dest);
|
|
279
|
+
return { output: `Moved ${path.relative(cwd, abs)} -> ${path.relative(cwd, dest)}.` };
|
|
280
|
+
}
|
|
281
|
+
if (name === 'search_files') {
|
|
282
|
+
const pattern = String(input.pattern ?? '').toLowerCase();
|
|
283
|
+
if (!pattern) return { output: 'Error: empty pattern.' };
|
|
284
|
+
const out = [];
|
|
285
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache']);
|
|
286
|
+
(function walk(d, depth) {
|
|
287
|
+
if (out.length >= 100 || depth > 5) return;
|
|
288
|
+
let entries;
|
|
289
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
if (out.length >= 100) return;
|
|
292
|
+
if (skip.has(e.name)) continue;
|
|
293
|
+
const p = path.join(d, e.name);
|
|
294
|
+
if (e.name.toLowerCase().includes(pattern)) out.push(path.relative(cwd, p) + (e.isDirectory() ? '/' : ''));
|
|
295
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
296
|
+
}
|
|
297
|
+
})(abs, 0);
|
|
298
|
+
return { output: out.length ? out.join('\n') : '(no matches)' };
|
|
299
|
+
}
|
|
300
|
+
if (name === 'list_tracked_files') {
|
|
301
|
+
const g = spawnSync('git', ['ls-files'], { cwd, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
|
302
|
+
if (g.status !== 0) return { output: 'Error: not a git repository (or git unavailable).' };
|
|
303
|
+
const files = g.stdout.split('\n').filter(Boolean);
|
|
304
|
+
return { output: files.length ? files.slice(0, 500).join('\n') + (files.length > 500 ? `\n(+${files.length - 500} more)` : '') : '(no tracked files)' };
|
|
305
|
+
}
|
|
211
306
|
return { output: `Unknown tool: ${name}` };
|
|
212
307
|
} catch (err) {
|
|
213
308
|
return { output: `Error: ${err.message}` };
|
package/src/ui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ui.js: terminal helpers. No dependencies, respects NO_COLOR and non-TTY.
|
|
2
2
|
|
|
3
|
-
export const VERSION = '1.
|
|
3
|
+
export const VERSION = '1.6.0';
|
|
4
4
|
|
|
5
5
|
const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
6
6
|
const wrap = (code, t) => USE_COLOR ? `\x1b[${code}m${t}\x1b[0m` : String(t);
|
|
@@ -18,6 +18,17 @@ export const trunc = (s, n = 120) => {
|
|
|
18
18
|
return o.length > n ? o.slice(0, n - 1) + '...' : o;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// Terminal-safe markdown: **bold** becomes ANSI bold (or disappears without color),
|
|
22
|
+
// headers lose their hashes. Files keep their markdown; only the screen is cleaned.
|
|
23
|
+
export const mdTerm = t => {
|
|
24
|
+
let s = String(t);
|
|
25
|
+
if (USE_COLOR) s = s.replace(/\*\*([^*\n]+)\*\*/g, `\x1b[1m$1\x1b[0m`);
|
|
26
|
+
else s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1');
|
|
27
|
+
s = s.replace(/(^|\n)#{1,6} /g, '$1');
|
|
28
|
+
s = s.replace(/\*([^*\n]+)\*/g, '$1');
|
|
29
|
+
return s;
|
|
30
|
+
};
|
|
31
|
+
|
|
21
32
|
const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
22
33
|
|
|
23
34
|
// Rounded box around lines. Width follows the longest line, capped to the terminal.
|