ineedcodes 1.4.0 → 1.5.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 -7
- package/src/config.js +9 -1
- package/src/humanize.js +1 -1
- package/src/processes.js +104 -0
- package/src/provider.js +62 -2
- package/src/session.js +109 -11
- package/src/ui.js +1 -1
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { chat } from './provider.js';
|
|
4
4
|
import { TOOLS, runTool, shellRun, isDestructive, GIT_TOOL_DEFS, runGitTool } from './tools.js';
|
|
5
5
|
import { fetchUrl, webSearch } from './web.js';
|
|
6
|
+
import { PROC_TOOL_DEFS, runProcTool } from './processes.js';
|
|
6
7
|
import { trunc, gray, cyan, dim } from './ui.js';
|
|
7
8
|
import { getMemoryProvider } from './memory.js';
|
|
8
9
|
import * as path from 'node:path';
|
|
@@ -30,6 +31,8 @@ Rules:
|
|
|
30
31
|
- Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
|
|
31
32
|
- Never push to remotes or delete data without being asked.
|
|
32
33
|
- Destructive commands are always blocked. Ask the user to run those themselves.
|
|
34
|
+
- Explain to match the user's depth preference (short: results only; normal: what changed and why; deep: also the reasoning and trade-offs).
|
|
35
|
+
- 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.
|
|
33
36
|
- 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.
|
|
34
37
|
- For objectives with 3 or more steps, keep a checklist with the todo tool and update statuses as you go (in_progress for what you are doing now).
|
|
35
38
|
- A "[steer from the user, newer than the objective]" message is a live steer: it is newer than the original objective. Adapt to it immediately; if it changes direction, change course without redoing finished work.
|
|
@@ -93,7 +96,8 @@ async function runWorker(cfg, spec, cwd, depth, hooks) {
|
|
|
93
96
|
const res = await runObjective(cfg, objective, cwd, [], {}, {
|
|
94
97
|
depth: depth + 1,
|
|
95
98
|
toolFilter: role.tools,
|
|
96
|
-
worker: { id: spec.id, role: roleName, prompt: role.prompt }
|
|
99
|
+
worker: { id: spec.id, role: roleName, prompt: role.prompt },
|
|
100
|
+
modelOverride: cfg.models?.[roleName] ?? cfg.models?.worker ?? null
|
|
97
101
|
});
|
|
98
102
|
return { id: spec.id, role: roleName, status: res.aborted ? 'incomplete' : 'completed', summary: res.answer || '(no output)', files: res.changed, commands: res.ran };
|
|
99
103
|
} catch (err) {
|
|
@@ -124,8 +128,11 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
124
128
|
let tools = plan ? TOOLS.filter(t => t.allowedInPlan) : [...TOOLS, SPAWN_TOOL];
|
|
125
129
|
// first-class git wrappers (read ones always, mutating ones gated by permEdit)
|
|
126
130
|
tools.push(...GIT_TOOL_DEFS.filter(t => plan ? !t.mutating : true));
|
|
131
|
+
// background process tools (build mode only)
|
|
132
|
+
if (!plan) tools.push(...PROC_TOOL_DEFS);
|
|
127
133
|
if (extra.toolFilter) tools = tools.filter(t => (extra.toolFilter).includes(t.name));
|
|
128
134
|
const canAsk = typeof hooks.onApprove === 'function';
|
|
135
|
+
const roleCfg = extra.modelOverride ? { ...cfg, model: extra.modelOverride } : cfg;
|
|
129
136
|
|
|
130
137
|
// MCP: load configured servers once per top-level objective, expose their tools
|
|
131
138
|
let mcpManager = null;
|
|
@@ -158,6 +165,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
158
165
|
|
|
159
166
|
const workerPrefix = extra.worker ? `You are ${extra.worker.id} (${extra.worker.role} worker) spawned by the lead agent. ${extra.worker.prompt}\n` : '';
|
|
160
167
|
const projectInstructions = extra.worker ? '' : loadProjectInstructions(cwd);
|
|
168
|
+
const depthNote = extra.worker ? '' : (cfg.explain === 'short' ? '\nAnswer style: short. Give results, skip explanations unless asked.' : cfg.explain === 'deep' ? '\nAnswer style: deep. Include reasoning, trade-offs, and what you ruled out.' : '');
|
|
161
169
|
const skills = extra.worker ? [] : listSkills(cwd);
|
|
162
170
|
const skillsBlock = skills.length ? `\nInstalled skills (follow a skill's instructions when the user invokes it by name or clearly asks for what it does):\n${skills.map(s => `- ${s.name} (${s.scope}): ${s.description}`).join('\n')}` : '';
|
|
163
171
|
const invokedSkill = !extra.worker
|
|
@@ -166,7 +174,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
166
174
|
const messages = [
|
|
167
175
|
{
|
|
168
176
|
role: 'system',
|
|
169
|
-
content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
|
|
177
|
+
content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}${depthNote}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
|
|
170
178
|
+ (recalled ? `\nRelevant memory from previous sessions with this user (durable facts, may be stale):\n${recalled}` : '')
|
|
171
179
|
+ (projectInstructions ? `\nProject instructions for this repository (follow them):\n${projectInstructions}` : '')
|
|
172
180
|
+ skillsBlock
|
|
@@ -177,6 +185,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
177
185
|
const changed = new Set();
|
|
178
186
|
const ran = [];
|
|
179
187
|
const todos = [];
|
|
188
|
+
const usage = { input: 0, output: 0 };
|
|
180
189
|
let answer = '';
|
|
181
190
|
let lastShown = '';
|
|
182
191
|
try {
|
|
@@ -195,7 +204,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
195
204
|
let msg;
|
|
196
205
|
try {
|
|
197
206
|
hooks.onThinkingStart?.();
|
|
198
|
-
msg = await chat(
|
|
207
|
+
msg = await chat(roleCfg, messages, tools, ctrl.signal, hooks.onDelta);
|
|
199
208
|
hooks.onThinkingEnd?.();
|
|
200
209
|
} catch (err) {
|
|
201
210
|
hooks.onThinkingEnd?.();
|
|
@@ -203,6 +212,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
203
212
|
throw err;
|
|
204
213
|
}
|
|
205
214
|
messages.push(msg);
|
|
215
|
+
if (msg._usage) { usage.input += msg._usage.input; usage.output += msg._usage.output; hooks.onUsage?.({ ...usage }); }
|
|
206
216
|
if (msg.content && msg.content !== lastShown) {
|
|
207
217
|
hooks.onText?.(msg.content);
|
|
208
218
|
lastShown = msg.content;
|
|
@@ -216,7 +226,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
216
226
|
await memory.store(`project ${cwd}: ${objective.slice(0, 150)} -> ${answer.slice(0, 300)}`);
|
|
217
227
|
} catch {}
|
|
218
228
|
}
|
|
219
|
-
return { answer, changed: [...changed], ran, todos: [...todos], aborted: false };
|
|
229
|
+
return { answer, changed: [...changed], ran, todos: [...todos], usage: { ...usage }, aborted: false };
|
|
220
230
|
}
|
|
221
231
|
// spawn_agent pre-pass: read-only workers run in parallel (max 4), writers sequentially
|
|
222
232
|
const spawnResults = new Map();
|
|
@@ -269,9 +279,31 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
269
279
|
result = allowedNow ? runGitTool(call.function?.name, input, cwd) : { output: `Denied: the user did not approve ${call.function?.name}.` };
|
|
270
280
|
}
|
|
271
281
|
} else if (call.function?.name === 'fetch_url') {
|
|
272
|
-
|
|
282
|
+
if (cfg.permNet === 'ask' && !hooks.approved?.has('net') && canAsk) {
|
|
283
|
+
const verdict = await hooks.onApprove('net', 'fetch_url', input);
|
|
284
|
+
if (verdict === 'always') hooks.approved?.add('net');
|
|
285
|
+
if (!verdict) result = { output: 'Denied: the user did not approve network access.' };
|
|
286
|
+
}
|
|
287
|
+
if (!result) result = await fetchUrl(input.url);
|
|
273
288
|
} else if (call.function?.name === 'web_search') {
|
|
274
|
-
|
|
289
|
+
if (cfg.permNet === 'ask' && !hooks.approved?.has('net') && canAsk) {
|
|
290
|
+
const verdict = await hooks.onApprove('net', 'web_search', input);
|
|
291
|
+
if (verdict === 'always') hooks.approved?.add('net');
|
|
292
|
+
if (!verdict) result = { output: 'Denied: the user did not approve network access.' };
|
|
293
|
+
}
|
|
294
|
+
if (!result) result = await webSearch(cfg, input.query);
|
|
295
|
+
} else if (call.function?.name?.startsWith('process_')) {
|
|
296
|
+
if (plan) result = { output: 'Refused: plan mode is read only.' };
|
|
297
|
+
else {
|
|
298
|
+
const def = PROC_TOOL_DEFS.find(t => t.name === call.function?.name);
|
|
299
|
+
let allowedNow = !def.mutating || cfg.permShell === 'allow' || hooks.approved?.has('shell');
|
|
300
|
+
if (!allowedNow && canAsk) {
|
|
301
|
+
const verdict = await hooks.onApprove('shell', call.function?.name, input);
|
|
302
|
+
if (verdict === 'always') hooks.approved?.add('shell');
|
|
303
|
+
allowedNow = Boolean(verdict);
|
|
304
|
+
}
|
|
305
|
+
result = allowedNow ? runProcTool(call.function?.name, input) : { output: `Denied: the user did not approve ${call.function?.name}.` };
|
|
306
|
+
}
|
|
275
307
|
} else if (call.function?.name === 'shell') {
|
|
276
308
|
if (plan) result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
|
|
277
309
|
else if (isDestructive(String(input.command ?? ''))) {
|
|
@@ -333,7 +365,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
333
365
|
hooks.onRunEnd?.();
|
|
334
366
|
}
|
|
335
367
|
const stopped = ctrl.signal.aborted;
|
|
336
|
-
return { answer, changed: [...changed], ran, todos: [...todos], aborted: true, stopped };
|
|
368
|
+
return { answer, changed: [...changed], ran, todos: [...todos], usage: { ...usage }, aborted: true, stopped };
|
|
337
369
|
}
|
|
338
370
|
|
|
339
371
|
export function pushTurn(history, objective, result) {
|
package/src/config.js
CHANGED
|
@@ -26,9 +26,17 @@ 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,
|
|
31
|
+
stream: c.stream === true,
|
|
32
|
+
searchUrl: c.searchUrl ? String(c.searchUrl) : '',
|
|
33
|
+
explain: ['short', 'deep'].includes(c.explain) ? c.explain : 'normal',
|
|
30
34
|
permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
|
|
31
|
-
permShell: c.permShell === 'allow' ? 'allow' : 'ask'
|
|
35
|
+
permShell: c.permShell === 'allow' ? 'allow' : 'ask',
|
|
36
|
+
permNet: c.permNet === 'ask' ? 'ask' : 'allow',
|
|
37
|
+
models: (c.models && typeof c.models === 'object' && !Array.isArray(c.models))
|
|
38
|
+
? Object.fromEntries(Object.entries(c.models).map(([k, v]) => [k, String(v)]))
|
|
39
|
+
: {}
|
|
32
40
|
};
|
|
33
41
|
}
|
|
34
42
|
|
package/src/humanize.js
CHANGED
|
@@ -63,7 +63,7 @@ function humanizeHtmlTextNodes(html, fn) {
|
|
|
63
63
|
|
|
64
64
|
async function humanizeWithModel(cfg, text, kind, signal) {
|
|
65
65
|
const prompt = (kind === 'html'
|
|
66
|
-
? `Below is the text content of an HTML page. Rewrite ONLY the marketing copy so it reads like a human wrote it: drop AI cliches ("game-changer", "cutting-edge", "unlock", "seamless"), filler openers, and excessive enthusiasm. Never use em dashes. Keep the message, facts, product names, numbers, and
|
|
66
|
+
? `Below is the text content of an HTML page. Rewrite ONLY the marketing copy so it reads like a human wrote it: drop AI cliches ("game-changer", "cutting-edge", "unlock", "seamless"), filler openers, and excessive enthusiasm. Never use em dashes. Keep the message, facts, product names, numbers, and the original language exactly. Reply with ONLY the rewritten text, same line structure. If nothing needs changing, reply with the text unchanged.`
|
|
67
67
|
: `Rewrite the text below so it reads like a human wrote it: drop AI cliches and filler, keep it natural and direct. Never use em dashes. Keep the message, facts, names, numbers, and language exactly. Keep the same line structure. Reply with ONLY the rewritten text. If nothing needs changing, reply with it unchanged.`)
|
|
68
68
|
+ `\n---\n${text.slice(0, 8000)}`;
|
|
69
69
|
const msg = await chat({ ...cfg, reasoning: 'low' }, [{ role: 'user', content: prompt }], undefined, signal);
|
package/src/processes.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// processes.js: background process tools (master prompt #25). Start dev servers or
|
|
2
|
+
// long commands, list them, tail their logs, stop them. Zero dependencies.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as os from 'node:os';
|
|
8
|
+
|
|
9
|
+
const registry = new Map(); // name -> { child, logPath, command, started, exited }
|
|
10
|
+
const LOG_DIR = path.join(os.tmpdir(), 'ineed-procs');
|
|
11
|
+
|
|
12
|
+
function cleanExit() {
|
|
13
|
+
for (const [, e] of registry) { try { if (!e.exited) e.child.kill('SIGKILL'); } catch {} }
|
|
14
|
+
}
|
|
15
|
+
process.on('exit', cleanExit);
|
|
16
|
+
|
|
17
|
+
const okName = n => /^[a-zA-Z0-9_-]{1,40}$/.test(String(n));
|
|
18
|
+
|
|
19
|
+
export const PROC_TOOL_DEFS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'process_start',
|
|
22
|
+
description: 'Start a long-running command in the background (dev server, watcher). Output goes to a log file.',
|
|
23
|
+
parameters: { type: 'object', properties: { name: { type: 'string' }, command: { type: 'string' } }, required: ['name', 'command'] },
|
|
24
|
+
proc: true, mutating: true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'process_status',
|
|
28
|
+
description: 'List background processes with pid, command, and log path.',
|
|
29
|
+
parameters: { type: 'object', properties: {} },
|
|
30
|
+
proc: true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'process_output',
|
|
34
|
+
description: 'Show the last lines of a background process log.',
|
|
35
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
36
|
+
proc: true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'process_stop',
|
|
40
|
+
description: 'Stop one background process by name.',
|
|
41
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
42
|
+
proc: true, mutating: true
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function runProcTool(name, input) {
|
|
47
|
+
const n = String(input.name ?? '');
|
|
48
|
+
if (name !== 'process_status' && !okName(n)) return { output: 'Error: name must be letters, digits, _ or - (max 40).' };
|
|
49
|
+
|
|
50
|
+
if (name === 'process_start') {
|
|
51
|
+
const command = String(input.command ?? '').trim();
|
|
52
|
+
if (!command) return { output: 'Error: empty command.' };
|
|
53
|
+
if (registry.has(n) && !registry.get(n).exited) {
|
|
54
|
+
return { output: `Error: "${n}" is already running (pid ${registry.get(n).child.pid}). Stop it first.` };
|
|
55
|
+
}
|
|
56
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
57
|
+
const logPath = path.join(LOG_DIR, n + '.log');
|
|
58
|
+
const out = fs.openSync(logPath, 'a');
|
|
59
|
+
let child;
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(command, { cwd: os.homedir(), shell: true, stdio: ['ignore', out, out], env: { ...process.env, NO_COLOR: '1' } });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
fs.closeSync(out);
|
|
64
|
+
return { output: `Error: ${err.message}` };
|
|
65
|
+
}
|
|
66
|
+
fs.closeSync(out);
|
|
67
|
+
const entry = { child, logPath, command: command.slice(0, 120), started: Date.now(), exited: false };
|
|
68
|
+
registry.set(n, entry);
|
|
69
|
+
child.on('exit', () => { entry.exited = true; });
|
|
70
|
+
return { output: `Started "${n}" (pid ${child.pid}). log: ${logPath}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (name === 'process_status') {
|
|
74
|
+
if (!registry.size) return { output: 'No background processes.' };
|
|
75
|
+
return {
|
|
76
|
+
output: [...registry.entries()].map(([name, e]) =>
|
|
77
|
+
`${name}: pid ${e.child.pid}${e.exited ? ' (exited)' : ' (running)'} cmd: ${e.command} log: ${e.logPath}`
|
|
78
|
+
).join('\n')
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (name === 'process_output') {
|
|
83
|
+
const e = registry.get(n);
|
|
84
|
+
if (!e) return { output: `Error: no process named "${n}".` };
|
|
85
|
+
try {
|
|
86
|
+
const log = fs.readFileSync(e.logPath, 'utf8');
|
|
87
|
+
return { output: log.split('\n').slice(-40).join('\n') || '(log empty)' };
|
|
88
|
+
} catch { return { output: '(log empty)' }; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (name === 'process_stop') {
|
|
92
|
+
const e = registry.get(n);
|
|
93
|
+
if (!e) return { output: `Error: no process named "${n}".` };
|
|
94
|
+
try { e.child.kill('SIGKILL'); } catch {}
|
|
95
|
+
registry.delete(n);
|
|
96
|
+
return { output: `Stopped "${n}".` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { output: `Unknown process tool: ${name}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function stopAllProcs() {
|
|
103
|
+
for (const [, e] of registry) { try { if (!e.exited) e.child.kill('SIGKILL'); } catch {} }
|
|
104
|
+
}
|
package/src/provider.js
CHANGED
|
@@ -30,9 +30,10 @@ export async function fetchModels(cfg, signal) {
|
|
|
30
30
|
return [...new Set(ids)];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export async function chat(cfg, messages, tools, signal) {
|
|
33
|
+
export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
34
34
|
const body = { model: cfg.model, messages, temperature: 0.2 };
|
|
35
35
|
if (cfg.reasoning === 'high') body.reasoning_effort = 'high';
|
|
36
|
+
if (cfg.stream === true) body.stream = true;
|
|
36
37
|
if (tools?.length) body.tools = tools.map(t => ({
|
|
37
38
|
type: 'function',
|
|
38
39
|
function: { name: t.name, description: t.description, parameters: t.parameters }
|
|
@@ -65,15 +66,74 @@ export async function chat(cfg, messages, tools, signal) {
|
|
|
65
66
|
// provider does not know reasoning_effort: retry once without it
|
|
66
67
|
if (body.reasoning_effort && (res.status === 400 || res.status === 422)) {
|
|
67
68
|
delete body.reasoning_effort;
|
|
69
|
+
delete body.stream;
|
|
68
70
|
const retry = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
|
|
69
71
|
if (!retry.ok) throw new Error(`HTTP ${retry.status}: ${(await retry.text()).slice(0, 200)}`);
|
|
70
72
|
return parseMessage(await retry.json());
|
|
71
73
|
}
|
|
74
|
+
// provider does not stream: fall back to a plain call instead of failing
|
|
75
|
+
if (body.stream && (res.status === 400 || res.status === 404 || res.status === 422)) {
|
|
76
|
+
delete body.stream;
|
|
77
|
+
res = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
|
|
78
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
79
|
+
return parseMessage(await res.json());
|
|
80
|
+
}
|
|
72
81
|
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
73
82
|
}
|
|
83
|
+
if (body.stream) return readStream(res, onDelta);
|
|
74
84
|
return parseMessage(await res.json());
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
// SSE stream: accumulate content and tool_calls, emit text deltas as they arrive.
|
|
88
|
+
async function readStream(res, onDelta) {
|
|
89
|
+
const reader = res.body.getReader();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let buffer = '';
|
|
92
|
+
let content = '';
|
|
93
|
+
const toolCalls = [];
|
|
94
|
+
let usage = null;
|
|
95
|
+
let role = 'assistant';
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done) break;
|
|
99
|
+
buffer += decoder.decode(value, { stream: true });
|
|
100
|
+
let idx;
|
|
101
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
102
|
+
const line = buffer.slice(0, idx).trim();
|
|
103
|
+
buffer = buffer.slice(idx + 1);
|
|
104
|
+
if (!line.startsWith('data:')) continue;
|
|
105
|
+
const payload = line.slice(5).trim();
|
|
106
|
+
if (payload === '[DONE]') continue;
|
|
107
|
+
let ev;
|
|
108
|
+
try { ev = JSON.parse(payload); } catch { continue; }
|
|
109
|
+
if (ev.usage) usage = ev.usage;
|
|
110
|
+
const d = ev.choices?.[0]?.delta;
|
|
111
|
+
if (!d) continue;
|
|
112
|
+
if (d.role) role = d.role;
|
|
113
|
+
if (d.content) {
|
|
114
|
+
content += d.content;
|
|
115
|
+
onDelta?.(d.content);
|
|
116
|
+
}
|
|
117
|
+
for (const tc of d.tool_calls ?? []) {
|
|
118
|
+
const i = tc.index ?? 0;
|
|
119
|
+
toolCalls[i] ??= { id: tc.id ?? ('call_' + i), type: 'function', function: { name: '', arguments: '' } };
|
|
120
|
+
if (tc.id) toolCalls[i].id = tc.id;
|
|
121
|
+
if (tc.function?.name) toolCalls[i].function.name += tc.function.name;
|
|
122
|
+
if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const msg = { role, content, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) };
|
|
127
|
+
if (usage) msg._usage = { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0 };
|
|
128
|
+
return msg;
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
function parseMessage(json) {
|
|
78
|
-
|
|
132
|
+
const msg = json.choices?.[0]?.message ?? { role: 'assistant', content: '', tool_calls: [] };
|
|
133
|
+
// surface token usage when the provider returns it (OpenAI-style usage block)
|
|
134
|
+
if (json.usage) msg._usage = {
|
|
135
|
+
input: json.usage.prompt_tokens ?? 0,
|
|
136
|
+
output: json.usage.completion_tokens ?? 0
|
|
137
|
+
};
|
|
138
|
+
return msg;
|
|
79
139
|
}
|
package/src/session.js
CHANGED
|
@@ -10,9 +10,15 @@ import { wizard } from './wizard.js';
|
|
|
10
10
|
import { getMemoryProvider, ICMAdapter } from './memory.js';
|
|
11
11
|
import { saveSession, listSessions, loadSession } from './sessions.js';
|
|
12
12
|
import * as boost from './boost.js';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
13
14
|
import { mcpConfigured } from './mcp.js';
|
|
14
15
|
import { listSkills, findSkill } from './skills.js';
|
|
15
16
|
|
|
17
|
+
function currentBranch(cwd) {
|
|
18
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8' });
|
|
19
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
17
23
|
|
|
18
24
|
// Context compaction (master prompt #32): when the saved conversation grows past the
|
|
@@ -20,7 +26,7 @@ const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
|
20
26
|
const COMPACT_CHARS = 24_000;
|
|
21
27
|
async function compactHistory(cfg, history, hooks = {}) {
|
|
22
28
|
const size = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
23
|
-
if (size < COMPACT_CHARS || history.length < 6) return history;
|
|
29
|
+
if ((!hooks.force && size < COMPACT_CHARS) || history.length < 6) return history;
|
|
24
30
|
const cut = Math.floor(history.length / 2);
|
|
25
31
|
const old = history.slice(0, cut);
|
|
26
32
|
const rest = history.slice(cut);
|
|
@@ -71,14 +77,27 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
71
77
|
const pendingLines = [];
|
|
72
78
|
const steerQueue = []; // notes typed while a task runs, injected mid-task
|
|
73
79
|
|
|
74
|
-
|
|
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'));
|
|
75
85
|
let sessionId = null;
|
|
76
86
|
let lastBoost = null;
|
|
87
|
+
let usage = { input: 0, output: 0 };
|
|
77
88
|
|
|
78
89
|
// ONE readline, ONE line dispatcher for the whole session
|
|
79
90
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
80
91
|
let handleRef = null;
|
|
81
92
|
const ask = makeInput(rl, l => handleRef?.(l));
|
|
93
|
+
// EOF (Ctrl+D or closed pipe): exit cleanly, unless a task is still running
|
|
94
|
+
rl.on('close', () => {
|
|
95
|
+
if (!busy) doExit();
|
|
96
|
+
else {
|
|
97
|
+
const wait = setInterval(() => { if (!busy) { clearInterval(wait); doExit(); } }, 200);
|
|
98
|
+
setTimeout(() => { clearInterval(wait); doExit(); }, 30_000);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
82
101
|
|
|
83
102
|
const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
|
|
84
103
|
|
|
@@ -108,6 +127,19 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
108
127
|
if (TUI) drawStatus();
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
let lastStreamedForHooks = '';
|
|
131
|
+
let streamFlushTimer = null;
|
|
132
|
+
let streamFlushedCount = 0;
|
|
133
|
+
let streamBaseLines = null;
|
|
134
|
+
|
|
135
|
+
function flushStreamed() {
|
|
136
|
+
if (!TUI || !lastStreamedForHooks) return;
|
|
137
|
+
if (streamBaseLines === null) streamBaseLines = chatLines.length;
|
|
138
|
+
chatLines.length = streamBaseLines; // re-render the growing answer in place
|
|
139
|
+
for (const l of wrapLines(lastStreamedForHooks, Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
|
|
140
|
+
redrawChat();
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
function hooksForRun(stopSpinner) {
|
|
112
144
|
let spinner = null;
|
|
113
145
|
const stop = () => { spinner?.stop(); spinner = null; };
|
|
@@ -122,6 +154,13 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
122
154
|
onTool: (name, input2) => { stop(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
|
|
123
155
|
onResult: out => { say(gray(' ' + trunc(out, 110))); },
|
|
124
156
|
onText: t => { stop(); },
|
|
157
|
+
onDelta: chunk => {
|
|
158
|
+
// stream into the chat buffer; the full text lands on flushStreamed()
|
|
159
|
+
lastStreamedForHooks += chunk;
|
|
160
|
+
if (TUI && !streamFlushTimer) {
|
|
161
|
+
streamFlushTimer = setTimeout(() => { streamFlushTimer = null; flushStreamed(); }, 120);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
125
164
|
onTodos: list => {
|
|
126
165
|
stop();
|
|
127
166
|
const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
|
|
@@ -131,8 +170,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
131
170
|
onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
132
171
|
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
133
172
|
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
134
|
-
|
|
135
|
-
|
|
173
|
+
onNote: note => { stop(); say(dim(' ◇ ' + note)); },
|
|
174
|
+
onUsage: u => { usage = u; if (TUI) drawStatus(); },
|
|
175
|
+
drainSteer: () => steerQueue.splice(0),
|
|
136
176
|
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
137
177
|
onApprove: async (cat, name, input2) => {
|
|
138
178
|
stop();
|
|
@@ -160,11 +200,14 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
160
200
|
|
|
161
201
|
async function runTask(input) {
|
|
162
202
|
busy = true;
|
|
203
|
+
lastStreamedForHooks = '';
|
|
204
|
+
streamFlushedCount = 0;
|
|
163
205
|
if (TUI) tuiUserLine(input);
|
|
164
206
|
const hooks = hooksForRun();
|
|
165
207
|
const stopSpinner = hooks.spinnerStop;
|
|
166
208
|
try {
|
|
167
209
|
const res = await runObjective(state, input, process.cwd(), history, hooks);
|
|
210
|
+
if (lastStreamedForHooks && TUI) process.stdout.write('\n');
|
|
168
211
|
history = pushTurn(history, input, res);
|
|
169
212
|
stopSpinner();
|
|
170
213
|
try { history = await compactHistory(state, history, { onNote: n => say(dim(' ◇ ' + n)) }); } catch {}
|
|
@@ -174,7 +217,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
174
217
|
} else {
|
|
175
218
|
const rows = [green(bold('■ Done'))];
|
|
176
219
|
if (res.changed?.length) rows.push(dim(' files: ') + res.changed.join(', '));
|
|
177
|
-
|
|
220
|
+
const answerIsStreamed = lastStreamedForHooks && res.answer === lastStreamedForHooks;
|
|
221
|
+
if (res.answer && !answerIsStreamed) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
|
|
222
|
+
else if (answerIsStreamed) rows.push(dim(' (streamed above)'));
|
|
178
223
|
else if (!res.changed?.length) rows.push(dim(' (no output)'));
|
|
179
224
|
say(box(rows));
|
|
180
225
|
}
|
|
@@ -214,6 +259,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
214
259
|
say(' ' + cyan('/boost') + ' isolated git-worktree run: /boost <objective>');
|
|
215
260
|
say(' ' + cyan('/config') + ' show provider config (key hidden)');
|
|
216
261
|
say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
|
|
262
|
+
say(' ' + cyan('/status') + ' everything about this session at a glance');
|
|
263
|
+
say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
|
|
264
|
+
say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
|
|
217
265
|
say(' ' + cyan('/resume') + ' bring back a saved conversation');
|
|
218
266
|
say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
|
|
219
267
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
@@ -310,6 +358,47 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
310
358
|
busy = false;
|
|
311
359
|
return afterTask();
|
|
312
360
|
}
|
|
361
|
+
if (input === '/status') {
|
|
362
|
+
say(box([
|
|
363
|
+
bold('Status'),
|
|
364
|
+
dim('model') + ' ' + state.model,
|
|
365
|
+
dim('mode') + ' ' + mode + dim(' / reasoning ' + state.reasoning + ' / depth ' + state.explain),
|
|
366
|
+
dim('perms') + ' edit:' + state.permEdit + ' shell:' + state.permShell + ' net:' + state.permNet,
|
|
367
|
+
dim('memory') + ' ' + (state.memory === false ? 'off' : 'on (icm)'),
|
|
368
|
+
dim('humanizer') + ' ' + (state.humanize === false ? 'off' : 'on'),
|
|
369
|
+
dim('mcp') + ' ' + (mcpConfigured() ? 'configured' : 'not configured'),
|
|
370
|
+
dim('tokens') + ' ' + (usage.input || usage.output ? usage.input + ' in / ' + usage.output + ' out' : '-'),
|
|
371
|
+
dim('session') + ' ' + (sessionId ?? 'not saved yet'),
|
|
372
|
+
dim('cwd') + ' ' + process.cwd()
|
|
373
|
+
]));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (input === '/compact') {
|
|
377
|
+
busy = true;
|
|
378
|
+
say(dim(' Compacting conversation...'));
|
|
379
|
+
const before = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
380
|
+
history = await compactHistory(state, history, { force: true, onNote: n => say(dim(' ◇ ' + n)) });
|
|
381
|
+
const after = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
382
|
+
say(after < before ? green(` Compact: ${(before / 1024).toFixed(1)}k -> ${(after / 1024).toFixed(1)}k chars.`) : dim(' Conversation too small to compact further.'));
|
|
383
|
+
busy = false;
|
|
384
|
+
return afterTask();
|
|
385
|
+
}
|
|
386
|
+
if (input === '/depth' || input.startsWith('/depth ')) {
|
|
387
|
+
const arg = input.split(/\s+/)[1];
|
|
388
|
+
if (['short', 'normal', 'deep'].includes(arg)) {
|
|
389
|
+
Object.assign(state, normalize({ ...state, explain: arg }));
|
|
390
|
+
saveConfig(state);
|
|
391
|
+
say(green('Explanation depth: ' + arg));
|
|
392
|
+
} else {
|
|
393
|
+
say(box([
|
|
394
|
+
bold('Explanation depth'),
|
|
395
|
+
dim('/depth short') + ' results only',
|
|
396
|
+
dim('/depth normal') + ' what changed and why (default)',
|
|
397
|
+
dim('/depth deep') + ' reasoning, trade-offs, ruled-out paths'
|
|
398
|
+
]));
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
313
402
|
if (input === '/humanizer' || input.startsWith('/humanizer ')) {
|
|
314
403
|
const arg = input.split(/\s+/)[1];
|
|
315
404
|
if (arg === 'on' || arg === 'off') {
|
|
@@ -452,7 +541,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
452
541
|
}
|
|
453
542
|
|
|
454
543
|
const plainPrompt = () => {
|
|
455
|
-
|
|
544
|
+
const branch = currentBranch(process.cwd());
|
|
545
|
+
const tok = usage.input || usage.output ? ` ${usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output} tok` : '';
|
|
546
|
+
rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))}${branch ? ' ' + gray('(' + branch + ')') : ''}${tok} ${green('❯')} `);
|
|
456
547
|
rl.prompt();
|
|
457
548
|
};
|
|
458
549
|
|
|
@@ -462,7 +553,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
462
553
|
green(bold('Welcome to ineed!')),
|
|
463
554
|
' You are all set: any OpenAI-compatible provider, any folder.',
|
|
464
555
|
' Type what you want in normal language, for example:',
|
|
465
|
-
dim(' "
|
|
556
|
+
dim(' "create an animated landing page in this folder"'),
|
|
466
557
|
dim(' "fix the failing tests and tell me what was wrong"'),
|
|
467
558
|
dim(' "explain this repository like I am a beginner"'),
|
|
468
559
|
' ' + dim('Helpers: /help commands · /perm auto or safe · /model · /memory on|off')
|
|
@@ -509,9 +600,16 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
509
600
|
|
|
510
601
|
function drawStatus() {
|
|
511
602
|
const cols = process.stdout.columns || 80;
|
|
512
|
-
const
|
|
603
|
+
const branch = currentBranch(process.cwd());
|
|
604
|
+
const ctxK = (history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0) / 1024).toFixed(1) + 'k';
|
|
605
|
+
const tok = usage.input || usage.output ? ` ${dim(usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output + '')} tok` : '';
|
|
606
|
+
const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}${branch ? ' ' + cyan('(' + branch + ')') : ''}`;
|
|
513
607
|
const mid = ` ${dim(state.model)}`;
|
|
514
|
-
const right =
|
|
608
|
+
const right =
|
|
609
|
+
` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)}` +
|
|
610
|
+
` ${dim('/')} ${dim(ctxK + ' ctx')}${tok}` +
|
|
611
|
+
` ${dim('/')} ${dim('mem:' + (state.memory === false ? 'off' : 'on'))}` +
|
|
612
|
+
` ${dim('/')} ${mode === 'plan' ? dim('perm') : state.permEdit === 'ask' ? green('perm:ask') : dim('perm:auto')} `;
|
|
515
613
|
screen.at(statusRow, 1);
|
|
516
614
|
screen.clearLine();
|
|
517
615
|
process.stdout.write(dim('─'.repeat(Math.max(0, cols - plain(left).length - plain(mid).length - plain(right).length))) + left + mid + right);
|
|
@@ -539,10 +637,10 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
539
637
|
redrawChat();
|
|
540
638
|
}
|
|
541
639
|
|
|
542
|
-
|
|
640
|
+
const tuiUserLine = input => {
|
|
543
641
|
for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
|
|
544
642
|
redrawChat();
|
|
545
|
-
}
|
|
643
|
+
};
|
|
546
644
|
|
|
547
645
|
const layout = () => {
|
|
548
646
|
const rows = process.stdout.rows || 24;
|
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.5.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);
|