@hasna/terminal 0.1.3 → 0.1.5
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/dist/App.js +219 -102
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/dist/ai.js +81 -38
- package/dist/cache.js +41 -0
- package/package.json +1 -1
- package/src/App.tsx +379 -232
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- package/src/ai.ts +98 -48
- package/src/cache.ts +43 -0
package/src/Browse.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { readdirSync, statSync } from "fs";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
|
|
6
|
+
interface BrowseProps {
|
|
7
|
+
cwd: string;
|
|
8
|
+
onCd: (path: string) => void;
|
|
9
|
+
onSelect: (path: string) => void;
|
|
10
|
+
onExit: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Entry {
|
|
14
|
+
name: string;
|
|
15
|
+
isDir: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readDir(dir: string): Entry[] {
|
|
19
|
+
try {
|
|
20
|
+
const names = readdirSync(dir);
|
|
21
|
+
const entries: Entry[] = [];
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
try {
|
|
24
|
+
const stat = statSync(join(dir, name));
|
|
25
|
+
entries.push({ name, isDir: stat.isDirectory() });
|
|
26
|
+
} catch {
|
|
27
|
+
entries.push({ name, isDir: false });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
entries.sort((a, b) => {
|
|
31
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
32
|
+
return a.name.localeCompare(b.name);
|
|
33
|
+
});
|
|
34
|
+
return entries;
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PAGE = 20;
|
|
41
|
+
|
|
42
|
+
export default function Browse({ cwd, onCd, onSelect, onExit }: BrowseProps) {
|
|
43
|
+
const [cursor, setCursor] = useState(0);
|
|
44
|
+
|
|
45
|
+
const entries = readDir(cwd);
|
|
46
|
+
const total = entries.length;
|
|
47
|
+
|
|
48
|
+
const safeIndex = Math.min(cursor, Math.max(0, total - 1));
|
|
49
|
+
|
|
50
|
+
const start = Math.max(0, Math.min(safeIndex - Math.floor(PAGE / 2), total - PAGE));
|
|
51
|
+
const slice = entries.slice(Math.max(0, start), Math.max(0, start) + PAGE);
|
|
52
|
+
|
|
53
|
+
useInput(useCallback((_input: string, key: any) => {
|
|
54
|
+
if (key.upArrow) {
|
|
55
|
+
setCursor(c => (c <= 0 ? Math.max(0, total - 1) : c - 1));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.downArrow) {
|
|
59
|
+
setCursor(c => (total === 0 ? 0 : (c >= total - 1 ? 0 : c + 1)));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (key.return) {
|
|
63
|
+
if (total === 0) return;
|
|
64
|
+
const entry = entries[safeIndex];
|
|
65
|
+
if (!entry) return;
|
|
66
|
+
const full = join(cwd, entry.name);
|
|
67
|
+
if (entry.isDir) {
|
|
68
|
+
setCursor(0);
|
|
69
|
+
onCd(full);
|
|
70
|
+
} else {
|
|
71
|
+
onSelect(full);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.backspace || key.delete || key.leftArrow) {
|
|
76
|
+
setCursor(0);
|
|
77
|
+
onCd(dirname(cwd));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (key.escape) {
|
|
81
|
+
onExit();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}, [cwd, entries, safeIndex, total, onCd, onSelect, onExit]));
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Box flexDirection="column">
|
|
88
|
+
<Text dimColor>{cwd}</Text>
|
|
89
|
+
{total === 0 && <Text dimColor> (empty)</Text>}
|
|
90
|
+
{slice.map((entry, i) => {
|
|
91
|
+
const absIdx = Math.max(0, start) + i;
|
|
92
|
+
const selected = absIdx === safeIndex;
|
|
93
|
+
const icon = entry.isDir ? "▸" : "·";
|
|
94
|
+
return (
|
|
95
|
+
<Box key={entry.name}>
|
|
96
|
+
<Text inverse={selected}>{` ${icon} ${entry.name}${entry.isDir ? "/" : ""} `}</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
<Text dimColor>{" enter backspace esc"}</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
|
|
4
|
+
interface FuzzyPickerProps {
|
|
5
|
+
history: string[];
|
|
6
|
+
onSelect: (nl: string) => void;
|
|
7
|
+
onExit: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_MATCHES = 10;
|
|
11
|
+
|
|
12
|
+
export default function FuzzyPicker({ history, onSelect, onExit }: FuzzyPickerProps) {
|
|
13
|
+
const [query, setQuery] = useState("");
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
15
|
+
|
|
16
|
+
const matches = query === ""
|
|
17
|
+
? history.slice().reverse().slice(0, MAX_MATCHES)
|
|
18
|
+
: history.slice().reverse().filter(h => h.toLowerCase().includes(query.toLowerCase())).slice(0, MAX_MATCHES);
|
|
19
|
+
|
|
20
|
+
const safeCursor = Math.min(cursor, Math.max(0, matches.length - 1));
|
|
21
|
+
|
|
22
|
+
useInput(useCallback((_input: string, key: any) => {
|
|
23
|
+
if (key.escape || key.ctrl && _input === "c") {
|
|
24
|
+
onExit();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
if (matches.length > 0) {
|
|
29
|
+
onSelect(matches[safeCursor]);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.upArrow) {
|
|
34
|
+
setCursor(c => Math.max(0, c - 1));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.downArrow) {
|
|
38
|
+
setCursor(c => Math.min(matches.length - 1, c + 1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (key.backspace || key.delete) {
|
|
42
|
+
setQuery(q => q.slice(0, -1));
|
|
43
|
+
setCursor(0);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!key.ctrl && !key.meta && _input && _input.length === 1) {
|
|
47
|
+
setQuery(q => q + _input);
|
|
48
|
+
setCursor(0);
|
|
49
|
+
}
|
|
50
|
+
}, [matches, safeCursor, onSelect, onExit]));
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
<Text>{` / ${query}_`}</Text>
|
|
55
|
+
{matches.length === 0
|
|
56
|
+
? <Text dimColor>{" no matches"}</Text>
|
|
57
|
+
: matches.map((m, i) => {
|
|
58
|
+
const selected = i === safeCursor;
|
|
59
|
+
return (
|
|
60
|
+
<Box key={i}>
|
|
61
|
+
<Text inverse={selected} dimColor={!selected}>{` ${m} `}</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
<Text dimColor>{" enter esc"}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/StatusBar.tsx
CHANGED
|
@@ -4,64 +4,58 @@ import { execSync } from "child_process";
|
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { type Permissions } from "./history.js";
|
|
6
6
|
|
|
7
|
-
function
|
|
8
|
-
const cwd = process.cwd();
|
|
7
|
+
function formatCwd(cwd: string): string {
|
|
9
8
|
const home = homedir();
|
|
10
9
|
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
function getGitBranch(): string | null {
|
|
12
|
+
function getGitBranch(cwd: string): string | null {
|
|
14
13
|
try {
|
|
15
|
-
return execSync("git branch --show-current 2>/dev/null", {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
14
|
+
return execSync("git branch --show-current 2>/dev/null", {
|
|
15
|
+
cwd,
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
}).toString().trim() || null;
|
|
18
|
+
} catch { return null; }
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
function getGitDirty(): boolean {
|
|
21
|
+
function getGitDirty(cwd: string): boolean {
|
|
24
22
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
23
|
+
return execSync("git status --porcelain 2>/dev/null", {
|
|
24
|
+
cwd,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
}).toString().trim().length > 0;
|
|
27
|
+
} catch { return false; }
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
function activePerms(perms: Permissions): string[] {
|
|
33
31
|
const labels: Array<[keyof Permissions, string]> = [
|
|
34
32
|
["destructive", "del"],
|
|
35
|
-
["network",
|
|
36
|
-
["sudo",
|
|
37
|
-
["install",
|
|
33
|
+
["network", "net"],
|
|
34
|
+
["sudo", "sudo"],
|
|
35
|
+
["install", "pkg"],
|
|
38
36
|
["write_outside_cwd", "write"],
|
|
39
37
|
];
|
|
40
|
-
|
|
38
|
+
// only show disabled ones — full access is the default so no need to clutter
|
|
39
|
+
const disabled = labels.filter(([k]) => !perms[k]).map(([, l]) => `no-${l}`);
|
|
40
|
+
return disabled;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
interface Props {
|
|
44
44
|
permissions: Permissions;
|
|
45
|
+
cwd?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export default function StatusBar({ permissions }: Props) {
|
|
48
|
-
const cwd =
|
|
49
|
-
const branch = getGitBranch();
|
|
50
|
-
const dirty = branch ? getGitDirty() : false;
|
|
51
|
-
const
|
|
48
|
+
export default function StatusBar({ permissions, cwd: cwdProp }: Props) {
|
|
49
|
+
const cwd = cwdProp ?? process.cwd();
|
|
50
|
+
const branch = getGitBranch(cwd);
|
|
51
|
+
const dirty = branch ? getGitDirty(cwd) : false;
|
|
52
|
+
const restricted = activePerms(permissions);
|
|
52
53
|
|
|
53
54
|
return (
|
|
54
55
|
<Box gap={2} paddingLeft={2} marginTop={1}>
|
|
55
|
-
<Text dimColor>{cwd}</Text>
|
|
56
|
-
{branch &&
|
|
57
|
-
|
|
58
|
-
{branch}
|
|
59
|
-
{dirty ? " ●" : ""}
|
|
60
|
-
</Text>
|
|
61
|
-
)}
|
|
62
|
-
{perms.length > 0 && (
|
|
63
|
-
<Text dimColor>{perms.join(" · ")}</Text>
|
|
64
|
-
)}
|
|
56
|
+
<Text dimColor>{formatCwd(cwd)}</Text>
|
|
57
|
+
{branch && <Text dimColor>{branch}{dirty ? " ●" : ""}</Text>}
|
|
58
|
+
{restricted.length > 0 && <Text dimColor>{restricted.join(" · ")}</Text>}
|
|
65
59
|
</Box>
|
|
66
60
|
);
|
|
67
61
|
}
|
package/src/ai.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { Permissions } from "./history.js";
|
|
3
|
+
import { cacheGet, cacheSet } from "./cache.js";
|
|
3
4
|
|
|
4
5
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
5
6
|
|
|
7
|
+
// ── model routing ─────────────────────────────────────────────────────────────
|
|
8
|
+
// Simple queries → haiku (fast). Complex/ambiguous → sonnet.
|
|
9
|
+
|
|
10
|
+
const COMPLEX_SIGNALS = [
|
|
11
|
+
/\b(undo|revert|rollback|previous|last)\b/i,
|
|
12
|
+
/\b(all files?|recursively|bulk|batch)\b/i,
|
|
13
|
+
/\b(pipeline|chain|then|and then|after)\b/i,
|
|
14
|
+
/\b(if|when|unless|only if)\b/i,
|
|
15
|
+
/[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function pickModel(nl: string): string {
|
|
19
|
+
const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
|
|
20
|
+
return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
7
24
|
|
|
8
25
|
const IRREVERSIBLE_PATTERNS = [
|
|
9
26
|
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
10
|
-
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
27
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
11
28
|
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
12
29
|
];
|
|
13
30
|
|
|
@@ -17,11 +34,11 @@ export function isIrreversible(command: string): boolean {
|
|
|
17
34
|
|
|
18
35
|
// ── permissions ───────────────────────────────────────────────────────────────
|
|
19
36
|
|
|
20
|
-
const DESTRUCTIVE_PATTERNS
|
|
21
|
-
const NETWORK_PATTERNS
|
|
22
|
-
const SUDO_PATTERNS
|
|
23
|
-
const INSTALL_PATTERNS
|
|
24
|
-
const WRITE_OUTSIDE_PATTERNS
|
|
37
|
+
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
38
|
+
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
39
|
+
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
40
|
+
const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
|
|
41
|
+
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
25
42
|
|
|
26
43
|
export function checkPermissions(command: string, perms: Permissions): string | null {
|
|
27
44
|
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
@@ -42,32 +59,87 @@ export function checkPermissions(command: string, perms: Permissions): string |
|
|
|
42
59
|
function buildSystemPrompt(perms: Permissions, sessionCmds: string[]): string {
|
|
43
60
|
const restrictions: string[] = [];
|
|
44
61
|
if (!perms.destructive)
|
|
45
|
-
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data
|
|
62
|
+
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
|
|
46
63
|
if (!perms.network)
|
|
47
|
-
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh,
|
|
64
|
+
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
|
|
48
65
|
if (!perms.sudo)
|
|
49
|
-
restrictions.push("- NEVER generate commands
|
|
66
|
+
restrictions.push("- NEVER generate commands requiring sudo");
|
|
50
67
|
if (!perms.write_outside_cwd)
|
|
51
|
-
restrictions.push("- NEVER
|
|
68
|
+
restrictions.push("- NEVER write to paths outside the current working directory");
|
|
52
69
|
if (!perms.install)
|
|
53
|
-
restrictions.push("- NEVER
|
|
70
|
+
restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
|
|
54
71
|
|
|
55
72
|
const restrictionBlock = restrictions.length > 0
|
|
56
|
-
? `\n\
|
|
73
|
+
? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
|
|
57
74
|
: "";
|
|
58
75
|
|
|
59
76
|
const contextBlock = sessionCmds.length > 0
|
|
60
|
-
? `\n\
|
|
77
|
+
? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
|
|
61
78
|
: "";
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
|
|
81
|
+
cwd: ${process.cwd()}
|
|
82
|
+
shell: zsh / macOS${restrictionBlock}${contextBlock}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── streaming translate ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export async function translateToCommand(
|
|
88
|
+
nl: string,
|
|
89
|
+
perms: Permissions,
|
|
90
|
+
sessionCmds: string[],
|
|
91
|
+
onToken?: (partial: string) => void
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
// cache hit — instant
|
|
94
|
+
const cached = cacheGet(nl);
|
|
95
|
+
if (cached) { onToken?.(cached); return cached; }
|
|
96
|
+
|
|
97
|
+
const model = pickModel(nl);
|
|
98
|
+
let result = "";
|
|
99
|
+
|
|
100
|
+
if (onToken) {
|
|
101
|
+
// streaming path
|
|
102
|
+
const stream = await client.messages.stream({
|
|
103
|
+
model,
|
|
104
|
+
max_tokens: 256,
|
|
105
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
106
|
+
messages: [{ role: "user", content: nl }],
|
|
107
|
+
});
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
|
110
|
+
result += chunk.delta.text;
|
|
111
|
+
onToken(result.trim());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
const message = await client.messages.create({
|
|
116
|
+
model,
|
|
117
|
+
max_tokens: 256,
|
|
118
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
119
|
+
messages: [{ role: "user", content: nl }],
|
|
120
|
+
});
|
|
121
|
+
const block = message.content[0];
|
|
122
|
+
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
123
|
+
result = block.text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = result.trim();
|
|
127
|
+
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
128
|
+
cacheSet(nl, text);
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── prefetch ──────────────────────────────────────────────────────────────────
|
|
133
|
+
// Silently warm the cache after a command runs — no await, fire and forget
|
|
64
134
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
135
|
+
export function prefetchNext(
|
|
136
|
+
lastNl: string,
|
|
137
|
+
perms: Permissions,
|
|
138
|
+
sessionCmds: string[]
|
|
139
|
+
) {
|
|
140
|
+
// Only prefetch if we don't have it cached already
|
|
141
|
+
if (cacheGet(lastNl)) return;
|
|
142
|
+
translateToCommand(lastNl, perms, sessionCmds).catch(() => {});
|
|
71
143
|
}
|
|
72
144
|
|
|
73
145
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
@@ -76,7 +148,7 @@ export async function explainCommand(command: string): Promise<string> {
|
|
|
76
148
|
const message = await client.messages.create({
|
|
77
149
|
model: "claude-haiku-4-5-20251001",
|
|
78
150
|
max_tokens: 128,
|
|
79
|
-
system: "Explain what this shell command does in one plain English sentence. No markdown
|
|
151
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
|
|
80
152
|
messages: [{ role: "user", content: command }],
|
|
81
153
|
});
|
|
82
154
|
const block = message.content[0];
|
|
@@ -94,35 +166,13 @@ export async function fixCommand(
|
|
|
94
166
|
sessionCmds: string[]
|
|
95
167
|
): Promise<string> {
|
|
96
168
|
const message = await client.messages.create({
|
|
97
|
-
model: "claude-
|
|
98
|
-
max_tokens: 256,
|
|
99
|
-
system: buildSystemPrompt(perms, sessionCmds),
|
|
100
|
-
messages: [
|
|
101
|
-
{
|
|
102
|
-
role: "user",
|
|
103
|
-
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
});
|
|
107
|
-
const block = message.content[0];
|
|
108
|
-
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
109
|
-
const text = block.text.trim();
|
|
110
|
-
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
111
|
-
return text;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── translate ─────────────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
export async function translateToCommand(
|
|
117
|
-
nl: string,
|
|
118
|
-
perms: Permissions,
|
|
119
|
-
sessionCmds: string[]
|
|
120
|
-
): Promise<string> {
|
|
121
|
-
const message = await client.messages.create({
|
|
122
|
-
model: "claude-opus-4-6",
|
|
169
|
+
model: "claude-sonnet-4-6",
|
|
123
170
|
max_tokens: 256,
|
|
124
171
|
system: buildSystemPrompt(perms, sessionCmds),
|
|
125
|
-
messages: [{
|
|
172
|
+
messages: [{
|
|
173
|
+
role: "user",
|
|
174
|
+
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
|
|
175
|
+
}],
|
|
126
176
|
});
|
|
127
177
|
const block = message.content[0];
|
|
128
178
|
if (block.type !== "text") throw new Error("Unexpected response type");
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// In-memory LRU cache + disk persistence for command translations
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
|
|
8
|
+
const MAX_ENTRIES = 500;
|
|
9
|
+
|
|
10
|
+
type CacheMap = Record<string, string>;
|
|
11
|
+
|
|
12
|
+
let mem: CacheMap = {};
|
|
13
|
+
|
|
14
|
+
export function loadCache() {
|
|
15
|
+
if (!existsSync(CACHE_FILE)) return;
|
|
16
|
+
try { mem = JSON.parse(readFileSync(CACHE_FILE, "utf8")); } catch {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function persistCache() {
|
|
20
|
+
try { writeFileSync(CACHE_FILE, JSON.stringify(mem)); } catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize a natural language query for cache lookup */
|
|
24
|
+
export function normalizeNl(nl: string): string {
|
|
25
|
+
return nl
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[^a-z0-9\s]/g, "") // strip punctuation
|
|
29
|
+
.replace(/\s+/g, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cacheGet(nl: string): string | null {
|
|
33
|
+
return mem[normalizeNl(nl)] ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function cacheSet(nl: string, command: string) {
|
|
37
|
+
const key = normalizeNl(nl);
|
|
38
|
+
// evict oldest if full
|
|
39
|
+
const keys = Object.keys(mem);
|
|
40
|
+
if (keys.length >= MAX_ENTRIES) delete mem[keys[0]];
|
|
41
|
+
mem[key] = command;
|
|
42
|
+
persistCache();
|
|
43
|
+
}
|