interference-agent 0.1.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 +74 -0
- package/assets/screenshot.png +0 -0
- package/bun.lock +159 -0
- package/package.json +39 -0
- package/src/agent/compaction.ts +114 -0
- package/src/agent/loop.ts +94 -0
- package/src/agent/prompt.ts +89 -0
- package/src/agent/subagent.ts +64 -0
- package/src/auth.ts +50 -0
- package/src/cli-plain.ts +274 -0
- package/src/cli.ts +87 -0
- package/src/commands/index.ts +184 -0
- package/src/config-file.ts +109 -0
- package/src/config.ts +212 -0
- package/src/context.ts +96 -0
- package/src/cost.ts +54 -0
- package/src/git.ts +22 -0
- package/src/permissions.ts +135 -0
- package/src/provider.ts +58 -0
- package/src/session/__tests__/session.test.ts +180 -0
- package/src/session/snapshot.ts +122 -0
- package/src/session/store.ts +120 -0
- package/src/skills.ts +177 -0
- package/src/tools/__tests__/mutating.test.ts +324 -0
- package/src/tools/__tests__/question.test.ts +53 -0
- package/src/tools/__tests__/todowrite.test.ts +57 -0
- package/src/tools/__tests__/tools.test.ts +217 -0
- package/src/tools/_fs.ts +12 -0
- package/src/tools/bash.ts +104 -0
- package/src/tools/edit.ts +98 -0
- package/src/tools/glob.ts +40 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/index.ts +21 -0
- package/src/tools/ls.ts +70 -0
- package/src/tools/question.ts +81 -0
- package/src/tools/read.ts +61 -0
- package/src/tools/registry.ts +36 -0
- package/src/tools/task.ts +71 -0
- package/src/tools/todowrite.ts +84 -0
- package/src/tools/webfetch.ts +111 -0
- package/src/tools/write.ts +51 -0
- package/src/tui/App.tsx +738 -0
- package/src/tui/ConfirmDialog.tsx +46 -0
- package/src/tui/DiffView.tsx +88 -0
- package/src/tui/MarkdownText.tsx +63 -0
- package/src/tui/Message.tsx +26 -0
- package/src/tui/ModelPicker.tsx +44 -0
- package/src/tui/Panel.tsx +39 -0
- package/src/tui/ProviderPicker.tsx +111 -0
- package/src/tui/QuestionDialog.tsx +64 -0
- package/src/tui/SessionList.tsx +72 -0
- package/src/tui/SlashAutocomplete.tsx +33 -0
- package/src/tui/StatusFooter.tsx +71 -0
- package/src/tui/ThinkingPicker.tsx +57 -0
- package/src/tui/Toast.tsx +64 -0
- package/src/tui/TodoList.tsx +49 -0
- package/src/tui/ToolStep.tsx +184 -0
- package/src/tui/Welcome.tsx +87 -0
- package/src/tui/__tests__/tui-render.test.tsx +59 -0
- package/src/tui/theme.ts +16 -0
- package/src/tui/wordmark.ts +7 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { setRules, type PermRule } from "./permissions.ts";
|
|
4
|
+
import { setMode, type AgentMode } from "./config.ts";
|
|
5
|
+
import type { InstructionBlock } from "./context.ts";
|
|
6
|
+
|
|
7
|
+
interface InterferenceConfig {
|
|
8
|
+
model?: string;
|
|
9
|
+
mode?: "plan" | "build";
|
|
10
|
+
permissions?: Record<string, string | Record<string, string>>;
|
|
11
|
+
instructions?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let loadedConfig: InterferenceConfig | null = null;
|
|
15
|
+
|
|
16
|
+
export function getLoadedConfig(): InterferenceConfig | null {
|
|
17
|
+
return loadedConfig;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadConfig(): Promise<InterferenceConfig | null> {
|
|
21
|
+
const filePath = findConfigFile();
|
|
22
|
+
if (!filePath) return null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(filePath, "utf-8");
|
|
26
|
+
const config = JSON.parse(raw) as InterferenceConfig;
|
|
27
|
+
loadedConfig = config;
|
|
28
|
+
return config;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findConfigFile(): string | null {
|
|
35
|
+
let dir = process.cwd();
|
|
36
|
+
const root = path.parse(dir).root;
|
|
37
|
+
|
|
38
|
+
while (dir !== root) {
|
|
39
|
+
const fp = path.join(dir, "interference.json");
|
|
40
|
+
try {
|
|
41
|
+
const stat = Bun.file(fp);
|
|
42
|
+
if (stat.size > 0) return fp;
|
|
43
|
+
} catch {}
|
|
44
|
+
const parent = path.dirname(dir);
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function applyConfig(config: InterferenceConfig): void {
|
|
52
|
+
// Model override (env INTERFERENCE_MODEL still wins in config.ts)
|
|
53
|
+
if (config.model && !process.env.INTERFERENCE_MODEL) {
|
|
54
|
+
process.env.INTERFERENCE_MODEL = config.model;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Mode
|
|
58
|
+
if (config.mode) {
|
|
59
|
+
setMode(config.mode as AgentMode);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Permissions: merge user rules with defaults
|
|
63
|
+
if (config.permissions) {
|
|
64
|
+
const rules = parsePermissionRules(config.permissions);
|
|
65
|
+
setRules(rules);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsePermissionRules(
|
|
70
|
+
permissions: Record<string, string | Record<string, string>>,
|
|
71
|
+
): PermRule[] {
|
|
72
|
+
const rules: PermRule[] = [];
|
|
73
|
+
|
|
74
|
+
for (const [tool, value] of Object.entries(permissions)) {
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
rules.push({
|
|
77
|
+
tool,
|
|
78
|
+
decision: value as "allow" | "ask" | "deny",
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
for (const [pattern, decision] of Object.entries(value)) {
|
|
82
|
+
rules.push({
|
|
83
|
+
tool,
|
|
84
|
+
pattern,
|
|
85
|
+
decision: decision as "allow" | "ask" | "deny",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return rules;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function loadConfigInstructions(
|
|
95
|
+
config: InterferenceConfig,
|
|
96
|
+
): Promise<InstructionBlock[]> {
|
|
97
|
+
const blocks: InstructionBlock[] = [];
|
|
98
|
+
if (!config.instructions) return blocks;
|
|
99
|
+
|
|
100
|
+
for (const filePath of config.instructions) {
|
|
101
|
+
const abs = path.resolve(process.cwd(), filePath);
|
|
102
|
+
try {
|
|
103
|
+
const content = await readFile(abs, "utf-8");
|
|
104
|
+
blocks.push({ source: abs, content });
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return blocks;
|
|
109
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Configurazione centralizzata (RF-CORE-02/03). Letta da env / `.env` (Bun carica
|
|
2
|
+
// `.env` in automatico). Nessun segreto hardcoded: le API key arrivano da env.
|
|
3
|
+
//
|
|
4
|
+
// Multi-provider con REASONING/THINKING a livello selezionabile a runtime
|
|
5
|
+
// (`/thinking`). Il meccanismo differisce per provider:
|
|
6
|
+
// - dedicati (anthropic/deepseek) → `providerOptions.<id>` passato a streamText
|
|
7
|
+
// - openai-compatible (glm/kimi) → campo `thinking` iniettato nel body (extraBody)
|
|
8
|
+
// `reasoningConfig()` traduce il livello corrente nelle opzioni giuste per provider.
|
|
9
|
+
|
|
10
|
+
export type ProviderId = "anthropic" | "deepseek" | "openai" | "glm" | "kimi";
|
|
11
|
+
|
|
12
|
+
export type ProviderKind = "anthropic" | "deepseek" | "openai-compatible";
|
|
13
|
+
|
|
14
|
+
/** Livello di ragionamento unificato. I provider mappano sul proprio meccanismo. */
|
|
15
|
+
export type ThinkingLevel = "off" | "low" | "medium" | "high" | "max";
|
|
16
|
+
|
|
17
|
+
export interface ProviderDef {
|
|
18
|
+
label: string;
|
|
19
|
+
/** Nome della env var con la API key. */
|
|
20
|
+
envKey: string;
|
|
21
|
+
/** Model id di default (override con INTERFERENCE_MODEL). */
|
|
22
|
+
defaultModel: string;
|
|
23
|
+
kind: ProviderKind;
|
|
24
|
+
/** Per kind "openai-compatible": baseURL ESATTO (non normalizzare). */
|
|
25
|
+
baseURL?: string;
|
|
26
|
+
/** Context window size in tokens (per compaction threshold). Default 200K. */
|
|
27
|
+
contextLimit?: number;
|
|
28
|
+
/** Livelli di thinking supportati (per /thinking). off = disabilitato. */
|
|
29
|
+
thinkingLevels: ThinkingLevel[];
|
|
30
|
+
/** Livello di default del provider. */
|
|
31
|
+
defaultThinking: ThinkingLevel;
|
|
32
|
+
/** Modelli conosciuti per questo provider (per /model picker). */
|
|
33
|
+
models: { id: string; label: string }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const PROVIDERS: Record<ProviderId, ProviderDef> = {
|
|
37
|
+
deepseek: {
|
|
38
|
+
label: "DeepSeek",
|
|
39
|
+
envKey: "DEEPSEEK_API_KEY",
|
|
40
|
+
defaultModel: "deepseek-v4-pro",
|
|
41
|
+
kind: "deepseek",
|
|
42
|
+
contextLimit: 1_000_000,
|
|
43
|
+
thinkingLevels: ["off", "low", "medium", "high", "max"],
|
|
44
|
+
defaultThinking: "max",
|
|
45
|
+
models: [
|
|
46
|
+
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro (1M ctx)" },
|
|
47
|
+
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
openai: {
|
|
51
|
+
label: "OpenAI",
|
|
52
|
+
envKey: "OPENAI_API_KEY",
|
|
53
|
+
defaultModel: "gpt-5.5",
|
|
54
|
+
kind: "openai-compatible",
|
|
55
|
+
baseURL: "https://api.openai.com/v1",
|
|
56
|
+
contextLimit: 1_000_000,
|
|
57
|
+
thinkingLevels: ["off", "low", "medium", "high", "max"],
|
|
58
|
+
defaultThinking: "high",
|
|
59
|
+
models: [
|
|
60
|
+
{ id: "gpt-5.5", label: "GPT-5.5 (1M ctx)" },
|
|
61
|
+
{ id: "gpt-5.4", label: "GPT-5.4 (1M ctx)" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
anthropic: {
|
|
65
|
+
label: "Anthropic (Claude)",
|
|
66
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
67
|
+
defaultModel: "claude-opus-4-8",
|
|
68
|
+
kind: "anthropic",
|
|
69
|
+
contextLimit: 1_000_000,
|
|
70
|
+
thinkingLevels: ["off", "low", "medium", "high", "max"],
|
|
71
|
+
defaultThinking: "high",
|
|
72
|
+
models: [
|
|
73
|
+
{ id: "claude-opus-4-8", label: "Claude Opus 4.8 (1M ctx)" },
|
|
74
|
+
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (1M ctx)" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
glm: {
|
|
78
|
+
label: "Zhipu GLM",
|
|
79
|
+
envKey: "GLM_API_KEY",
|
|
80
|
+
defaultModel: "glm-5.2",
|
|
81
|
+
kind: "openai-compatible",
|
|
82
|
+
contextLimit: 1_000_000,
|
|
83
|
+
baseURL: "https://api.z.ai/api/paas/v4",
|
|
84
|
+
thinkingLevels: ["off", "max"],
|
|
85
|
+
defaultThinking: "max",
|
|
86
|
+
models: [
|
|
87
|
+
{ id: "glm-5.2", label: "GLM-5.2 (1M ctx)" },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
kimi: {
|
|
91
|
+
label: "Moonshot Kimi",
|
|
92
|
+
envKey: "KIMI_API_KEY",
|
|
93
|
+
defaultModel: "kimi-k2.7",
|
|
94
|
+
kind: "openai-compatible",
|
|
95
|
+
contextLimit: 1_000_000,
|
|
96
|
+
baseURL: "https://api.moonshot.ai/v1",
|
|
97
|
+
thinkingLevels: ["off", "max"],
|
|
98
|
+
defaultThinking: "max",
|
|
99
|
+
models: [
|
|
100
|
+
{ id: "kimi-k2.7", label: "Kimi K2.7 (1M ctx)" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function parseProvider(raw: string | undefined): ProviderId {
|
|
106
|
+
const id = (raw ?? "deepseek") as ProviderId;
|
|
107
|
+
return id in PROVIDERS ? id : "deepseek";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const config = {
|
|
111
|
+
provider: parseProvider(process.env.INTERFERENCE_PROVIDER),
|
|
112
|
+
/** Override esplicito del modello; se assente si usa il default del provider. */
|
|
113
|
+
modelOverride: process.env.INTERFERENCE_MODEL,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Definizione del provider attualmente selezionato (override runtime > env var > default). */
|
|
117
|
+
let _providerOverride: ProviderId | null = null;
|
|
118
|
+
|
|
119
|
+
export function currentProvider(): ProviderDef {
|
|
120
|
+
const id = (_providerOverride ?? config.provider) as ProviderId;
|
|
121
|
+
return PROVIDERS[id] ?? PROVIDERS.deepseek;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function setProvider(providerId: ProviderId) {
|
|
125
|
+
_providerOverride = providerId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Model id effettivo (override runtime > env var > default del provider). */
|
|
129
|
+
let _modelOverride: string | null = null;
|
|
130
|
+
|
|
131
|
+
export function currentModel(): string {
|
|
132
|
+
return _modelOverride ?? config.modelOverride ?? currentProvider().defaultModel;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function setModel(modelId: string) {
|
|
136
|
+
_modelOverride = modelId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resetModel() {
|
|
140
|
+
_modelOverride = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Mode (Plan/Build) ------------------------------------------------------
|
|
144
|
+
export type AgentMode = "plan" | "build";
|
|
145
|
+
|
|
146
|
+
let _mode: AgentMode = "build";
|
|
147
|
+
|
|
148
|
+
export function currentMode(): AgentMode {
|
|
149
|
+
return _mode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function setMode(mode: AgentMode) {
|
|
153
|
+
_mode = mode;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Thinking level (runtime, /thinking) ------------------------------------
|
|
157
|
+
let _thinking: ThinkingLevel | null = null;
|
|
158
|
+
|
|
159
|
+
/** Livello di thinking corrente (override runtime o default del provider). */
|
|
160
|
+
export function currentThinking(): ThinkingLevel {
|
|
161
|
+
return _thinking ?? currentProvider().defaultThinking;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setThinking(level: ThinkingLevel) {
|
|
165
|
+
_thinking = level;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ReasoningConfig {
|
|
169
|
+
providerOptions?: Record<string, unknown>;
|
|
170
|
+
extraBody?: Record<string, unknown>;
|
|
171
|
+
maxOutputTokens?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Anthropic: livello → budget token del thinking (maxOutputTokens deve superarlo).
|
|
175
|
+
const ANTHROPIC_BUDGET: Record<Exclude<ThinkingLevel, "off">, number> = {
|
|
176
|
+
low: 8_000,
|
|
177
|
+
medium: 16_000,
|
|
178
|
+
high: 32_000,
|
|
179
|
+
max: 60_000,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Traduce il livello di thinking corrente nelle opzioni del provider attivo. */
|
|
183
|
+
export function reasoningConfig(): ReasoningConfig {
|
|
184
|
+
const level = currentThinking();
|
|
185
|
+
|
|
186
|
+
switch (config.provider) {
|
|
187
|
+
case "deepseek":
|
|
188
|
+
if (level === "off") {
|
|
189
|
+
return { providerOptions: { deepseek: { thinking: { type: "disabled" } } } };
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
providerOptions: { deepseek: { thinking: { type: "enabled" }, reasoningEffort: level } },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
case "anthropic": {
|
|
196
|
+
if (level === "off") return {};
|
|
197
|
+
const budget = ANTHROPIC_BUDGET[level];
|
|
198
|
+
return {
|
|
199
|
+
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: budget } } },
|
|
200
|
+
maxOutputTokens: budget + 8_000,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "openai":
|
|
205
|
+
case "glm":
|
|
206
|
+
return { extraBody: { thinking: { type: level === "off" ? "disabled" : "enabled" } } };
|
|
207
|
+
|
|
208
|
+
case "kimi":
|
|
209
|
+
if (level === "off") return { extraBody: { thinking: { type: "disabled" } } };
|
|
210
|
+
return { extraBody: { thinking: { type: "enabled", keep: "all" } } };
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const GLOBAL_CONFIG = path.join(
|
|
6
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
|
7
|
+
".config",
|
|
8
|
+
"interference",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const GLOBAL_AGENTS = path.join(GLOBAL_CONFIG, "AGENTS.md");
|
|
12
|
+
const GLOBAL_CLAUDE = path.join(
|
|
13
|
+
process.env.HOME ?? "/tmp",
|
|
14
|
+
".claude",
|
|
15
|
+
"CLAUDE.md",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const PROJECT_FILES = ["AGENTS.md", "CLAUDE.md"] as const;
|
|
19
|
+
|
|
20
|
+
export interface InstructionBlock {
|
|
21
|
+
source: string;
|
|
22
|
+
content: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadInstructions(): Promise<InstructionBlock[]> {
|
|
26
|
+
const blocks: InstructionBlock[] = [];
|
|
27
|
+
|
|
28
|
+
// 1. Global AGENTS.md
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(GLOBAL_AGENTS, "utf-8");
|
|
31
|
+
blocks.push({ source: GLOBAL_AGENTS, content });
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
// 2. ~/.claude/CLAUDE.md
|
|
35
|
+
try {
|
|
36
|
+
const content = await readFile(GLOBAL_CLAUDE, "utf-8");
|
|
37
|
+
blocks.push({ source: GLOBAL_CLAUDE, content });
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
// 3. Project instructions — walk up from cwd, first match wins per file
|
|
41
|
+
let dir = process.cwd();
|
|
42
|
+
const root = path.parse(dir).root;
|
|
43
|
+
const found = new Set<string>();
|
|
44
|
+
|
|
45
|
+
while (dir !== root) {
|
|
46
|
+
for (const name of PROJECT_FILES) {
|
|
47
|
+
if (found.has(name)) continue;
|
|
48
|
+
const fp = path.join(dir, name);
|
|
49
|
+
try {
|
|
50
|
+
const content = await readFile(fp, "utf-8");
|
|
51
|
+
blocks.push({ source: fp, content });
|
|
52
|
+
found.add(name);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
if (found.size >= PROJECT_FILES.length) break;
|
|
56
|
+
const parent = path.dirname(dir);
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findProjectInstructionPath(): string | null {
|
|
65
|
+
let dir = process.cwd();
|
|
66
|
+
const root = path.parse(dir).root;
|
|
67
|
+
|
|
68
|
+
while (dir !== root) {
|
|
69
|
+
for (const name of PROJECT_FILES) {
|
|
70
|
+
const fp = path.join(dir, name);
|
|
71
|
+
if (existsSync(fp)) return fp;
|
|
72
|
+
}
|
|
73
|
+
const parent = path.dirname(dir);
|
|
74
|
+
if (parent === dir) break;
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadInstructionContent(filePath: string): Promise<string | null> {
|
|
81
|
+
try {
|
|
82
|
+
return await readFile(filePath, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatInstructionBlock(block: InstructionBlock): string {
|
|
89
|
+
const short = block.source.startsWith(process.env.HOME ?? "/")
|
|
90
|
+
? block.source.replace(process.env.HOME ?? "/tmp", "~")
|
|
91
|
+
: block.source;
|
|
92
|
+
const trimmed = block.content.length > 4000
|
|
93
|
+
? block.content.slice(0, 4000) + "\n… [truncated]"
|
|
94
|
+
: block.content;
|
|
95
|
+
return `--- Instructions from: ${short} ---\n${trimmed}`;
|
|
96
|
+
}
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { currentModel } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
interface Pricing {
|
|
4
|
+
inputPer1M: number;
|
|
5
|
+
outputPer1M: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const PRICING: Record<string, Pricing> = {
|
|
9
|
+
"deepseek-v4-pro": { inputPer1M: 2.0, outputPer1M: 8.0 },
|
|
10
|
+
"deepseek-v4-flash": { inputPer1M: 0.27, outputPer1M: 1.10 },
|
|
11
|
+
"gpt-5.5": { inputPer1M: 5.0, outputPer1M: 30.0 },
|
|
12
|
+
"gpt-5.4": { inputPer1M: 2.50, outputPer1M: 15.0 },
|
|
13
|
+
"claude-opus-4-8": { inputPer1M: 5.0, outputPer1M: 25.0 },
|
|
14
|
+
"claude-sonnet-4-6": { inputPer1M: 3.0, outputPer1M: 15.0 },
|
|
15
|
+
"glm-5.2": { inputPer1M: 1.0, outputPer1M: 1.0 },
|
|
16
|
+
"kimi-k2.7": { inputPer1M: 0.5, outputPer1M: 2.0 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function getPricing(modelId?: string): Pricing {
|
|
20
|
+
return PRICING[modelId ?? currentModel()] ?? { inputPer1M: 2.0, outputPer1M: 8.0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let totalInputTokens = 0;
|
|
24
|
+
let totalOutputTokens = 0;
|
|
25
|
+
|
|
26
|
+
export function trackUsage(inputTokens: number, outputTokens: number) {
|
|
27
|
+
totalInputTokens += inputTokens;
|
|
28
|
+
totalOutputTokens += outputTokens;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTotalCost(): number {
|
|
32
|
+
const pricing = getPricing(currentModel());
|
|
33
|
+
return (
|
|
34
|
+
(totalInputTokens / 1_000_000) * pricing.inputPer1M +
|
|
35
|
+
(totalOutputTokens / 1_000_000) * pricing.outputPer1M
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function estimateCost(inputTokens: number): number {
|
|
40
|
+
const pricing = getPricing(currentModel());
|
|
41
|
+
return (inputTokens / 1_000_000) * pricing.inputPer1M;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatCost(cost: number): string {
|
|
45
|
+
if (cost < 0.0001) return "<$0.01";
|
|
46
|
+
return `$${cost.toFixed(2)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getUsageStats() {
|
|
50
|
+
return {
|
|
51
|
+
inputTokens: totalInputTokens,
|
|
52
|
+
outputTokens: totalOutputTokens,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
let cachedBranch: string | null = null;
|
|
2
|
+
|
|
3
|
+
export async function getGitBranch(): Promise<string> {
|
|
4
|
+
if (cachedBranch !== null) return cachedBranch;
|
|
5
|
+
try {
|
|
6
|
+
const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
7
|
+
stdout: "pipe",
|
|
8
|
+
stderr: "pipe",
|
|
9
|
+
});
|
|
10
|
+
const out = await proc.stdout.text();
|
|
11
|
+
if (proc.exitCode === 0) {
|
|
12
|
+
cachedBranch = out.trim();
|
|
13
|
+
return cachedBranch;
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
cachedBranch = "no-git";
|
|
17
|
+
return cachedBranch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function invalidateGitCache() {
|
|
21
|
+
cachedBranch = null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
type Decision = "allow" | "ask" | "deny";
|
|
2
|
+
|
|
3
|
+
export interface PermRule {
|
|
4
|
+
tool?: string;
|
|
5
|
+
pattern?: string;
|
|
6
|
+
decision: Decision;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RULES: PermRule[] = [
|
|
10
|
+
{ pattern: "rm -rf*", decision: "deny" },
|
|
11
|
+
{ pattern: "rm -r *", decision: "deny" },
|
|
12
|
+
{ pattern: "rm -rf /*", decision: "deny" },
|
|
13
|
+
{ pattern: "sudo *", decision: "deny" },
|
|
14
|
+
{ pattern: "curl * | *sh*", decision: "deny" },
|
|
15
|
+
{ pattern: "wget * -O *|*sh*", decision: "deny" },
|
|
16
|
+
{ pattern: "git push --force*", decision: "deny" },
|
|
17
|
+
{ pattern: "git push -f*", decision: "deny" },
|
|
18
|
+
{ pattern: "> /dev/sda*", decision: "deny" },
|
|
19
|
+
{ pattern: "mkfs.*", decision: "deny" },
|
|
20
|
+
{ pattern: "dd if=*", decision: "deny" },
|
|
21
|
+
{ pattern: "chmod 777 *", decision: "deny" },
|
|
22
|
+
{ pattern: ":(){ :|:& };:*", decision: "deny" },
|
|
23
|
+
{ tool: "write", pattern: "*.env", decision: "deny" },
|
|
24
|
+
{ tool: "write", pattern: "**/*.env", decision: "deny" },
|
|
25
|
+
{ tool: "write", pattern: "*.pem", decision: "deny" },
|
|
26
|
+
{ tool: "write", pattern: "**/*.pem", decision: "deny" },
|
|
27
|
+
{ tool: "write", pattern: "*.key", decision: "deny" },
|
|
28
|
+
{ tool: "write", pattern: "**/*.key", decision: "deny" },
|
|
29
|
+
{ tool: "write", pattern: "secrets/**", decision: "deny" },
|
|
30
|
+
{ tool: "write", pattern: "**/secrets/**", decision: "deny" },
|
|
31
|
+
{ tool: "edit", pattern: "*.env", decision: "deny" },
|
|
32
|
+
{ tool: "edit", pattern: "**/*.env", decision: "deny" },
|
|
33
|
+
{ tool: "edit", pattern: "*.pem", decision: "deny" },
|
|
34
|
+
{ tool: "edit", pattern: "**/*.pem", decision: "deny" },
|
|
35
|
+
{ tool: "edit", pattern: "*.key", decision: "deny" },
|
|
36
|
+
{ tool: "edit", pattern: "**/*.key", decision: "deny" },
|
|
37
|
+
{ tool: "edit", pattern: "secrets/**", decision: "deny" },
|
|
38
|
+
{ tool: "edit", pattern: "**/secrets/**", decision: "deny" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
let rules: PermRule[] = [...DEFAULT_RULES];
|
|
42
|
+
|
|
43
|
+
export function setRules(newRules: PermRule[]) {
|
|
44
|
+
rules = [...newRules, ...DEFAULT_RULES];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getRules(): PermRule[] {
|
|
48
|
+
return rules;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resetRules() {
|
|
52
|
+
rules = [...DEFAULT_RULES];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function decide(toolName: string, subject: string): Decision {
|
|
56
|
+
if (toolName === "read" || toolName === "ls" || toolName === "glob" || toolName === "grep" || toolName === "webfetch") {
|
|
57
|
+
return "allow";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const rule of rules) {
|
|
61
|
+
if (rule.tool && rule.tool !== toolName) continue;
|
|
62
|
+
if (rule.pattern) {
|
|
63
|
+
if (toolName === "bash") {
|
|
64
|
+
if (matchBashPattern(rule.pattern, subject)) {
|
|
65
|
+
return rule.decision;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
if (matchGlobPath(rule.pattern, subject)) {
|
|
69
|
+
return rule.decision;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return rule.decision;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return "allow";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function matchBashPattern(pattern: string, command: string): boolean {
|
|
81
|
+
return globMatch(command, pattern, ".");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matchGlobPath(glob: string, filePath: string): boolean {
|
|
85
|
+
return globMatch(filePath, glob, "/");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function globMatch(str: string, glob: string, sep: string): boolean {
|
|
89
|
+
const single = sep === "/" ? "[^/]*" : ".*";
|
|
90
|
+
const any = sep === "/" ? ".*" : ".*";
|
|
91
|
+
let p = glob
|
|
92
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
93
|
+
.replace(/\*\*/g, "\u0000GLOBSTAR\u0000")
|
|
94
|
+
.replace(/\*/g, single)
|
|
95
|
+
.replace(/\u0000GLOBSTAR\u0000/g, any)
|
|
96
|
+
.replace(/\?/g, sep === "/" ? "[^/]" : ".");
|
|
97
|
+
return new RegExp(`^${p}$`).test(str);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Conferma delle azioni `ask`. Modello EVENT-DRIVEN (niente race con lo stream):
|
|
101
|
+
// la CLI registra un handler con setConfirmHandler; `requestConfirmation` (invocato
|
|
102
|
+
// dentro l'execute del tool) lo chiama direttamente e ne attende la risposta.
|
|
103
|
+
// Se nessun handler è registrato (es. nei test), si usa il fallback manuale
|
|
104
|
+
// answerConfirmation/needsConfirmation.
|
|
105
|
+
|
|
106
|
+
export type ConfirmHandler = (tool: string, preview: string) => Promise<boolean>;
|
|
107
|
+
|
|
108
|
+
let confirmHandler: ConfirmHandler | null = null;
|
|
109
|
+
let pendingResolver: ((answer: boolean) => void) | null = null;
|
|
110
|
+
let pending: { tool: string; preview: string } | null = null;
|
|
111
|
+
|
|
112
|
+
export function setConfirmHandler(handler: ConfirmHandler | null) {
|
|
113
|
+
confirmHandler = handler;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function requestConfirmation(tool: string, preview: string): Promise<boolean> {
|
|
117
|
+
if (confirmHandler) return confirmHandler(tool, preview);
|
|
118
|
+
// Fallback senza handler (test): risolto da answerConfirmation.
|
|
119
|
+
pending = { tool, preview };
|
|
120
|
+
return new Promise<boolean>((resolve) => {
|
|
121
|
+
pendingResolver = resolve;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function needsConfirmation(): { tool: string; preview: string } | null {
|
|
126
|
+
return pending;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function answerConfirmation(answer: boolean) {
|
|
130
|
+
if (pendingResolver) {
|
|
131
|
+
pendingResolver(answer);
|
|
132
|
+
pendingResolver = null;
|
|
133
|
+
pending = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Astrazione provider (RF-CORE-03). Risolve un `LanguageModel` del Vercel AI SDK
|
|
2
|
+
// per il provider selezionato. Reasoning/thinking abilitato per ogni provider:
|
|
3
|
+
// - anthropic/deepseek: via providerOptions (gestito nell'agent loop)
|
|
4
|
+
// - glm/kimi (openai-compatible): il campo `thinking` viene iniettato nel body
|
|
5
|
+
// con `transformRequestBody`; un middleware estrae eventuali <think> inline.
|
|
6
|
+
|
|
7
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
8
|
+
import { createDeepSeek } from "@ai-sdk/deepseek";
|
|
9
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
10
|
+
import { extractReasoningMiddleware, wrapLanguageModel, type LanguageModel } from "ai";
|
|
11
|
+
import { currentModel, currentProvider, reasoningConfig, type ProviderDef } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
export class MissingApiKeyError extends Error {
|
|
14
|
+
constructor(provider: ProviderDef) {
|
|
15
|
+
super(
|
|
16
|
+
`${provider.label}: ${provider.envKey} non è impostata.\n` +
|
|
17
|
+
` Aggiungila al file .env (o esportala): ${provider.envKey}=...`,
|
|
18
|
+
);
|
|
19
|
+
this.name = "MissingApiKeyError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Risolve il modello del provider selezionato. Solleva MissingApiKeyError se manca la key. */
|
|
24
|
+
export function resolveModel(): LanguageModel {
|
|
25
|
+
const def = currentProvider();
|
|
26
|
+
const apiKey = process.env[def.envKey];
|
|
27
|
+
if (!apiKey) throw new MissingApiKeyError(def);
|
|
28
|
+
|
|
29
|
+
const model = currentModel();
|
|
30
|
+
|
|
31
|
+
switch (def.kind) {
|
|
32
|
+
case "anthropic":
|
|
33
|
+
return createAnthropic({ apiKey })(model);
|
|
34
|
+
|
|
35
|
+
case "deepseek":
|
|
36
|
+
return createDeepSeek({ apiKey })(model);
|
|
37
|
+
|
|
38
|
+
case "openai-compatible": {
|
|
39
|
+
// Opzioni di thinking per il livello corrente (/thinking), calcolate ad ogni turno.
|
|
40
|
+
const extraBody = reasoningConfig().extraBody;
|
|
41
|
+
const provider = createOpenAICompatible({
|
|
42
|
+
name: def.label,
|
|
43
|
+
baseURL: def.baseURL ?? "",
|
|
44
|
+
apiKey,
|
|
45
|
+
// Inietta i campi non-OpenAI-standard (es. `thinking`) nel body grezzo.
|
|
46
|
+
transformRequestBody: extraBody
|
|
47
|
+
? (body: Record<string, unknown>) => ({ ...body, ...extraBody })
|
|
48
|
+
: undefined,
|
|
49
|
+
});
|
|
50
|
+
// Fallback: se il modello inlinea il reasoning tra <think>...</think>, estrailo
|
|
51
|
+
// come reasoning (per chi manda reasoning_content separato è un no-op).
|
|
52
|
+
return wrapLanguageModel({
|
|
53
|
+
model: provider(model),
|
|
54
|
+
middleware: extractReasoningMiddleware({ tagName: "think" }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|