kritya 0.8.2-beta
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 +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { hardenWindowsDir } from "./winAcl.js";
|
|
5
|
+
import { debugLog } from "./debug.js";
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".kritya");
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
9
|
+
/**
|
|
10
|
+
* Built-in OpenAI-compatible providers. Users select one with `provider` in
|
|
11
|
+
* config (or --provider) and supply the key via the named env var or a .env
|
|
12
|
+
* file. Entries in config.providers override these by name.
|
|
13
|
+
*/
|
|
14
|
+
export const BUILTIN_PROVIDERS = {
|
|
15
|
+
nvidia: { baseUrl: NVIDIA_BASE_URL, apiKeyEnv: "NVIDIA_API_KEY" },
|
|
16
|
+
openai: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" },
|
|
17
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKeyEnv: "OPENROUTER_API_KEY" },
|
|
18
|
+
groq: { baseUrl: "https://api.groq.com/openai/v1", apiKeyEnv: "GROQ_API_KEY" },
|
|
19
|
+
deepseek: { baseUrl: "https://api.deepseek.com", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
|
20
|
+
mistral: { baseUrl: "https://api.mistral.ai/v1", apiKeyEnv: "MISTRAL_API_KEY" },
|
|
21
|
+
together: { baseUrl: "https://api.together.xyz/v1", apiKeyEnv: "TOGETHER_API_KEY" },
|
|
22
|
+
ollama: { baseUrl: "http://localhost:11434/v1", apiKey: "ollama" },
|
|
23
|
+
// Anthropic and Google both expose OpenAI-compatible endpoints, so they ride
|
|
24
|
+
// the same ProviderClient path as the rest — no native SDK required.
|
|
25
|
+
anthropic: { baseUrl: "https://api.anthropic.com/v1/", apiKeyEnv: "ANTHROPIC_API_KEY" },
|
|
26
|
+
gemini: {
|
|
27
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
28
|
+
apiKeyEnv: "GEMINI_API_KEY",
|
|
29
|
+
},
|
|
30
|
+
// Not a real network target: `baseUrl` here is a placeholder. The actual
|
|
31
|
+
// client is built by createSwitchyardClient (switchyardClient.ts), which
|
|
32
|
+
// launches a local switchyard-server sidecar on a free port and points at
|
|
33
|
+
// that instead. Needs NVIDIA_API_KEY — the sidecar's own outbound calls
|
|
34
|
+
// and kritya's cross-model fallback both use it directly.
|
|
35
|
+
switchyard: { baseUrl: "http://127.0.0.1/v1", apiKeyEnv: "NVIDIA_API_KEY" },
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the active provider's base URL and API key. Precedence for the
|
|
39
|
+
* provider name: explicit override (--provider) > config.provider > "nvidia".
|
|
40
|
+
* Legacy top-level apiKey/baseUrl and NVIDIA_API_KEY continue to work for the
|
|
41
|
+
* default nvidia provider.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveProvider(config, override) {
|
|
44
|
+
const name = override || config.provider || "nvidia";
|
|
45
|
+
const merged = {
|
|
46
|
+
...BUILTIN_PROVIDERS[name],
|
|
47
|
+
...config.providers?.[name],
|
|
48
|
+
};
|
|
49
|
+
let baseUrl = merged.baseUrl;
|
|
50
|
+
if (name === "nvidia" && config.baseUrl)
|
|
51
|
+
baseUrl = config.baseUrl;
|
|
52
|
+
if (!baseUrl)
|
|
53
|
+
baseUrl = NVIDIA_BASE_URL;
|
|
54
|
+
let apiKey;
|
|
55
|
+
if (merged.apiKeyEnv)
|
|
56
|
+
apiKey = process.env[merged.apiKeyEnv];
|
|
57
|
+
if (!apiKey)
|
|
58
|
+
apiKey = merged.apiKey;
|
|
59
|
+
if (!apiKey && name === "nvidia")
|
|
60
|
+
apiKey = process.env.NVIDIA_API_KEY || config.apiKey;
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
baseUrl,
|
|
64
|
+
apiKey,
|
|
65
|
+
temperature: merged.temperature,
|
|
66
|
+
topP: merged.topP,
|
|
67
|
+
maxTokens: merged.maxTokens,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The legacy top-level `config.model`, but only for the provider it was
|
|
72
|
+
* actually chosen on.
|
|
73
|
+
*
|
|
74
|
+
* That field predates per-provider models and is shared by every provider, so
|
|
75
|
+
* a value saved while (say) nvidia was active would otherwise be applied to
|
|
76
|
+
* every other provider too — which is how an nvidia model id ends up
|
|
77
|
+
* bypassing switchyard's routing and 404-ing. `config.provider` records which
|
|
78
|
+
* provider was active when it was written, so scoping it there keeps existing
|
|
79
|
+
* configs working for their own provider without leaking anywhere else.
|
|
80
|
+
* Returns undefined for every other provider, letting them fall through to
|
|
81
|
+
* their own default.
|
|
82
|
+
*/
|
|
83
|
+
export function legacyGlobalModel(config, providerName) {
|
|
84
|
+
const savedUnder = config.provider || "nvidia";
|
|
85
|
+
return providerName === savedUnder ? config.model : undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* All providers kritya knows about — builtins plus anything named under
|
|
89
|
+
* `config.providers` — with whether each currently resolves to a usable API
|
|
90
|
+
* key. This is the fallback chain offered when the active provider's
|
|
91
|
+
* requests keep failing (see RetryExhaustedError / the /provider command):
|
|
92
|
+
* only entries with `hasKey: true` are actually switchable right now.
|
|
93
|
+
*/
|
|
94
|
+
export function listProviders(config) {
|
|
95
|
+
const names = new Set([
|
|
96
|
+
...Object.keys(BUILTIN_PROVIDERS),
|
|
97
|
+
...Object.keys(config.providers ?? {}),
|
|
98
|
+
]);
|
|
99
|
+
return [...names]
|
|
100
|
+
.sort()
|
|
101
|
+
.map((name) => ({ name, hasKey: !!resolveProvider(config, name).apiKey }));
|
|
102
|
+
}
|
|
103
|
+
export function loadConfig() {
|
|
104
|
+
try {
|
|
105
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
106
|
+
return JSON.parse(raw);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// A missing file is normal on first run; a malformed one is worth being
|
|
110
|
+
// able to see when someone reports "my config isn't taking effect".
|
|
111
|
+
debugLog(`loadConfig(${CONFIG_FILE})`, err);
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Persist `model` as the default for one provider, leaving every other
|
|
117
|
+
* provider's entry — and that provider's own other fields (apiKey, baseUrl…)
|
|
118
|
+
* — intact. Merges against what's currently on disk rather than an in-memory
|
|
119
|
+
* snapshot, so a provider added to config.json after startup isn't dropped.
|
|
120
|
+
*
|
|
121
|
+
* This is the per-provider replacement for writing the top-level
|
|
122
|
+
* `config.model`, which every provider shared; see legacyGlobalModel.
|
|
123
|
+
*/
|
|
124
|
+
export function saveProviderModel(providerName, model) {
|
|
125
|
+
const providers = loadConfig().providers ?? {};
|
|
126
|
+
saveConfig({
|
|
127
|
+
providers: {
|
|
128
|
+
...providers,
|
|
129
|
+
[providerName]: { ...providers[providerName], model },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function saveConfig(patch) {
|
|
134
|
+
const current = loadConfig();
|
|
135
|
+
const next = { ...current, ...patch };
|
|
136
|
+
// config.json can hold a literal apiKey — keep it readable only by the owner.
|
|
137
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
138
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
139
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
140
|
+
// `mode` on writeFileSync only applies when creating a new file; enforce it
|
|
141
|
+
// even if config.json pre-existed with looser permissions.
|
|
142
|
+
try {
|
|
143
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
debugLog(`saveConfig chmod(${CONFIG_FILE})`, err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A copy of process.env with kritya-managed secrets removed. Every built-in
|
|
151
|
+
* provider key and the web-search key end in _API_KEY; commands the agent runs
|
|
152
|
+
* (and user hooks) have no business reading them, and under prompt injection
|
|
153
|
+
* an approved-looking shell command is an easy exfiltration channel. Commands
|
|
154
|
+
* that genuinely need such a key must receive it explicitly (e.g. inline in
|
|
155
|
+
* the command), same as MCP servers declare theirs via `env`.
|
|
156
|
+
*/
|
|
157
|
+
// Provider credentials (and anything shaped like a credential) that shell
|
|
158
|
+
// commands and background processes must not inherit. Beyond the provider
|
|
159
|
+
// *_API_KEY vars this always covered, this also strips *_TOKEN, *_SECRET,
|
|
160
|
+
// *_PASSWORD (common CI/CD and package-registry credential shapes — GitHub,
|
|
161
|
+
// npm, Docker Hub tokens; DB passwords) and the AWS credential trio, which
|
|
162
|
+
// were previously passed through intact. AWS_REGION/AWS_PROFILE etc. are
|
|
163
|
+
// left alone — they're not secrets and ordinary `aws` CLI calls need them.
|
|
164
|
+
const SCRUBBED_ENV_RE = /_(API_KEY|TOKEN|SECRET|PASSWORD)$/i;
|
|
165
|
+
const SCRUBBED_AWS_ENV_RE = /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN|SECURITY_TOKEN)$/i;
|
|
166
|
+
export function scrubbedShellEnv() {
|
|
167
|
+
const env = {};
|
|
168
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
169
|
+
if (SCRUBBED_ENV_RE.test(key))
|
|
170
|
+
continue;
|
|
171
|
+
if (SCRUBBED_AWS_ENV_RE.test(key))
|
|
172
|
+
continue;
|
|
173
|
+
env[key] = value;
|
|
174
|
+
}
|
|
175
|
+
return env;
|
|
176
|
+
}
|
|
177
|
+
/** Parse simple KEY=VALUE lines; ignores comments, blanks, and export prefixes. */
|
|
178
|
+
export function parseDotEnv(raw) {
|
|
179
|
+
const vars = {};
|
|
180
|
+
for (const line of raw.split("\n")) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
183
|
+
continue;
|
|
184
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed);
|
|
185
|
+
if (!match)
|
|
186
|
+
continue;
|
|
187
|
+
let value = match[2].trim();
|
|
188
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
189
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
190
|
+
value = value.slice(1, -1);
|
|
191
|
+
}
|
|
192
|
+
vars[match[1]] = value;
|
|
193
|
+
}
|
|
194
|
+
return vars;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Load .env files into process.env without overriding variables that are
|
|
198
|
+
* already set. Missing files are skipped silently.
|
|
199
|
+
*/
|
|
200
|
+
export function loadDotEnv(paths) {
|
|
201
|
+
for (const p of paths) {
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = fs.readFileSync(p, "utf8");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const [key, value] of Object.entries(parseDotEnv(raw))) {
|
|
210
|
+
if (process.env[key] === undefined)
|
|
211
|
+
process.env[key] = value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Most best-effort persistence (session/audit/telemetry writes, config and
|
|
3
|
+
* trust-file reads, hook loading, …) deliberately swallows its own errors —
|
|
4
|
+
* a failed log write must never crash a turn. That's the right default, but
|
|
5
|
+
* it also means those failures are normally invisible even when you're
|
|
6
|
+
* trying to track one down. Set KRITYA_DEBUG=1 to print them to stderr
|
|
7
|
+
* instead of silently dropping them; unset (the default) costs nothing.
|
|
8
|
+
*/
|
|
9
|
+
export function debugLog(context, err) {
|
|
10
|
+
if ((process.env.KRITYA_DEBUG ?? "").toLowerCase() !== "1" && process.env.KRITYA_DEBUG !== "true")
|
|
11
|
+
return;
|
|
12
|
+
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
13
|
+
try {
|
|
14
|
+
process.stderr.write(`[kritya debug] ${context}: ${message}\n`);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// stderr itself failing isn't something debug logging can do anything about
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Fallback context window when neither config nor the registry knows the model. */
|
|
2
|
+
export const DEFAULT_CONTEXT_WINDOW = 120_000;
|
|
3
|
+
/**
|
|
4
|
+
* Curated models on build.nvidia.com known to handle tool calling well.
|
|
5
|
+
* Verified against the live /v1/models catalog on 2026-07-16. IDs change over
|
|
6
|
+
* time — add newer ones via `customModels` in ~/.kritya/config.json rather
|
|
7
|
+
* than editing this file.
|
|
8
|
+
*/
|
|
9
|
+
export const CURATED_MODELS = [
|
|
10
|
+
{
|
|
11
|
+
id: "nvidia/nemotron-3.5-lightning-30b-a3b",
|
|
12
|
+
label: "Nemotron 3.5 Lightning 30B",
|
|
13
|
+
note: "default",
|
|
14
|
+
contextWindow: 128_000,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "nvidia/nemotron-3-super-120b-a12b",
|
|
18
|
+
label: "Nemotron 3 Super 120B",
|
|
19
|
+
contextWindow: 128_000,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "nvidia/nemotron-3-ultra-550b-a55b",
|
|
23
|
+
label: "Nemotron 3 Ultra 550B",
|
|
24
|
+
contextWindow: 128_000,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "meta/muse-glimmer-30b",
|
|
28
|
+
label: "Muse Glimmer 30B",
|
|
29
|
+
contextWindow: 128_000,
|
|
30
|
+
},
|
|
31
|
+
{ id: "thinkingmachines/inkling", label: "Inkling", contextWindow: 128_000 },
|
|
32
|
+
{ id: "z-ai/glm-5.2", label: "GLM 5.2", note: "strong agentic coder", contextWindow: 200_000 },
|
|
33
|
+
{
|
|
34
|
+
id: "qwen/qwen3.5-397b-a17b",
|
|
35
|
+
label: "Qwen3.5 397B",
|
|
36
|
+
note: "strong coding + tool use",
|
|
37
|
+
contextWindow: 256_000,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "moonshotai/kimi-k2.6",
|
|
41
|
+
label: "Kimi K2.6",
|
|
42
|
+
note: "strong agentic tool use",
|
|
43
|
+
contextWindow: 256_000,
|
|
44
|
+
},
|
|
45
|
+
{ id: "deepseek-ai/deepseek-v4-pro", label: "DeepSeek V4 Pro", contextWindow: 128_000 },
|
|
46
|
+
{
|
|
47
|
+
id: "deepseek-ai/deepseek-v4-flash-0731",
|
|
48
|
+
label: "DeepSeek V4 Flash",
|
|
49
|
+
note: "fast + cheap",
|
|
50
|
+
contextWindow: 128_000,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
export const DEFAULT_MODEL = CURATED_MODELS[0].id;
|
|
54
|
+
/**
|
|
55
|
+
* Context window for a model. Explicit config.contextWindow always wins; then
|
|
56
|
+
* the curated registry; then the default. Custom models can carry a window via
|
|
57
|
+
* config.pricing? no — via the registry lookup falling back to the default.
|
|
58
|
+
*/
|
|
59
|
+
export function contextWindowFor(modelId, config) {
|
|
60
|
+
if (config.contextWindow)
|
|
61
|
+
return config.contextWindow;
|
|
62
|
+
const known = CURATED_MODELS.find((m) => m.id === modelId)?.contextWindow;
|
|
63
|
+
return known ?? DEFAULT_CONTEXT_WINDOW;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A short display form of a model id for status-line use — the provider
|
|
67
|
+
* prefix plus a slugified curated label, size/param suffix (30B, 550B, …)
|
|
68
|
+
* dropped. Falls back to the raw id for anything not in the curated list
|
|
69
|
+
* (custom models), since there's no label to slugify.
|
|
70
|
+
*/
|
|
71
|
+
export function modelDisplaySlug(modelId) {
|
|
72
|
+
const curated = CURATED_MODELS.find((m) => m.id === modelId);
|
|
73
|
+
if (!curated)
|
|
74
|
+
return modelId;
|
|
75
|
+
const prefix = modelId.includes("/") ? modelId.slice(0, modelId.indexOf("/") + 1) : "";
|
|
76
|
+
const slug = curated.label
|
|
77
|
+
.replace(/\s+\d+B$/i, "")
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.replace(/[^a-z0-9.]+/g, "-")
|
|
80
|
+
.replace(/^-+|-+$/g, "");
|
|
81
|
+
return `${prefix}${slug}`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default days to keep session transcripts, audit logs, and telemetry
|
|
3
|
+
* before auto-deleting them. Chosen deliberately short: these can carry
|
|
4
|
+
* secrets that passed through tool output, so there's no reason to keep
|
|
5
|
+
* them around by default longer than a couple of work weeks.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_RETENTION_DAYS = 15;
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the retention window: KRITYA_RETENTION_DAYS env var wins if set,
|
|
10
|
+
* then config.retentionDays, then the 15-day default. 0 or negative means
|
|
11
|
+
* "keep forever" — auto-delete is disabled, not scheduled for "never".
|
|
12
|
+
*/
|
|
13
|
+
export function retentionDaysFor(config) {
|
|
14
|
+
const envVal = process.env.KRITYA_RETENTION_DAYS;
|
|
15
|
+
if (envVal !== undefined && envVal.trim() !== "") {
|
|
16
|
+
const n = Number(envVal);
|
|
17
|
+
if (Number.isFinite(n))
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
if (typeof config.retentionDays === "number" && Number.isFinite(config.retentionDays)) {
|
|
21
|
+
return config.retentionDays;
|
|
22
|
+
}
|
|
23
|
+
return DEFAULT_RETENTION_DAYS;
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
/**
|
|
4
|
+
* chmod's 0o600/0o700 modes are no-ops on NTFS — Windows access control
|
|
5
|
+
* lives in ACLs, not POSIX mode bits — so on Windows the secrets under
|
|
6
|
+
* CONFIG_DIR (config.json, trusted.json, mcp-trusted.json, session
|
|
7
|
+
* transcripts) would otherwise rely solely on the default per-user-profile
|
|
8
|
+
* ACL, giving no equivalent of the chmod-based owner-only isolation used on
|
|
9
|
+
* Linux/macOS. This strips inherited ACEs from the directory and grants full
|
|
10
|
+
* control, inheritable to everything created under it afterward, to only the
|
|
11
|
+
* current user and SYSTEM.
|
|
12
|
+
*
|
|
13
|
+
* Cached per-path per-process: the inheritable grant covers files and
|
|
14
|
+
* subdirectories created later, so this only needs to run once per directory
|
|
15
|
+
* per run rather than on every write.
|
|
16
|
+
*/
|
|
17
|
+
const hardenedDirs = new Set();
|
|
18
|
+
function currentUserAccount() {
|
|
19
|
+
if (process.env.USERDOMAIN && process.env.USERNAME) {
|
|
20
|
+
return `${process.env.USERDOMAIN}\\${process.env.USERNAME}`;
|
|
21
|
+
}
|
|
22
|
+
return os.userInfo().username;
|
|
23
|
+
}
|
|
24
|
+
export function hardenWindowsDir(dirPath) {
|
|
25
|
+
if (os.platform() !== "win32")
|
|
26
|
+
return;
|
|
27
|
+
if (hardenedDirs.has(dirPath))
|
|
28
|
+
return;
|
|
29
|
+
hardenedDirs.add(dirPath);
|
|
30
|
+
try {
|
|
31
|
+
const user = currentUserAccount();
|
|
32
|
+
spawnSync("icacls", [dirPath, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`, "/grant:r", "SYSTEM:(OI)(CI)F"], { stdio: "ignore", windowsHide: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Best-effort — if icacls is unavailable, isolation still falls back to
|
|
36
|
+
// the default user-profile ACLs.
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/crash.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort handlers for an error that escaped every other guard.
|
|
3
|
+
*
|
|
4
|
+
* Without these, an unhandled rejection anywhere — an Ink render error, an MCP
|
|
5
|
+
* callback, a filesystem watcher edge case — terminates the process *without*
|
|
6
|
+
* firing the "exit" event, which is where every cleanup path in kritya hangs
|
|
7
|
+
* off. The visible cost is orphaned children: background dev servers, MCP
|
|
8
|
+
* stdio processes, and LSP servers all outlive the session that started them.
|
|
9
|
+
* The less visible cost is the terminal, which Ink leaves in raw mode with the
|
|
10
|
+
* cursor hidden, so the shell the user drops back into is unusable until they
|
|
11
|
+
* blind-type `reset`.
|
|
12
|
+
*
|
|
13
|
+
* A crash handler cannot make the crash not have happened. What it can do is
|
|
14
|
+
* make it survivable: release the machine's resources, hand the terminal back
|
|
15
|
+
* in a usable state, and tell the user where the transcript is so the session
|
|
16
|
+
* can be resumed rather than retyped.
|
|
17
|
+
*/
|
|
18
|
+
let installed = false;
|
|
19
|
+
let handling = false;
|
|
20
|
+
/**
|
|
21
|
+
* Put the terminal back the way we found it: raw mode off (or the shell reads
|
|
22
|
+
* keystrokes one at a time), cursor visible again (Ink hides it while
|
|
23
|
+
* rendering), and any alternate screen exited.
|
|
24
|
+
*/
|
|
25
|
+
function restoreTerminal() {
|
|
26
|
+
try {
|
|
27
|
+
if (process.stdin.isTTY && process.stdin.setRawMode)
|
|
28
|
+
process.stdin.setRawMode(false);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Nothing to do — we're already on the failure path.
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
if (process.stdout.isTTY)
|
|
35
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[0m");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// ditto
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function describe(err) {
|
|
42
|
+
if (err instanceof Error)
|
|
43
|
+
return err.stack || `${err.name}: ${err.message}`;
|
|
44
|
+
return String(err);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Install `uncaughtException` / `unhandledRejection` handlers. Idempotent, and
|
|
48
|
+
* re-entrancy-safe: a second crash raised *while* handling the first exits
|
|
49
|
+
* immediately rather than recursing through cleanup again.
|
|
50
|
+
*/
|
|
51
|
+
export function installCrashHandlers(opts) {
|
|
52
|
+
if (installed)
|
|
53
|
+
return;
|
|
54
|
+
installed = true;
|
|
55
|
+
const onCrash = (err, kind) => {
|
|
56
|
+
if (handling) {
|
|
57
|
+
// Something in the crash path itself threw. Take the exit and go.
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
handling = true;
|
|
61
|
+
try {
|
|
62
|
+
opts.unmountUi?.();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// The UI is what crashed, quite possibly.
|
|
66
|
+
}
|
|
67
|
+
if (opts.restoreTerminal)
|
|
68
|
+
restoreTerminal();
|
|
69
|
+
try {
|
|
70
|
+
opts.cleanup();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Best-effort by definition — we're exiting either way.
|
|
74
|
+
}
|
|
75
|
+
const lines = [
|
|
76
|
+
"",
|
|
77
|
+
`kritya crashed (${kind}). Background processes and MCP servers were shut down.`,
|
|
78
|
+
describe(err),
|
|
79
|
+
];
|
|
80
|
+
try {
|
|
81
|
+
lines.push(...(opts.details?.() ?? []));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A details() that throws must not cost us the error report above.
|
|
85
|
+
}
|
|
86
|
+
lines.push("", "This is a bug in kritya — the trace above is what a report needs.");
|
|
87
|
+
try {
|
|
88
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// stderr is gone; nothing further to try.
|
|
92
|
+
}
|
|
93
|
+
process.exit(1);
|
|
94
|
+
};
|
|
95
|
+
process.on("uncaughtException", (err) => onCrash(err, "uncaught exception"));
|
|
96
|
+
process.on("unhandledRejection", (reason) => onCrash(reason, "unhandled rejection"));
|
|
97
|
+
}
|
|
98
|
+
/** Test seam: forget that handlers were installed. */
|
|
99
|
+
export function resetCrashHandlersForTest() {
|
|
100
|
+
installed = false;
|
|
101
|
+
handling = false;
|
|
102
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Agent } from "./agent/loop.js";
|
|
3
|
+
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
4
|
+
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
5
|
+
import { PermissionManager } from "./permissions/permissions.js";
|
|
6
|
+
import { loadRules } from "./permissions/rules.js";
|
|
7
|
+
import { ProviderClient } from "./provider/client.js";
|
|
8
|
+
import { createSwitchyardClient } from "./provider/switchyardClient.js";
|
|
9
|
+
import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, stopSwitchyardSidecar, } from "./provider/switchyardSidecar.js";
|
|
10
|
+
import { SessionStore } from "./session/store.js";
|
|
11
|
+
import { AuditLog } from "./audit/audit.js";
|
|
12
|
+
import { createTracer, cleanupOldTelemetry } from "./telemetry/tracer.js";
|
|
13
|
+
import { createMeter } from "./telemetry/metrics.js";
|
|
14
|
+
import { retentionDaysFor } from "./config/retention.js";
|
|
15
|
+
import { backgroundManager } from "./shell/background.js";
|
|
16
|
+
import { lspManager } from "./lsp/manager.js";
|
|
17
|
+
import { ALL_TOOLS } from "./tools/index.js";
|
|
18
|
+
import { loadHooks, HookRunner } from "./hooks/hooks.js";
|
|
19
|
+
import { gatedContentHash, isTrusted } from "./trust/trust.js";
|
|
20
|
+
import { installCrashHandlers } from "./crash.js";
|
|
21
|
+
import { defaultSandboxMode } from "./shell/sandbox.js";
|
|
22
|
+
/**
|
|
23
|
+
* Frontend-agnostic session bootstrap shared by every UI that drives the
|
|
24
|
+
* agent core (the Ink CLI builds its own richer version of this in
|
|
25
|
+
* index.tsx for trust/MCP prompts; headless.ts has its own one-shot
|
|
26
|
+
* variant). This is the version non-terminal frontends (e.g. the Electron
|
|
27
|
+
* app) should call instead of duplicating the setup — a change to how
|
|
28
|
+
* Agent is constructed or tools are wired only needs to happen here.
|
|
29
|
+
*/
|
|
30
|
+
export async function createEngineSession(dir, opts = {}) {
|
|
31
|
+
const workspace = path.resolve(dir);
|
|
32
|
+
loadDotEnv([path.join(CONFIG_DIR, ".env")]);
|
|
33
|
+
const hash = gatedContentHash(workspace);
|
|
34
|
+
const trustWorkspace = !hash || isTrusted(workspace, hash);
|
|
35
|
+
if (trustWorkspace)
|
|
36
|
+
loadDotEnv([path.join(workspace, ".env")]);
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
const provider = resolveProvider(config, opts.provider);
|
|
39
|
+
if (!provider.apiKey) {
|
|
40
|
+
throw new Error(`No API key found for provider "${provider.name}". Set it via env var, a .env file, or ~/.kritya/config.json.`);
|
|
41
|
+
}
|
|
42
|
+
const providerDefaultModel = config.providers?.[provider.name]?.model;
|
|
43
|
+
let currentModel = resolveEffectiveModel(provider.name, [opts.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
|
|
44
|
+
const sampling = {
|
|
45
|
+
temperature: provider.temperature,
|
|
46
|
+
topP: provider.topP,
|
|
47
|
+
maxTokens: provider.maxTokens,
|
|
48
|
+
};
|
|
49
|
+
const client = provider.name === "switchyard"
|
|
50
|
+
? await createSwitchyardClient(provider.apiKey, sampling)
|
|
51
|
+
: new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
|
|
52
|
+
const session = new SessionStore(workspace);
|
|
53
|
+
session.start([]);
|
|
54
|
+
const sessionMeter = createMeter(session.id, config.otel);
|
|
55
|
+
// The crash path is fire-and-forget best-effort by Node's own constraints
|
|
56
|
+
// (a crash handler can't reliably await async work), so this uses the
|
|
57
|
+
// synchronous flush() rather than flushAndWait().
|
|
58
|
+
installCrashHandlers({
|
|
59
|
+
cleanup: () => {
|
|
60
|
+
backgroundManager.killAll();
|
|
61
|
+
lspManager.disposeAll();
|
|
62
|
+
sessionMeter.flush();
|
|
63
|
+
sessionMeter.stop();
|
|
64
|
+
},
|
|
65
|
+
details: () => (session.path ? ["", `Transcript: ${session.path}`] : []),
|
|
66
|
+
});
|
|
67
|
+
const retentionDays = retentionDaysFor(config);
|
|
68
|
+
SessionStore.cleanupOldSessions(retentionDays);
|
|
69
|
+
AuditLog.cleanupOld(retentionDays);
|
|
70
|
+
cleanupOldTelemetry(retentionDays);
|
|
71
|
+
const permissions = new PermissionManager(loadRules(workspace, trustWorkspace));
|
|
72
|
+
const agent = new Agent(client, () => currentModel, ALL_TOOLS, { workspace, sandboxMode: config.sandboxExec ?? defaultSandboxMode(), trustWorkspace }, permissions, session, []);
|
|
73
|
+
agent.contextWindow = contextWindowFor(currentModel, config);
|
|
74
|
+
if (config.maxSteps && config.maxSteps > 0)
|
|
75
|
+
agent.maxSteps = config.maxSteps;
|
|
76
|
+
if (config.toolTimeoutSeconds !== undefined) {
|
|
77
|
+
agent.toolTimeoutMs = config.toolTimeoutSeconds * 1000;
|
|
78
|
+
}
|
|
79
|
+
if (trustWorkspace) {
|
|
80
|
+
agent.hooks = new HookRunner(loadHooks(workspace, trustWorkspace), workspace);
|
|
81
|
+
}
|
|
82
|
+
agent.audit = AuditLog.forSession(session.id, config.audit);
|
|
83
|
+
agent.tracer = createTracer(session.id, config.otel);
|
|
84
|
+
agent.meter = sessionMeter;
|
|
85
|
+
if (agent.hooks)
|
|
86
|
+
agent.hooks.tracer = agent.tracer;
|
|
87
|
+
return {
|
|
88
|
+
agent,
|
|
89
|
+
session,
|
|
90
|
+
workspace,
|
|
91
|
+
provider: provider.name,
|
|
92
|
+
get model() {
|
|
93
|
+
return currentModel;
|
|
94
|
+
},
|
|
95
|
+
setModel(next) {
|
|
96
|
+
currentModel = next;
|
|
97
|
+
agent.contextWindow = contextWindowFor(next, config);
|
|
98
|
+
},
|
|
99
|
+
async dispose() {
|
|
100
|
+
backgroundManager.killAll();
|
|
101
|
+
lspManager.disposeAll();
|
|
102
|
+
if (provider.name === "switchyard")
|
|
103
|
+
stopSwitchyardSidecar();
|
|
104
|
+
// Normal (non-crash) shutdown: give the final metrics export a real
|
|
105
|
+
// chance to land, capped so a hung collector can't stall dispose().
|
|
106
|
+
await Promise.race([
|
|
107
|
+
sessionMeter.flushAndWait(),
|
|
108
|
+
new Promise((resolve) => setTimeout(resolve, 2000).unref()),
|
|
109
|
+
]);
|
|
110
|
+
sessionMeter.stop();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/git/git.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
function git(cwd, args) {
|
|
3
|
+
try {
|
|
4
|
+
return execFileSync("git", args, {
|
|
5
|
+
cwd,
|
|
6
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
7
|
+
timeout: 3000,
|
|
8
|
+
})
|
|
9
|
+
.toString()
|
|
10
|
+
.trimEnd();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null; // not a repo, or git missing
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function gitBranch(cwd) {
|
|
17
|
+
const out = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
18
|
+
return out || null;
|
|
19
|
+
}
|
|
20
|
+
/** Branch + working-tree status, capped at 30 lines; null outside a repo. */
|
|
21
|
+
export function gitStatusShort(cwd) {
|
|
22
|
+
const out = git(cwd, ["status", "--porcelain", "-b"]);
|
|
23
|
+
if (out === null)
|
|
24
|
+
return null;
|
|
25
|
+
const lines = out.split("\n");
|
|
26
|
+
return lines.length > 30
|
|
27
|
+
? [...lines.slice(0, 30), `… (${lines.length - 30} more changed files)`].join("\n")
|
|
28
|
+
: out;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A summary of uncommitted changes: the diffstat plus a capped unified diff of
|
|
32
|
+
* both staged and unstaged work. Null outside a repo; empty string if clean.
|
|
33
|
+
*/
|
|
34
|
+
export function gitDiffStat(cwd, maxLines = 200) {
|
|
35
|
+
const stat = git(cwd, ["diff", "HEAD", "--stat"]);
|
|
36
|
+
if (stat === null)
|
|
37
|
+
return null;
|
|
38
|
+
if (!stat.trim())
|
|
39
|
+
return "";
|
|
40
|
+
const diff = git(cwd, ["diff", "HEAD"]) ?? "";
|
|
41
|
+
const lines = diff.split("\n");
|
|
42
|
+
const capped = lines.length > maxLines
|
|
43
|
+
? [...lines.slice(0, maxLines), `… (${lines.length - maxLines} more diff lines)`].join("\n")
|
|
44
|
+
: diff;
|
|
45
|
+
return `${stat}\n\n${capped}`;
|
|
46
|
+
}
|