ineedcodes 1.4.0 → 1.5.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 -7
- package/src/config.js +8 -1
- package/src/humanize.js +1 -1
- package/src/processes.js +104 -0
- package/src/provider.js +62 -2
- package/src/session.js +78 -8
- 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
|
@@ -27,8 +27,15 @@ export function normalize(c) {
|
|
|
27
27
|
memory: c.memory !== false,
|
|
28
28
|
mcp: c.mcp !== false,
|
|
29
29
|
humanize: c.humanize !== false,
|
|
30
|
+
stream: c.stream === true,
|
|
31
|
+
searchUrl: c.searchUrl ? String(c.searchUrl) : '',
|
|
32
|
+
explain: ['short', 'deep'].includes(c.explain) ? c.explain : 'normal',
|
|
30
33
|
permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
|
|
31
|
-
permShell: c.permShell === 'allow' ? 'allow' : 'ask'
|
|
34
|
+
permShell: c.permShell === 'allow' ? 'allow' : 'ask',
|
|
35
|
+
permNet: c.permNet === 'ask' ? 'ask' : 'allow',
|
|
36
|
+
models: (c.models && typeof c.models === 'object' && !Array.isArray(c.models))
|
|
37
|
+
? Object.fromEntries(Object.entries(c.models).map(([k, v]) => [k, String(v)]))
|
|
38
|
+
: {}
|
|
32
39
|
};
|
|
33
40
|
}
|
|
34
41
|
|
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);
|
|
@@ -74,6 +80,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
74
80
|
const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
75
81
|
let sessionId = null;
|
|
76
82
|
let lastBoost = null;
|
|
83
|
+
let usage = { input: 0, output: 0 };
|
|
77
84
|
|
|
78
85
|
// ONE readline, ONE line dispatcher for the whole session
|
|
79
86
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -108,6 +115,8 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
108
115
|
if (TUI) drawStatus();
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
let lastStreamedForHooks = '';
|
|
119
|
+
|
|
111
120
|
function hooksForRun(stopSpinner) {
|
|
112
121
|
let spinner = null;
|
|
113
122
|
const stop = () => { spinner?.stop(); spinner = null; };
|
|
@@ -122,6 +131,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
122
131
|
onTool: (name, input2) => { stop(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
|
|
123
132
|
onResult: out => { say(gray(' ' + trunc(out, 110))); },
|
|
124
133
|
onText: t => { stop(); },
|
|
134
|
+
onDelta: chunk => {
|
|
135
|
+
if (TUI) { process.stdout.write(chunk); lastStreamedForHooks += chunk; }
|
|
136
|
+
},
|
|
125
137
|
onTodos: list => {
|
|
126
138
|
stop();
|
|
127
139
|
const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
|
|
@@ -131,8 +143,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
131
143
|
onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
132
144
|
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
133
145
|
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
onNote: note => { stop(); say(dim(' ◇ ' + note)); },
|
|
147
|
+
onUsage: u => { usage = u; if (TUI) drawStatus(); },
|
|
148
|
+
drainSteer: () => steerQueue.splice(0),
|
|
136
149
|
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
137
150
|
onApprove: async (cat, name, input2) => {
|
|
138
151
|
stop();
|
|
@@ -160,11 +173,13 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
160
173
|
|
|
161
174
|
async function runTask(input) {
|
|
162
175
|
busy = true;
|
|
176
|
+
lastStreamedForHooks = '';
|
|
163
177
|
if (TUI) tuiUserLine(input);
|
|
164
178
|
const hooks = hooksForRun();
|
|
165
179
|
const stopSpinner = hooks.spinnerStop;
|
|
166
180
|
try {
|
|
167
181
|
const res = await runObjective(state, input, process.cwd(), history, hooks);
|
|
182
|
+
if (lastStreamedForHooks && TUI) process.stdout.write('\n');
|
|
168
183
|
history = pushTurn(history, input, res);
|
|
169
184
|
stopSpinner();
|
|
170
185
|
try { history = await compactHistory(state, history, { onNote: n => say(dim(' ◇ ' + n)) }); } catch {}
|
|
@@ -174,7 +189,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
174
189
|
} else {
|
|
175
190
|
const rows = [green(bold('■ Done'))];
|
|
176
191
|
if (res.changed?.length) rows.push(dim(' files: ') + res.changed.join(', '));
|
|
177
|
-
|
|
192
|
+
const answerIsStreamed = lastStreamedForHooks && res.answer === lastStreamedForHooks;
|
|
193
|
+
if (res.answer && !answerIsStreamed) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
|
|
194
|
+
else if (answerIsStreamed) rows.push(dim(' (streamed above)'));
|
|
178
195
|
else if (!res.changed?.length) rows.push(dim(' (no output)'));
|
|
179
196
|
say(box(rows));
|
|
180
197
|
}
|
|
@@ -214,6 +231,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
214
231
|
say(' ' + cyan('/boost') + ' isolated git-worktree run: /boost <objective>');
|
|
215
232
|
say(' ' + cyan('/config') + ' show provider config (key hidden)');
|
|
216
233
|
say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
|
|
234
|
+
say(' ' + cyan('/status') + ' everything about this session at a glance');
|
|
235
|
+
say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
|
|
236
|
+
say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
|
|
217
237
|
say(' ' + cyan('/resume') + ' bring back a saved conversation');
|
|
218
238
|
say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
|
|
219
239
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
@@ -310,6 +330,47 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
310
330
|
busy = false;
|
|
311
331
|
return afterTask();
|
|
312
332
|
}
|
|
333
|
+
if (input === '/status') {
|
|
334
|
+
say(box([
|
|
335
|
+
bold('Status'),
|
|
336
|
+
dim('model') + ' ' + state.model,
|
|
337
|
+
dim('mode') + ' ' + mode + dim(' / reasoning ' + state.reasoning + ' / depth ' + state.explain),
|
|
338
|
+
dim('perms') + ' edit:' + state.permEdit + ' shell:' + state.permShell + ' net:' + state.permNet,
|
|
339
|
+
dim('memory') + ' ' + (state.memory === false ? 'off' : 'on (icm)'),
|
|
340
|
+
dim('humanizer') + ' ' + (state.humanize === false ? 'off' : 'on'),
|
|
341
|
+
dim('mcp') + ' ' + (mcpConfigured() ? 'configured' : 'not configured'),
|
|
342
|
+
dim('tokens') + ' ' + (usage.input || usage.output ? usage.input + ' in / ' + usage.output + ' out' : '-'),
|
|
343
|
+
dim('session') + ' ' + (sessionId ?? 'not saved yet'),
|
|
344
|
+
dim('cwd') + ' ' + process.cwd()
|
|
345
|
+
]));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (input === '/compact') {
|
|
349
|
+
busy = true;
|
|
350
|
+
say(dim(' Compacting conversation...'));
|
|
351
|
+
const before = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
352
|
+
history = await compactHistory(state, history, { force: true, onNote: n => say(dim(' ◇ ' + n)) });
|
|
353
|
+
const after = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
354
|
+
say(after < before ? green(` Compact: ${(before / 1024).toFixed(1)}k -> ${(after / 1024).toFixed(1)}k chars.`) : dim(' Conversation too small to compact further.'));
|
|
355
|
+
busy = false;
|
|
356
|
+
return afterTask();
|
|
357
|
+
}
|
|
358
|
+
if (input === '/depth' || input.startsWith('/depth ')) {
|
|
359
|
+
const arg = input.split(/\s+/)[1];
|
|
360
|
+
if (['short', 'normal', 'deep'].includes(arg)) {
|
|
361
|
+
Object.assign(state, normalize({ ...state, explain: arg }));
|
|
362
|
+
saveConfig(state);
|
|
363
|
+
say(green('Explanation depth: ' + arg));
|
|
364
|
+
} else {
|
|
365
|
+
say(box([
|
|
366
|
+
bold('Explanation depth'),
|
|
367
|
+
dim('/depth short') + ' results only',
|
|
368
|
+
dim('/depth normal') + ' what changed and why (default)',
|
|
369
|
+
dim('/depth deep') + ' reasoning, trade-offs, ruled-out paths'
|
|
370
|
+
]));
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
313
374
|
if (input === '/humanizer' || input.startsWith('/humanizer ')) {
|
|
314
375
|
const arg = input.split(/\s+/)[1];
|
|
315
376
|
if (arg === 'on' || arg === 'off') {
|
|
@@ -452,7 +513,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
452
513
|
}
|
|
453
514
|
|
|
454
515
|
const plainPrompt = () => {
|
|
455
|
-
|
|
516
|
+
const branch = currentBranch(process.cwd());
|
|
517
|
+
const tok = usage.input || usage.output ? ` ${usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output} tok` : '';
|
|
518
|
+
rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))}${branch ? ' ' + gray('(' + branch + ')') : ''}${tok} ${green('❯')} `);
|
|
456
519
|
rl.prompt();
|
|
457
520
|
};
|
|
458
521
|
|
|
@@ -462,7 +525,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
462
525
|
green(bold('Welcome to ineed!')),
|
|
463
526
|
' You are all set: any OpenAI-compatible provider, any folder.',
|
|
464
527
|
' Type what you want in normal language, for example:',
|
|
465
|
-
dim(' "
|
|
528
|
+
dim(' "create an animated landing page in this folder"'),
|
|
466
529
|
dim(' "fix the failing tests and tell me what was wrong"'),
|
|
467
530
|
dim(' "explain this repository like I am a beginner"'),
|
|
468
531
|
' ' + dim('Helpers: /help commands · /perm auto or safe · /model · /memory on|off')
|
|
@@ -509,9 +572,16 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
|
509
572
|
|
|
510
573
|
function drawStatus() {
|
|
511
574
|
const cols = process.stdout.columns || 80;
|
|
512
|
-
const
|
|
575
|
+
const branch = currentBranch(process.cwd());
|
|
576
|
+
const ctxK = (history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0) / 1024).toFixed(1) + 'k';
|
|
577
|
+
const tok = usage.input || usage.output ? ` ${dim(usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output + '')} tok` : '';
|
|
578
|
+
const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}${branch ? ' ' + cyan('(' + branch + ')') : ''}`;
|
|
513
579
|
const mid = ` ${dim(state.model)}`;
|
|
514
|
-
const right =
|
|
580
|
+
const right =
|
|
581
|
+
` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)}` +
|
|
582
|
+
` ${dim('/')} ${dim(ctxK + ' ctx')}${tok}` +
|
|
583
|
+
` ${dim('/')} ${dim('mem:' + (state.memory === false ? 'off' : 'on'))}` +
|
|
584
|
+
` ${dim('/')} ${mode === 'plan' ? dim('perm') : state.permEdit === 'ask' ? green('perm:ask') : dim('perm:auto')} `;
|
|
515
585
|
screen.at(statusRow, 1);
|
|
516
586
|
screen.clearLine();
|
|
517
587
|
process.stdout.write(dim('─'.repeat(Math.max(0, cols - plain(left).length - plain(mid).length - plain(right).length))) + left + mid + right);
|
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.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);
|