ineedcodes 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.2.0",
3
+ "version": "1.3.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/boost.js ADDED
@@ -0,0 +1,50 @@
1
+ // boost.js: isolated execution in a git worktree (master prompt #19-20).
2
+ // Work happens away from the user's tree; reconcile only after verification.
3
+
4
+ import { spawnSync } from 'node:child_process';
5
+ import * as path from 'node:path';
6
+ import * as os from 'node:os';
7
+
8
+ function git(args, cwd) {
9
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
10
+ return { ok: r.status === 0, out: ((r.stdout ?? '') + (r.stderr ?? '')).trim() };
11
+ }
12
+
13
+ export function boostAvailable(cwd) {
14
+ const inside = git(['rev-parse', '--is-inside-work-tree'], cwd);
15
+ return inside.ok && inside.out === 'true';
16
+ }
17
+
18
+ export function currentBranch(cwd) {
19
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd).out;
20
+ }
21
+
22
+ export function startBoost(cwd) {
23
+ const n = Date.now() % 1_000_000;
24
+ const dir = path.join(os.tmpdir(), 'ineed-boost-' + n);
25
+ const branch = 'ineed-boost-' + n;
26
+ const r = git(['worktree', 'add', '-b', branch, dir], cwd);
27
+ if (!r.ok) return { ok: false, error: r.out };
28
+ return { ok: true, dir, branch };
29
+ }
30
+
31
+ export function commitBoost(dir, message) {
32
+ git(['add', '-A'], dir);
33
+ const r = git(['commit', '-m', message, '--allow-empty'], dir);
34
+ return r.ok;
35
+ }
36
+
37
+ export function boostDiff(dir) {
38
+ const stat = git(['diff', 'HEAD~1', '--stat'], dir);
39
+ const files = git(['diff', 'HEAD~1', '--name-only'], dir);
40
+ return { stat: stat.out, files: files.out.split('\n').filter(Boolean) };
41
+ }
42
+
43
+ export function mergeBoost(cwd, branch) {
44
+ return git(['merge', '--no-edit', branch], cwd);
45
+ }
46
+
47
+ export function cleanupBoost(cwd, dir, branch) {
48
+ git(['worktree', 'remove', '--force', dir], cwd);
49
+ git(['branch', '-D', branch], cwd);
50
+ }
package/src/cli.js CHANGED
@@ -95,5 +95,12 @@ if (args.length > 0 && args[0] !== '--reset') {
95
95
  }
96
96
  const { startSession } = await import('./session.js');
97
97
  const wasFresh = fresh || process.env.INEED_FRESH === '1';
98
- await startSession(cfg, { fresh: wasFresh });
98
+ let resumeHistory = null;
99
+ if (args[0] === '--resume' || args[0] === '-r') {
100
+ const { listSessions } = await import('./sessions.js');
101
+ const latest = listSessions()[0];
102
+ if (latest?.history?.length) { resumeHistory = latest.history; console.log(dim(`Resuming ${Math.floor(latest.history.length / 2)} turns.`)); }
103
+ else console.log(dim('No saved session found. Starting fresh.'));
104
+ }
105
+ await startSession(cfg, { fresh: wasFresh, resume: resumeHistory });
99
106
  }
package/src/config.js CHANGED
@@ -6,6 +6,7 @@ import * as path from 'node:path';
6
6
 
7
7
  export const CONFIG_DIR = process.env.INEED_CONFIG_DIR || path.join(os.homedir(), '.ineedcodes');
8
8
  export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+ export { CONFIG_DIR as configDirPath };
9
10
 
