@hasna/terminal 2.3.1 → 3.0.1
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 +404 -0
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/Onboarding.js +51 -0
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +49 -0
- package/dist/ai.js +296 -0
- package/dist/cache.js +42 -0
- package/dist/cli.js +1 -1
- package/dist/command-rewriter.js +64 -0
- package/dist/command-validator.js +86 -0
- package/dist/compression.js +85 -0
- package/dist/context-hints.js +285 -0
- package/dist/diff-cache.js +107 -0
- package/dist/discover.js +212 -0
- package/dist/economy.js +155 -0
- package/dist/expand-store.js +44 -0
- package/dist/file-cache.js +72 -0
- package/dist/file-index.js +62 -0
- package/dist/history.js +62 -0
- package/dist/lazy-executor.js +54 -0
- package/dist/line-dedup.js +59 -0
- package/dist/loop-detector.js +75 -0
- package/dist/mcp/install.js +98 -0
- package/dist/mcp/server.js +545 -0
- package/dist/noise-filter.js +86 -0
- package/dist/output-processor.js +132 -0
- package/dist/output-router.js +41 -0
- package/dist/output-store.js +111 -0
- package/dist/parsers/base.js +2 -0
- package/dist/parsers/build.js +64 -0
- package/dist/parsers/errors.js +101 -0
- package/dist/parsers/files.js +78 -0
- package/dist/parsers/git.js +99 -0
- package/dist/parsers/index.js +48 -0
- package/dist/parsers/tests.js +89 -0
- package/dist/providers/anthropic.js +43 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +8 -0
- package/dist/providers/groq.js +8 -0
- package/dist/providers/index.js +122 -0
- package/dist/providers/openai-compat.js +93 -0
- package/dist/providers/xai.js +8 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/storage.js +136 -0
- package/dist/search/content-search.js +68 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +5 -0
- package/dist/search/semantic.js +320 -0
- package/dist/session-boot.js +59 -0
- package/dist/session-context.js +55 -0
- package/dist/sessions-db.js +173 -0
- package/dist/smart-display.js +286 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/test-watchlist.js +131 -0
- package/dist/tokens.js +17 -0
- package/dist/tool-profiles.js +129 -0
- package/dist/tree.js +94 -0
- package/dist/usage-cache.js +65 -0
- package/package.json +8 -1
- package/src/ai.ts +60 -90
- package/src/cache.ts +3 -2
- package/src/cli.tsx +1 -1
- package/src/compression.ts +8 -35
- package/src/context-hints.ts +20 -10
- package/src/diff-cache.ts +1 -1
- package/src/discover.ts +1 -1
- package/src/economy.ts +37 -5
- package/src/expand-store.ts +8 -1
- package/src/mcp/server.ts +45 -73
- package/src/output-processor.ts +11 -8
- package/src/providers/anthropic.ts +6 -2
- package/src/providers/base.ts +2 -0
- package/src/providers/cerebras.ts +6 -105
- package/src/providers/groq.ts +6 -105
- package/src/providers/index.ts +84 -33
- package/src/providers/openai-compat.ts +109 -0
- package/src/providers/xai.ts +6 -105
- package/src/tokens.ts +18 -0
- package/src/tool-profiles.ts +9 -2
- package/.claude/scheduled_tasks.lock +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -20
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -14
- package/CONTRIBUTING.md +0 -80
- package/benchmarks/benchmark.mjs +0 -115
- package/imported_modules.txt +0 -0
- package/src/compression.test.ts +0 -49
- package/src/output-router.ts +0 -56
- package/src/parsers/base.ts +0 -72
- package/src/parsers/build.ts +0 -73
- package/src/parsers/errors.ts +0 -107
- package/src/parsers/files.ts +0 -91
- package/src/parsers/git.ts +0 -101
- package/src/parsers/index.ts +0 -66
- package/src/parsers/parsers.test.ts +0 -153
- package/src/parsers/tests.ts +0 -98
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Tool profiles — config-driven AI enhancement for specific command categories
|
|
2
|
+
// Profiles are loaded from ~/.terminal/profiles/ (user-customizable)
|
|
3
|
+
// Each profile tells the AI how to handle a specific tool's output
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
const PROFILES_DIR = join(process.env.HOME ?? "~", ".terminal", "profiles");
|
|
7
|
+
/** Built-in profiles — sensible defaults, user can override */
|
|
8
|
+
const BUILTIN_PROFILES = [
|
|
9
|
+
{
|
|
10
|
+
name: "git",
|
|
11
|
+
detect: "^git\\b",
|
|
12
|
+
hints: {
|
|
13
|
+
compress: "For git output: show branch, file counts, insertions/deletions summary. Collapse individual diffs to file-level stats.",
|
|
14
|
+
errors: "Git errors often include a suggested fix (e.g., 'did you mean X?'). Extract the suggestion.",
|
|
15
|
+
success: "Clean working tree, successful push/pull, merge complete.",
|
|
16
|
+
},
|
|
17
|
+
output: { preservePatterns: ["conflict", "CONFLICT", "fatal", "error", "diverged"] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "test",
|
|
21
|
+
detect: "\\b(bun|npm|yarn|pnpm)\\s+(test|run\\s+test)|\\bpytest\\b|\\bcargo\\s+test\\b|\\bgo\\s+test\\b",
|
|
22
|
+
hints: {
|
|
23
|
+
compress: "For test output: show pass/fail counts FIRST, then list ONLY failing test names with error snippets. Skip passing tests entirely.",
|
|
24
|
+
errors: "Test failures have: test name, expected vs actual, stack trace. Extract all three.",
|
|
25
|
+
success: "All tests passing = one line: '✓ N tests pass, 0 fail'",
|
|
26
|
+
},
|
|
27
|
+
output: { preservePatterns: ["FAIL", "fail", "Error", "✗", "expected", "received"] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "build",
|
|
31
|
+
detect: "\\b(tsc|bun\\s+run\\s+build|npm\\s+run\\s+build|cargo\\s+build|go\\s+build|make)\\b",
|
|
32
|
+
hints: {
|
|
33
|
+
compress: "For build output: if success with no errors, say '✓ Build succeeded'. If errors, list each error with file:line and message.",
|
|
34
|
+
errors: "Build errors have file:line:column format. Group by file.",
|
|
35
|
+
success: "Empty output or exit 0 = build succeeded.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "lint",
|
|
40
|
+
detect: "\\b(eslint|biome|ruff|clippy|golangci-lint|prettier|tsc\\s+--noEmit)\\b",
|
|
41
|
+
hints: {
|
|
42
|
+
compress: "For lint output: group violations by rule name, show count per rule, one example per rule. Skip clean files.",
|
|
43
|
+
errors: "Lint violations: file:line rule-name message. Group by rule.",
|
|
44
|
+
},
|
|
45
|
+
output: { maxLines: 100 },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "install",
|
|
49
|
+
detect: "\\b(npm\\s+install|bun\\s+install|yarn|pip\\s+install|cargo\\s+build|go\\s+mod)\\b",
|
|
50
|
+
hints: {
|
|
51
|
+
compress: "For install output: show only errors and final summary (packages added/removed/updated). Strip progress bars, funding notices, deprecation warnings.",
|
|
52
|
+
},
|
|
53
|
+
output: { stripPatterns: ["npm warn", "packages are looking for funding", "run `npm fund`"] },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "find",
|
|
57
|
+
detect: "^find\\b",
|
|
58
|
+
hints: {
|
|
59
|
+
compress: "For find output: if >50 results, group by top-level directory with counts. Show first 10 results as examples.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "docker",
|
|
64
|
+
detect: "\\b(docker|kubectl|helm)\\b",
|
|
65
|
+
hints: {
|
|
66
|
+
compress: "For container output: show container status, image, ports. Strip pull progress and layer hashes.",
|
|
67
|
+
errors: "Docker errors: extract the error message after 'Error response from daemon:'",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
/** Load user profiles from ~/.terminal/profiles/ */
|
|
72
|
+
function loadUserProfiles() {
|
|
73
|
+
if (!existsSync(PROFILES_DIR))
|
|
74
|
+
return [];
|
|
75
|
+
const profiles = [];
|
|
76
|
+
try {
|
|
77
|
+
for (const file of readdirSync(PROFILES_DIR)) {
|
|
78
|
+
if (!file.endsWith(".json"))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const content = JSON.parse(readFileSync(join(PROFILES_DIR, file), "utf8"));
|
|
82
|
+
if (content.name && content.detect)
|
|
83
|
+
profiles.push(content);
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
return profiles;
|
|
90
|
+
}
|
|
91
|
+
/** Get all profiles — user profiles override builtins by name (cached 30s) */
|
|
92
|
+
let _cachedProfiles = null;
|
|
93
|
+
let _cachedProfilesAt = 0;
|
|
94
|
+
export function getProfiles() {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (_cachedProfiles && now - _cachedProfilesAt < 30_000)
|
|
97
|
+
return _cachedProfiles;
|
|
98
|
+
const user = loadUserProfiles();
|
|
99
|
+
const userNames = new Set(user.map(p => p.name));
|
|
100
|
+
const builtins = BUILTIN_PROFILES.filter(p => !userNames.has(p.name));
|
|
101
|
+
_cachedProfiles = [...user, ...builtins];
|
|
102
|
+
_cachedProfilesAt = now;
|
|
103
|
+
return _cachedProfiles;
|
|
104
|
+
}
|
|
105
|
+
/** Find the matching profile for a command */
|
|
106
|
+
export function matchProfile(command) {
|
|
107
|
+
for (const profile of getProfiles()) {
|
|
108
|
+
try {
|
|
109
|
+
if (new RegExp(profile.detect).test(command))
|
|
110
|
+
return profile;
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/** Format profile hints for injection into AI prompt */
|
|
117
|
+
export function formatProfileHints(command) {
|
|
118
|
+
const profile = matchProfile(command);
|
|
119
|
+
if (!profile)
|
|
120
|
+
return "";
|
|
121
|
+
const lines = [`TOOL PROFILE (${profile.name}):`];
|
|
122
|
+
if (profile.hints.compress)
|
|
123
|
+
lines.push(` Compression: ${profile.hints.compress}`);
|
|
124
|
+
if (profile.hints.errors)
|
|
125
|
+
lines.push(` Errors: ${profile.hints.errors}`);
|
|
126
|
+
if (profile.hints.success)
|
|
127
|
+
lines.push(` Success: ${profile.hints.success}`);
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
package/dist/tree.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Tree compression — convert flat file paths to compact tree representation
|
|
2
|
+
import { readdirSync, statSync } from "fs";
|
|
3
|
+
import { join, basename } from "path";
|
|
4
|
+
import { DEFAULT_EXCLUDE_DIRS } from "./search/filters.js";
|
|
5
|
+
/** Build a tree from a directory */
|
|
6
|
+
export function buildTree(dirPath, options = {}) {
|
|
7
|
+
const { maxDepth = 2, includeHidden = false, depth = 0 } = options;
|
|
8
|
+
const name = basename(dirPath) || dirPath;
|
|
9
|
+
const node = { name, type: "dir", children: [], fileCount: 0 };
|
|
10
|
+
if (depth >= maxDepth) {
|
|
11
|
+
// Count files without listing them
|
|
12
|
+
try {
|
|
13
|
+
const entries = readdirSync(dirPath);
|
|
14
|
+
node.fileCount = entries.length;
|
|
15
|
+
node.children = undefined; // don't expand
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
node.fileCount = 0;
|
|
19
|
+
}
|
|
20
|
+
return node;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const entries = readdirSync(dirPath);
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!includeHidden && entry.startsWith("."))
|
|
26
|
+
continue;
|
|
27
|
+
if (DEFAULT_EXCLUDE_DIRS.includes(entry)) {
|
|
28
|
+
// Show as collapsed with count
|
|
29
|
+
try {
|
|
30
|
+
const subPath = join(dirPath, entry);
|
|
31
|
+
const subStat = statSync(subPath);
|
|
32
|
+
if (subStat.isDirectory()) {
|
|
33
|
+
node.children.push({ name: entry, type: "dir", fileCount: -1 }); // -1 = hidden
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const fullPath = join(dirPath, entry);
|
|
42
|
+
try {
|
|
43
|
+
const stat = statSync(fullPath);
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
node.children.push(buildTree(fullPath, { maxDepth, includeHidden, depth: depth + 1 }));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
node.children.push({ name: entry, type: "file", size: stat.size });
|
|
49
|
+
node.fileCount++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
return node;
|
|
59
|
+
}
|
|
60
|
+
/** Render tree as compact string (for agents — minimum tokens) */
|
|
61
|
+
export function compactTree(node, indent = 0) {
|
|
62
|
+
const pad = " ".repeat(indent);
|
|
63
|
+
if (node.type === "file")
|
|
64
|
+
return `${pad}${node.name}`;
|
|
65
|
+
if (node.fileCount === -1)
|
|
66
|
+
return `${pad}${node.name}/ (hidden)`;
|
|
67
|
+
if (!node.children || node.children.length === 0)
|
|
68
|
+
return `${pad}${node.name}/ (empty)`;
|
|
69
|
+
if (!node.children.some(c => c.children)) {
|
|
70
|
+
// Leaf directory — compact single line
|
|
71
|
+
const files = node.children.filter(c => c.type === "file").map(c => c.name);
|
|
72
|
+
const dirs = node.children.filter(c => c.type === "dir");
|
|
73
|
+
const parts = [];
|
|
74
|
+
if (files.length <= 5) {
|
|
75
|
+
parts.push(...files);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
parts.push(`${files.length} files`);
|
|
79
|
+
}
|
|
80
|
+
for (const d of dirs) {
|
|
81
|
+
parts.push(`${d.name}/${d.fileCount != null ? ` (${d.fileCount === -1 ? "hidden" : d.fileCount + " files"})` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
return `${pad}${node.name}/ [${parts.join(", ")}]`;
|
|
84
|
+
}
|
|
85
|
+
const lines = [`${pad}${node.name}/`];
|
|
86
|
+
for (const child of node.children) {
|
|
87
|
+
lines.push(compactTree(child, indent + 1));
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
/** Render tree as JSON (for MCP) */
|
|
92
|
+
export function treeToJson(node) {
|
|
93
|
+
return node;
|
|
94
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Usage learning cache — zero-cost repeated queries
|
|
2
|
+
// After 3 identical prompt→command mappings, cache locally
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
const DIR = join(homedir(), ".terminal");
|
|
8
|
+
const CACHE_FILE = join(DIR, "learned.json");
|
|
9
|
+
function ensureDir() {
|
|
10
|
+
if (!existsSync(DIR))
|
|
11
|
+
mkdirSync(DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
function hash(s) {
|
|
14
|
+
return createHash("md5").update(s).digest("hex").slice(0, 12);
|
|
15
|
+
}
|
|
16
|
+
function cacheKey(prompt) {
|
|
17
|
+
const projectHash = hash(process.cwd());
|
|
18
|
+
const promptHash = hash(prompt.toLowerCase().trim());
|
|
19
|
+
return `${projectHash}:${promptHash}`;
|
|
20
|
+
}
|
|
21
|
+
function loadCache() {
|
|
22
|
+
ensureDir();
|
|
23
|
+
if (!existsSync(CACHE_FILE))
|
|
24
|
+
return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(CACHE_FILE, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveCache(cache) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache));
|
|
35
|
+
}
|
|
36
|
+
/** Check if we have a learned command for this prompt (3+ identical mappings) */
|
|
37
|
+
export function getLearned(prompt) {
|
|
38
|
+
const key = cacheKey(prompt);
|
|
39
|
+
const cache = loadCache();
|
|
40
|
+
const entry = cache[key];
|
|
41
|
+
if (entry && entry.count >= 3)
|
|
42
|
+
return entry.command;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Record a prompt→command mapping */
|
|
46
|
+
export function recordMapping(prompt, command) {
|
|
47
|
+
const key = cacheKey(prompt);
|
|
48
|
+
const cache = loadCache();
|
|
49
|
+
const existing = cache[key];
|
|
50
|
+
if (existing && existing.command === command) {
|
|
51
|
+
existing.count++;
|
|
52
|
+
existing.lastUsed = Date.now();
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
cache[key] = { command, count: 1, lastUsed: Date.now() };
|
|
56
|
+
}
|
|
57
|
+
saveCache(cache);
|
|
58
|
+
}
|
|
59
|
+
/** Get cache stats */
|
|
60
|
+
export function learnedStats() {
|
|
61
|
+
const cache = loadCache();
|
|
62
|
+
const entries = Object.keys(cache).length;
|
|
63
|
+
const cached = Object.values(cache).filter(e => e.count >= 3).length;
|
|
64
|
+
return { entries, cached };
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**",
|
|
8
|
+
"src/**",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md"
|
|
12
|
+
],
|
|
6
13
|
"bin": {
|
|
7
14
|
"t": "dist/cli.js",
|
|
8
15
|
"terminal": "dist/cli.js"
|
package/src/ai.ts
CHANGED
|
@@ -26,16 +26,25 @@ const MODEL_DEFAULTS: Record<string, { fast: string; smart: string }> = {
|
|
|
26
26
|
anthropic: { fast: "claude-haiku-4-5-20251001", smart: "claude-sonnet-4-6" },
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/** Load user model overrides from ~/.terminal/config.json */
|
|
29
|
+
/** Load user model overrides from ~/.terminal/config.json (cached 30s) */
|
|
30
|
+
let _modelOverrides: Record<string, { fast?: string; smart?: string }> | null = null;
|
|
31
|
+
let _modelOverridesAt = 0;
|
|
32
|
+
|
|
30
33
|
function loadModelOverrides(): Record<string, { fast?: string; smart?: string }> {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (_modelOverrides && now - _modelOverridesAt < 30_000) return _modelOverrides;
|
|
31
36
|
try {
|
|
32
37
|
const configPath = join(process.env.HOME ?? "~", ".terminal", "config.json");
|
|
33
38
|
if (existsSync(configPath)) {
|
|
34
39
|
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
35
|
-
|
|
40
|
+
_modelOverrides = config.models ?? {};
|
|
41
|
+
_modelOverridesAt = now;
|
|
42
|
+
return _modelOverrides!;
|
|
36
43
|
}
|
|
37
44
|
} catch {}
|
|
38
|
-
|
|
45
|
+
_modelOverrides = {};
|
|
46
|
+
_modelOverridesAt = now;
|
|
47
|
+
return _modelOverrides;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/** Model routing per provider — config-driven with defaults */
|
|
@@ -148,6 +157,8 @@ function detectProjectContext(): string {
|
|
|
148
157
|
// ── system prompt ─────────────────────────────────────────────────────────────
|
|
149
158
|
|
|
150
159
|
function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[], currentPrompt?: string): string {
|
|
160
|
+
const nl = currentPrompt?.toLowerCase() ?? "";
|
|
161
|
+
|
|
151
162
|
const restrictions: string[] = [];
|
|
152
163
|
if (!perms.destructive)
|
|
153
164
|
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
|
|
@@ -178,7 +189,6 @@ function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[], c
|
|
|
178
189
|
|
|
179
190
|
const projectContext = detectProjectContext();
|
|
180
191
|
|
|
181
|
-
// Inject safety hints for the command being generated (AI sees what's risky)
|
|
182
192
|
const safetyBlock = sessionEntries.length > 0
|
|
183
193
|
? (() => {
|
|
184
194
|
const lastCmd = sessionEntries[sessionEntries.length - 1]?.cmd;
|
|
@@ -190,70 +200,32 @@ function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[], c
|
|
|
190
200
|
})()
|
|
191
201
|
: "";
|
|
192
202
|
|
|
203
|
+
// ── Conditional sections (only included when relevant) ──
|
|
204
|
+
const wantsStructure = /\b(function|class|interface|export|symbol|structure|hierarchy|outline)\b/i.test(nl);
|
|
205
|
+
const astBlock = wantsStructure ? `\nAST-POWERED QUERIES: For code STRUCTURE questions, use "terminal symbols" instead of grep. It uses AST parsing for TypeScript, Python, Go, Rust.` : "";
|
|
206
|
+
|
|
207
|
+
const wantsMultiple = /\b(and|both|also|plus|as well)\b/i.test(nl);
|
|
208
|
+
const compoundBlock = wantsMultiple ? `\nCOMPOUND QUESTIONS: Prefer ONE command that captures all info. NEVER split into separate expensive commands.` : "";
|
|
209
|
+
|
|
210
|
+
const wantsAnalysis = /\b(quality|lint|coverage|complexity|unused|dead code|security|audit|scan|dependency)\b/i.test(nl);
|
|
211
|
+
const blockedAltBlock = wantsAnalysis ? `\nBLOCKED ALTERNATIVES: If your preferred command needs installing packages, try READ-ONLY alternatives (grep, cat, wc, awk). NEVER give up on analysis questions.` : "";
|
|
212
|
+
|
|
193
213
|
return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
|
|
194
|
-
The user describes what they want in plain English. You translate to the exact shell command.
|
|
195
214
|
|
|
196
215
|
RULES:
|
|
197
|
-
- SIMPLICITY FIRST: Use the simplest command
|
|
198
|
-
- ALWAYS use grep -rn
|
|
199
|
-
- When user refers to items from previous output, use
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
- Search src/ directory, NOT dist/ or node_modules/ for code queries.
|
|
211
|
-
- Use exact file paths from the project context below. Do NOT guess paths.
|
|
212
|
-
- For "what would break if I deleted X": use grep -rn "from.*X\\|import.*X\\|require.*X" src/ to find all importers.
|
|
213
|
-
- For "find where X is defined": use grep -rn "export.*function X\\|export.*class X\\|export.*const X" src/
|
|
214
|
-
- For "show me the code of function X": if you know the file, use grep -A 30 "function X" src/file.ts. If not, use grep -rn -A 30 "function X" src/ --include="*.ts"
|
|
215
|
-
- ALWAYS use grep -rn (recursive) when searching directories. NEVER use grep without -r on a directory — it will fail.
|
|
216
|
-
- For conceptual questions about what code does: use cat on the relevant file, the AI summary will explain it.
|
|
217
|
-
- For DESTRUCTIVE requests (delete, remove, install, push): output BLOCKED: <reason>. NEVER try to execute destructive commands.
|
|
218
|
-
|
|
219
|
-
AST-POWERED QUERIES: For code STRUCTURE questions, use the built-in AST tool instead of grep:
|
|
220
|
-
- "find all exported functions" → terminal symbols src/ (lists all functions, classes, interfaces with line numbers)
|
|
221
|
-
- "show all interfaces" → terminal symbols src/ | grep interface
|
|
222
|
-
- "what does file X export" → terminal symbols src/file.ts
|
|
223
|
-
- "show me the class hierarchy" → terminal symbols src/
|
|
224
|
-
The "terminal symbols" command uses AST parsing (not regex) — it understands TypeScript, Python, Go, Rust code structure.
|
|
225
|
-
For TEXT search (TODO, string matches, imports) → use grep as normal.
|
|
226
|
-
|
|
227
|
-
COMPOUND QUESTIONS: For questions asking multiple things, prefer ONE command that captures all info. Extract multiple answers from a single output.
|
|
228
|
-
- "how many tests and do they pass" → bun test (extract count AND pass/fail from output)
|
|
229
|
-
- "what files changed and how many lines" → git log --stat -3 (shows files AND line counts)
|
|
230
|
-
- "what version of node and bun" → node -v && bun -v (only use && for trivial non-failing commands)
|
|
231
|
-
NEVER split into separate test runs or expensive commands chained with &&.
|
|
232
|
-
|
|
233
|
-
BLOCKED ALTERNATIVES: If your preferred command would require installing packages (npx, npm install), ALWAYS try a READ-ONLY alternative:
|
|
234
|
-
- Code quality analysis → grep -rn "TODO\\|FIXME\\|HACK\\|XXX" src/
|
|
235
|
-
- Linting → check if "lint" or "typecheck" exists in package.json scripts, run that
|
|
236
|
-
- Security scan → grep -rn "eval\\|exec\\|spawn\\|password\\|secret" src/
|
|
237
|
-
- Dependency audit → cat package.json | grep -A 50 dependencies
|
|
238
|
-
- Test coverage → bun test --coverage (or npm run test:coverage if available)
|
|
239
|
-
NEVER give up. NEVER output BLOCKED for analysis questions. Always try a grep/find/cat/wc/awk read-only alternative.
|
|
240
|
-
- Cyclomatic complexity → grep -rn "if\\|else\\|for\\|while\\|switch\\|case\\|catch\\|&&\\|||" src/ --include="*.ts" | wc -l
|
|
241
|
-
- Unused exports → grep -rn "export function\|export const\|export class" src/ --include="*.ts" | sed 's/.*export [a-z]* //' | sed 's/[(<:].*//' | sort -u
|
|
242
|
-
- Dead code → for each exported name, grep -rn "name" src/ --include="*.ts" | wc -l (if only 1 match = unused)
|
|
243
|
-
- Dependency graph → grep -rn "from " src/ --include="*.ts" | sed 's/:.*from "/→/' | sed 's/".*//' | sort -u
|
|
244
|
-
- Most parameters → grep -rn "function " src/ --include="*.ts" | awk -F'[()]' '{print gsub(/,/,",",$2)+1, $0}' | sort -nr | head -10
|
|
245
|
-
ALWAYS try a heuristic shell approach before giving up. NEVER say BLOCKED for analysis questions.
|
|
246
|
-
|
|
247
|
-
SEMANTIC MAPPING: When the user references a concept, search the file tree for RELATED terms:
|
|
248
|
-
- Look at directory names: src/agent/ likely contains "agentic" code
|
|
249
|
-
- Look at file names: lazy-executor.ts likely handles "lazy mode"
|
|
250
|
-
- When uncertain: grep -rn "keyword" src/ --include="*.ts" -l (list matching files)
|
|
251
|
-
|
|
252
|
-
ACTION vs CONCEPTUAL: If the prompt starts with "run", "execute", "check", "test", "build", "show output of" — ALWAYS generate an executable command. NEVER read README for action requests. Only read docs for "explain why", "what does X mean", "how was X designed".
|
|
253
|
-
|
|
254
|
-
EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we have", "does X exist" — NEVER run/start/launch anything. Use ls, find, or test -d to CHECK existence. These are READ-ONLY questions.
|
|
255
|
-
|
|
256
|
-
MONOREPO: If the project context says "MONOREPO", search packages/ or apps/ NOT src/. Use: grep -rn "pattern" packages/ --include="*.ts". For specific packages, use packages/PKGNAME/src/.
|
|
216
|
+
- SIMPLICITY FIRST: Use the simplest command. Prefer grep | sort | head over 10-pipe chains.
|
|
217
|
+
- ALWAYS use grep -rn when searching directories. NEVER grep without -r on a directory.
|
|
218
|
+
- When user refers to items from previous output, use EXACT names shown.
|
|
219
|
+
- For text search use grep -rn, NOT nm or objdump.
|
|
220
|
+
- macOS/BSD tools: du -d 1 (not --max-depth), NEVER grep -P, use grep -E for extended regex.
|
|
221
|
+
- NEVER invent commands. Stick to standard Unix/macOS.
|
|
222
|
+
- NEVER install packages. READ-ONLY terminal.
|
|
223
|
+
- NEVER modify source code. Only observe.
|
|
224
|
+
- Search src/ not dist/ or node_modules/.
|
|
225
|
+
- Use exact file paths from project context. Do NOT guess paths.
|
|
226
|
+
- For DESTRUCTIVE requests: output BLOCKED: <reason>.
|
|
227
|
+
- ACTION vs CONCEPTUAL: "run/test/build/check" → executable command. "explain/what does X mean" → read docs.
|
|
228
|
+
- EXISTENCE CHECKS: "is there/does X exist" → use ls/find/test, NEVER run/launch.${astBlock}${compoundBlock}${blockedAltBlock}
|
|
257
229
|
cwd: ${process.cwd()}
|
|
258
230
|
shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}${currentPrompt ? loadCorrectionHints(currentPrompt) : ""}`;
|
|
259
231
|
}
|
|
@@ -280,11 +252,11 @@ export async function translateToCommand(
|
|
|
280
252
|
let text: string;
|
|
281
253
|
|
|
282
254
|
if (onToken) {
|
|
283
|
-
text = await provider.stream(nl, { model, maxTokens: 256, system }, {
|
|
255
|
+
text = await provider.stream(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system }, {
|
|
284
256
|
onToken: (partial) => onToken(partial),
|
|
285
257
|
});
|
|
286
258
|
} else {
|
|
287
|
-
text = await provider.complete(nl, { model, maxTokens: 256, system });
|
|
259
|
+
text = await provider.complete(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system });
|
|
288
260
|
}
|
|
289
261
|
|
|
290
262
|
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
@@ -334,6 +306,7 @@ export async function explainCommand(command: string): Promise<string> {
|
|
|
334
306
|
return provider.complete(command, {
|
|
335
307
|
model: routing.fast,
|
|
336
308
|
maxTokens: 128,
|
|
309
|
+
temperature: 0,
|
|
337
310
|
system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
|
|
338
311
|
});
|
|
339
312
|
}
|
|
@@ -345,37 +318,34 @@ export async function fixCommand(
|
|
|
345
318
|
failedCommand: string,
|
|
346
319
|
errorOutput: string,
|
|
347
320
|
perms: Permissions,
|
|
348
|
-
|
|
321
|
+
_sessionEntries: SessionEntry[]
|
|
349
322
|
): Promise<string> {
|
|
350
323
|
const provider = getProvider();
|
|
351
324
|
const routing = pickModel(originalNl);
|
|
325
|
+
|
|
326
|
+
// Lightweight fix prompt — no full project context, just rules + restrictions
|
|
327
|
+
const restrictions: string[] = [];
|
|
328
|
+
if (!perms.destructive) restrictions.push("- NEVER delete/remove/overwrite files");
|
|
329
|
+
if (!perms.network) restrictions.push("- NEVER make network requests");
|
|
330
|
+
if (!perms.install) restrictions.push("- NEVER install packages");
|
|
331
|
+
|
|
332
|
+
const fixSystem = `You are a terminal assistant. Output ONLY the corrected shell command — no explanation.
|
|
333
|
+
macOS/BSD tools. NEVER use grep -P. Use grep -E for extended regex.
|
|
334
|
+
NEVER install packages. READ-ONLY terminal.
|
|
335
|
+
cwd: ${process.cwd()}${restrictions.length > 0 ? `\nRESTRICTIONS:\n${restrictions.join("\n")}` : ""}`;
|
|
336
|
+
|
|
352
337
|
const text = await provider.complete(
|
|
353
|
-
`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
|
|
338
|
+
`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput.slice(0, 2000)}\n\nGive me the corrected command only.`,
|
|
354
339
|
{
|
|
355
|
-
model: routing.smart,
|
|
340
|
+
model: routing.smart,
|
|
356
341
|
maxTokens: 256,
|
|
357
|
-
|
|
342
|
+
temperature: 0,
|
|
343
|
+
stop: ["\n"],
|
|
344
|
+
system: fixSystem,
|
|
358
345
|
}
|
|
359
346
|
);
|
|
360
347
|
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
361
|
-
return text;
|
|
348
|
+
return text.trim();
|
|
362
349
|
}
|
|
363
350
|
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
export async function summarizeOutput(
|
|
367
|
-
command: string,
|
|
368
|
-
output: string,
|
|
369
|
-
maxTokens: number = 200
|
|
370
|
-
): Promise<string> {
|
|
371
|
-
const provider = getProvider();
|
|
372
|
-
const routing = pickModel("summarize");
|
|
373
|
-
return provider.complete(
|
|
374
|
-
`Command: ${command}\nOutput:\n${output}\n\nSummarize this output concisely for an AI agent. Focus on: status, key results, errors. Be terse.`,
|
|
375
|
-
{
|
|
376
|
-
model: routing.fast,
|
|
377
|
-
maxTokens,
|
|
378
|
-
system: "You summarize command output for AI agents. Be extremely concise. Return structured info. No prose.",
|
|
379
|
-
}
|
|
380
|
-
);
|
|
381
|
-
}
|
|
351
|
+
// summarizeOutput() removed — all output processing goes through processOutput() in output-processor.ts
|
package/src/cache.ts
CHANGED
|
@@ -20,12 +20,13 @@ function persistCache() {
|
|
|
20
20
|
try { writeFileSync(CACHE_FILE, JSON.stringify(mem)); } catch {}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** Normalize a natural language query for cache lookup
|
|
23
|
+
/** Normalize a natural language query for cache lookup.
|
|
24
|
+
* Keeps . / - _ which are meaningful in file paths and shell context. */
|
|
24
25
|
export function normalizeNl(nl: string): string {
|
|
25
26
|
return nl
|
|
26
27
|
.toLowerCase()
|
|
27
28
|
.trim()
|
|
28
|
-
.replace(/[^a-z0-9\s]/g, "") //
|
|
29
|
+
.replace(/[^a-z0-9\s.\/_-]/g, "") // keep meaningful shell chars
|
|
29
30
|
.replace(/\s+/g, " ");
|
|
30
31
|
}
|
|
31
32
|
|
package/src/cli.tsx
CHANGED
|
@@ -446,7 +446,7 @@ else if (args.length > 0) {
|
|
|
446
446
|
const { rewriteCommand } = await import("./command-rewriter.js");
|
|
447
447
|
const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
|
|
448
448
|
const { saveOutput, formatOutputHint } = await import("./output-store.js");
|
|
449
|
-
const {
|
|
449
|
+
const { estimateTokens } = await import("./tokens.js");
|
|
450
450
|
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
451
451
|
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
452
452
|
const { detectLoop } = await import("./loop-detector.js");
|
package/src/compression.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// Token compression engine — reduces CLI output to fit within token budgets
|
|
2
|
+
// No regex parsing — just ANSI stripping, deduplication, and smart truncation.
|
|
3
|
+
// All intelligent output processing goes through AI via processOutput().
|
|
2
4
|
|
|
3
|
-
import {
|
|
5
|
+
import { estimateTokens } from "./tokens.js";
|
|
4
6
|
|
|
5
7
|
export interface CompressOptions {
|
|
6
8
|
/** Max tokens for the output (default: unlimited) */
|
|
7
9
|
maxTokens?: number;
|
|
8
|
-
/** Output format */
|
|
9
|
-
format?: "text" | "json" | "summary";
|
|
10
10
|
/** Strip ANSI escape codes (default: true) */
|
|
11
11
|
stripAnsi?: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface CompressedOutput {
|
|
15
15
|
content: string;
|
|
16
|
-
format: "text" | "json" | "summary";
|
|
17
16
|
originalTokens: number;
|
|
18
17
|
compressedTokens: number;
|
|
19
18
|
tokensSaved: number;
|
|
@@ -36,7 +35,6 @@ function deduplicateLines(lines: string[]): string[] {
|
|
|
36
35
|
|
|
37
36
|
for (let i = 0; i < lines.length; i++) {
|
|
38
37
|
const line = lines[i];
|
|
39
|
-
// Extract a "pattern" — the line without numbers, paths, specific identifiers
|
|
40
38
|
const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
|
|
41
39
|
|
|
42
40
|
if (pattern === repeatPattern) {
|
|
@@ -45,7 +43,6 @@ function deduplicateLines(lines: string[]): string[] {
|
|
|
45
43
|
if (repeatCount > 2) {
|
|
46
44
|
result.push(` ... (${repeatCount} similar lines)`);
|
|
47
45
|
} else if (repeatCount > 0) {
|
|
48
|
-
// Push the skipped lines back
|
|
49
46
|
for (let j = i - repeatCount; j < i; j++) {
|
|
50
47
|
result.push(lines[j]);
|
|
51
48
|
}
|
|
@@ -67,14 +64,13 @@ function deduplicateLines(lines: string[]): string[] {
|
|
|
67
64
|
return result;
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
/** Smart truncation: keep first
|
|
67
|
+
/** Smart truncation: keep first 60% + last 40% of lines */
|
|
71
68
|
function smartTruncate(text: string, maxTokens: number): string {
|
|
72
69
|
const lines = text.split("\n");
|
|
73
70
|
const currentTokens = estimateTokens(text);
|
|
74
71
|
|
|
75
72
|
if (currentTokens <= maxTokens) return text;
|
|
76
73
|
|
|
77
|
-
// Keep proportional first/last, with first getting more
|
|
78
74
|
const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
|
|
79
75
|
const firstCount = Math.ceil(targetLines * 0.6);
|
|
80
76
|
const lastCount = Math.floor(targetLines * 0.4);
|
|
@@ -88,42 +84,20 @@ function smartTruncate(text: string, maxTokens: number): string {
|
|
|
88
84
|
return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
/** Compress command output
|
|
87
|
+
/** Compress command output — ANSI strip, dedup, truncate. No parsing. */
|
|
92
88
|
export function compress(command: string, output: string, options: CompressOptions = {}): CompressedOutput {
|
|
93
|
-
const { maxTokens,
|
|
89
|
+
const { maxTokens, stripAnsi: doStrip = true } = options;
|
|
94
90
|
const originalTokens = estimateTokens(output);
|
|
95
91
|
|
|
96
92
|
// Step 1: Strip ANSI codes
|
|
97
93
|
let text = doStrip ? stripAnsi(output) : output;
|
|
98
94
|
|
|
99
|
-
// Step 2:
|
|
100
|
-
if (format === "json" || format === "summary") {
|
|
101
|
-
const parsed = parseOutput(command, text);
|
|
102
|
-
if (parsed) {
|
|
103
|
-
const json = JSON.stringify(parsed.data, null, format === "summary" ? 0 : 2);
|
|
104
|
-
const savings = tokenSavings(output, parsed.data);
|
|
105
|
-
const compressedTokens = estimateTokens(json);
|
|
106
|
-
|
|
107
|
-
// ONLY use JSON if it actually saves tokens (never return larger output)
|
|
108
|
-
if (savings.saved > 0 && (!maxTokens || compressedTokens <= maxTokens)) {
|
|
109
|
-
return {
|
|
110
|
-
content: json,
|
|
111
|
-
format: "json",
|
|
112
|
-
originalTokens,
|
|
113
|
-
compressedTokens,
|
|
114
|
-
tokensSaved: savings.saved,
|
|
115
|
-
savingsPercent: savings.percent,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Step 3: Deduplicate similar lines
|
|
95
|
+
// Step 2: Deduplicate similar lines
|
|
122
96
|
const lines = text.split("\n");
|
|
123
97
|
const deduped = deduplicateLines(lines);
|
|
124
98
|
text = deduped.join("\n");
|
|
125
99
|
|
|
126
|
-
// Step
|
|
100
|
+
// Step 3: Smart truncation if over budget
|
|
127
101
|
if (maxTokens) {
|
|
128
102
|
text = smartTruncate(text, maxTokens);
|
|
129
103
|
}
|
|
@@ -131,7 +105,6 @@ export function compress(command: string, output: string, options: CompressOptio
|
|
|
131
105
|
const compressedTokens = estimateTokens(text);
|
|
132
106
|
return {
|
|
133
107
|
content: text,
|
|
134
|
-
format: "text",
|
|
135
108
|
originalTokens,
|
|
136
109
|
compressedTokens,
|
|
137
110
|
tokensSaved: Math.max(0, originalTokens - compressedTokens),
|