ineedcodes 1.1.0 → 1.3.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 +22 -0
- package/src/boost.js +50 -0
- package/src/cli.js +8 -1
- package/src/config.js +2 -0
- package/src/humanize.js +139 -0
- package/src/session.js +189 -39
- package/src/sessions.js +31 -0
- package/src/ui.js +1 -1
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -4,6 +4,7 @@ import { chat } from './provider.js';
|
|
|
4
4
|
import { TOOLS, runTool, shellRun, isDestructive } from './tools.js';
|
|
5
5
|
import { trunc, gray, cyan, dim } from './ui.js';
|
|
6
6
|
import { getMemoryProvider } from './memory.js';
|
|
7
|
+
import * as path from 'node:path';
|
|
7
8
|
|
|
8
9
|
export const MAX_STEPS = 30;
|
|
9
10
|
export const MAX_HISTORY_CHARS = 30_000;
|
|
@@ -17,6 +18,8 @@ Rules:
|
|
|
17
18
|
- Destructive commands are always blocked. Ask the user to run those themselves.
|
|
18
19
|
- 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.
|
|
19
20
|
- 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).
|
|
21
|
+
- 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.
|
|
22
|
+
- When building web pages or UI: commit to one coherent style; restrained palette (1 primary, 1 accent, neutral background); a real Google Fonts pairing; no emoji as icons (use inline SVG); cursor-pointer on clickables; visible focus states; text contrast at least 4.5:1; responsive at 375, 768, 1024, 1440px; respect prefers-reduced-motion; avoid generic AI purple/pink gradients and default template blue.
|
|
20
23
|
- When the objective is done, verify it (run the tests, read the file back, whatever proves it), then reply with the final result in this shape:
|
|
21
24
|
What changed, what you ran, the evidence you saw.`;
|
|
22
25
|
|
|
@@ -155,6 +158,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
155
158
|
try {
|
|
156
159
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
157
160
|
if (ctrl.signal.aborted) break;
|
|
161
|
+
// steering: notes typed while the task runs join the conversation here
|
|
162
|
+
if (hooks.drainSteer) {
|
|
163
|
+
const steer = hooks.drainSteer();
|
|
164
|
+
if (steer.length) {
|
|
165
|
+
for (const s of steer) {
|
|
166
|
+
messages.push({ role: 'user', content: `[steer from the user, newer than the objective] ${s}` });
|
|
167
|
+
}
|
|
168
|
+
hooks.onSteer?.(steer);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
158
171
|
let msg;
|
|
159
172
|
try {
|
|
160
173
|
hooks.onThinkingStart?.();
|
|
@@ -259,6 +272,15 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
|
|
|
259
272
|
if (allowedNow && !plan && isEdit
|
|
260
273
|
&& !/^(Refused|Error|Denied)/.test(String(result.output))) {
|
|
261
274
|
changed.add(String(input.path ?? ''));
|
|
275
|
+
// humanizer pass: prose files only (html/md/txt), code never touched (rule 21)
|
|
276
|
+
if (name === 'write_file' && cfg.humanize !== false) {
|
|
277
|
+
const abs = path.resolve(cwd, String(input.path ?? ''));
|
|
278
|
+
try {
|
|
279
|
+
const { humanizeFile } = await import('./humanize.js');
|
|
280
|
+
const hr = await humanizeFile(cfg, abs, ctrl.signal, { skipModel: false });
|
|
281
|
+
if (hr.changed) hooks.onNote?.(`humanized copy in ${String(input.path)}`);
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
262
284
|
}
|
|
263
285
|
}
|
|
264
286
|
}
|
package/src/boost.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// boost.js: isolated execution in a git worktree (master prompt #19-20).
|
|
2
|
+
// Work happens away from the user's tree; reconcile only after verification.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
|
|
8
|
+
function git(args, cwd) {
|
|
9
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
10
|
+
return { ok: r.status === 0, out: ((r.stdout ?? '') + (r.stderr ?? '')).trim() };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function boostAvailable(cwd) {
|
|
14
|
+
const inside = git(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
15
|
+
return inside.ok && inside.out === 'true';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentBranch(cwd) {
|
|
19
|
+
return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd).out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function startBoost(cwd) {
|
|
23
|
+
const n = Date.now() % 1_000_000;
|
|
24
|
+
const dir = path.join(os.tmpdir(), 'ineed-boost-' + n);
|
|
25
|
+
const branch = 'ineed-boost-' + n;
|
|
26
|
+
const r = git(['worktree', 'add', '-b', branch, dir], cwd);
|
|
27
|
+
if (!r.ok) return { ok: false, error: r.out };
|
|
28
|
+
return { ok: true, dir, branch };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function commitBoost(dir, message) {
|
|
32
|
+
git(['add', '-A'], dir);
|
|
33
|
+
const r = git(['commit', '-m', message, '--allow-empty'], dir);
|
|
34
|
+
return r.ok;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function boostDiff(dir) {
|
|
38
|
+
const stat = git(['diff', 'HEAD~1', '--stat'], dir);
|
|
39
|
+
const files = git(['diff', 'HEAD~1', '--name-only'], dir);
|
|
40
|
+
return { stat: stat.out, files: files.out.split('\n').filter(Boolean) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mergeBoost(cwd, branch) {
|
|
44
|
+
return git(['merge', '--no-edit', branch], cwd);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function cleanupBoost(cwd, dir, branch) {
|
|
48
|
+
git(['worktree', 'remove', '--force', dir], cwd);
|
|
49
|
+
git(['branch', '-D', branch], cwd);
|
|
50
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -95,5 +95,12 @@ if (args.length > 0 && args[0] !== '--reset') {
|
|
|
95
95
|
}
|
|
96
96
|
const { startSession } = await import('./session.js');
|
|
97
97
|
const wasFresh = fresh || process.env.INEED_FRESH === '1';
|
|
98
|
-
|
|
98
|
+
let resumeHistory = null;
|
|
99
|
+
if (args[0] === '--resume' || args[0] === '-r') {
|
|
100
|
+
const { listSessions } = await import('./sessions.js');
|
|
101
|
+
const latest = listSessions()[0];
|
|
102
|
+
if (latest?.history?.length) { resumeHistory = latest.history; console.log(dim(`Resuming ${Math.floor(latest.history.length / 2)} turns.`)); }
|
|
103
|
+
else console.log(dim('No saved session found. Starting fresh.'));
|
|
104
|
+
}
|
|
105
|
+
await startSession(cfg, { fresh: wasFresh, resume: resumeHistory });
|
|
99
106
|
}
|
package/src/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
export const CONFIG_DIR = process.env.INEED_CONFIG_DIR || path.join(os.homedir(), '.ineedcodes');
|
|
8
8
|
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
export { CONFIG_DIR as configDirPath };
|
|
9
10
|
|
|
10
11
|
export function loadConfig() {
|
|
11
12
|
try {
|
|
@@ -25,6 +26,7 @@ export function normalize(c) {
|
|
|
25
26
|
mode: c.mode === 'plan' ? 'plan' : 'build',
|
|
26
27
|
memory: c.memory !== false,
|
|
27
28
|
mcp: c.mcp !== false,
|
|
29
|
+
humanize: c.humanize !== false,
|
|
28
30
|
permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
|
|
29
31
|
permShell: c.permShell === 'allow' ? 'allow' : 'ask'
|
|
30
32
|
};
|
package/src/humanize.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// humanize.js: post-processing pass for prose content. Scope is strict:
|
|
2
|
+
// it rewrites marketing/marketing-adjacent copy inside HTML pages and plain prose files
|
|
3
|
+
// (landing pages, posts, README-style text). It never touches code, attributes, URLs,
|
|
4
|
+
// JSON, YAML, or technical values. Meaning and facts are preserved.
|
|
5
|
+
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import { chat } from './provider.js';
|
|
8
|
+
|
|
9
|
+
const PROSE_EXT = new Set(['.html', '.htm', '.md', '.markdown', '.txt']);
|
|
10
|
+
const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.css', '.scss', '.py', '.rb', '.go', '.rs', '.java', '.php', '.sh', '.sql', '.toml', '.xml', '.svg']);
|
|
11
|
+
|
|
12
|
+
export function isHumanizableFile(absPath) {
|
|
13
|
+
const ext = absPath.slice(absPath.lastIndexOf('.')).toLowerCase();
|
|
14
|
+
if (CODE_EXT.has(ext)) return false;
|
|
15
|
+
return PROSE_EXT.has(ext);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Rule-based cleanups that are safe everywhere inside prose text nodes.
|
|
19
|
+
const RULES = [
|
|
20
|
+
[/ ?\u2014 ?/g, ', '],
|
|
21
|
+
[/ ?\u2013 ?/g, ', '],
|
|
22
|
+
[/\bgame-?changer\b/gi, 'a real improvement'],
|
|
23
|
+
[/\bcutting-?edge\b/gi, 'new'],
|
|
24
|
+
[/\brevolutionar(y|ily)\b/gi, 'genuinely new'],
|
|
25
|
+
[/\bseamless(ly)?\b/gi, 'smooth'],
|
|
26
|
+
[/\bunlock(ing)? the (full )?potential\b/gi, 'get more out of it'],
|
|
27
|
+
[/\btake .{0,20} to the next level\b/gi, 'go further'],
|
|
28
|
+
[/\bdive (deep )?into\b/gi, 'look at'],
|
|
29
|
+
[/\blet.s (get )?started\b/gi, 'here is how'],
|
|
30
|
+
[/\bworld-?class\b/gi, 'top'],
|
|
31
|
+
[/\bblazing(ly)? (fast|quick)\b/gi, 'fast'],
|
|
32
|
+
[/\bwhisper-?quiet\b/gi, 'quiet'],
|
|
33
|
+
[/\bwe understand that\b/gi, ''],
|
|
34
|
+
[/\bin today.s (fast-?paced )?(digital|modern) world\b/gi, ''],
|
|
35
|
+
[/\blook no further\b/gi, ''],
|
|
36
|
+
[/\bdreams? (come|become) (true|reality)\b/gi, 'happens'],
|
|
37
|
+
[/\bempower(s|ing|ed)?\b/gi, 'help'],
|
|
38
|
+
[/\bcrucial|pivotal\b/gi, 'important'],
|
|
39
|
+
[/\bmost importantly,?/gi, ''],
|
|
40
|
+
[/!{2,}/g, '!']
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function applyRules(text) {
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const [re, to] of RULES) out = out.replace(re, to);
|
|
46
|
+
out = out.replace(/[ \t]{2,}/g, ' ');
|
|
47
|
+
out = out.replace(/ +([.,!?])/g, '$1');
|
|
48
|
+
out = out.replace(/ \n/g, '\n');
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract copy blocks from HTML: between > and < (text nodes). Tag names, attributes,
|
|
53
|
+
// scripts, styles, and comments are left byte-identical.
|
|
54
|
+
function humanizeHtmlTextNodes(html, fn) {
|
|
55
|
+
return html.replace(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<!--[\s\S]*?-->)/gi, m => '\x00BLOCK' + Buffer.from(m).toString('base64') + '\x00')
|
|
56
|
+
.replace(/>([^<>]+)</g, (m, text) => {
|
|
57
|
+
if (!/[a-zA-Z]/.test(text)) return m;
|
|
58
|
+
const next = fn(text);
|
|
59
|
+
return next === text ? m : '>' + next + '<';
|
|
60
|
+
})
|
|
61
|
+
.replace(/\x00BLOCK([A-Za-z0-9+/=]+)\x00/g, (_, b64) => Buffer.from(b64, 'base64').toString());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function humanizeWithModel(cfg, text, kind, signal) {
|
|
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 language (id vs en) exactly. Reply with ONLY the rewritten text, same line structure. If nothing needs changing, reply with the text unchanged.`
|
|
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
|
+
+ `\n---\n${text.slice(0, 8000)}`;
|
|
69
|
+
const msg = await chat({ ...cfg, reasoning: 'low' }, [{ role: 'user', content: prompt }], undefined, signal);
|
|
70
|
+
const out = String(msg.content ?? '').trim();
|
|
71
|
+
return out || text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function humanizeFile(cfg, absPath, signal, hooks = {}) {
|
|
75
|
+
if (!isHumanizableFile(absPath)) return { changed: false, reason: 'not a prose file' };
|
|
76
|
+
let src;
|
|
77
|
+
try { src = fs.readFileSync(absPath, 'utf8'); } catch (err) { return { changed: false, reason: err.message }; }
|
|
78
|
+
if (src.length < 40) return { changed: false, reason: 'too short' };
|
|
79
|
+
|
|
80
|
+
const ext = absPath.slice(absPath.lastIndexOf('.')).toLowerCase();
|
|
81
|
+
const isHtml = ext === '.html' || ext === '.htm';
|
|
82
|
+
const kind = isHtml ? 'html' : 'text';
|
|
83
|
+
|
|
84
|
+
// step 1: deterministic rules
|
|
85
|
+
let next = isHtml
|
|
86
|
+
? humanizeHtmlTextNodes(src, applyRules)
|
|
87
|
+
: applyRules(src);
|
|
88
|
+
|
|
89
|
+
// step 2: model pass (skipped in plan mode or when no hooks provide a provider)
|
|
90
|
+
const before = next;
|
|
91
|
+
if (cfg?.apiKey && !hooks.skipModel) {
|
|
92
|
+
try {
|
|
93
|
+
if (isHtml) {
|
|
94
|
+
next = humanizeHtmlTextNodes(next, t => t); // normalize markers once
|
|
95
|
+
const plain = next; // model sees text-node friendly form already
|
|
96
|
+
const rewritten = await humanizeWithModel(cfg, stripTagsForPrompt(plain), kind, signal);
|
|
97
|
+
// sanity: refuse junk replies (too short or structure-destroying)
|
|
98
|
+
const okLen = rewritten.length >= Math.max(20, plain.length * 0.4);
|
|
99
|
+
if (okLen) next = applyModelToTextNodes(next, rewritten);
|
|
100
|
+
else hooks.onNote?.('humanizer model pass skipped: reply looked wrong');
|
|
101
|
+
} else {
|
|
102
|
+
const rewritten = await humanizeWithModel(cfg, next, kind, signal);
|
|
103
|
+
if (rewritten.length >= Math.max(20, next.length * 0.4)) next = rewritten;
|
|
104
|
+
else hooks.onNote?.('humanizer model pass skipped: reply looked wrong');
|
|
105
|
+
}
|
|
106
|
+
// final sanitization: the model pass may reintroduce cliches or em dashes
|
|
107
|
+
next = isHtml ? humanizeHtmlTextNodes(next, applyRules) : applyRules(next);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
hooks.onNote?.(`humanizer model pass skipped: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (next !== src && next.trim()) {
|
|
114
|
+
fs.writeFileSync(absPath, next);
|
|
115
|
+
return { changed: true, before, after: next };
|
|
116
|
+
}
|
|
117
|
+
return { changed: false, reason: 'already clean' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stripTagsForPrompt(html) {
|
|
121
|
+
return html.replace(/<[^>]+>/g, '\n').replace(/\n{2,}/g, '\n').trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Map model-rewritten plain text back onto HTML text nodes: greedy line pairing.
|
|
125
|
+
// Structure is preserved; if pairing fails, the node text is left unchanged.
|
|
126
|
+
function applyModelToTextNodes(html, modelText) {
|
|
127
|
+
const lines = modelText.split('\n').map(l => l.trim()).filter(Boolean);
|
|
128
|
+
let li = 0;
|
|
129
|
+
return html.replace(/>([^<>]+)</g, (m, text) => {
|
|
130
|
+
const trimmed = text.trim();
|
|
131
|
+
if (li < lines.length && /[a-zA-Z]/.test(trimmed) && trimmed.length > 2) {
|
|
132
|
+
const candidate = lines[li++];
|
|
133
|
+
if (candidate && candidate !== trimmed && candidate.length > 2) {
|
|
134
|
+
return '>' + text.replace(trimmed, candidate) + '<';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return m;
|
|
138
|
+
});
|
|
139
|
+
}
|
package/src/session.js
CHANGED
|
@@ -8,10 +8,36 @@ import { fetchModels } from './provider.js';
|
|
|
8
8
|
import { makeInput, bold, dim, red, green, yellow, cyan, gray, trunc, BANNER, logo, box, startSpinner, VERSION, RULE, userBubble, screen } from './ui.js';
|
|
9
9
|
import { wizard } from './wizard.js';
|
|
10
10
|
import { getMemoryProvider, ICMAdapter } from './memory.js';
|
|
11
|
+
import { saveSession, listSessions, loadSession } from './sessions.js';
|
|
12
|
+
import * as boost from './boost.js';
|
|
11
13
|
import { mcpConfigured } from './mcp.js';
|
|
12
14
|
|
|
13
15
|
const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
14
16
|
|
|
17
|
+
// Context compaction (master prompt #32): when the saved conversation grows past the
|
|
18
|
+
// cap, summarize the oldest half into a factual checkpoint and drop the raw turns.
|
|
19
|
+
const COMPACT_CHARS = 24_000;
|
|
20
|
+
async function compactHistory(cfg, history, hooks = {}) {
|
|
21
|
+
const size = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
|
|
22
|
+
if (size < COMPACT_CHARS || history.length < 6) return history;
|
|
23
|
+
const cut = Math.floor(history.length / 2);
|
|
24
|
+
const old = history.slice(0, cut);
|
|
25
|
+
const rest = history.slice(cut);
|
|
26
|
+
const digest = old.map(m => `${m.role}: ${String(m.content ?? '').replaceAll('\n', ' ').slice(0, 160)}`).join('\n');
|
|
27
|
+
try {
|
|
28
|
+
const { chat } = await import('./provider.js');
|
|
29
|
+
const msg = await chat({ ...cfg, reasoning: 'low' }, [
|
|
30
|
+
{ role: 'user', content: `Summarize this conversation into a factual checkpoint: goals, decisions, files touched, unresolved work. Max 12 lines. No prose flourish.\n---\n${digest.slice(0, 10_000)}` }
|
|
31
|
+
]);
|
|
32
|
+
const summary = String(msg.content ?? '').trim();
|
|
33
|
+
if (summary.length > 20) {
|
|
34
|
+
hooks.onNote?.(`compacted ${cut} turns into a checkpoint (${(size / 1000).toFixed(0)}k -> ${((summary.length + rest.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0)) / 1000).toFixed(0)}k chars)`);
|
|
35
|
+
return [{ role: 'user', content: '[conversation checkpoint] ' + summary }, { role: 'assistant', content: 'Checkpoint noted. Continuing from there.' }, ...rest];
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
return history.slice(-10); // provider unavailable: keep the newest turns
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
function wrapLines(text, width) {
|
|
16
42
|
const out = [];
|
|
17
43
|
for (const raw of String(text).split('\n')) {
|
|
@@ -32,9 +58,9 @@ function wrapLines(text, width) {
|
|
|
32
58
|
return out;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
|
-
export async function startSession(cfg, { fresh = false } = {}) {
|
|
61
|
+
export async function startSession(cfg, { fresh = false, resume = null } = {}) {
|
|
36
62
|
const state = normalize(cfg);
|
|
37
|
-
let history = [];
|
|
63
|
+
let history = resume?.length ? [...resume] : [];
|
|
38
64
|
let busy = false;
|
|
39
65
|
let activeRun = null;
|
|
40
66
|
let mode = state.mode;
|
|
@@ -42,8 +68,11 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
42
68
|
let closed = false;
|
|
43
69
|
const approved = new Set(); // session-wide "always allow" grants
|
|
44
70
|
const pendingLines = [];
|
|
71
|
+
const steerQueue = []; // notes typed while a task runs, injected mid-task
|
|
45
72
|
|
|
46
73
|
const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
74
|
+
let sessionId = null;
|
|
75
|
+
let lastBoost = null;
|
|
47
76
|
|
|
48
77
|
// ONE readline, ONE line dispatcher for the whole session
|
|
49
78
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -78,48 +107,67 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
78
107
|
if (TUI) drawStatus();
|
|
79
108
|
}
|
|
80
109
|
|
|
110
|
+
function hooksForRun(stopSpinner) {
|
|
111
|
+
let spinner = null;
|
|
112
|
+
const stop = () => { spinner?.stop(); spinner = null; };
|
|
113
|
+
return {
|
|
114
|
+
spinnerStop: stop,
|
|
115
|
+
onMemoryStart: () => { stop(); spinner = startSpinner('recalling memory'); },
|
|
116
|
+
onMemoryEnd: () => stop(),
|
|
117
|
+
onThinkingStart: () => { stop(); spinner = startSpinner('thinking'); },
|
|
118
|
+
onThinkingEnd: () => stop(),
|
|
119
|
+
onWorkStart: label => { stop(); spinner = startSpinner(label || 'working'); },
|
|
120
|
+
onWorkEnd: () => stop(),
|
|
121
|
+
onTool: (name, input2) => { stop(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
|
|
122
|
+
onResult: out => { say(gray(' ' + trunc(out, 110))); },
|
|
123
|
+
onText: t => { stop(); },
|
|
124
|
+
onTodos: list => {
|
|
125
|
+
stop();
|
|
126
|
+
const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
|
|
127
|
+
say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
|
|
128
|
+
},
|
|
129
|
+
onAgentStart: (id, input) => { stop(); say(cyan(' ◆ spawn ' + id) + gray(` role=${input.role ?? '?'} task=${trunc(String(input.objective ?? ''), 70)}`)); },
|
|
130
|
+
onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
131
|
+
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
132
|
+
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
133
|
+
onNote: note => { stop(); say(dim(' ◇ ' + note)); },
|
|
134
|
+
drainSteer: () => steerQueue.splice(0),
|
|
135
|
+
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
136
|
+
onApprove: async (cat, name, input2) => {
|
|
137
|
+
stop();
|
|
138
|
+
say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
|
|
139
|
+
const a = await ask(' [y] once · [a] this session · [s] always (save) · [n] no: ');
|
|
140
|
+
const c = a.trim().toLowerCase();
|
|
141
|
+
if (c === 's' || c === 'save') {
|
|
142
|
+
approved.add(cat);
|
|
143
|
+
if (cat === 'edit') Object.assign(state, normalize({ ...state, permEdit: 'allow' }));
|
|
144
|
+
if (cat === 'shell') Object.assign(state, normalize({ ...state, permShell: 'allow' }));
|
|
145
|
+
saveConfig(state);
|
|
146
|
+
say(dim(' always allowed, saved to config. /perm safe to undo.'));
|
|
147
|
+
return 'always';
|
|
148
|
+
}
|
|
149
|
+
if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
|
|
150
|
+
if (c === 'y' || c === 'yes') return true;
|
|
151
|
+
say(dim(' denied.'));
|
|
152
|
+
return false;
|
|
153
|
+
},
|
|
154
|
+
approved,
|
|
155
|
+
onRunStart: c => { activeRun = c; },
|
|
156
|
+
onRunEnd: () => { activeRun = null; stop(); }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
81
160
|
async function runTask(input) {
|
|
82
161
|
busy = true;
|
|
83
|
-
let lastStreamed = '';
|
|
84
162
|
if (TUI) tuiUserLine(input);
|
|
85
|
-
|
|
86
|
-
const stopSpinner =
|
|
163
|
+
const hooks = hooksForRun();
|
|
164
|
+
const stopSpinner = hooks.spinnerStop;
|
|
87
165
|
try {
|
|
88
|
-
const res = await runObjective(state, input, process.cwd(), history,
|
|
89
|
-
onMemoryStart: () => { stopSpinner(); spinner = startSpinner('recalling memory'); },
|
|
90
|
-
onMemoryEnd: () => stopSpinner(),
|
|
91
|
-
onThinkingStart: () => { stopSpinner(); spinner = startSpinner('thinking'); },
|
|
92
|
-
onThinkingEnd: () => stopSpinner(),
|
|
93
|
-
onWorkStart: label => { stopSpinner(); spinner = startSpinner(label || 'working'); },
|
|
94
|
-
onWorkEnd: () => stopSpinner(),
|
|
95
|
-
onTool: (name, input2) => { stopSpinner(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
|
|
96
|
-
onResult: out => { say(gray(' ' + trunc(out, 110))); },
|
|
97
|
-
onText: t => { stopSpinner(); lastStreamed = t; },
|
|
98
|
-
onTodos: list => {
|
|
99
|
-
stopSpinner();
|
|
100
|
-
const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
|
|
101
|
-
say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
|
|
102
|
-
},
|
|
103
|
-
onAgentStart: (id, input) => { stopSpinner(); say(cyan(' ◆ spawn ' + id) + gray(` role=${input.role ?? '?'} task=${trunc(String(input.objective ?? ''), 70)}`)); },
|
|
104
|
-
onAgentEnd: (id, r) => { stopSpinner(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
105
|
-
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
106
|
-
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
107
|
-
onApprove: async (cat, name, input2) => {
|
|
108
|
-
stopSpinner();
|
|
109
|
-
say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
|
|
110
|
-
const a = await ask(' [y] once · [a] always for ' + cat + ' · [n] no: ');
|
|
111
|
-
const c = a.trim().toLowerCase();
|
|
112
|
-
if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
|
|
113
|
-
if (c === 'y' || c === 'yes') return true;
|
|
114
|
-
say(dim(' denied.'));
|
|
115
|
-
return false;
|
|
116
|
-
},
|
|
117
|
-
approved,
|
|
118
|
-
onRunStart: c => { activeRun = c; },
|
|
119
|
-
onRunEnd: () => { activeRun = null; stopSpinner(); }
|
|
120
|
-
});
|
|
166
|
+
const res = await runObjective(state, input, process.cwd(), history, hooks);
|
|
121
167
|
history = pushTurn(history, input, res);
|
|
122
168
|
stopSpinner();
|
|
169
|
+
try { history = await compactHistory(state, history, { onNote: n => say(dim(' ◇ ' + n)) }); } catch {}
|
|
170
|
+
try { sessionId = saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history }); } catch {}
|
|
123
171
|
if (res.aborted) {
|
|
124
172
|
say(yellow(' ■ Stopped') + dim(' - partly done. Ask me to continue.'));
|
|
125
173
|
} else {
|
|
@@ -132,6 +180,7 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
132
180
|
} catch (err) {
|
|
133
181
|
stopSpinner();
|
|
134
182
|
history = pushTurn(history, input, { answer: '(task failed: ' + err.message + ')' });
|
|
183
|
+
try { sessionId = saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history }); } catch {}
|
|
135
184
|
say(red(' ✗ ' + err.message) + dim(' context kept.'));
|
|
136
185
|
} finally {
|
|
137
186
|
busy = false;
|
|
@@ -143,6 +192,8 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
143
192
|
|
|
144
193
|
async function afterTask() {
|
|
145
194
|
if (closed) return;
|
|
195
|
+
// notes typed near the end that never reached the model become follow-up tasks
|
|
196
|
+
if (steerQueue.length) pendingLines.unshift(...steerQueue.splice(0));
|
|
146
197
|
if (TUI) { drawStatus(); scrollRegion(); return; }
|
|
147
198
|
plainPrompt();
|
|
148
199
|
while (!busy && !closed) {
|
|
@@ -159,12 +210,17 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
159
210
|
say(' ' + cyan('/build') + ' build mode: real changes (default)');
|
|
160
211
|
say(' ' + cyan('/reason') + ' toggle reasoning low/high');
|
|
161
212
|
say(' ' + cyan('/perm') + ' permissions: /perm auto | /perm safe | /perm');
|
|
213
|
+
say(' ' + cyan('/boost') + ' isolated git-worktree run: /boost <objective>');
|
|
162
214
|
say(' ' + cyan('/config') + ' show provider config (key hidden)');
|
|
163
215
|
say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
|
|
216
|
+
say(' ' + cyan('/resume') + ' bring back a saved conversation');
|
|
164
217
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
218
|
+
say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
|
|
165
219
|
say(' ' + cyan('/clear') + ' forget this conversation');
|
|
166
220
|
say(' ' + cyan('/setup') + ' redo provider setup');
|
|
167
221
|
say(' ' + cyan('/reset') + ' clear saved config');
|
|
222
|
+
say(' ' + cyan('/help') + ' this list. Type normally to work, steer mid-task anytime');
|
|
223
|
+
say(dim(' while a task runs: your text is a live steer, /stop cancels it'));
|
|
168
224
|
say(' ' + cyan('/exit') + ' quit');
|
|
169
225
|
},
|
|
170
226
|
'/config': () => {
|
|
@@ -185,7 +241,12 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
185
241
|
|
|
186
242
|
async function handle(input) {
|
|
187
243
|
if (!input) return;
|
|
188
|
-
if (busy) {
|
|
244
|
+
if (busy) {
|
|
245
|
+
if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
246
|
+
if (input.startsWith('/')) { pendingLines.push(input); return; }
|
|
247
|
+
steerQueue.push(input);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
189
250
|
if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
|
|
190
251
|
if (input === '/help' || input === '?') return commands['/help']();
|
|
191
252
|
if (input === '/config') return commands['/config']();
|
|
@@ -247,6 +308,95 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
247
308
|
busy = false;
|
|
248
309
|
return afterTask();
|
|
249
310
|
}
|
|
311
|
+
if (input === '/humanizer' || input.startsWith('/humanizer ')) {
|
|
312
|
+
const arg = input.split(/\s+/)[1];
|
|
313
|
+
if (arg === 'on' || arg === 'off') {
|
|
314
|
+
Object.assign(state, normalize({ ...state, humanize: arg === 'on' }));
|
|
315
|
+
saveConfig(state);
|
|
316
|
+
say(arg === 'on'
|
|
317
|
+
? green('Humanizer: on') + dim(' - web pages and posts get a natural-writing pass after they are written.')
|
|
318
|
+
: yellow('Humanizer: off') + dim(' - files are written exactly as the model produces them.'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
say(box([
|
|
322
|
+
bold('Humanizer') + dim(' ' + (state.humanize === false ? 'off' : 'on')),
|
|
323
|
+
dim('scope') + ' .html .htm .md .txt (web pages, posts, docs)',
|
|
324
|
+
dim('never touches') + ' code, tags, attributes, URLs, JSON, technical values',
|
|
325
|
+
dim('toggle') + ' /humanizer on | /humanizer off'
|
|
326
|
+
]));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (input === '/boost cancel') {
|
|
330
|
+
if (!lastBoost) { say(dim('No boost run to cancel.')); return; }
|
|
331
|
+
const { dir, branch } = lastBoost;
|
|
332
|
+
boost.cleanupBoost(process.cwd(), dir, branch);
|
|
333
|
+
lastBoost = null;
|
|
334
|
+
say(yellow('Boost worktree and branch removed.'));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (input === '/boost' || input.startsWith('/boost ')) {
|
|
338
|
+
const objective = input.slice(6).trim();
|
|
339
|
+
if (!objective) {
|
|
340
|
+
say(box([
|
|
341
|
+
bold('Boost') + dim(' isolated execution in a git worktree'),
|
|
342
|
+
dim('/boost <objective>') + ' run the task away from your tree, review, then merge',
|
|
343
|
+
dim('/boost cancel') + ' remove the last boost worktree'
|
|
344
|
+
]));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
busy = true;
|
|
348
|
+
try {
|
|
349
|
+
if (!boost.boostAvailable(process.cwd())) { say(yellow('Boost needs a git repository (with a commit).')); return; }
|
|
350
|
+
const b = boost.startBoost(process.cwd());
|
|
351
|
+
if (!b.ok) { say(red('Boost failed: ' + b.error)); return; }
|
|
352
|
+
lastBoost = b;
|
|
353
|
+
say(cyan(` ⚡ boost ${b.branch}`) + dim(` worktree at ${b.dir}`));
|
|
354
|
+
const res = await runObjective(state, objective, b.dir, [], { ...hooksForRun(), skipMemory: true });
|
|
355
|
+
say((res.aborted ? yellow(' ■ Stopped') : green(' ⚡ Boost task done')) + dim(` in ${b.branch}`));
|
|
356
|
+
boost.commitBoost(b.dir, 'boost: ' + objective.slice(0, 80));
|
|
357
|
+
const d = boost.boostDiff(b.dir);
|
|
358
|
+
if (d.files.length) {
|
|
359
|
+
say(box([bold('Boost changes'), ...d.files.map(f => ' ' + f)]));
|
|
360
|
+
const a = await ask(` [y] merge into ${boost.currentBranch(process.cwd())} · [n] keep worktree: `);
|
|
361
|
+
if (/^y/i.test(a.trim())) {
|
|
362
|
+
const m = boost.mergeBoost(process.cwd(), b.branch);
|
|
363
|
+
if (m.ok) { say(green('Merged into your branch.')); boost.cleanupBoost(process.cwd(), b.dir, b.branch); lastBoost = null; }
|
|
364
|
+
else { say(red('Merge conflict, worktree kept: ' + m.out)); }
|
|
365
|
+
} else {
|
|
366
|
+
say(dim('Worktree kept: ' + b.dir + ' (' + b.branch + '). /boost cancel removes it.'));
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
say(dim('No file changes came out of the boost run.'));
|
|
370
|
+
boost.cleanupBoost(process.cwd(), b.dir, b.branch);
|
|
371
|
+
lastBoost = null;
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
say(red(' ✗ boost failed: ' + err.message));
|
|
375
|
+
} finally {
|
|
376
|
+
busy = false;
|
|
377
|
+
await afterTask();
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (input === '/resume') {
|
|
382
|
+
busy = true;
|
|
383
|
+
const list = listSessions();
|
|
384
|
+
if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
|
|
385
|
+
list.slice(0, 5).forEach((s, i) => {
|
|
386
|
+
const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
|
|
387
|
+
say(` ${i + 1}. ${new Date(s.time).toLocaleString()} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
|
|
388
|
+
});
|
|
389
|
+
const pick = await ask(' Resume which? [1]: ');
|
|
390
|
+
const n = Number(pick) || 1;
|
|
391
|
+
const s = loadSession(list[n - 1]?.id);
|
|
392
|
+
if (s?.history?.length) {
|
|
393
|
+
history = s.history;
|
|
394
|
+
sessionId = s.id;
|
|
395
|
+
say(green(`Resumed ${Math.floor(s.history.length / 2)} turns. Continue where we left off.`));
|
|
396
|
+
} else say(red('Could not load that session.'));
|
|
397
|
+
busy = false;
|
|
398
|
+
return afterTask();
|
|
399
|
+
}
|
|
250
400
|
if (input === '/mcp' || input === '/mcp reload') {
|
|
251
401
|
if (!mcpConfigured()) {
|
|
252
402
|
say(yellow('No MCP servers configured.') + dim(' Add them to ~/.ineedcodes/mcp.json, e.g.: {"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"]}}'));
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// sessions.js: session persistence. Conversation checkpoints live outside the repo,
|
|
2
|
+
// under the config dir, so users can leave and resume work (master prompt #34).
|
|
3
|
+
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { CONFIG_DIR as configDirPath } from './config.js';
|
|
7
|
+
const DIR = path.join(configDirPath, 'sessions');
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export function saveSession(data) {
|
|
11
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
12
|
+
const id = data.id || 's_' + Date.now();
|
|
13
|
+
fs.writeFileSync(path.join(DIR, id + '.json'), JSON.stringify({ ...data, id, time: Date.now() }, null, 2), { mode: 0o600 });
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listSessions() {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readdirSync(DIR)
|
|
20
|
+
.filter(f => f.endsWith('.json'))
|
|
21
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')); } catch { return null; } })
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.sort((a, b) => b.time - a.time)
|
|
24
|
+
.slice(0, 20);
|
|
25
|
+
} catch { return []; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadSession(id) {
|
|
29
|
+
if (!id) return null;
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(path.join(DIR, id + '.json'), 'utf8')); } catch { return null; }
|
|
31
|
+
}
|
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.3.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);
|