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 +1 -1
- package/src/boost.js +50 -0
- package/src/cli.js +8 -1
- package/src/config.js +1 -0
- package/src/session.js +159 -49
- package/src/sessions.js +31 -0
- package/src/ui.js +1 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
87
|
-
const stopSpinner =
|
|
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"]}}'));
|
package/src/sessions.js
ADDED
|
@@ -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.
|
|
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);
|