ineedcodes 1.0.2 → 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/README.md +34 -1
- package/package.json +1 -1
- package/src/agent.js +152 -6
- package/src/config.js +2 -0
- package/src/humanize.js +139 -0
- package/src/mcp.js +203 -0
- package/src/session.js +71 -2
- package/src/ui.js +1 -1
package/README.md
CHANGED
|
@@ -64,8 +64,12 @@ The agent decides what it needs: list files, read code, search, edit, run shell
|
|
|
64
64
|
| `/plan` | read-only mode, the agent suggests but changes nothing |
|
|
65
65
|
| `/build` | default mode, the agent makes real changes |
|
|
66
66
|
| `/reason` | toggle reasoning effort low/high |
|
|
67
|
-
| `/
|
|
67
|
+
| `/perm` | permission modes: `/perm auto`, `/perm safe` |
|
|
68
|
+
| `/memory` | durable memory status, `/memory on\|off` |
|
|
69
|
+
| `/mcp` | list MCP servers and their tools |
|
|
68
70
|
| `/setup` | redo provider setup |
|
|
71
|
+
| `/config` | show provider config, API key hidden |
|
|
72
|
+
| `/clear` | forget the current conversation |
|
|
69
73
|
| `/exit` | quit |
|
|
70
74
|
|
|
71
75
|
Shortcuts are optional. Normal language always works.
|
|
@@ -94,13 +98,42 @@ Not a chatbot that prints code. A loop that does the work, checks the results, a
|
|
|
94
98
|
| `write_file` | create files |
|
|
95
99
|
| `edit_file` | targeted patch, not a rewrite |
|
|
96
100
|
| `delete_file` | remove a file |
|
|
101
|
+
| `todo` | visible checklist for multi-step work |
|
|
102
|
+
| `spawn_agent` | delegate to a focused sub-agent |
|
|
97
103
|
| `shell` | build, test, install, git, anything |
|
|
98
104
|
|
|
105
|
+
### Multi-agent
|
|
106
|
+
|
|
107
|
+
Big tasks get delegated. The lead agent spawns workers with a role that fits:
|
|
108
|
+
|
|
109
|
+
| role | can |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `research` | read and report, nothing else, runs in parallel |
|
|
112
|
+
| `review` | inspect code, report findings, runs in parallel |
|
|
113
|
+
| `test` | run tests and commands, no source edits |
|
|
114
|
+
| `implement` | make the change, verify it |
|
|
115
|
+
| `debug` | find the root cause, fix it |
|
|
116
|
+
|
|
117
|
+
Workers report back with status, summary, evidence, files changed, and commands run. The lead reconciles everything and answers you.
|
|
118
|
+
|
|
119
|
+
### MCP (Model Context Protocol)
|
|
120
|
+
|
|
121
|
+
Connect any MCP server and its tools appear in the agent automatically. Create `~/.ineedcodes/mcp.json`:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Restart ineed, and every tool from that server is callable. Check what is loaded with `/mcp` inside a session.
|
|
130
|
+
|
|
99
131
|
### Safety
|
|
100
132
|
|
|
101
133
|
- Secrets (.env, ssh keys, pem files) never enter the model context.
|
|
102
134
|
- Destructive shell commands are refused.
|
|
103
135
|
- Everything is jailed to your working directory.
|
|
136
|
+
- Edits and shell commands ask for your approval first (`/perm auto` relaxes this).
|
|
104
137
|
- Plan mode lets you preview intent before any change.
|
|
105
138
|
|
|
106
139
|
## Works with any provider
|
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
|
|
|
@@ -30,15 +33,106 @@ export function trimHistory(history) {
|
|
|
30
33
|
return history.slice(start);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
// ── multi-agent: roles, task packets, worker execution ──
|
|
37
|
+
const ROLES = {
|
|
38
|
+
research: {
|
|
39
|
+
readonly: true,
|
|
40
|
+
tools: ['list_files', 'read_file', 'search_text', 'todo'],
|
|
41
|
+
prompt: 'You are a research worker. Gather facts and report them. Change nothing.'
|
|
42
|
+
},
|
|
43
|
+
review: {
|
|
44
|
+
readonly: true,
|
|
45
|
+
tools: ['list_files', 'read_file', 'search_text', 'todo'],
|
|
46
|
+
prompt: 'You are a review worker. Inspect the code for correctness, bugs, and quality. Report findings, change nothing.'
|
|
47
|
+
},
|
|
48
|
+
test: {
|
|
49
|
+
readonly: false,
|
|
50
|
+
tools: ['list_files', 'read_file', 'search_text', 'shell', 'todo'],
|
|
51
|
+
prompt: 'You are a test worker. Run the relevant tests or commands and report the evidence. Do not modify source files.'
|
|
52
|
+
},
|
|
53
|
+
implement: {
|
|
54
|
+
readonly: false,
|
|
55
|
+
tools: null, // all tools
|
|
56
|
+
prompt: 'You are an implementation worker. Make the change the lead asked for, verify it works, and report what you did.'
|
|
57
|
+
},
|
|
58
|
+
debug: {
|
|
59
|
+
readonly: false,
|
|
60
|
+
tools: null, // all tools
|
|
61
|
+
prompt: 'You are a debugging worker. Find the root cause, fix it if you can, and report cause plus evidence.'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let workerSeq = 0;
|
|
66
|
+
|
|
67
|
+
function workerResultText(r) {
|
|
68
|
+
const files = r.files?.length ? ` files: ${r.files.join(', ')};` : '';
|
|
69
|
+
const cmds = r.commands?.length ? ` ran ${r.commands.length} command(s);` : '';
|
|
70
|
+
return `Worker ${r.id} [${r.status}]: ${String(r.summary).slice(0, 800)}.${files}${cmds}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function runWorker(cfg, spec, cwd, depth, hooks) {
|
|
74
|
+
const roleName = ROLES[spec.input.role] ? spec.input.role : 'research';
|
|
75
|
+
const role = ROLES[roleName];
|
|
76
|
+
const objective = String(spec.input.objective ?? '')
|
|
77
|
+
+ (spec.input.context ? `\nContext from lead agent: ${String(spec.input.context).slice(0, 1_000)}` : '');
|
|
78
|
+
try {
|
|
79
|
+
const res = await runObjective(cfg, objective, cwd, [], {}, {
|
|
80
|
+
depth: depth + 1,
|
|
81
|
+
toolFilter: role.tools,
|
|
82
|
+
worker: { id: spec.id, role: roleName, prompt: role.prompt }
|
|
83
|
+
});
|
|
84
|
+
return { id: spec.id, role: roleName, status: res.aborted ? 'incomplete' : 'completed', summary: res.answer || '(no output)', files: res.changed, commands: res.ran };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { id: spec.id, role: roleName, status: 'failed', summary: err.message, files: [], commands: [] };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SPAWN_TOOL = {
|
|
91
|
+
name: 'spawn_agent',
|
|
92
|
+
description: 'Spawn a focused sub-agent worker. Roles: research (read only), review (read only), test (runs commands, does not edit), implement (edits), debug (finds and fixes). Read-only workers can run in parallel.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
role: { type: 'string', enum: Object.keys(ROLES) },
|
|
97
|
+
objective: { type: 'string', description: 'the exact task for this worker' },
|
|
98
|
+
context: { type: 'string', description: 'relevant context: files, errors, constraints' }
|
|
99
|
+
},
|
|
100
|
+
required: ['role', 'objective']
|
|
101
|
+
},
|
|
102
|
+
allowedInPlan: true
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export async function runObjective(cfg, objective, cwd, history, hooks = {}, extra = {}) {
|
|
34
106
|
const ctrl = new AbortController();
|
|
35
107
|
hooks.onRunStart?.(ctrl);
|
|
36
108
|
const plan = cfg.mode === 'plan';
|
|
37
|
-
const
|
|
109
|
+
const depth = extra.depth ?? 0;
|
|
110
|
+
let tools = plan ? TOOLS.filter(t => t.allowedInPlan) : [...TOOLS, SPAWN_TOOL];
|
|
111
|
+
if (extra.toolFilter) tools = tools.filter(t => (extra.toolFilter).includes(t.name));
|
|
38
112
|
const canAsk = typeof hooks.onApprove === 'function';
|
|
39
113
|
|
|
114
|
+
// MCP: load configured servers once per top-level objective, expose their tools
|
|
115
|
+
let mcpManager = null;
|
|
116
|
+
const mcpMap = new Map();
|
|
117
|
+
if (depth === 0 && !plan && cfg.mcp !== false) {
|
|
118
|
+
try {
|
|
119
|
+
const { McpManager, mcpConfigured } = await import('./mcp.js');
|
|
120
|
+
if (mcpConfigured()) {
|
|
121
|
+
mcpManager = new McpManager();
|
|
122
|
+
const errors = await mcpManager.loadFromConfig();
|
|
123
|
+
errors.forEach(e => hooks.onText?.(dim(e)));
|
|
124
|
+
const mcpTools = await mcpManager.allTools();
|
|
125
|
+
const names = new Set(tools.map(t => t.name));
|
|
126
|
+
for (const t of mcpTools) {
|
|
127
|
+
if (!names.has(t.name)) { tools.push(t); mcpMap.set(t.name, t.mcp); names.add(t.name); }
|
|
128
|
+
}
|
|
129
|
+
hooks.onMCP?.(mcpTools.map(t => t.name));
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
|
|
40
134
|
// recall durable memory before meaningful work (rule 12/15: MemoryProvider abstraction)
|
|
41
|
-
const memory = getMemoryProvider(cfg);
|
|
135
|
+
const memory = extra.skipMemory ? null : getMemoryProvider(cfg);
|
|
42
136
|
let recalled = '';
|
|
43
137
|
if (memory) {
|
|
44
138
|
hooks.onMemoryStart?.();
|
|
@@ -46,10 +140,11 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
|
|
|
46
140
|
hooks.onMemoryEnd?.(recalled);
|
|
47
141
|
}
|
|
48
142
|
|
|
143
|
+
const workerPrefix = extra.worker ? `You are ${extra.worker.id} (${extra.worker.role} worker) spawned by the lead agent. ${extra.worker.prompt}\n` : '';
|
|
49
144
|
const messages = [
|
|
50
145
|
{
|
|
51
146
|
role: 'system',
|
|
52
|
-
content: `${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
|
|
147
|
+
content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
|
|
53
148
|
+ (recalled ? `\nRelevant memory from previous sessions with this user (durable facts, may be stale):\n${recalled}` : '')
|
|
54
149
|
},
|
|
55
150
|
...trimHistory(history),
|
|
@@ -63,6 +158,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
|
|
|
63
158
|
try {
|
|
64
159
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
65
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
|
+
}
|
|
66
171
|
let msg;
|
|
67
172
|
try {
|
|
68
173
|
hooks.onThinkingStart?.();
|
|
@@ -89,12 +194,44 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
|
|
|
89
194
|
}
|
|
90
195
|
return { answer, changed: [...changed], ran, todos: [...todos], aborted: false };
|
|
91
196
|
}
|
|
197
|
+
// spawn_agent pre-pass: read-only workers run in parallel (max 4), writers sequentially
|
|
198
|
+
const spawnResults = new Map();
|
|
199
|
+
const spawnCalls = calls.filter(c => c.function?.name === 'spawn_agent');
|
|
200
|
+
if (spawnCalls.length && depth === 0) {
|
|
201
|
+
const specs = spawnCalls.map(c => {
|
|
202
|
+
let input = {};
|
|
203
|
+
try { input = JSON.parse(c.function?.arguments || '{}'); } catch {}
|
|
204
|
+
const role = ROLES[input.role] ? input.role : 'research';
|
|
205
|
+
return { call: c, input, role, id: `${role}-${++workerSeq}` };
|
|
206
|
+
});
|
|
207
|
+
const runOne = async s => {
|
|
208
|
+
hooks.onAgentStart?.(s.id, s.input);
|
|
209
|
+
const r = await runWorker(cfg, s, cwd, depth, hooks);
|
|
210
|
+
hooks.onAgentEnd?.(s.id, r);
|
|
211
|
+
spawnResults.set(s.call.id, r);
|
|
212
|
+
};
|
|
213
|
+
const readonly = specs.filter(s => ROLES[s.role].readonly).slice(0, 4);
|
|
214
|
+
const writers = specs.filter(s => !ROLES[s.role].readonly);
|
|
215
|
+
for (let i = 0; i < readonly.length; i += 4) {
|
|
216
|
+
await Promise.all(readonly.slice(i, i + 4).map(runOne));
|
|
217
|
+
}
|
|
218
|
+
for (const s of writers) await runOne(s);
|
|
219
|
+
}
|
|
220
|
+
|
|
92
221
|
for (const call of calls) {
|
|
93
222
|
let input = {};
|
|
94
223
|
try { input = JSON.parse(call.function?.arguments || '{}'); } catch {}
|
|
95
224
|
hooks.onTool?.(call.function?.name, input);
|
|
96
225
|
let result;
|
|
97
|
-
if (call.
|
|
226
|
+
if (spawnResults.has(call.id)) {
|
|
227
|
+
result = { output: workerResultText(spawnResults.get(call.id)) };
|
|
228
|
+
} else if (call.function?.name === 'spawn_agent') {
|
|
229
|
+
result = { output: 'Refused: workers cannot spawn more agents.' };
|
|
230
|
+
} else if (mcpMap.has(call.function?.name)) {
|
|
231
|
+
const m = mcpMap.get(call.function?.name);
|
|
232
|
+
result = await mcpManager.call(m.server, m.tool, input);
|
|
233
|
+
hooks.onMCPResult?.(call.function?.name, result.output);
|
|
234
|
+
} else if (call.function?.name === 'shell') {
|
|
98
235
|
if (plan) result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
|
|
99
236
|
else if (isDestructive(String(input.command ?? ''))) {
|
|
100
237
|
result = { output: 'Refused: that command is destructive. Run it yourself if you are sure.' };
|
|
@@ -112,7 +249,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
|
|
|
112
249
|
ran.push(String(input.command ?? '').slice(0, 120));
|
|
113
250
|
}
|
|
114
251
|
} else {
|
|
115
|
-
if (plan && !
|
|
252
|
+
if (plan && !tools.find(t => t.name === call.function?.name)) {
|
|
116
253
|
result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
|
|
117
254
|
} else if (call.function?.name === 'todo') {
|
|
118
255
|
const list = Array.isArray(input.todos) ? input.todos : [];
|
|
@@ -135,6 +272,15 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
|
|
|
135
272
|
if (allowedNow && !plan && isEdit
|
|
136
273
|
&& !/^(Refused|Error|Denied)/.test(String(result.output))) {
|
|
137
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
|
+
}
|
|
138
284
|
}
|
|
139
285
|
}
|
|
140
286
|
}
|
package/src/config.js
CHANGED
|
@@ -24,6 +24,8 @@ export function normalize(c) {
|
|
|
24
24
|
reasoning: c.reasoning === 'high' ? 'high' : 'low',
|
|
25
25
|
mode: c.mode === 'plan' ? 'plan' : 'build',
|
|
26
26
|
memory: c.memory !== false,
|
|
27
|
+
mcp: c.mcp !== false,
|
|
28
|
+
humanize: c.humanize !== false,
|
|
27
29
|
permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
|
|
28
30
|
permShell: c.permShell === 'allow' ? 'allow' : 'ask'
|
|
29
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/mcp.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// mcp.js: minimal MCP client. Connects MCP servers over stdio using JSON-RPC 2.0,
|
|
2
|
+
// lists their tools, and lets the agent call them like native tools.
|
|
3
|
+
// Servers are configured in ~/.ineedcodes/mcp.json: { "name": { "command": "...", "args": ["..."] } }
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
|
|
10
|
+
export const MCP_CONFIG_FILE = process.env.INEED_MCP_CONFIG
|
|
11
|
+
|| path.join(os.homedir(), '.ineedcodes', 'mcp.json');
|
|
12
|
+
|
|
13
|
+
let nextId = 1;
|
|
14
|
+
|
|
15
|
+
class McpServer {
|
|
16
|
+
constructor(name, spec) {
|
|
17
|
+
this.name = name;
|
|
18
|
+
this.spec = spec;
|
|
19
|
+
this.child = null;
|
|
20
|
+
this.buffer = '';
|
|
21
|
+
this.pending = new Map(); // id -> resolve
|
|
22
|
+
this.tools = [];
|
|
23
|
+
this.dead = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start() {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
let child;
|
|
30
|
+
try {
|
|
31
|
+
child = spawn(this.spec.command, this.spec.args ?? [], {
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
env: { ...process.env, NO_COLOR: '1' }
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
this.dead = true;
|
|
37
|
+
return reject(new Error(`cannot start MCP server ${this.name}: ${err.message}`));
|
|
38
|
+
}
|
|
39
|
+
this.child = child;
|
|
40
|
+
child.stdout.on('data', chunk => {
|
|
41
|
+
this.buffer += chunk.toString();
|
|
42
|
+
let idx;
|
|
43
|
+
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
|
44
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
45
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
let msg;
|
|
48
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
49
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
50
|
+
const r = this.pending.get(msg.id);
|
|
51
|
+
this.pending.delete(msg.id);
|
|
52
|
+
r(msg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on('data', () => {}); // servers log freely; never leaks into the model
|
|
57
|
+
child.on('error', err => {
|
|
58
|
+
this.dead = true;
|
|
59
|
+
if (!settled) { settled = true; reject(new Error(`MCP server ${this.name}: ${err.message}`)); }
|
|
60
|
+
});
|
|
61
|
+
child.on('close', code => {
|
|
62
|
+
this.dead = true;
|
|
63
|
+
for (const r of this.pending.values()) r({ error: { message: `MCP server ${this.name} exited (code ${code})` } });
|
|
64
|
+
this.pending.clear();
|
|
65
|
+
});
|
|
66
|
+
const fail = setTimeout(() => {
|
|
67
|
+
if (!settled) { settled = true; reject(new Error(`MCP server ${this.name} did not answer initialize (10s)`)); }
|
|
68
|
+
}, 10_000);
|
|
69
|
+
this.request('initialize', {
|
|
70
|
+
protocolVersion: '2024-11-05',
|
|
71
|
+
capabilities: {},
|
|
72
|
+
clientInfo: { name: 'ineed', version: '1.1.0' }
|
|
73
|
+
}).then(init => {
|
|
74
|
+
if (init.error) throw new Error(init.error.message ?? 'initialize failed');
|
|
75
|
+
this.notify('notifications/initialized', {});
|
|
76
|
+
clearTimeout(fail);
|
|
77
|
+
settled = true;
|
|
78
|
+
resolve(this);
|
|
79
|
+
}).catch(err => {
|
|
80
|
+
clearTimeout(fail);
|
|
81
|
+
settled = true;
|
|
82
|
+
this.kill();
|
|
83
|
+
reject(err);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
request(method, params) {
|
|
89
|
+
if (this.dead) return Promise.resolve({ error: { message: `MCP server ${this.name} is not running` } });
|
|
90
|
+
const id = nextId++;
|
|
91
|
+
return new Promise(resolve => {
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
this.pending.delete(id);
|
|
94
|
+
resolve({ error: { message: `MCP server ${this.name} timed out on ${method}` } });
|
|
95
|
+
}, 60_000);
|
|
96
|
+
this.pending.set(id, msg => { clearTimeout(timer); resolve(msg); });
|
|
97
|
+
try {
|
|
98
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
this.pending.delete(id);
|
|
102
|
+
resolve({ error: { message: `MCP write failed: ${err.message}` } });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
notify(method, params) {
|
|
108
|
+
try { this.child?.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async listTools() {
|
|
112
|
+
const res = await this.request('tools/list', {});
|
|
113
|
+
if (res.error) return [];
|
|
114
|
+
this.tools = (res.result?.tools ?? []).map(t => ({
|
|
115
|
+
name: t.name,
|
|
116
|
+
description: t.description ?? '',
|
|
117
|
+
inputSchema: t.inputSchema ?? { type: 'object', properties: {} }
|
|
118
|
+
}));
|
|
119
|
+
return this.tools;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async callTool(name, args) {
|
|
123
|
+
const res = await this.request('tools/call', { name, arguments: args ?? {} });
|
|
124
|
+
if (res.error) return { output: `Error: MCP ${this.name}/${name}: ${res.error.message}` };
|
|
125
|
+
const parts = res.result?.content ?? [];
|
|
126
|
+
const text = parts.filter(p => p.type === 'text').map(p => p.text).join('\n');
|
|
127
|
+
return { output: (res.result?.isError ? 'Error: ' : '') + (text || '(empty result)') };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
kill() {
|
|
131
|
+
this.dead = true;
|
|
132
|
+
try { this.child?.kill('SIGKILL'); } catch {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class McpManager {
|
|
137
|
+
constructor() {
|
|
138
|
+
this.servers = new Map();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async loadFromConfig() {
|
|
142
|
+
let cfg;
|
|
143
|
+
try { cfg = JSON.parse(fs.readFileSync(MCP_CONFIG_FILE, 'utf8')); } catch { return []; }
|
|
144
|
+
const errors = [];
|
|
145
|
+
for (const [name, spec] of Object.entries(cfg)) {
|
|
146
|
+
if (!spec?.command) { errors.push(`mcp: ${name} has no command`); continue; }
|
|
147
|
+
if (this.servers.has(name)) continue;
|
|
148
|
+
try {
|
|
149
|
+
const server = new McpServer(name, spec);
|
|
150
|
+
await server.start();
|
|
151
|
+
this.servers.set(name, server);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
errors.push(err.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return errors;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// native-style tool descriptors, namespaced mcp_<server>_<tool>
|
|
160
|
+
async allTools() {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const [serverName, server] of this.servers) {
|
|
163
|
+
for (const t of await server.listTools()) {
|
|
164
|
+
out.push({
|
|
165
|
+
name: `mcp_${serverName}_${t.name}`.slice(0, 64).replace(/[^a-zA-Z0-9_]/g, '_'),
|
|
166
|
+
description: `[MCP ${serverName}] ${t.description}`.trim(),
|
|
167
|
+
parameters: jsonSchemaToParameters(t.inputSchema),
|
|
168
|
+
mcp: { server: serverName, tool: t.name },
|
|
169
|
+
allowedInPlan: false
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
hasTools() {
|
|
177
|
+
for (const s of this.servers.values()) if (!s.dead && s.tools.length) return true;
|
|
178
|
+
return this.servers.size > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async call(serverName, toolName, args) {
|
|
182
|
+
const server = this.servers.get(serverName);
|
|
183
|
+
if (!server) return { output: `Error: unknown MCP server ${serverName}` };
|
|
184
|
+
if (server.dead) return { output: `Error: MCP server ${serverName} is not running` };
|
|
185
|
+
return server.callTool(toolName, args);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
killAll() {
|
|
189
|
+
for (const s of this.servers.values()) s.kill();
|
|
190
|
+
this.servers.clear();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function jsonSchemaToParameters(schema) {
|
|
195
|
+
// OpenAI function parameters are JSON Schema; pass through with light sanitation
|
|
196
|
+
const s = schema && typeof schema === 'object' ? schema : { type: 'object' };
|
|
197
|
+
if (s.type !== 'object') return { type: 'object', properties: {} };
|
|
198
|
+
return { type: 'object', properties: s.properties ?? {}, required: s.required ?? [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function mcpConfigured() {
|
|
202
|
+
try { return Object.keys(JSON.parse(fs.readFileSync(MCP_CONFIG_FILE, 'utf8'))).length > 0; } catch { return false; }
|
|
203
|
+
}
|
package/src/session.js
CHANGED
|
@@ -8,6 +8,7 @@ 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 { mcpConfigured } from './mcp.js';
|
|
11
12
|
|
|
12
13
|
const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
13
14
|
|
|
@@ -41,6 +42,7 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
41
42
|
let closed = false;
|
|
42
43
|
const approved = new Set(); // session-wide "always allow" grants
|
|
43
44
|
const pendingLines = [];
|
|
45
|
+
const steerQueue = []; // notes typed while a task runs, injected mid-task
|
|
44
46
|
|
|
45
47
|
const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
46
48
|
|
|
@@ -99,11 +101,26 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
99
101
|
const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
|
|
100
102
|
say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
|
|
101
103
|
},
|
|
104
|
+
onAgentStart: (id, input) => { stopSpinner(); say(cyan(' ◆ spawn ' + id) + gray(` role=${input.role ?? '?'} task=${trunc(String(input.objective ?? ''), 70)}`)); },
|
|
105
|
+
onAgentEnd: (id, r) => { stopSpinner(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
|
|
106
|
+
onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
|
|
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); },
|
|
102
111
|
onApprove: async (cat, name, input2) => {
|
|
103
112
|
stopSpinner();
|
|
104
113
|
say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
|
|
105
|
-
const a = await ask(' [y] once · [a]
|
|
114
|
+
const a = await ask(' [y] once · [a] this session · [s] always (save) · [n] no: ');
|
|
106
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
|
+
}
|
|
107
124
|
if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
|
|
108
125
|
if (c === 'y' || c === 'yes') return true;
|
|
109
126
|
say(dim(' denied.'));
|
|
@@ -138,6 +155,8 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
138
155
|
|
|
139
156
|
async function afterTask() {
|
|
140
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));
|
|
141
160
|
if (TUI) { drawStatus(); scrollRegion(); return; }
|
|
142
161
|
plainPrompt();
|
|
143
162
|
while (!busy && !closed) {
|
|
@@ -156,9 +175,13 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
156
175
|
say(' ' + cyan('/perm') + ' permissions: /perm auto | /perm safe | /perm');
|
|
157
176
|
say(' ' + cyan('/config') + ' show provider config (key hidden)');
|
|
158
177
|
say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
|
|
178
|
+
say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
|
|
179
|
+
say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
|
|
159
180
|
say(' ' + cyan('/clear') + ' forget this conversation');
|
|
160
181
|
say(' ' + cyan('/setup') + ' redo provider setup');
|
|
161
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'));
|
|
162
185
|
say(' ' + cyan('/exit') + ' quit');
|
|
163
186
|
},
|
|
164
187
|
'/config': () => {
|
|
@@ -179,7 +202,12 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
179
202
|
|
|
180
203
|
async function handle(input) {
|
|
181
204
|
if (!input) return;
|
|
182
|
-
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
|
+
}
|
|
183
211
|
if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
|
|
184
212
|
if (input === '/help' || input === '?') return commands['/help']();
|
|
185
213
|
if (input === '/config') return commands['/config']();
|
|
@@ -241,6 +269,47 @@ export async function startSession(cfg, { fresh = false } = {}) {
|
|
|
241
269
|
busy = false;
|
|
242
270
|
return afterTask();
|
|
243
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
|
+
}
|
|
290
|
+
if (input === '/mcp' || input === '/mcp reload') {
|
|
291
|
+
if (!mcpConfigured()) {
|
|
292
|
+
say(yellow('No MCP servers configured.') + dim(' Add them to ~/.ineedcodes/mcp.json, e.g.: {"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"]}}'));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
busy = true;
|
|
296
|
+
try {
|
|
297
|
+
const { McpManager } = await import('./mcp.js');
|
|
298
|
+
const mgr = new McpManager();
|
|
299
|
+
const errors = await mgr.loadFromConfig();
|
|
300
|
+
for (const e of errors) say(red(' ✗ ' + e));
|
|
301
|
+
const tools = await mgr.allTools();
|
|
302
|
+
if (tools.length) {
|
|
303
|
+
say(green(` ${mgr.servers.size} MCP server(s), ${tools.length} tool(s):`));
|
|
304
|
+
for (const t of tools) say(' ' + cyan(t.name) + gray(' ' + trunc(t.description, 90)));
|
|
305
|
+
} else if (!errors.length) {
|
|
306
|
+
say(yellow(' Servers connected but exposed no tools.'));
|
|
307
|
+
}
|
|
308
|
+
mgr.killAll();
|
|
309
|
+
} catch (err) { say(red(' ✗ ' + err.message)); }
|
|
310
|
+
busy = false;
|
|
311
|
+
return afterTask();
|
|
312
|
+
}
|
|
244
313
|
if (input === '/setup' || input === '/reset') {
|
|
245
314
|
clearConfig();
|
|
246
315
|
say(dim('Config cleared. Running setup...'));
|
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.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);
|