@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/ai.js CHANGED
@@ -1,9 +1,23 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
+ import { cacheGet, cacheSet } from "./cache.js";
2
3
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
4
+ // ── model routing ─────────────────────────────────────────────────────────────
5
+ // Simple queries → haiku (fast). Complex/ambiguous → sonnet.
6
+ const COMPLEX_SIGNALS = [
7
+ /\b(undo|revert|rollback|previous|last)\b/i,
8
+ /\b(all files?|recursively|bulk|batch)\b/i,
9
+ /\b(pipeline|chain|then|and then|after)\b/i,
10
+ /\b(if|when|unless|only if)\b/i,
11
+ /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
12
+ ];
13
+ function pickModel(nl) {
14
+ const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
15
+ return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
16
+ }
3
17
  // ── irreversibility ───────────────────────────────────────────────────────────
4
18
  const IRREVERSIBLE_PATTERNS = [
5
19
  /\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
6
- /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
20
+ /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
7
21
  /\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
8
22
  ];
9
23
  export function isIrreversible(command) {
@@ -32,35 +46,82 @@ export function checkPermissions(command, perms) {
32
46
  function buildSystemPrompt(perms, sessionCmds) {
33
47
  const restrictions = [];
34
48
  if (!perms.destructive)
35
- restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
49
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
36
50
  if (!perms.network)
37
- restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, scp, ping, nc, etc.)");
51
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
38
52
  if (!perms.sudo)
39
- restrictions.push("- NEVER generate commands that use sudo or require root privileges");
53
+ restrictions.push("- NEVER generate commands requiring sudo");
40
54
  if (!perms.write_outside_cwd)
41
- restrictions.push("- NEVER generate commands that write to paths outside the current working directory");
55
+ restrictions.push("- NEVER write to paths outside the current working directory");
42
56
  if (!perms.install)
43
- restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
57
+ restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
44
58
  const restrictionBlock = restrictions.length > 0
45
- ? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
59
+ ? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
46
60
  : "";
47
61
  const contextBlock = sessionCmds.length > 0
48
- ? `\n\nRECENT COMMANDS THIS SESSION (for context — e.g. "undo that", "do the same for X"):\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
62
+ ? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
49
63
  : "";
50
- const cwd = process.cwd();
51
- return `You are a terminal assistant. The user will describe what they want to do in plain English.
52
- Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
53
- No explanation. No markdown. No backticks. Just the raw command.
54
- If multiple commands are needed, join them with && or use a newline.
55
- Assume macOS/Linux zsh environment.
56
- Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
64
+ return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
65
+ cwd: ${process.cwd()}
66
+ shell: zsh / macOS${restrictionBlock}${contextBlock}`;
67
+ }
68
+ // ── streaming translate ───────────────────────────────────────────────────────
69
+ export async function translateToCommand(nl, perms, sessionCmds, onToken) {
70
+ // cache hit — instant
71
+ const cached = cacheGet(nl);
72
+ if (cached) {
73
+ onToken?.(cached);
74
+ return cached;
75
+ }
76
+ const model = pickModel(nl);
77
+ let result = "";
78
+ if (onToken) {
79
+ // streaming path
80
+ const stream = await client.messages.stream({
81
+ model,
82
+ max_tokens: 256,
83
+ system: buildSystemPrompt(perms, sessionCmds),
84
+ messages: [{ role: "user", content: nl }],
85
+ });
86
+ for await (const chunk of stream) {
87
+ if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
88
+ result += chunk.delta.text;
89
+ onToken(result.trim());
90
+ }
91
+ }
92
+ }
93
+ else {
94
+ const message = await client.messages.create({
95
+ model,
96
+ max_tokens: 256,
97
+ system: buildSystemPrompt(perms, sessionCmds),
98
+ messages: [{ role: "user", content: nl }],
99
+ });
100
+ const block = message.content[0];
101
+ if (block.type !== "text")
102
+ throw new Error("Unexpected response type");
103
+ result = block.text;
104
+ }
105
+ const text = result.trim();
106
+ if (text.startsWith("BLOCKED:"))
107
+ throw new Error(text);
108
+ cacheSet(nl, text);
109
+ return text;
110
+ }
111
+ // ── prefetch ──────────────────────────────────────────────────────────────────
112
+ // Silently warm the cache after a command runs — no await, fire and forget
113
+ export function prefetchNext(lastNl, perms, sessionCmds) {
114
+ // Only prefetch if we don't have it cached already
115
+ if (cacheGet(lastNl))
116
+ return;
117
+ translateToCommand(lastNl, perms, sessionCmds).catch(() => { });
57
118
  }
58
119
  // ── explain ───────────────────────────────────────────────────────────────────
59
120
  export async function explainCommand(command) {
60
121
  const message = await client.messages.create({
61
122
  model: "claude-haiku-4-5-20251001",
62
123
  max_tokens: 128,
63
- system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
124
+ system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
64
125
  messages: [{ role: "user", content: command }],
65
126
  });
66
127
  const block = message.content[0];
@@ -71,31 +132,13 @@ export async function explainCommand(command) {
71
132
  // ── auto-fix ──────────────────────────────────────────────────────────────────
72
133
  export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
73
134
  const message = await client.messages.create({
74
- model: "claude-opus-4-6",
135
+ model: "claude-sonnet-4-6",
75
136
  max_tokens: 256,
76
137
  system: buildSystemPrompt(perms, sessionCmds),
77
- messages: [
78
- {
138
+ messages: [{
79
139
  role: "user",
80
- content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
81
- },
82
- ],
83
- });
84
- const block = message.content[0];
85
- if (block.type !== "text")
86
- throw new Error("Unexpected response type");
87
- const text = block.text.trim();
88
- if (text.startsWith("BLOCKED:"))
89
- throw new Error(text);
90
- return text;
91
- }
92
- // ── translate ─────────────────────────────────────────────────────────────────
93
- export async function translateToCommand(nl, perms, sessionCmds) {
94
- const message = await client.messages.create({
95
- model: "claude-opus-4-6",
96
- max_tokens: 256,
97
- system: buildSystemPrompt(perms, sessionCmds),
98
- messages: [{ role: "user", content: nl }],
140
+ content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
141
+ }],
99
142
  });
100
143
  const block = message.content[0];
101
144
  if (block.type !== "text")
package/dist/cache.js ADDED
@@ -0,0 +1,41 @@
1
+ // In-memory LRU cache + disk persistence for command translations
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
6
+ const MAX_ENTRIES = 500;
7
+ let mem = {};
8
+ export function loadCache() {
9
+ if (!existsSync(CACHE_FILE))
10
+ return;
11
+ try {
12
+ mem = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
13
+ }
14
+ catch { }
15
+ }
16
+ function persistCache() {
17
+ try {
18
+ writeFileSync(CACHE_FILE, JSON.stringify(mem));
19
+ }
20
+ catch { }
21
+ }
22
+ /** Normalize a natural language query for cache lookup */
23
+ export function normalizeNl(nl) {
24
+ return nl
25
+ .toLowerCase()
26
+ .trim()
27
+ .replace(/[^a-z0-9\s]/g, "") // strip punctuation
28
+ .replace(/\s+/g, " ");
29
+ }
30
+ export function cacheGet(nl) {
31
+ return mem[normalizeNl(nl)] ?? null;
32
+ }
33
+ export function cacheSet(nl, command) {
34
+ const key = normalizeNl(nl);
35
+ // evict oldest if full
36
+ const keys = Object.keys(mem);
37
+ if (keys.length >= MAX_ENTRIES)
38
+ delete mem[keys[0]];
39
+ mem[key] = command;
40
+ persistCache();
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Natural language terminal — speak plain English, get shell commands",
5
5
  "type": "module",
6
6
  "bin": {