ineedcodes 1.1.0 → 1.2.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/config.js +1 -0
- package/src/humanize.js +139 -0
- package/src/session.js +42 -2
- 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/config.js
CHANGED
|
@@ -25,6 +25,7 @@ export function normalize(c) {
|
|
|
25
25
|
mode: c.mode === 'plan' ? 'plan' : 'build',
|
|
26
26
|
memory: c.memory !== false,
|
|
27
27
|
mcp: c.mcp !== false,
|
|
28
|
+
humanize: c.humanize !== false,
|
|
28
29
|
permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
|
|
29
30
|
permShell: c.permShell === 'allow' ? 'allow' : 'ask'
|
|
30
31
|
};
|
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
|
@@ -42,6 +42,7 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
42
42
|
let closed = false;
|
|
43
43
|
const approved = new Set(); // session-wide "always allow" grants
|
|
44
44
|
const pendingLines = [];
|
|
45
|
+
const steerQueue = []; // notes typed while a task runs, injected mid-task
|
|
45
46
|
|
|
46
47
|
const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
47
48
|
|
|
@@ -104,11 +105,22 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
104
105
|
onAgentEnd: (id, r) => { stopSpinner(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
105
106
|
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
106
107
|
onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
|
|
108
|
+
onNote: note => { stopSpinner(); say(dim(' ◇ ' + note)); },
|
|
109
|
+
drainSteer: () => steerQueue.splice(0),
|
|
110
|
+
onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
|
|
107
111
|
onApprove: async (cat, name, input2) => {
|
|
108
112
|
stopSpinner();
|
|
109
113
|
say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
|
|
110
|
-
const a = await ask(' [y] once · [a]
|
|
114
|
+
const a = await ask(' [y] once · [a] this session · [s] always (save) · [n] no: ');
|
|
111
115
|
const c = a.trim().toLowerCase();
|
|
116
|
+
if (c === 's' || c === 'save') {
|
|
117
|
+
approved.add(cat);
|
|
118
|
+
if (cat === 'edit') Object.assign(state, normalize({ ...state, permEdit: 'allow' }));
|
|
119
|
+
if (cat === 'shell') Object.assign(state, normalize({ ...state, permShell: 'allow' }));
|
|
120
|
+
saveConfig(state);
|
|
121
|
+
say(dim(' always allowed, saved to config. /perm safe to undo.'));
|
|
122
|
+
return 'always';
|
|
123
|
+
}
|
|
112
124
|
if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
|
|
113
125
|
if (c === 'y' || c === 'yes') return true;
|
|
114
126
|
say(dim(' denied.'));
|
|
@@ -143,6 +155,8 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
143
155
|
|
|
144
156
|
async function afterTask() {
|
|
145
157
|
if (closed) return;
|
|
158
|
+
// notes typed near the end that never reached the model become follow-up tasks
|
|
159
|
+
if (steerQueue.length) pendingLines.unshift(...steerQueue.splice(0));
|
|
146
160
|
if (TUI) { drawStatus(); scrollRegion(); return; }
|
|
147
161
|
plainPrompt();
|
|
148
162
|
while (!busy && !closed) {
|
|
@@ -162,9 +176,12 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
162
176
|
say(' ' + cyan('/config') + ' show provider config (key hidden)');
|
|
163
177
|
say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
|
|
164
178
|
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
179
|
+
say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
|
|
165
180
|
say(' ' + cyan('/clear') + ' forget this conversation');
|
|
166
181
|
say(' ' + cyan('/setup') + ' redo provider setup');
|
|
167
182
|
say(' ' + cyan('/reset') + ' clear saved config');
|
|
183
|
+
say(' ' + cyan('/help') + ' this list. Type normally to work, steer mid-task anytime');
|
|
184
|
+
say(dim(' while a task runs: your text is a live steer, /stop cancels it'));
|
|
168
185
|
say(' ' + cyan('/exit') + ' quit');
|
|
169
186
|
},
|
|
170
187
|
'/config': () => {
|
|
@@ -185,7 +202,12 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
185
202
|
|
|
186
203
|
async function handle(input) {
|
|
187
204
|
if (!input) return;
|
|
188
|
-
if (busy) {
|
|
205
|
+
if (busy) {
|
|
206
|
+
if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
|
|
207
|
+
if (input.startsWith('/')) { pendingLines.push(input); return; }
|
|
208
|
+
steerQueue.push(input);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
189
211
|
if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
|
|
190
212
|
if (input === '/help' || input === '?') return commands['/help']();
|
|
191
213
|
if (input === '/config') return commands['/config']();
|
|
@@ -247,6 +269,24 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
247
269
|
busy = false;
|
|
248
270
|
return afterTask();
|
|
249
271
|
}
|
|
272
|
+
if (input === '/humanizer' || input.startsWith('/humanizer ')) {
|
|
273
|
+
const arg = input.split(/\s+/)[1];
|
|
274
|
+
if (arg === 'on' || arg === 'off') {
|
|
275
|
+
Object.assign(state, normalize({ ...state, humanize: arg === 'on' }));
|
|
276
|
+
saveConfig(state);
|
|
277
|
+
say(arg === 'on'
|
|
278
|
+
? green('Humanizer: on') + dim(' - web pages and posts get a natural-writing pass after they are written.')
|
|
279
|
+
: yellow('Humanizer: off') + dim(' - files are written exactly as the model produces them.'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
say(box([
|
|
283
|
+
bold('Humanizer') + dim(' ' + (state.humanize === false ? 'off' : 'on')),
|
|
284
|
+
dim('scope') + ' .html .htm .md .txt (web pages, posts, docs)',
|
|
285
|
+
dim('never touches') + ' code, tags, attributes, URLs, JSON, technical values',
|
|
286
|
+
dim('toggle') + ' /humanizer on | /humanizer off'
|
|
287
|
+
]));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
250
290
|
if (input === '/mcp' || input === '/mcp reload') {
|
|
251
291
|
if (!mcpConfigured()) {
|
|
252
292
|
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/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.2.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);
|