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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Your terminal, now autonomous. Just say what you want, ineed does the rest.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
  };
@@ -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] always for ' + cat + ' · [n] no: ');
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) { pendingLines.push(input); return; }
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.1.0';
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);