ineedcodes 1.5.1 → 1.6.1
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/provider.js +7 -1
- package/src/session.js +77 -20
- 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/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
|
@@ -85,6 +85,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
85
85
|
let sessionId = null;
|
|
86
86
|
let lastBoost = null;
|
|
87
87
|
let usage = { input: 0, output: 0 };
|
|
88
|
+
var tuiReady = false;
|
|
88
89
|
|
|
89
90
|
// ONE readline, ONE line dispatcher for the whole session
|
|
90
91
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -131,6 +132,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
131
132
|
let streamFlushTimer = null;
|
|
132
133
|
let streamFlushedCount = 0;
|
|
133
134
|
let streamBaseLines = null;
|
|
135
|
+
const chatLines = [];
|
|
134
136
|
|
|
135
137
|
function flushStreamed() {
|
|
136
138
|
if (!TUI || !lastStreamedForHooks) return;
|
|
@@ -170,7 +172,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
170
172
|
onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
171
173
|
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
172
174
|
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
173
|
-
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)); },
|
|
174
176
|
onUsage: u => { usage = u; if (TUI) drawStatus(); },
|
|
175
177
|
drainSteer: () => steerQueue.splice(0),
|
|
176
178
|
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
@@ -262,7 +264,8 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
262
264
|
say(' ' + cyan('/status') + ' everything about this session at a glance');
|
|
263
265
|
say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
|
|
264
266
|
say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
|
|
265
|
-
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');
|
|
266
269
|
say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
|
|
267
270
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
268
271
|
say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
|
|
@@ -292,9 +295,12 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
292
295
|
async function handle(input) {
|
|
293
296
|
if (!input) return;
|
|
294
297
|
if (busy) {
|
|
295
|
-
if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
298
|
+
if (input === '/stop' || input.startsWith('/stop ') || input === 'stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
296
299
|
if (input.startsWith('/')) { pendingLines.push(input); return; }
|
|
297
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...'));
|
|
298
304
|
return;
|
|
299
305
|
}
|
|
300
306
|
if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
|
|
@@ -481,22 +487,43 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
481
487
|
say(all.length ? all.map(s => ` ${cyan(s.name)} ${dim('(' + s.scope + ')')} ${s.description}`).join('\n') : yellow('No skills installed.'));
|
|
482
488
|
return;
|
|
483
489
|
}
|
|
484
|
-
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();
|
|
485
500
|
busy = true;
|
|
486
501
|
const list = listSessions();
|
|
487
502
|
if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
|
|
488
|
-
|
|
503
|
+
if (codeArg) {
|
|
504
|
+
const s = loadSession(codeArg);
|
|
505
|
+
if (s?.history?.length) {
|
|
506
|
+
history = s.history;
|
|
507
|
+
sessionId = s.id;
|
|
508
|
+
renderHistory(s.history);
|
|
509
|
+
say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
|
|
510
|
+
} else say(red(`No session with code ${codeArg}. Check /resume for the list of codes.`));
|
|
511
|
+
busy = false;
|
|
512
|
+
return afterTask();
|
|
513
|
+
}
|
|
514
|
+
list.slice(0, 8).forEach((s, i) => {
|
|
489
515
|
const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
|
|
490
|
-
say(` ${
|
|
516
|
+
say(` ${s.id} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
|
|
491
517
|
});
|
|
492
|
-
const pick = await ask(' Resume which?
|
|
493
|
-
const
|
|
494
|
-
const s = loadSession(list[n - 1]?.id);
|
|
518
|
+
const pick = await ask(' Resume which? (code, e.g. 1425-0609, empty = newest): ');
|
|
519
|
+
const s = pick ? loadSession(pick.trim()) : list[0];
|
|
495
520
|
if (s?.history?.length) {
|
|
496
521
|
history = s.history;
|
|
497
522
|
sessionId = s.id;
|
|
498
|
-
|
|
499
|
-
|
|
523
|
+
renderHistory(s.history);
|
|
524
|
+
say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
|
|
525
|
+
if (TUI) redrawChat();
|
|
526
|
+
} else say(red(`No session with code ${pick}. Check the list above.`));
|
|
500
527
|
busy = false;
|
|
501
528
|
return afterTask();
|
|
502
529
|
}
|
|
@@ -584,7 +611,6 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
584
611
|
}
|
|
585
612
|
|
|
586
613
|
// ── full TUI mode ──
|
|
587
|
-
const chatLines = []; // completed chat lines (ANSI strings)
|
|
588
614
|
let chatTop = 0, chatBot = 0; // scroll region rows
|
|
589
615
|
let statusRow = 0;
|
|
590
616
|
|
|
@@ -621,14 +647,14 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
621
647
|
}
|
|
622
648
|
|
|
623
649
|
function redrawChat() {
|
|
624
|
-
|
|
625
|
-
screen.at(i, 1);
|
|
626
|
-
screen.clearLine();
|
|
627
|
-
}
|
|
628
|
-
screen.at(chatTop, 1);
|
|
650
|
+
// build the whole frame in one buffer, then paint once: no flicker
|
|
629
651
|
const vis = chatBot - chatTop + 1;
|
|
630
652
|
const show = chatLines.slice(-vis);
|
|
631
|
-
|
|
653
|
+
let buf = '';
|
|
654
|
+
for (let i = 0; i < vis; i++) {
|
|
655
|
+
buf += `\x1b[${chatTop + i};1H\x1b[2K` + (show[i] ?? '');
|
|
656
|
+
}
|
|
657
|
+
process.stdout.write(buf);
|
|
632
658
|
scrollRegion();
|
|
633
659
|
}
|
|
634
660
|
|
|
@@ -637,10 +663,29 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
637
663
|
redrawChat();
|
|
638
664
|
}
|
|
639
665
|
|
|
640
|
-
|
|
666
|
+
function renderHistory(h) {
|
|
667
|
+
if (!TUI) {
|
|
668
|
+
for (const m of h) {
|
|
669
|
+
const c = String(m.content ?? '').replaceAll('\n', ' ').slice(0, 90);
|
|
670
|
+
if (c) console.log(' ' + gray((m.role === 'user' ? 'you: ' : 'ineed: ') + c));
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!tuiReady) return;
|
|
675
|
+
setTimeout(() => {
|
|
676
|
+
for (const m of h) {
|
|
677
|
+
if (m.role === 'user') tuiUserLine(String(m.content ?? ''));
|
|
678
|
+
else if (m.content) tuiPrint(box([dim(' (earlier) ') + trunc(String(m.content).replaceAll('\n', ' '), 90)]));
|
|
679
|
+
}
|
|
680
|
+
drawStatus();
|
|
681
|
+
scrollRegion();
|
|
682
|
+
}, 0);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function tuiUserLine(input) {
|
|
641
686
|
for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
|
|
642
687
|
redrawChat();
|
|
643
|
-
}
|
|
688
|
+
}
|
|
644
689
|
|
|
645
690
|
const layout = () => {
|
|
646
691
|
const rows = process.stdout.rows || 24;
|
|
@@ -658,6 +703,18 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
658
703
|
|
|
659
704
|
rl.on('resize', layout);
|
|
660
705
|
|
|
706
|
+
// typed-ahead input while busy: echo it above the status bar so the user sees what they type
|
|
707
|
+
let typedAhead = '';
|
|
708
|
+
rl.on('keypress', (ch, key) => {
|
|
709
|
+
if (!busy || !key) return;
|
|
710
|
+
if (key.name === 'backspace') typedAhead = typedAhead.slice(0, -1);
|
|
711
|
+
else if (key.name === 'return') typedAhead = '';
|
|
712
|
+
else if (key.ctrl || !ch || ch < ' ') return;
|
|
713
|
+
else typedAhead += ch;
|
|
714
|
+
process.stdout.write(`\x1b[${statusRow - 1};1H\x1b[2K` + dim(' you: ') + typedAhead);
|
|
715
|
+
scrollRegion();
|
|
716
|
+
});
|
|
717
|
+
|
|
661
718
|
// keep Ctrl+Z from suspending us in raw mode: swallow the key, tell the user
|
|
662
719
|
const origWrite = rl.write.bind(rl);
|
|
663
720
|
rl.write = (d, key) => {
|
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.1';
|
|
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.
|