ineedcodes 1.0.1 → 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/salsabila2507/ineedcodes/main/assets/logo.svg" alt="ineed" width="320">
3
+ <img src="https://raw.githubusercontent.com/salsabila2507/ineedcodes/main/assets/logo.svg" alt="ineed" width="480">
4
4
 
5
5
  **Your terminal, now autonomous.**
6
6
 
@@ -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
- | `/clear` | forget the current conversation |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.0.1",
3
+ "version": "1.1.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
@@ -3,6 +3,7 @@
3
3
  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
+ import { getMemoryProvider } from './memory.js';
6
7
 
7
8
  export const MAX_STEPS = 30;
8
9
  export const MAX_HISTORY_CHARS = 30_000;
@@ -13,7 +14,9 @@ Rules:
13
14
  - Use the tools to do real work. Never invent output. Every success claim needs evidence from a tool result.
14
15
  - Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
15
16
  - Never push to remotes or delete data without being asked.
16
- - Destructive commands are blocked. Ask the user to run those themselves.
17
+ - Destructive commands are always blocked. Ask the user to run those themselves.
18
+ - 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
+ - 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).
17
20
  - 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:
18
21
  What changed, what you ran, the evidence you saw.`;
19
22
 
@@ -27,21 +30,126 @@ export function trimHistory(history) {
27
30
  return history.slice(start);
28
31
  }
29
32
 
30
- export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
33
+ // ── multi-agent: roles, task packets, worker execution ──
34
+ const ROLES = {
35
+ research: {
36
+ readonly: true,
37
+ tools: ['list_files', 'read_file', 'search_text', 'todo'],
38
+ prompt: 'You are a research worker. Gather facts and report them. Change nothing.'
39
+ },
40
+ review: {
41
+ readonly: true,
42
+ tools: ['list_files', 'read_file', 'search_text', 'todo'],
43
+ prompt: 'You are a review worker. Inspect the code for correctness, bugs, and quality. Report findings, change nothing.'
44
+ },
45
+ test: {
46
+ readonly: false,
47
+ tools: ['list_files', 'read_file', 'search_text', 'shell', 'todo'],
48
+ prompt: 'You are a test worker. Run the relevant tests or commands and report the evidence. Do not modify source files.'
49
+ },
50
+ implement: {
51
+ readonly: false,
52
+ tools: null, // all tools
53
+ prompt: 'You are an implementation worker. Make the change the lead asked for, verify it works, and report what you did.'
54
+ },
55
+ debug: {
56
+ readonly: false,
57
+ tools: null, // all tools
58
+ prompt: 'You are a debugging worker. Find the root cause, fix it if you can, and report cause plus evidence.'
59
+ }
60
+ };
61
+
62
+ let workerSeq = 0;
63
+
64
+ function workerResultText(r) {
65
+ const files = r.files?.length ? ` files: ${r.files.join(', ')};` : '';
66
+ const cmds = r.commands?.length ? ` ran ${r.commands.length} command(s);` : '';
67
+ return `Worker ${r.id} [${r.status}]: ${String(r.summary).slice(0, 800)}.${files}${cmds}`;
68
+ }
69
+
70
+ async function runWorker(cfg, spec, cwd, depth, hooks) {
71
+ const roleName = ROLES[spec.input.role] ? spec.input.role : 'research';
72
+ const role = ROLES[roleName];
73
+ const objective = String(spec.input.objective ?? '')
74
+ + (spec.input.context ? `\nContext from lead agent: ${String(spec.input.context).slice(0, 1_000)}` : '');
75
+ try {
76
+ const res = await runObjective(cfg, objective, cwd, [], {}, {
77
+ depth: depth + 1,
78
+ toolFilter: role.tools,
79
+ worker: { id: spec.id, role: roleName, prompt: role.prompt }
80
+ });
81
+ return { id: spec.id, role: roleName, status: res.aborted ? 'incomplete' : 'completed', summary: res.answer || '(no output)', files: res.changed, commands: res.ran };
82
+ } catch (err) {
83
+ return { id: spec.id, role: roleName, status: 'failed', summary: err.message, files: [], commands: [] };
84
+ }
85
+ }
86
+
87
+ const SPAWN_TOOL = {
88
+ name: 'spawn_agent',
89
+ 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.',
90
+ parameters: {
91
+ type: 'object',
92
+ properties: {
93
+ role: { type: 'string', enum: Object.keys(ROLES) },
94
+ objective: { type: 'string', description: 'the exact task for this worker' },
95
+ context: { type: 'string', description: 'relevant context: files, errors, constraints' }
96
+ },
97
+ required: ['role', 'objective']
98
+ },
99
+ allowedInPlan: true
100
+ };
101
+
102
+ export async function runObjective(cfg, objective, cwd, history, hooks = {}, extra = {}) {
31
103
  const ctrl = new AbortController();
32
104
  hooks.onRunStart?.(ctrl);
33
105
  const plan = cfg.mode === 'plan';
34
- const tools = plan ? TOOLS.filter(t => t.allowedInPlan) : TOOLS;
106
+ const depth = extra.depth ?? 0;
107
+ let tools = plan ? TOOLS.filter(t => t.allowedInPlan) : [...TOOLS, SPAWN_TOOL];
108
+ if (extra.toolFilter) tools = tools.filter(t => (extra.toolFilter).includes(t.name));
109
+ const canAsk = typeof hooks.onApprove === 'function';
110
+
111
+ // MCP: load configured servers once per top-level objective, expose their tools
112
+ let mcpManager = null;
113
+ const mcpMap = new Map();
114
+ if (depth === 0 && !plan && cfg.mcp !== false) {
115
+ try {
116
+ const { McpManager, mcpConfigured } = await import('./mcp.js');
117
+ if (mcpConfigured()) {
118
+ mcpManager = new McpManager();
119
+ const errors = await mcpManager.loadFromConfig();
120
+ errors.forEach(e => hooks.onText?.(dim(e)));
121
+ const mcpTools = await mcpManager.allTools();
122
+ const names = new Set(tools.map(t => t.name));
123
+ for (const t of mcpTools) {
124
+ if (!names.has(t.name)) { tools.push(t); mcpMap.set(t.name, t.mcp); names.add(t.name); }
125
+ }
126
+ hooks.onMCP?.(mcpTools.map(t => t.name));
127
+ }
128
+ } catch {}
129
+ }
130
+
131
+ // recall durable memory before meaningful work (rule 12/15: MemoryProvider abstraction)
132
+ const memory = extra.skipMemory ? null : getMemoryProvider(cfg);
133
+ let recalled = '';
134
+ if (memory) {
135
+ hooks.onMemoryStart?.();
136
+ try { recalled = await memory.recall(objective); } catch { recalled = ''; }
137
+ hooks.onMemoryEnd?.(recalled);
138
+ }
139
+
140
+ const workerPrefix = extra.worker ? `You are ${extra.worker.id} (${extra.worker.role} worker) spawned by the lead agent. ${extra.worker.prompt}\n` : '';
35
141
  const messages = [
36
142
  {
37
143
  role: 'system',
38
- content: `${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
144
+ content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
145
+ + (recalled ? `\nRelevant memory from previous sessions with this user (durable facts, may be stale):\n${recalled}` : '')
39
146
  },
