dotdotdot-cli 1.0.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/LICENSE +21 -0
- package/README.md +707 -0
- package/bin/dotdotdot.js +170 -0
- package/lib/colors.js +244 -0
- package/lib/config.js +224 -0
- package/lib/context.js +265 -0
- package/lib/executor.js +274 -0
- package/lib/index.js +16 -0
- package/lib/llm.js +471 -0
- package/lib/menu.js +100 -0
- package/lib/planner.js +169 -0
- package/lib/postinstall.js +20 -0
- package/lib/renderer.js +145 -0
- package/lib/safety.js +71 -0
- package/lib/session.js +165 -0
- package/lib/tokens.js +291 -0
- package/lib/ui.js +207 -0
- package/package.json +56 -0
package/lib/planner.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// planner.js — Multi-step task orchestrator (compact, minimal output)
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const { bold, dim, cyan, green, red, yellow, symbols } = require('./colors');
|
|
8
|
+
const { Spinner, stepLine, taskPlan, printError, printWarning, printInfo, commandBox, truncate } = require('./renderer');
|
|
9
|
+
const { runCommand, copyToClipboard, stripShellWrapper } = require('./executor');
|
|
10
|
+
const { selectMenu, confirm } = require('./menu');
|
|
11
|
+
const { analyzeSteps } = require('./safety');
|
|
12
|
+
const { queryLLM } = require('./llm');
|
|
13
|
+
const { setUserIntent, addEntry } = require('./session');
|
|
14
|
+
const { recordUsage, tokenLine, estimateCost } = require('./tokens');
|
|
15
|
+
|
|
16
|
+
async function runTask(userInput, context, config, opts = {}) {
|
|
17
|
+
const debug = opts.debug || false;
|
|
18
|
+
// ─── Plan ─────────────────────────────────────────────────────────────
|
|
19
|
+
const spinner = new Spinner('planning...').start();
|
|
20
|
+
|
|
21
|
+
let plan;
|
|
22
|
+
try {
|
|
23
|
+
plan = await queryLLM(userInput, context, config, 'task');
|
|
24
|
+
} catch (err) {
|
|
25
|
+
spinner.fail(err.message);
|
|
26
|
+
if (err.debugLog) {
|
|
27
|
+
const { subtle } = require('./renderer');
|
|
28
|
+
process.stderr.write(` ${subtle('log: ' + err.debugLog)}\n`);
|
|
29
|
+
}
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!plan?.steps?.length) {
|
|
34
|
+
// Show the summary if the LLM explained why (e.g. "no_git is true")
|
|
35
|
+
const reason = plan?.summary || 'No steps generated. Try rephrasing.';
|
|
36
|
+
spinner.fail(reason);
|
|
37
|
+
if (plan?._debugLog) {
|
|
38
|
+
const { subtle } = require('./renderer');
|
|
39
|
+
process.stderr.write(` ${subtle('log: ' + plan._debugLog)}\n`);
|
|
40
|
+
}
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Record and display token usage
|
|
45
|
+
const tokenUsage = plan._tokenUsage;
|
|
46
|
+
if (tokenUsage) {
|
|
47
|
+
recordUsage(tokenUsage, config.provider, config.model);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const steps = analyzeSteps(plan.steps);
|
|
51
|
+
const cost = estimateCost(tokenUsage, config.provider, config.model);
|
|
52
|
+
const costStr = cost ? dim(` ~$${cost}`) : '';
|
|
53
|
+
spinner.succeed(`${steps.length} steps planned | ${tokenLine(tokenUsage)}${costStr}`);
|
|
54
|
+
|
|
55
|
+
if (debug && plan._debugLog) {
|
|
56
|
+
const { subtle } = require('./renderer');
|
|
57
|
+
process.stderr.write(` ${subtle('log: ' + plan._debugLog)}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Show plan ────────────────────────────────────────────────────────
|
|
61
|
+
if (plan.summary) printInfo(plan.summary);
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(taskPlan(steps));
|
|
64
|
+
|
|
65
|
+
const hasRisk = steps.some(s => s.computedRisk === 'high');
|
|
66
|
+
if (hasRisk) printWarning('has destructive steps — will ask before those');
|
|
67
|
+
|
|
68
|
+
console.log();
|
|
69
|
+
const proceed = await selectMenu([
|
|
70
|
+
{ label: 'Run', key: 'a' },
|
|
71
|
+
{ label: 'Step by step', key: 's' },
|
|
72
|
+
{ label: 'Copy', key: 'c' },
|
|
73
|
+
{ label: 'Cancel', key: 'q' },
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
if (!proceed || proceed === 'q') { console.log(` ${dim('cancelled')}`); return; }
|
|
77
|
+
|
|
78
|
+
if (proceed === 'c') {
|
|
79
|
+
const all = steps.map((s, i) => `# ${i+1}. ${s.description}\n${s.command}`).join('\n\n');
|
|
80
|
+
if (copyToClipboard(all)) console.log(` ${green(symbols.check)} ${dim('copied')}`);
|
|
81
|
+
else console.log('\n' + all + '\n');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stepByStep = proceed === 's';
|
|
86
|
+
|
|
87
|
+
// ─── Execute ──────────────────────────────────────────────────────────
|
|
88
|
+
const states = new Array(steps.length).fill('pending'); // done, fail, skip, pending
|
|
89
|
+
let aborted = false;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < steps.length; i++) {
|
|
92
|
+
const step = steps[i];
|
|
93
|
+
states[i] = 'current';
|
|
94
|
+
|
|
95
|
+
// Print current step header
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(stepLine(i, steps.length, step.description, 'current'));
|
|
98
|
+
if (step.command) {
|
|
99
|
+
const w = (process.stdout.columns || 80) - 12;
|
|
100
|
+
console.log(` ${dim('$')} ${dim(truncate(step.command, w))}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Only pause if: step-by-step mode, or this specific step is high-risk
|
|
104
|
+
const needsOk = stepByStep || step.computedRisk === 'high';
|
|
105
|
+
|
|
106
|
+
if (needsOk) {
|
|
107
|
+
const action = await selectMenu([
|
|
108
|
+
{ label: 'Run', key: 'e' },
|
|
109
|
+
{ label: 'Skip', key: 's' },
|
|
110
|
+
{ label: 'Abort', key: 'q' },
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
if (!action || action === 'q') { aborted = true; states[i] = 'skip'; break; }
|
|
114
|
+
if (action === 's') { states[i] = 'skip'; continue; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Execute
|
|
118
|
+
setUserIntent(`${userInput} [${i+1}/${steps.length}]`);
|
|
119
|
+
|
|
120
|
+
// Safety: strip shell wrappers if the LLM accidentally added them
|
|
121
|
+
// (e.g. "powershell -Command ..." when already running in PowerShell)
|
|
122
|
+
const cmd = stripShellWrapper(step.command);
|
|
123
|
+
|
|
124
|
+
// Always show output to the user — never use captureOnly in task mode.
|
|
125
|
+
// Output is captured via the returned string regardless.
|
|
126
|
+
const { code, output } = await runCommand(cmd, config, {
|
|
127
|
+
silent: false,
|
|
128
|
+
captureOnly: false,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (code === 0) {
|
|
132
|
+
states[i] = 'done';
|
|
133
|
+
} else {
|
|
134
|
+
states[i] = 'fail';
|
|
135
|
+
|
|
136
|
+
// In "Run" mode, auto-continue on failure (user chose to run all steps).
|
|
137
|
+
// In "Step by step" mode, ask what to do.
|
|
138
|
+
if (stepByStep) {
|
|
139
|
+
const next = await selectMenu([
|
|
140
|
+
{ label: 'Continue', key: 'c' },
|
|
141
|
+
{ label: 'Retry', key: 'r' },
|
|
142
|
+
{ label: 'Abort', key: 'q' },
|
|
143
|
+
]);
|
|
144
|
+
if (!next || next === 'q') { aborted = true; break; }
|
|
145
|
+
if (next === 'r') { states[i] = 'pending'; i--; continue; }
|
|
146
|
+
}
|
|
147
|
+
// In "Run" mode, just continue to next step automatically
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (step.captureOutput && output) {
|
|
151
|
+
addEntry(step.command, output, code, `step ${i+1}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Summary (one line) ───────────────────────────────────────────────
|
|
156
|
+
const ok = states.filter(s => s === 'done').length;
|
|
157
|
+
const fail = states.filter(s => s === 'fail').length;
|
|
158
|
+
const skip = states.filter(s => s === 'skip').length;
|
|
159
|
+
|
|
160
|
+
console.log();
|
|
161
|
+
let line = ` ${ok === steps.length ? green(symbols.check) : yellow(symbols.warning)} ${ok}/${steps.length} done`;
|
|
162
|
+
if (fail) line += ` ${red(fail + ' failed')}`;
|
|
163
|
+
if (skip) line += ` ${yellow(skip + ' skipped')}`;
|
|
164
|
+
console.log(line);
|
|
165
|
+
|
|
166
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { runTask };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const G = '\x1b[32m'; // green
|
|
5
|
+
const C = '\x1b[36m'; // cyan
|
|
6
|
+
const D = '\x1b[2m'; // dim
|
|
7
|
+
const B = '\x1b[1m'; // bold
|
|
8
|
+
const W = '\x1b[97m'; // bright white
|
|
9
|
+
const R = '\x1b[0m'; // reset
|
|
10
|
+
|
|
11
|
+
console.log();
|
|
12
|
+
console.log(` ${G}\u2714${R} ${B}${W}dotdotdot${R} installed`);
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(` ${B}Get started:${R}`);
|
|
15
|
+
console.log(` ${C}... -c${R} ${D}Configure your API key${R}`);
|
|
16
|
+
console.log(` ${C}... list all png files${R} ${D}Try a quick command${R}`);
|
|
17
|
+
console.log(` ${C}... find tmp files then delete${R} ${D}Try a multi-step task${R}`);
|
|
18
|
+
console.log();
|
|
19
|
+
console.log(` ${D}Zero dependencies. 5 providers. Your terminal, your way.${R}`);
|
|
20
|
+
console.log();
|
package/lib/renderer.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// renderer.js — Premium terminal UI
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const { bold, dim, cyan, gray, green, yellow, red, brightWhite, brightCyan,
|
|
8
|
+
stripAnsi, visibleLength, symbols, c256, rgb, bg256 } = require('./colors');
|
|
9
|
+
|
|
10
|
+
const termWidth = () => Math.min(process.stdout.columns || 80, 90);
|
|
11
|
+
|
|
12
|
+
// ─── Accent colors ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const dot1 = c256(39); // bright blue
|
|
15
|
+
const dot2 = c256(44); // teal
|
|
16
|
+
const dot3 = c256(49); // mint
|
|
17
|
+
const accent = c256(39); // bright blue
|
|
18
|
+
const subtle = c256(240); // dark gray
|
|
19
|
+
const mid = c256(245); // medium gray
|
|
20
|
+
|
|
21
|
+
// ─── Truncate ───────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function truncate(str, max) {
|
|
24
|
+
if (!str) return str;
|
|
25
|
+
if (str.length <= max) return str;
|
|
26
|
+
return str.slice(0, max - 1) + '\u2026';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function wordWrap(str, max, indent = ' ') {
|
|
30
|
+
if (!str || str.length <= max) return indent + str;
|
|
31
|
+
const words = str.split(' ');
|
|
32
|
+
const lines = [];
|
|
33
|
+
let line = '';
|
|
34
|
+
for (const word of words) {
|
|
35
|
+
if (line && (line.length + 1 + word.length) > max) {
|
|
36
|
+
lines.push(indent + line);
|
|
37
|
+
line = word;
|
|
38
|
+
} else {
|
|
39
|
+
line = line ? line + ' ' + word : word;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (line) lines.push(indent + line);
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Command display ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function commandBox(command, explanation, warning) {
|
|
49
|
+
const w = termWidth() - 6;
|
|
50
|
+
const out = [];
|
|
51
|
+
if (warning) out.push(` ${yellow(symbols.warning)} ${dim(warning)}`);
|
|
52
|
+
out.push(` ${accent('\u276F')} ${bold(brightWhite(truncate(command, w)))}`);
|
|
53
|
+
if (explanation) out.push(subtle(wordWrap(explanation, w)));
|
|
54
|
+
return out.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Step line ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function stepLine(i, total, label, state) {
|
|
60
|
+
const num = subtle(`${i+1}/${total}`);
|
|
61
|
+
if (state === 'done') return ` ${green(symbols.check)} ${num} ${mid(label)}`;
|
|
62
|
+
if (state === 'current') return ` ${accent(symbols.arrowRight)} ${num} ${brightWhite(label)}`;
|
|
63
|
+
if (state === 'skip') return ` ${yellow('-')} ${num} ${subtle(label)}`;
|
|
64
|
+
if (state === 'fail') return ` ${red(symbols.cross)} ${num} ${subtle(label)}`;
|
|
65
|
+
return ` ${subtle('\u2500')} ${num} ${subtle(label)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Spinner ────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
class Spinner {
|
|
71
|
+
constructor(message = '', color = accent) {
|
|
72
|
+
this.message = message;
|
|
73
|
+
this.color = color;
|
|
74
|
+
this.frameIndex = 0;
|
|
75
|
+
this.interval = null;
|
|
76
|
+
this.stream = process.stderr;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
start() {
|
|
80
|
+
if (!this.stream.isTTY) return this;
|
|
81
|
+
this.stream.write('\x1b[?25l');
|
|
82
|
+
this.interval = setInterval(() => {
|
|
83
|
+
const f = symbols.spinnerFrames[this.frameIndex];
|
|
84
|
+
this.frameIndex = (this.frameIndex + 1) % symbols.spinnerFrames.length;
|
|
85
|
+
this.stream.write(`\r\x1b[2K ${this.color(f)} ${subtle(this.message)}`);
|
|
86
|
+
}, 80);
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
update(msg) { this.message = msg; }
|
|
91
|
+
|
|
92
|
+
succeed(msg) {
|
|
93
|
+
this._end();
|
|
94
|
+
this.stream.write(`\r\x1b[2K ${green(symbols.check)} ${subtle(msg || this.message)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fail(msg) {
|
|
98
|
+
this._end();
|
|
99
|
+
this.stream.write(`\r\x1b[2K ${red(symbols.cross)} ${msg || this.message}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
stop() { this._end(); this.stream.write('\r\x1b[2K'); }
|
|
103
|
+
|
|
104
|
+
_end() {
|
|
105
|
+
if (this.interval) { clearInterval(this.interval); this.interval = null; }
|
|
106
|
+
if (this.stream.isTTY) this.stream.write('\x1b[?25h');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Banner ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function printBanner() {
|
|
113
|
+
console.log();
|
|
114
|
+
console.log(` ${dot1('\u25CF')} ${dot2('\u25CF')} ${dot3('\u25CF')} ${bold(brightWhite('dotdotdot'))}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Task plan ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function taskPlan(steps) {
|
|
120
|
+
const w = termWidth() - 12;
|
|
121
|
+
const lines = [];
|
|
122
|
+
for (let i = 0; i < steps.length; i++) {
|
|
123
|
+
const s = steps[i];
|
|
124
|
+
const risk = s.computedRisk === 'high' ? red('!') : s.computedRisk === 'medium' ? yellow('~') : subtle('\u2500');
|
|
125
|
+
lines.push(` ${risk} ${subtle(`${i+1}.`)} ${truncate(s.description, w)}`);
|
|
126
|
+
if (s.command) lines.push(` ${subtle('$')} ${subtle(truncate(s.command, w - 4))}`);
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── One-liners ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function printError(msg) { console.error(` ${red(symbols.cross)} ${msg}`); }
|
|
134
|
+
function printWarning(msg) { console.error(` ${yellow(symbols.warning)} ${msg}`); }
|
|
135
|
+
function printSuccess(msg) { console.log(` ${green(symbols.check)} ${msg}`); }
|
|
136
|
+
function printInfo(msg) { console.log(` ${subtle(msg)}`); }
|
|
137
|
+
function keyValue(label, value, indent = 4) {
|
|
138
|
+
return `${' '.repeat(indent)}${subtle(label)} ${brightWhite(value)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
commandBox, stepLine, Spinner, printBanner, taskPlan, truncate,
|
|
143
|
+
printError, printWarning, printSuccess, printInfo, keyValue, termWidth,
|
|
144
|
+
accent, subtle, mid, dot1, dot2, dot3,
|
|
145
|
+
};
|
package/lib/safety.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// safety.js — Command risk analysis
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const HIGH_RISK = [
|
|
8
|
+
// Deletion — recursive/forced only
|
|
9
|
+
{ pattern: /\brm\s+(-[a-z]*r|-[a-z]*f|--recursive|--force)/i, reason: 'Recursive/forced deletion' },
|
|
10
|
+
{ pattern: /\bRemove-Item\b.*(-Recurse|-Force)/i, reason: 'Recursive/forced deletion' },
|
|
11
|
+
{ pattern: /\bdel\s+\/[sS]/i, reason: 'Recursive deletion' },
|
|
12
|
+
{ pattern: /\brm\s+-rf\s+[\/~]/i, reason: 'Deleting from root or home' },
|
|
13
|
+
|
|
14
|
+
// System-level — only actual disk format commands, not PowerShell Format-*
|
|
15
|
+
{ pattern: /\bformat\s+[a-zA-Z]:/i, reason: 'Disk formatting' },
|
|
16
|
+
{ pattern: /\bmkfs\b/i, reason: 'Filesystem creation' },
|
|
17
|
+
{ pattern: /\bdd\s+if=/i, reason: 'Low-level disk write' },
|
|
18
|
+
{ pattern: /\bchmod\s+777/i, reason: 'Insecure permissions' },
|
|
19
|
+
|
|
20
|
+
// Dangerous pipes
|
|
21
|
+
{ pattern: /\bcurl\b.*\|\s*(bash|sh|zsh)/i, reason: 'Download and execute' },
|
|
22
|
+
|
|
23
|
+
// Elevated
|
|
24
|
+
{ pattern: /\bsudo\b/i, reason: 'Elevated privileges' },
|
|
25
|
+
{ pattern: /\brunas\b/i, reason: 'Elevated privileges' },
|
|
26
|
+
{ pattern: /Set-ExecutionPolicy\s+Unrestricted/i, reason: 'Weakening execution policy' },
|
|
27
|
+
|
|
28
|
+
// Registry
|
|
29
|
+
{ pattern: /\breg\s+delete\b/i, reason: 'Registry deletion' },
|
|
30
|
+
{ pattern: /Remove-ItemProperty.*HKLM/i, reason: 'System registry modification' },
|
|
31
|
+
|
|
32
|
+
// Firewall
|
|
33
|
+
{ pattern: /\biptables\b/i, reason: 'Firewall modification' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const MEDIUM_RISK = [
|
|
37
|
+
{ pattern: /\brm\b(?!.*Format)/i, reason: 'File deletion' },
|
|
38
|
+
{ pattern: /\bRemove-Item\b(?!Property)/i, reason: 'File deletion' },
|
|
39
|
+
{ pattern: /\bMove-Item\b|\bmv\b/i, reason: 'Moving files' },
|
|
40
|
+
{ pattern: /\bkill\b|\bStop-Process\b|\btaskkill\b/i, reason: 'Process termination' },
|
|
41
|
+
{ pattern: /\bnpm\s+(install|uninstall)\s+-g/i, reason: 'Global package change' },
|
|
42
|
+
{ pattern: /\bgit\s+(push|reset\s+--hard|rebase|force)/i, reason: 'Git history change' },
|
|
43
|
+
{ pattern: /\bdocker\s+(rm|rmi|stop|kill|prune)/i, reason: 'Docker resource removal' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function analyzeRisk(command) {
|
|
47
|
+
if (!command) return { level: 'low', reasons: [] };
|
|
48
|
+
|
|
49
|
+
for (const { pattern, reason } of HIGH_RISK) {
|
|
50
|
+
if (pattern.test(command)) return { level: 'high', reasons: [reason] };
|
|
51
|
+
}
|
|
52
|
+
for (const { pattern, reason } of MEDIUM_RISK) {
|
|
53
|
+
if (pattern.test(command)) return { level: 'medium', reasons: [reason] };
|
|
54
|
+
}
|
|
55
|
+
return { level: 'low', reasons: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function analyzeSteps(steps) {
|
|
59
|
+
return steps.map(step => {
|
|
60
|
+
const risk = analyzeRisk(step.command);
|
|
61
|
+
return {
|
|
62
|
+
...step,
|
|
63
|
+
computedRisk: risk.level,
|
|
64
|
+
riskReasons: risk.reasons,
|
|
65
|
+
// Only force approval on actually dangerous steps
|
|
66
|
+
needsApproval: step.needsApproval || risk.level === 'high',
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { analyzeRisk, analyzeSteps };
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// session.js — Session history for conversational follow-ups & multi-step tasks
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const SESSION_FILE = path.join(os.tmpdir(), 'dotdotdot-session.json');
|
|
12
|
+
const MAX_ENTRIES = 20;
|
|
13
|
+
const SESSION_TTL = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
const MAX_OUTPUT = 2000; // max chars of output to store (reduced from 4000)
|
|
15
|
+
|
|
16
|
+
// Terminal session ID — detect new shell sessions to clear stale context.
|
|
17
|
+
// Uses parent PID + shell PID as a fingerprint. New terminal = new session.
|
|
18
|
+
function getTerminalSessionId() {
|
|
19
|
+
try {
|
|
20
|
+
return `${process.ppid || 0}`;
|
|
21
|
+
} catch { return '0'; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Load session ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function loadSession() {
|
|
27
|
+
const empty = { entries: [], previousSummary: null, taskId: null, taskSteps: null, terminalId: null };
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(SESSION_FILE, 'utf8');
|
|
30
|
+
const session = JSON.parse(raw);
|
|
31
|
+
|
|
32
|
+
// Guard against malformed/old session files
|
|
33
|
+
if (!session || !Array.isArray(session.entries)) {
|
|
34
|
+
return empty;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clear on new terminal session — user unlikely wants old context
|
|
38
|
+
const currentTerminal = getTerminalSessionId();
|
|
39
|
+
if (session.terminalId && session.terminalId !== currentTerminal) {
|
|
40
|
+
return { ...empty, terminalId: currentTerminal };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check timeout
|
|
44
|
+
if (session.entries.length > 0) {
|
|
45
|
+
const lastTime = session.entries[session.entries.length - 1].time;
|
|
46
|
+
if (Date.now() - lastTime > SESSION_TTL) {
|
|
47
|
+
return { ...empty, terminalId: currentTerminal };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { ...empty, ...session, entries: session.entries, terminalId: currentTerminal };
|
|
52
|
+
} catch {
|
|
53
|
+
return empty;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Save session ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function saveSession(session) {
|
|
60
|
+
try {
|
|
61
|
+
// Trim entries
|
|
62
|
+
if (session.entries.length > MAX_ENTRIES) {
|
|
63
|
+
session.entries = session.entries.slice(-MAX_ENTRIES);
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(session));
|
|
66
|
+
} catch { /* ignore write errors */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Add entry ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function addEntry(command, output, exitCode, intent) {
|
|
72
|
+
const session = loadSession();
|
|
73
|
+
session.entries.push({
|
|
74
|
+
command,
|
|
75
|
+
output: output ? output.slice(0, MAX_OUTPUT) : '',
|
|
76
|
+
exitCode,
|
|
77
|
+
intent: intent || '',
|
|
78
|
+
time: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
saveSession(session);
|
|
81
|
+
return session;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Get session history formatted for LLM ──────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function getHistory() {
|
|
87
|
+
const session = loadSession();
|
|
88
|
+
if (!session.entries.length) return null;
|
|
89
|
+
|
|
90
|
+
// Return compact array — only last 5 entries, minimal data
|
|
91
|
+
const compact = session.entries.slice(-5).map(e => {
|
|
92
|
+
const h = { cmd: e.command, ok: e.exitCode === 0 };
|
|
93
|
+
if (e.intent) h.q = e.intent;
|
|
94
|
+
// Include output snippet — follow-ups need prior output (e.g. "delete those files")
|
|
95
|
+
if (e.output) {
|
|
96
|
+
const maxOut = e.exitCode !== 0 ? 300 : 300;
|
|
97
|
+
h.out = e.output.length > maxOut ? e.output.slice(0, maxOut) + '…' : e.output;
|
|
98
|
+
}
|
|
99
|
+
return h;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return compact;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Task state management (for multi-step tasks) ───────────────────────────
|
|
106
|
+
|
|
107
|
+
function setTaskState(taskId, steps, currentStep) {
|
|
108
|
+
const session = loadSession();
|
|
109
|
+
session.taskId = taskId;
|
|
110
|
+
session.taskSteps = steps;
|
|
111
|
+
session.taskCurrentStep = currentStep || 0;
|
|
112
|
+
saveSession(session);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getTaskState() {
|
|
116
|
+
const session = loadSession();
|
|
117
|
+
return {
|
|
118
|
+
taskId: session.taskId,
|
|
119
|
+
steps: session.taskSteps,
|
|
120
|
+
currentStep: session.taskCurrentStep || 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clearTaskState() {
|
|
125
|
+
const session = loadSession();
|
|
126
|
+
session.taskId = null;
|
|
127
|
+
session.taskSteps = null;
|
|
128
|
+
session.taskCurrentStep = 0;
|
|
129
|
+
saveSession(session);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Set user intent for next entry ─────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
let _pendingIntent = '';
|
|
135
|
+
|
|
136
|
+
function setUserIntent(intent) {
|
|
137
|
+
_pendingIntent = intent;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getUserIntent() {
|
|
141
|
+
const i = _pendingIntent;
|
|
142
|
+
_pendingIntent = '';
|
|
143
|
+
return i;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Clear entire session ───────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function clearSession() {
|
|
149
|
+
try {
|
|
150
|
+
fs.unlinkSync(SESSION_FILE);
|
|
151
|
+
} catch { /* ignore if already gone */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
loadSession,
|
|
156
|
+
saveSession,
|
|
157
|
+
addEntry,
|
|
158
|
+
getHistory,
|
|
159
|
+
setTaskState,
|
|
160
|
+
getTaskState,
|
|
161
|
+
clearTaskState,
|
|
162
|
+
clearSession,
|
|
163
|
+
setUserIntent,
|
|
164
|
+
getUserIntent,
|
|
165
|
+
};
|