10
11
  export function loadConfig() {
11
12
  try {
package/src/session.js CHANGED
@@ -8,10 +8,36 @@ import { fetchModels } from './provider.js';
8
8
  import { makeInput, bold, dim, red, green, yellow, cyan, gray, trunc, BANNER, logo, box, startSpinner, VERSION, RULE, userBubble, screen } from './ui.js';
9
9
  import { wizard } from './wizard.js';
10
10
  import { getMemoryProvider, ICMAdapter } from './memory.js';
11
+ import { saveSession, listSessions, loadSession } from './sessions.js';
12
+ import * as boost from './boost.js';
11
13
  import { mcpConfigured } from './mcp.js';
12
14
 
13
15
  const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
14
16
 
17
+ // Context compaction (master prompt #32): when the saved conversation grows past the
18
+ // cap, summarize the oldest half into a factual checkpoint and drop the raw turns.
19
+ const COMPACT_CHARS = 24_000;
20
+ async function compactHistory(cfg, history, hooks = {}) {
21
+ const size = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
22
+ if (size < COMPACT_CHARS || history.length < 6) return history;
23
+ const cut = Math.floor(history.length / 2);
24
+ const old = history.slice(0, cut);
25
+ const rest = history.slice(cut);
26
+ const digest = old.map(m => `${m.role}: ${String(m.content ?? '').replaceAll('\n', ' ').slice(0, 160)}`).join('\n');
27
+ try {
28
+ const { chat } = await import('./provider.js');
29
+ const msg = await chat({ ...cfg, reasoning: 'low' }, [
30
+ { role: 'user', content: `Summarize this conversation into a factual checkpoint: goals, decisions, files touched, unresolved work. Max 12 lines. No prose flourish.\n---\n${digest.slice(0, 10_000)}` }
31
+ ]);
32
+ const summary = String(msg.content ?? '').trim();
33
+ if (summary.length > 20) {
34
+ hooks.onNote?.(`compacted ${cut} turns into a checkpoint (${(size / 1000).toFixed(0)}k -> ${((summary.length + rest.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0)) / 1000).toFixed(0)}k chars)`);
35
+ return [{ role: 'user', content: '[conversation checkpoint] ' + summary }, { role: 'assistant', content: 'Checkpoint noted. Continuing from there.' }, ...rest];
36
+ }
37
+ } catch {}
38
+ return history.slice(-10); // provider unavailable: keep the newest turns
39
+ }
40
+
15
41
  function wrapLines(text, width) {
16
42
  const out = [];
17
43
  for (const raw of String(text).split('\n')) {
@@ -32,9 +58,9 @@ function wrapLines(text, width) {
32
58
  return out;
33
59
  }
34
60
 
35
- export async function startSession(cfg, { fresh = false } = {}) {
61
+ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
36
62
  const state = normalize(cfg);
37
- let history = [];
63
+ let history = resume?.length ? [...resume] : [];
38
64
  let busy = false;
39
65
  let activeRun = null;
40
66
  let mode = state.mode;
@@ -45,6 +71,8 @@ export async function startSession(cfg, { fresh = false } = {}) {
45
71
  const steerQueue = []; // notes typed while a task runs, injected mid-task
46
72
 
47
73
  const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
74
+ let sessionId = null;
75
+ let lastBoost = null;
48
76
 
49
77
  // ONE readline, ONE line dispatcher for the whole session
50
78
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -79,59 +107,67 @@ export async function startSession(cfg, { fresh = false } = {}) {
79
107
  if (TUI) drawStatus();
80
108
  }
81
109
 
110
+ function hooksForRun(stopSpinner) {
111
+ let spinner = null;
112
+ const stop = () => { spinner?.stop(); spinner = null; };
113
+ return {
114
+ spinnerStop: stop,
115
+ onMemoryStart: () => { stop(); spinner = startSpinner('recalling memory'); },
116
+ onMemoryEnd: () => stop(),
117
+ onThinkingStart: () => { stop(); spinner = startSpinner('thinking'); },
118
+ onThinkingEnd: () => stop(),
119
+ onWorkStart: label => { stop(); spinner = startSpinner(label || 'working'); },
120
+ onWorkEnd: () => stop(),
121
+ onTool: (name, input2) => { stop(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
122
+ onResult: out => { say(gray(' ' + trunc(out, 110))); },
123
+ onText: t => { stop(); },
124
+ onTodos: list => {
125
+ stop();
126
+ const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
127
+ say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
128
+ },
129
+ onAgentStart: (id, input) => { stop(); say(cyan(' ◆ spawn ' + id) + gray(` role=${input.role ?? '?'} task=${trunc(String(input.objective ?? ''), 70)}`)); },
130
+ onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
131
+ onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
132
+ onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
133
+ onNote: note => { stop(); say(dim(' ◇ ' + note)); },
134
+ drainSteer: () => steerQueue.splice(0),
135
+ onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
136
+ onApprove: async (cat, name, input2) => {
137
+ stop();
138
+ say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
139
+ const a = await ask(' [y] once · [a] this session · [s] always (save) · [n] no: ');
140
+ const c = a.trim().toLowerCase();
141
+ if (c === 's' || c === 'save') {
142
+ approved.add(cat);
143
+ if (cat === 'edit') Object.assign(state, normalize({ ...state, permEdit: 'allow' }));
144
+ if (cat === 'shell') Object.assign(state, normalize({ ...state, permShell: 'allow' }));
145
+ saveConfig(state);
146
+ say(dim(' always allowed, saved to config. /perm safe to undo.'));
147
+ return 'always';
148
+ }
149
+ if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
150
+ if (c === 'y' || c === 'yes') return true;
151
+ say(dim(' denied.'));
152
+ return false;
153
+ },
154
+ approved,
155
+ onRunStart: c => { activeRun = c; },
156
+ onRunEnd: () => { activeRun = null; stop(); }
157
+ };
158
+ }
159
+
82
160
  async function runTask(input) {
83
161
  busy = true;
84
- let lastStreamed = '';
85
162
  if (TUI) tuiUserLine(input);
86
- let spinner = null;
87
- const stopSpinner = () => { spinner?.stop(); spinner = null; };
163
+ const hooks = hooksForRun();
164
+ const stopSpinner = hooks.spinnerStop;
88
165
  try {
89
- const res = await runObjective(state, input, process.cwd(), history, {
90
- onMemoryStart: () => { stopSpinner(); spinner = startSpinner('recalling memory'); },
91
- onMemoryEnd: () => stopSpinner(),
92
- onThinkingStart: () => { stopSpinner(); spinner = startSpinner('thinking'); },
93
- onThinkingEnd: () => stopSpinner(),
94
- onWorkStart: label => { stopSpinner(); spinner = startSpinner(label || 'working'); },
95
- onWorkEnd: () => stopSpinner(),
96
- onTool: (name, input2) => { stopSpinner(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
97
- onResult: out => { say(gray(' ' + trunc(out, 110))); },
98
- onText: t => { stopSpinner(); lastStreamed = t; },
99
- onTodos: list => {
100
- stopSpinner();
101
- const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
102
- say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
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); },
111
- onApprove: async (cat, name, input2) => {
112
- stopSpinner();
113
- say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
114
- const a = await ask(' [y] once · [a] this session · [s] always (save) · [n] no: ');
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
- }
124
- if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
125
- if (c === 'y' || c === 'yes') return true;
126
- say(dim(' denied.'));
127
- return false;
128
- },
129
- approved,
130
- onRunStart: c => { activeRun = c; },
131
- onRunEnd: () => { activeRun = null; stopSpinner(); }
132
- });
166
+ const res = await runObjective(state, input, process.cwd(), history, hooks);
133
167
  history = pushTurn(history, input, res);
134
168
  stopSpinner();
169
+ try { history = await compactHistory(state, history, { onNote: n => say(dim(' ◇ ' + n)) }); } catch {}
170
+ try { sessionId = saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history }); } catch {}
135
171
  if (res.aborted) {
136
172
  say(yellow(' ■ Stopped') + dim(' - partly done. Ask me to continue.'));
137
173
  } else {
@@ -144,6 +180,7 @@ export async function startSession(cfg, { fresh = false } = {}) {
144
180
  } catch (err) {
145
181
  stopSpinner();
146
182
  history = pushTurn(history, input, { answer: '(task failed: ' + err.message + ')' });
183
+ try { sessionId = saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history }); } catch {}
147
184
  say(red(' ✗ ' + err.message) + dim(' context kept.'));
148
185
  } finally {
149
186
  busy = false;
@@ -173,8 +210,10 @@ export async function startSession(cfg, { fresh = false } = {}) {
173
210
  say(' ' + cyan('/build') + ' build mode: real changes (default)');
174
211
  say(' ' + cyan('/reason') + ' toggle reasoning low/high');
175
212
  say(' ' + cyan('/perm') + ' permissions: /perm auto | /perm safe | /perm');
213
+ say(' ' + cyan('/boost') + ' isolated git-worktree run: /boost <objective>');
176
214
  say(' ' + cyan('/config') + ' show provider config (key hidden)');
177
215
  say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
216
+ say(' ' + cyan('/resume') + ' bring back a saved conversation');
178
217
  say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
179
218
  say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
180
219
  say(' ' + cyan('/clear') + ' forget this conversation');
@@ -287,6 +326,77 @@ export async function startSession(cfg, { fresh = false } = {}) {
287
326
  ]));
288
327
  return;
289
328
  }
329
+ if (input === '/boost cancel') {
330
+ if (!lastBoost) { say(dim('No boost run to cancel.')); return; }
331
+ const { dir, branch } = lastBoost;
332
+ boost.cleanupBoost(process.cwd(), dir, branch);
333
+ lastBoost = null;
334
+ say(yellow('Boost worktree and branch removed.'));
335
+ return;
336
+ }
337
+ if (input === '/boost' || input.startsWith('/boost ')) {
338
+ const objective = input.slice(6).trim();
339
+ if (!objective) {
340
+ say(box([
341
+ bold('Boost') + dim(' isolated execution in a git worktree'),
342
+ dim('/boost <objective>') + ' run the task away from your tree, review, then merge',
343
+ dim('/boost cancel') + ' remove the last boost worktree'
344
+ ]));
345
+ return;
346
+ }
347
+ busy = true;
348
+ try {
349
+ if (!boost.boostAvailable(process.cwd())) { say(yellow('Boost needs a git repository (with a commit).')); return; }
350
+ const b = boost.startBoost(process.cwd());
351
+ if (!b.ok) { say(red('Boost failed: ' + b.error)); return; }
352
+ lastBoost = b;
353
+ say(cyan(` ⚡ boost ${b.branch}`) + dim(` worktree at ${b.dir}`));
354
+ const res = await runObjective(state, objective, b.dir, [], { ...hooksForRun(), skipMemory: true });
355
+ say((res.aborted ? yellow(' ■ Stopped') : green(' ⚡ Boost task done')) + dim(` in ${b.branch}`));
356
+ boost.commitBoost(b.dir, 'boost: ' + objective.slice(0, 80));
357
+ const d = boost.boostDiff(b.dir);
358
+ if (d.files.length) {
359
+ say(box([bold('Boost changes'), ...d.files.map(f => ' ' + f)]));
360
+ const a = await ask(` [y] merge into ${boost.currentBranch(process.cwd())} · [n] keep worktree: `);
361
+ if (/^y/i.test(a.trim())) {
362
+ const m = boost.mergeBoost(process.cwd(), b.branch);
363
+ if (m.ok) { say(green('Merged into your branch.')); boost.cleanupBoost(process.cwd(), b.dir, b.branch); lastBoost = null; }
364
+ else { say(red('Merge conflict, worktree kept: ' + m.out)); }
365
+ } else {
366
+ say(dim('Worktree kept: ' + b.dir + ' (' + b.branch + '). /boost cancel removes it.'));
367
+ }
368
+ } else {
369
+ say(dim('No file changes came out of the boost run.'));
370
+ boost.cleanupBoost(process.cwd(), b.dir, b.branch);
371
+ lastBoost = null;
372
+ }
373
+ } catch (err) {
374
+ say(red(' ✗ boost failed: ' + err.message));
375
+ } finally {
376
+ busy = false;
377
+ await afterTask();
378
+ }
379
+ return;
380
+ }
381
+ if (input === '/resume') {
382
+ busy = true;
383
+ const list = listSessions();
384
+ if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
385
+ list.slice(0, 5).forEach((s, i) => {
386
+ const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
387
+ say(` ${i + 1}. ${new Date(s.time).toLocaleString()} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
388
+ });
389
+ const pick = await ask(' Resume which? [1]: ');
390
+ const n = Number(pick) || 1;
391
+ const s = loadSession(list[n - 1]?.id);
392
+ if (s?.history?.length) {
393
+ history = s.history;
394
+ sessionId = s.id;
395
+ say(green(`Resumed ${Math.floor(s.history.length / 2)} turns. Continue where we left off.`));
396
+ } else say(red('Could not load that session.'));
397
+ busy = false;
398
+ return afterTask();
399
+ }
290
400
  if (input === '/mcp' || input === '/mcp reload') {
291
401
  if (!mcpConfigured()) {
292
402
  say(yellow('No MCP servers configured.') + dim(' Add them to ~/.ineedcodes/mcp.json, e.g.: {"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"]}}'));
@@ -0,0 +1,31 @@
1
+ // sessions.js: session persistence. Conversation checkpoints live outside the repo,
2
+ // under the config dir, so users can leave and resume work (master prompt #34).
3
+
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { CONFIG_DIR as configDirPath } from './config.js';
7
+ const DIR = path.join(configDirPath, 'sessions');
8
+
9
+
10
+ export function saveSession(data) {
11
+ fs.mkdirSync(DIR, { recursive: true });
12
+ const id = data.id || 's_' + Date.now();
13
+ fs.writeFileSync(path.join(DIR, id + '.json'), JSON.stringify({ ...data, id, time: Date.now() }, null, 2), { mode: 0o600 });
14
+ return id;
15
+ }
16
+
17
+ export function listSessions() {
18
+ try {
19
+ return fs.readdirSync(DIR)
20
+ .filter(f => f.endsWith('.json'))
21
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')); } catch { return null; } })
22
+ .filter(Boolean)
23
+ .sort((a, b) => b.time - a.time)
24
+ .slice(0, 20);
25
+ } catch { return []; }
26
+ }
27
+
28
+ export function loadSession(id) {
29
+ if (!id) return null;
30
+ try { return JSON.parse(fs.readFileSync(path.join(DIR, id + '.json'), 'utf8')); } catch { return null; }
31
+ }
package/src/ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // ui.js: terminal helpers. No dependencies, respects NO_COLOR and non-TTY.
2
2
 
3
- export const VERSION = '1.2.0';
3
+ export const VERSION = '1.3.0';
4
4
 
5
5
  const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
6
6
  const wrap = (code, t) => USE_COLOR ? `\x1b[${code}m${t}\x1b[0m` : String(t);