40
147
  ...trimHistory(history),
41
148
  { role: 'user', content: objective }
42
149
  ];
43
150
  const changed = new Set();
44
151
  const ran = [];
152
+ const todos = [];
45
153
  let answer = '';
46
154
  let lastShown = '';
47
155
  try {
@@ -49,8 +157,11 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
49
157
  if (ctrl.signal.aborted) break;
50
158
  let msg;
51
159
  try {
160
+ hooks.onThinkingStart?.();
52
161
  msg = await chat(cfg, messages, tools, ctrl.signal);
162
+ hooks.onThinkingEnd?.();
53
163
  } catch (err) {
164
+ hooks.onThinkingEnd?.();
54
165
  if (ctrl.signal.aborted) break;
55
166
  throw err;
56
167
  }
@@ -62,28 +173,91 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
62
173
  }
63
174
  const calls = msg.tool_calls ?? [];
64
175
  if (calls.length === 0) {
65
- return { answer, changed: [...changed], ran, aborted: false };
176
+ // task finished: store durable knowledge only when something actually changed
177
+ if (memory && answer && (changed.size > 0 || ran.length > 0) && !ctrl.signal.aborted) {
178
+ try {
179
+ await memory.store(`project ${cwd}: ${objective.slice(0, 150)} -> ${answer.slice(0, 300)}`);
180
+ } catch {}
181
+ }
182
+ return { answer, changed: [...changed], ran, todos: [...todos], aborted: false };
66
183
  }
184
+ // spawn_agent pre-pass: read-only workers run in parallel (max 4), writers sequentially
185
+ const spawnResults = new Map();
186
+ const spawnCalls = calls.filter(c => c.function?.name === 'spawn_agent');
187
+ if (spawnCalls.length && depth === 0) {
188
+ const specs = spawnCalls.map(c => {
189
+ let input = {};
190
+ try { input = JSON.parse(c.function?.arguments || '{}'); } catch {}
191
+ const role = ROLES[input.role] ? input.role : 'research';
192
+ return { call: c, input, role, id: `${role}-${++workerSeq}` };
193
+ });
194
+ const runOne = async s => {
195
+ hooks.onAgentStart?.(s.id, s.input);
196
+ const r = await runWorker(cfg, s, cwd, depth, hooks);
197
+ hooks.onAgentEnd?.(s.id, r);
198
+ spawnResults.set(s.call.id, r);
199
+ };
200
+ const readonly = specs.filter(s => ROLES[s.role].readonly).slice(0, 4);
201
+ const writers = specs.filter(s => !ROLES[s.role].readonly);
202
+ for (let i = 0; i < readonly.length; i += 4) {
203
+ await Promise.all(readonly.slice(i, i + 4).map(runOne));
204
+ }
205
+ for (const s of writers) await runOne(s);
206
+ }
207
+
67
208
  for (const call of calls) {
68
209
  let input = {};
69
210
  try { input = JSON.parse(call.function?.arguments || '{}'); } catch {}
70
211
  hooks.onTool?.(call.function?.name, input);
71
212
  let result;
72
- if (call.function?.name === 'shell') {
213
+ if (spawnResults.has(call.id)) {
214
+ result = { output: workerResultText(spawnResults.get(call.id)) };
215
+ } else if (call.function?.name === 'spawn_agent') {
216
+ result = { output: 'Refused: workers cannot spawn more agents.' };
217
+ } else if (mcpMap.has(call.function?.name)) {
218
+ const m = mcpMap.get(call.function?.name);
219
+ result = await mcpManager.call(m.server, m.tool, input);
220
+ hooks.onMCPResult?.(call.function?.name, result.output);
221
+ } else if (call.function?.name === 'shell') {
73
222
  if (plan) result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
74
223
  else if (isDestructive(String(input.command ?? ''))) {
75
224
  result = { output: 'Refused: that command is destructive. Run it yourself if you are sure.' };
76
- } else {
225
+ } else if (cfg.permShell !== 'allow' && !hooks.approved?.has('shell')) {
226
+ const verdict = canAsk ? await hooks.onApprove('shell', 'shell', input) : true; // cannot ask: CI-style allow
227
+ if (verdict === 'always') hooks.approved?.add('shell');
228
+ if (!verdict) {
229
+ result = { output: 'Denied: the user did not approve this shell command.' };
230
+ }
231
+ }
232
+ if (!result) {
233
+ hooks.onWorkStart?.(`running: ${trunc(String(input.command ?? ''), 60)}`);
77
234
  result = await shellRun(String(input.command ?? ''), cwd, ctrl.signal);
235
+ hooks.onWorkEnd?.();
78
236
  ran.push(String(input.command ?? '').slice(0, 120));
79
237
  }
80
238
  } else {
81
- if (plan && !TOOLS.find(t => t.name === call.function?.name)?.allowedInPlan) {
239
+ if (plan && !tools.find(t => t.name === call.function?.name)) {
82
240
  result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
241
+ } else if (call.function?.name === 'todo') {
242
+ const list = Array.isArray(input.todos) ? input.todos : [];
243
+ todos.splice(0, todos.length, ...list.slice(0, 50).map(t => ({
244
+ content: String(t.content ?? '').slice(0, 200),
245
+ status: ['pending', 'in_progress', 'completed'].includes(t.status) ? t.status : 'pending'
246
+ })));
247
+ hooks.onTodos?.([...todos]);
248
+ result = { output: `Todo list updated (${todos.filter(t => t.status === 'completed').length}/${todos.length} done).` };
83
249
  } else {
84
- result = runTool(call.function?.name, input, cwd);
85
- if (!plan && ['write_file', 'edit_file', 'delete_file'].includes(call.function?.name)
86
- && !/^(Refused|Error)/.test(String(result.output))) {
250
+ const name = call.function?.name;
251
+ const isEdit = ['write_file', 'edit_file', 'delete_file'].includes(name);
252
+ let allowedNow = true;
253
+ if (isEdit && cfg.permEdit !== 'allow' && !hooks.approved?.has('edit')) {
254
+ const verdict = canAsk ? await hooks.onApprove('edit', name, input) : true; // cannot ask: CI-style allow
255
+ if (verdict === 'always') hooks.approved?.add('edit');
256
+ allowedNow = Boolean(verdict);
257
+ }
258
+ result = allowedNow ? runTool(name, input, cwd) : { output: `Denied: the user did not approve ${name}.` };
259
+ if (allowedNow && !plan && isEdit
260
+ && !/^(Refused|Error|Denied)/.test(String(result.output))) {
87
261
  changed.add(String(input.path ?? ''));
88
262
  }
89
263
  }
@@ -96,7 +270,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
96
270
  hooks.onRunEnd?.();
97
271
  }
98
272
  const stopped = ctrl.signal.aborted;
99
- return { answer, changed: [...changed], ran, aborted: true, stopped };
273
+ return { answer, changed: [...changed], ran, todos: [...todos], aborted: true, stopped };
100
274
  }
101
275
 
102
276
  export function pushTurn(history, objective, result) {
package/src/cli.js CHANGED
@@ -2,7 +2,6 @@
2
2
  // ineed: your terminal, now autonomous.
3
3
  // First open asks for provider setup. After that, just say what you want.
4
4
 
5
- import * as fs from 'node:fs';
6
5
  import { spawn } from 'node:child_process';
7
6
 
8
7
  const [MAJOR] = process.versions.node.split('.').map(Number);
@@ -13,7 +12,7 @@ if (!(MAJOR >= 20)) {
13
12
  }
14
13
 
15
14
  const { loadConfig } = await import('./config.js');
16
- const { VERSION, bold, dim, red, green, yellow } = await import('./ui.js');
15
+ const { VERSION, bold, dim, red, green, yellow, cyan, box } = await import('./ui.js');
17
16
 
18
17
  const args = process.argv.slice(2);
19
18
 
@@ -46,7 +45,12 @@ if (args[0] === '--child') {
46
45
  process.exit(1);
47
46
  }
48
47
  try {
49
- const res = await runObjective(cfg, task, process.cwd(), []);
48
+ const res = await runObjective(cfg, task, process.cwd(), [], {
49
+ onTodos: list => {
50
+ const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
51
+ console.log(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
52
+ }
53
+ });
50
54
  if (res.aborted) {
51
55
  console.log('\n' + yellow('Stopped.') + dim(' Task did not finish (step limit or Ctrl+C). Re-run to continue.'));
52
56
  process.exit(2);
@@ -69,7 +73,6 @@ if (args.length > 0 && args[0] !== '--reset') {
69
73
  const readline = await import('node:readline');
70
74
  const { makeInput } = await import('./ui.js');
71
75
  const { wizard } = await import('./wizard.js');
72
- const { startSession } = await import('./session.js');
73
76
  const { clearConfig } = await import('./config.js');
74
77
 
75
78
  if (args[0] === '--reset') {
@@ -78,6 +81,7 @@ if (args.length > 0 && args[0] !== '--reset') {
78
81
  }
79
82
 
80
83
  let cfg = loadConfig();
84
+ const fresh = !cfg;
81
85
  if (!cfg) {
82
86
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
87
  const ask = makeInput(rl);
@@ -89,5 +93,7 @@ if (args.length > 0 && args[0] !== '--reset') {
89
93
  }
90
94
  rl.close();
91
95
  }
92
- await startSession(cfg);
96
+ const { startSession } = await import('./session.js');
97
+ const wasFresh = fresh || process.env.INEED_FRESH === '1';
98
+ await startSession(cfg, { fresh: wasFresh });
93
99
  }
package/src/config.js CHANGED
@@ -22,7 +22,11 @@ export function normalize(c) {
22
22
  apiKey: String(c.apiKey ?? ''),
23
23
  model: String(c.model),
24
24
  reasoning: c.reasoning === 'high' ? 'high' : 'low',
25
- mode: c.mode === 'plan' ? 'plan' : 'build'
25
+ mode: c.mode === 'plan' ? 'plan' : 'build',
26
+ memory: c.memory !== false,
27
+ mcp: c.mcp !== false,
28
+ permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
29
+ permShell: c.permShell === 'allow' ? 'allow' : 'ask'
26
30
  };
27
31
  }
28
32
 
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/memory.js ADDED
@@ -0,0 +1,61 @@
1
+ // memory.js: MemoryProvider abstraction. Default adapter shells out to the `icm` CLI.
2
+ // The agent never depends on icm internals; if icm is missing or slow, memory is silently empty.
3
+
4
+ import { spawn } from 'node:child_process';
5
+
6
+ function icm(args, timeoutMs = 10_000) {
7
+ return new Promise(resolve => {
8
+ let child;
9
+ try {
10
+ child = spawn('icm', args, { stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
11
+ } catch {
12
+ resolve(null);
13
+ return;
14
+ }
15
+ let out = '';
16
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} resolve(null); }, timeoutMs);
17
+ child.stdout.on('data', c => {
18
+ out += c.toString();
19
+ if (out.length > 20_000) { try { child.kill('SIGKILL'); } catch {} }
20
+ });
21
+ child.stderr.on('data', () => {});
22
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
23
+ child.on('close', code => {
24
+ clearTimeout(timer);
25
+ resolve(code === 0 ? out.slice(0, 8_000) : null);
26
+ });
27
+ });
28
+ }
29
+
30
+ export const ICMAdapter = {
31
+ name: 'icm',
32
+
33
+ // recall relevant durable memory for an objective. Returns '' when nothing/no icm.
34
+ async recall(query) {
35
+ if (!query?.trim()) return '';
36
+ const out = await icm(['recall', query.slice(0, 200), '--limit', '3', '--read-only']);
37
+ if (!out) return '';
38
+ const lines = out.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('memories['));
39
+ const text = lines.join('\n').slice(0, 2_000);
40
+ return /no (memories|results)|\(empty\)/i.test(text) ? '' : text;
41
+ },
42
+
43
+ // store durable knowledge. Fire-and-forget friendly. Never stores secrets (caller filters).
44
+ async store(content) {
45
+ if (!content?.trim()) return false;
46
+ const out = await icm(['remember', content.trim().slice(0, 1_000)]);
47
+ return out !== null;
48
+ },
49
+
50
+ async available() {
51
+ return (await icm(['--help'], 5_000)) !== null;
52
+ }
53
+ };
54
+
55
+ // pick the provider. Only icm exists today; the abstraction keeps that swappable.
56
+ // Disable with config memory:false or env INEED_NO_MEMORY=1 (used by the test suite).
57
+ export function getMemoryProvider(cfg) {
58
+ if (cfg?.memory === false) return null;
59
+ if (process.env.INEED_NO_MEMORY === '1') return null;
60
+ return ICMAdapter;
61
+ }