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,149 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { cacheSavingsFor, costFor, crossedBudgetWarnThreshold, tokenBudgetFor, } from "../agent/budget.js";
|
|
3
|
+
import { crossedContextWarnThreshold } from "../agent/contextWarning.js";
|
|
4
|
+
/** Owns context/usage/cost tracking and the token-budget cap for the session. */
|
|
5
|
+
export function useUsageBudget({ agent, config, modelRef, model, addItem, abortRef, }) {
|
|
6
|
+
const [usageByModel, setUsageByModel] = useState({});
|
|
7
|
+
const [ctxPct, setCtxPct] = useState(0);
|
|
8
|
+
const ctxPctRef = useRef(0);
|
|
9
|
+
const [tokenBudget, setTokenBudget] = useState(() => tokenBudgetFor(config));
|
|
10
|
+
const [budgetPct, setBudgetPct] = useState(0);
|
|
11
|
+
const budgetPctRef = useRef(0);
|
|
12
|
+
const [budgetUsed, setBudgetUsed] = useState(0);
|
|
13
|
+
const totalTokensRef = useRef(0);
|
|
14
|
+
const [budgetStopped, setBudgetStopped] = useState(false);
|
|
15
|
+
const totalUsage = Object.values(usageByModel).reduce((acc, u) => ({
|
|
16
|
+
promptTokens: acc.promptTokens + u.promptTokens,
|
|
17
|
+
completionTokens: acc.completionTokens + u.completionTokens,
|
|
18
|
+
cachedPromptTokens: (acc.cachedPromptTokens ?? 0) + (u.cachedPromptTokens ?? 0),
|
|
19
|
+
estimated: acc.estimated || Boolean(u.estimated),
|
|
20
|
+
}), { promptTokens: 0, completionTokens: 0, cachedPromptTokens: 0, estimated: false });
|
|
21
|
+
const totalCost = Object.entries(usageByModel).reduce((sum, [id, u]) => {
|
|
22
|
+
const p = config.pricing?.[id];
|
|
23
|
+
return p ? sum + costFor(u, p) : sum;
|
|
24
|
+
}, 0);
|
|
25
|
+
const costReport = useCallback(() => {
|
|
26
|
+
const lines = Object.entries(usageByModel).map(([id, u]) => {
|
|
27
|
+
const p = config.pricing?.[id];
|
|
28
|
+
const dollars = p ? ` ≈ $${costFor(u, p).toFixed(4)}` : "";
|
|
29
|
+
const cached = u.cachedPromptTokens ?? 0;
|
|
30
|
+
const hitRate = cached > 0 && u.promptTokens > 0
|
|
31
|
+
? ` (${cached.toLocaleString()} cached, ${Math.round((cached / u.promptTokens) * 100)}% hit rate)`
|
|
32
|
+
: "";
|
|
33
|
+
// Some providers omit real token counts; those turns fall back to a
|
|
34
|
+
// text-length estimate (see agent/loop.ts). Flag it here rather than
|
|
35
|
+
// let an approximate number pass as an exact one.
|
|
36
|
+
const estNote = u.estimated ? " (some figures estimated — provider didn't report usage)" : "";
|
|
37
|
+
return ` ${id}: ${u.promptTokens.toLocaleString()} in${hitRate} / ${u.completionTokens.toLocaleString()} out${dollars}${estNote}`;
|
|
38
|
+
});
|
|
39
|
+
if (!lines.length)
|
|
40
|
+
return "No usage yet this session.";
|
|
41
|
+
const totalSavings = Object.entries(usageByModel).reduce((sum, [id, u]) => {
|
|
42
|
+
const p = config.pricing?.[id];
|
|
43
|
+
return p ? sum + cacheSavingsFor(u, p) : sum;
|
|
44
|
+
}, 0);
|
|
45
|
+
const savingsNote = totalSavings > 0 ? ` (saved $${totalSavings.toFixed(4)} via prompt caching)` : "";
|
|
46
|
+
const total = totalCost > 0 ? `\nEstimated total: $${totalCost.toFixed(4)}${savingsNote}` : "";
|
|
47
|
+
const ctxNote = `\nContext window: ${ctxPctRef.current}% used`;
|
|
48
|
+
const budgetNote = `\nToken budget: ${budgetUsed.toLocaleString()} / ${tokenBudget.toLocaleString()} ` +
|
|
49
|
+
`(${budgetPct}%)${budgetStopped ? " — STOPPED, run /budget reset or /budget <number>" : ""}`;
|
|
50
|
+
const hint = totalCost > 0
|
|
51
|
+
? ""
|
|
52
|
+
: `\nTip: add per-model prices (USD per 1M tokens) to ~/.kritya/config.json to see $ estimates:\n "pricing": { "${model}": { "input": 0.6, "output": 2.4, "cachedInput": 0.15 } }\n("cachedInput" is optional — the discounted rate for cache-hit prompt tokens, for cache-savings reporting.)`;
|
|
53
|
+
return `Usage this session:\n${lines.join("\n")}${total}${ctxNote}${budgetNote}${hint}`;
|
|
54
|
+
}, [usageByModel, config, totalCost, budgetUsed, tokenBudget, budgetPct, budgetStopped, model]);
|
|
55
|
+
const resetBudget = () => {
|
|
56
|
+
totalTokensRef.current = 0;
|
|
57
|
+
budgetPctRef.current = 0;
|
|
58
|
+
setBudgetUsed(0);
|
|
59
|
+
setBudgetPct(0);
|
|
60
|
+
setBudgetStopped(false);
|
|
61
|
+
addItem({ kind: "info", text: "Token budget usage reset for this session." });
|
|
62
|
+
};
|
|
63
|
+
const setBudgetLimit = (n) => {
|
|
64
|
+
setTokenBudget(n);
|
|
65
|
+
const pct = Math.min(999, Math.round((totalTokensRef.current / n) * 100));
|
|
66
|
+
budgetPctRef.current = pct;
|
|
67
|
+
setBudgetPct(pct);
|
|
68
|
+
if (pct < 100)
|
|
69
|
+
setBudgetStopped(false);
|
|
70
|
+
addItem({ kind: "info", text: `Token budget set to ${n.toLocaleString()} for this session.` });
|
|
71
|
+
};
|
|
72
|
+
/** Feed a turn's reported usage into the running totals; call from onUsage. */
|
|
73
|
+
const recordUsage = useCallback((u) => {
|
|
74
|
+
const pct = Math.round(agent.contextUsage() * 100);
|
|
75
|
+
if (crossedContextWarnThreshold(ctxPctRef.current, pct)) {
|
|
76
|
+
addItem({
|
|
77
|
+
kind: "info",
|
|
78
|
+
text: `⚠ Context usage at ${pct}% — kritya will auto-compact older history soon to stay within the model's context window.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
ctxPctRef.current = pct;
|
|
82
|
+
setCtxPct(pct);
|
|
83
|
+
const id = modelRef.current;
|
|
84
|
+
setUsageByModel((prev) => ({
|
|
85
|
+
...prev,
|
|
86
|
+
[id]: {
|
|
87
|
+
promptTokens: (prev[id]?.promptTokens ?? 0) + u.promptTokens,
|
|
88
|
+
completionTokens: (prev[id]?.completionTokens ?? 0) + u.completionTokens,
|
|
89
|
+
cachedPromptTokens: (prev[id]?.cachedPromptTokens ?? 0) + (u.cachedPromptTokens ?? 0),
|
|
90
|
+
// Once any turn's usage for this model was estimated rather
|
|
91
|
+
// than provider-reported, the running total is no longer
|
|
92
|
+
// exact — keep it flagged for the rest of the session.
|
|
93
|
+
estimated: Boolean(prev[id]?.estimated) || Boolean(u.estimated),
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
totalTokensRef.current += u.promptTokens + u.completionTokens;
|
|
97
|
+
setBudgetUsed(totalTokensRef.current);
|
|
98
|
+
const bPct = Math.min(999, Math.round((totalTokensRef.current / tokenBudget) * 100));
|
|
99
|
+
if (crossedBudgetWarnThreshold(budgetPctRef.current, bPct)) {
|
|
100
|
+
addItem({
|
|
101
|
+
kind: "info",
|
|
102
|
+
text: `⚠ Token budget at ${bPct}% (${totalTokensRef.current.toLocaleString()} / ` +
|
|
103
|
+
`${tokenBudget.toLocaleString()} tokens this session). kritya will stop once it ` +
|
|
104
|
+
`hits 100% — run /budget to check or raise it.`,
|
|
105
|
+
});
|
|
106
|
+
agent.audit?.logTool({
|
|
107
|
+
tool: "budget",
|
|
108
|
+
summary: `token budget at ${bPct}% (${totalTokensRef.current}/${tokenBudget})`,
|
|
109
|
+
outcome: "ok",
|
|
110
|
+
});
|
|
111
|
+
agent.turnSpan?.addEvent("budget.warn", { "kritya.budget_pct": bPct });
|
|
112
|
+
}
|
|
113
|
+
if (bPct >= 100 && budgetPctRef.current < 100) {
|
|
114
|
+
addItem({
|
|
115
|
+
kind: "info",
|
|
116
|
+
text: `⛔ Token budget reached (${totalTokensRef.current.toLocaleString()} / ` +
|
|
117
|
+
`${tokenBudget.toLocaleString()} tokens). Stopping further turns — run ` +
|
|
118
|
+
`/budget reset to clear it, or /budget <number> to raise the cap.`,
|
|
119
|
+
});
|
|
120
|
+
agent.audit?.logTool({
|
|
121
|
+
tool: "budget",
|
|
122
|
+
summary: `token budget reached, stopping (${totalTokensRef.current}/${tokenBudget})`,
|
|
123
|
+
outcome: "blocked",
|
|
124
|
+
});
|
|
125
|
+
agent.turnSpan?.addEvent("budget.stopped", { "kritya.budget_pct": bPct });
|
|
126
|
+
setBudgetStopped(true);
|
|
127
|
+
abortRef.current?.abort();
|
|
128
|
+
}
|
|
129
|
+
budgetPctRef.current = bPct;
|
|
130
|
+
setBudgetPct(bPct);
|
|
131
|
+
}, [agent, addItem, modelRef, tokenBudget, abortRef]);
|
|
132
|
+
return {
|
|
133
|
+
usageByModel,
|
|
134
|
+
totalUsage,
|
|
135
|
+
totalCost,
|
|
136
|
+
costReport,
|
|
137
|
+
ctxPct,
|
|
138
|
+
ctxPctRef,
|
|
139
|
+
setCtxPct,
|
|
140
|
+
tokenBudget,
|
|
141
|
+
budgetPct,
|
|
142
|
+
budgetUsed,
|
|
143
|
+
budgetStopped,
|
|
144
|
+
resetBudget,
|
|
145
|
+
setBudgetLimit,
|
|
146
|
+
recordUsage,
|
|
147
|
+
totalTokensRef,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { wrapInline } from "./inline.js";
|
|
2
|
+
/** Rows below the streaming region: prompt, status line, spinner, breathing room. */
|
|
3
|
+
const RESERVED_ROWS = 8;
|
|
4
|
+
/** Never collapse the live view to nothing, however short the terminal. */
|
|
5
|
+
const MIN_ROWS = 4;
|
|
6
|
+
/**
|
|
7
|
+
* Rows held back against an estimate that comes in low. Underestimating by
|
|
8
|
+
* even one row brings back the bug this file exists to prevent, and the only
|
|
9
|
+
* cost of overestimating is showing slightly less of the live text.
|
|
10
|
+
*/
|
|
11
|
+
const SAFETY_ROWS = 4;
|
|
12
|
+
/** Columns the renderer itself works in: the terminal, less its padding. */
|
|
13
|
+
const RENDER_INSET = 2;
|
|
14
|
+
/** A table row wraps its cells; assume the worst rather than guess low. */
|
|
15
|
+
const TABLE_ROW_HEIGHT = 3;
|
|
16
|
+
/**
|
|
17
|
+
* Terminal rows a line will occupy once rendered. Measured with the same
|
|
18
|
+
* word-wrapping the renderer uses — dividing width by columns assumes words
|
|
19
|
+
* can be split mid-word, which under-counts every wrapped paragraph.
|
|
20
|
+
*/
|
|
21
|
+
function rowsFor(line, columns) {
|
|
22
|
+
const width = Math.max(1, columns - RENDER_INSET);
|
|
23
|
+
const wrapped = wrapInline(line, width).length;
|
|
24
|
+
return line.includes("|") ? Math.max(wrapped, TABLE_ROW_HEIGHT) : Math.max(1, wrapped);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The tail of a streaming answer that fits the viewport.
|
|
28
|
+
*
|
|
29
|
+
* Ink erases its live region by rewinding a line count, which can only reach
|
|
30
|
+
* what is still on screen. Let the streaming text grow past the terminal's
|
|
31
|
+
* height and the erase silently comes up short, stranding a partial copy in
|
|
32
|
+
* the scrollback — then the finished message prints below it and the answer
|
|
33
|
+
* appears twice. Showing only what fits keeps the erase exact; the whole
|
|
34
|
+
* answer is printed once when the turn ends.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The terminal's size, falling back to 80x24 when it isn't known.
|
|
38
|
+
*
|
|
39
|
+
* `columns` is 0 — not undefined — whenever Node can't get a window size from
|
|
40
|
+
* the tty (a pty opened without one, some CI runners, a few terminal
|
|
41
|
+
* emulators mid-resize). `?? 80` sails straight past that and hands the
|
|
42
|
+
* callers a 0, which after subtracting a margin clamps to the 20-column floor:
|
|
43
|
+
* Ink still draws its frames 80 wide (it uses `|| 80`), so the prose inside
|
|
44
|
+
* them wrapped into a narrow ribbon. Matching Ink's own fallback keeps the two
|
|
45
|
+
* in agreement.
|
|
46
|
+
*/
|
|
47
|
+
export function terminalColumns(stdout) {
|
|
48
|
+
return stdout?.columns || 80;
|
|
49
|
+
}
|
|
50
|
+
export function terminalRows(stdout) {
|
|
51
|
+
return stdout?.rows || 24;
|
|
52
|
+
}
|
|
53
|
+
export function tailForViewport(text, columns, rows) {
|
|
54
|
+
const budget = Math.max(MIN_ROWS, rows - RESERVED_ROWS - SAFETY_ROWS);
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
let used = 0;
|
|
57
|
+
let start = lines.length;
|
|
58
|
+
while (start > 0) {
|
|
59
|
+
const next = used + rowsFor(lines[start - 1], columns);
|
|
60
|
+
if (next > budget)
|
|
61
|
+
break;
|
|
62
|
+
used = next;
|
|
63
|
+
start--;
|
|
64
|
+
}
|
|
65
|
+
if (start === 0)
|
|
66
|
+
return text;
|
|
67
|
+
const tail = lines.slice(start);
|
|
68
|
+
// Reopen a fence the cut landed inside, so the tail doesn't render as prose.
|
|
69
|
+
const fencesDropped = lines.slice(0, start).filter((l) => l.trimStart().startsWith("```")).length;
|
|
70
|
+
return fencesDropped % 2 === 1 ? `\`\`\`\n${tail.join("\n")}` : tail.join("\n");
|
|
71
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
4
|
+
const MAX_ENTRIES = 100;
|
|
5
|
+
// A watcher event this soon after our own snapshot() is treated as kritya's
|
|
6
|
+
// own write settling to disk, not an external edit — write+watch-event
|
|
7
|
+
// latency on a local disk is milliseconds, so this is a generous margin.
|
|
8
|
+
const OWN_WRITE_GRACE_MS = 750;
|
|
9
|
+
// One logical write can fire several raw filesystem events (create, write,
|
|
10
|
+
// close); wait for them to go quiet before reading the file, so we don't
|
|
11
|
+
// catch it mid-write.
|
|
12
|
+
const WATCHER_DEBOUNCE_MS = 100;
|
|
13
|
+
/**
|
|
14
|
+
* Stack of file states captured before each agent write/edit, grouped by turn.
|
|
15
|
+
* /undo reverts everything the most recent changing turn did and records the
|
|
16
|
+
* inverse so /redo can reapply it. Making a new change clears the redo stack.
|
|
17
|
+
*
|
|
18
|
+
* Also watches every file kritya has touched this session for changes made
|
|
19
|
+
* outside kritya (the user editing in their own editor between turns) and
|
|
20
|
+
* checkpoints those too, as their own undo-able step in the right
|
|
21
|
+
* chronological position — instead of only implicitly and fragilely
|
|
22
|
+
* preserving them via whatever the redo stack happens to capture.
|
|
23
|
+
*/
|
|
24
|
+
export class UndoStack {
|
|
25
|
+
entries = [];
|
|
26
|
+
redoStack = [];
|
|
27
|
+
turn = 0;
|
|
28
|
+
// File-watcher checkpointing. Scoped to files kritya has itself snapshotted
|
|
29
|
+
// this session — never a whole-repo recursive watch — so this can't pick up
|
|
30
|
+
// noise from build output, node_modules, or unrelated files, and can't leak
|
|
31
|
+
// anything not already touched by an undo-tracked write.
|
|
32
|
+
trackedFiles = new Map(); // absPath -> relPath
|
|
33
|
+
lastKnownContent = new Map(); // absPath -> content we believe is on disk
|
|
34
|
+
lastOwnWriteAt = new Map(); // absPath -> Date.now() of our last snapshot()
|
|
35
|
+
watchedDirs = new Map(); // parent dir -> watcher
|
|
36
|
+
debounceTimers = new Map(); // absPath -> pending settle check
|
|
37
|
+
ownWriteSyncTimers = new Map(); // absPath -> pending self-write re-read
|
|
38
|
+
/** Fires when a file-watcher checkpoint is created for an external edit. */
|
|
39
|
+
onExternalChange;
|
|
40
|
+
/** Mark the start of a new agent turn; subsequent snapshots group under it. */
|
|
41
|
+
beginTurn() {
|
|
42
|
+
this.turn++;
|
|
43
|
+
}
|
|
44
|
+
/** Capture the current state of a file before it is modified. */
|
|
45
|
+
snapshot(absPath, relPath) {
|
|
46
|
+
this.entries.push({ relPath, absPath, content: readOrNull(absPath), turn: this.turn });
|
|
47
|
+
this.evictOldestTurnsOverCap();
|
|
48
|
+
// A fresh change invalidates any redo history.
|
|
49
|
+
this.redoStack = [];
|
|
50
|
+
this.lastOwnWriteAt.set(absPath, Date.now());
|
|
51
|
+
this.watchForExternalEdits(absPath, relPath);
|
|
52
|
+
this.scheduleOwnWriteSync(absPath);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Re-read the file once the own-write grace window closes, and record that
|
|
56
|
+
* as what we believe is on disk.
|
|
57
|
+
*
|
|
58
|
+
* `lastKnownContent` used to be updated only when a watcher event arrived,
|
|
59
|
+
* which quietly assumed every write produces one. macOS coalesces FSEvents
|
|
60
|
+
* and can drop the event for a write that lands just after the watch is
|
|
61
|
+
* registered — and when the event for *our own* write goes missing, the
|
|
62
|
+
* baseline stays at whatever it was before (typically null, for a file we
|
|
63
|
+
* just created). The next genuine hand-edit is then checkpointed against
|
|
64
|
+
* that null, and undoing it deletes the user's file rather than restoring
|
|
65
|
+
* it. Reading the file ourselves removes the dependency on the event.
|
|
66
|
+
*/
|
|
67
|
+
scheduleOwnWriteSync(absPath) {
|
|
68
|
+
const existing = this.ownWriteSyncTimers.get(absPath);
|
|
69
|
+
if (existing)
|
|
70
|
+
clearTimeout(existing);
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
this.ownWriteSyncTimers.delete(absPath);
|
|
73
|
+
// Only meaningful while the grace window is the reason we'd ignore a
|
|
74
|
+
// change; a checkpoint taken in the meantime already set the baseline.
|
|
75
|
+
this.lastKnownContent.set(absPath, readOrNull(absPath));
|
|
76
|
+
}, OWN_WRITE_GRACE_MS);
|
|
77
|
+
timer.unref();
|
|
78
|
+
this.ownWriteSyncTimers.set(absPath, timer);
|
|
79
|
+
}
|
|
80
|
+
/** Evict whole turns, never part of one — a half-evicted turn would make
|
|
81
|
+
* /undo silently restore only some of that turn's files. If a single turn
|
|
82
|
+
* alone exceeds the cap, keep it intact rather than truncate it. */
|
|
83
|
+
evictOldestTurnsOverCap() {
|
|
84
|
+
while (this.entries.length > MAX_ENTRIES &&
|
|
85
|
+
this.entries[0].turn !== this.entries[this.entries.length - 1].turn) {
|
|
86
|
+
const oldest = this.entries[0].turn;
|
|
87
|
+
while (this.entries.length && this.entries[0].turn === oldest)
|
|
88
|
+
this.entries.shift();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Starts watching `absPath`'s parent directory the first time we see this file. */
|
|
92
|
+
watchForExternalEdits(absPath, relPath) {
|
|
93
|
+
if (this.trackedFiles.has(absPath))
|
|
94
|
+
return;
|
|
95
|
+
this.trackedFiles.set(absPath, relPath);
|
|
96
|
+
this.lastKnownContent.set(absPath, readOrNull(absPath));
|
|
97
|
+
// Watch the parent directory, not the file itself: many editors save by
|
|
98
|
+
// writing a temp file and renaming it over the original, which would
|
|
99
|
+
// silently stop a watch on the file's own (now-replaced) inode.
|
|
100
|
+
const dir = path.dirname(absPath);
|
|
101
|
+
if (this.watchedDirs.has(dir))
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
const watcher = fs.watch(dir, (_eventType, filename) => {
|
|
105
|
+
if (!filename)
|
|
106
|
+
return;
|
|
107
|
+
const target = path.join(dir, filename);
|
|
108
|
+
if (this.trackedFiles.has(target))
|
|
109
|
+
this.scheduleWatcherCheck(target);
|
|
110
|
+
});
|
|
111
|
+
watcher.on("error", () => {
|
|
112
|
+
// Best-effort: undo still works via normal snapshotting even if the
|
|
113
|
+
// watcher errors out (e.g. the directory was removed).
|
|
114
|
+
});
|
|
115
|
+
// Don't let a background checkpoint watcher keep the process alive —
|
|
116
|
+
// closeAll() closes these explicitly on normal exit, but nothing else
|
|
117
|
+
// (tests, a crash path, an unexpected early return) should hang waiting
|
|
118
|
+
// on a handle that only exists for a nice-to-have.
|
|
119
|
+
watcher.unref();
|
|
120
|
+
this.watchedDirs.set(dir, watcher);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Watching isn't available (platform limits, permissions, etc.) — the
|
|
124
|
+
// rest of undo/redo is unaffected, we just won't catch hand edits.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A single logical write (create + write + close) can fire several raw
|
|
129
|
+
* filesystem events, and reading the file synchronously on the first one
|
|
130
|
+
* can catch it mid-write (e.g. truncated to empty). Debounce so we only
|
|
131
|
+
* ever inspect the file once it's settled.
|
|
132
|
+
*/
|
|
133
|
+
scheduleWatcherCheck(absPath) {
|
|
134
|
+
const existing = this.debounceTimers.get(absPath);
|
|
135
|
+
if (existing)
|
|
136
|
+
clearTimeout(existing);
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
this.debounceTimers.delete(absPath);
|
|
139
|
+
this.handleWatcherEvent(absPath);
|
|
140
|
+
}, WATCHER_DEBOUNCE_MS);
|
|
141
|
+
timer.unref();
|
|
142
|
+
this.debounceTimers.set(absPath, timer);
|
|
143
|
+
}
|
|
144
|
+
handleWatcherEvent(absPath) {
|
|
145
|
+
const relPath = this.trackedFiles.get(absPath);
|
|
146
|
+
if (!relPath)
|
|
147
|
+
return;
|
|
148
|
+
const current = readOrNull(absPath);
|
|
149
|
+
const isOwnWrite = Date.now() - (this.lastOwnWriteAt.get(absPath) ?? 0) < OWN_WRITE_GRACE_MS;
|
|
150
|
+
const known = this.lastKnownContent.get(absPath) ?? null;
|
|
151
|
+
this.lastKnownContent.set(absPath, current);
|
|
152
|
+
if (isOwnWrite || current === known)
|
|
153
|
+
return; // our own write, or no real change
|
|
154
|
+
this.turn++;
|
|
155
|
+
this.entries.push({ relPath, absPath, content: known, turn: this.turn, external: true });
|
|
156
|
+
this.evictOldestTurnsOverCap();
|
|
157
|
+
this.redoStack = [];
|
|
158
|
+
this.onExternalChange?.(relPath);
|
|
159
|
+
}
|
|
160
|
+
/** Stop all file watchers — call on process exit to avoid dangling handles. */
|
|
161
|
+
closeAll() {
|
|
162
|
+
for (const watcher of this.watchedDirs.values()) {
|
|
163
|
+
try {
|
|
164
|
+
watcher.close();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// best-effort
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.watchedDirs.clear();
|
|
171
|
+
for (const timer of this.debounceTimers.values())
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
this.debounceTimers.clear();
|
|
174
|
+
for (const timer of this.ownWriteSyncTimers.values())
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
this.ownWriteSyncTimers.clear();
|
|
177
|
+
}
|
|
178
|
+
get size() {
|
|
179
|
+
return this.entries.length;
|
|
180
|
+
}
|
|
181
|
+
/** The current turn counter — used to mark a checkpoint's position in time. */
|
|
182
|
+
currentTurn() {
|
|
183
|
+
return this.turn;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Revert every file change made after `targetTurn` — used by /rewind to roll
|
|
187
|
+
* the files back to a checkpoint. Reverts one turn at a time via undo(), so
|
|
188
|
+
* each reverted turn is still individually redoable afterwards. Returns a
|
|
189
|
+
* summary, or null if nothing newer than the checkpoint needed reverting.
|
|
190
|
+
*/
|
|
191
|
+
rewindTo(targetTurn) {
|
|
192
|
+
const results = [];
|
|
193
|
+
while (this.entries.length && this.entries[this.entries.length - 1].turn > targetTurn) {
|
|
194
|
+
const result = this.undo();
|
|
195
|
+
if (result === null)
|
|
196
|
+
break;
|
|
197
|
+
results.push(result);
|
|
198
|
+
}
|
|
199
|
+
return results.length ? results.join("\n") : null;
|
|
200
|
+
}
|
|
201
|
+
/** Revert every change from the most recent changing turn. Returns a description, or null if empty. */
|
|
202
|
+
undo() {
|
|
203
|
+
const last = this.entries[this.entries.length - 1];
|
|
204
|
+
if (!last)
|
|
205
|
+
return null;
|
|
206
|
+
const turn = last.turn;
|
|
207
|
+
const group = [];
|
|
208
|
+
while (this.entries.length && this.entries[this.entries.length - 1].turn === turn) {
|
|
209
|
+
// unshift so the group reads in the order the writes happened.
|
|
210
|
+
group.unshift(this.entries.pop());
|
|
211
|
+
}
|
|
212
|
+
// One restore per file, however many times the turn wrote it. A turn that
|
|
213
|
+
// edits a file twice (an edit, then a fix-up) snapshots it twice, and only
|
|
214
|
+
// the *first* snapshot holds the state the turn started from — the rest are
|
|
215
|
+
// intermediate. Walking them all restored an intermediate state as if it
|
|
216
|
+
// were the original, and captured those same intermediates as the redo
|
|
217
|
+
// state, so a redo reinstated a half-finished file.
|
|
218
|
+
const originals = new Map();
|
|
219
|
+
for (const entry of group) {
|
|
220
|
+
if (!originals.has(entry.absPath))
|
|
221
|
+
originals.set(entry.absPath, entry);
|
|
222
|
+
}
|
|
223
|
+
// Read every current state before restoring any of it, so redo reapplies
|
|
224
|
+
// what the turn actually produced rather than what an earlier restore in
|
|
225
|
+
// this same loop left behind.
|
|
226
|
+
const redoGroup = [...originals.values()].map((entry) => ({
|
|
227
|
+
relPath: entry.relPath,
|
|
228
|
+
absPath: entry.absPath,
|
|
229
|
+
content: readOrNull(entry.absPath),
|
|
230
|
+
}));
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const entry of originals.values()) {
|
|
233
|
+
// undo/redo's own restores must not be mistaken for external edits by
|
|
234
|
+
// the file watcher — mark them the same way snapshot() marks a write.
|
|
235
|
+
this.lastOwnWriteAt.set(entry.absPath, Date.now());
|
|
236
|
+
results.push(restore(entry));
|
|
237
|
+
this.lastKnownContent.set(entry.absPath, entry.content);
|
|
238
|
+
}
|
|
239
|
+
this.redoStack.push(redoGroup);
|
|
240
|
+
return summarize(results, "Reverted");
|
|
241
|
+
}
|
|
242
|
+
/** Reapply the most recently undone turn. Returns a description, or null if nothing to redo. */
|
|
243
|
+
redo() {
|
|
244
|
+
const group = this.redoStack.pop();
|
|
245
|
+
if (!group)
|
|
246
|
+
return null;
|
|
247
|
+
this.turn++;
|
|
248
|
+
// Same ordering care as undo(): capture what is on disk now, before any of
|
|
249
|
+
// this group's restores overwrite it, so the turn stays undoable.
|
|
250
|
+
const current = group.map((state) => readOrNull(state.absPath));
|
|
251
|
+
const results = [];
|
|
252
|
+
for (const [i, state] of group.entries()) {
|
|
253
|
+
this.entries.push({ ...state, content: current[i], turn: this.turn });
|
|
254
|
+
this.lastOwnWriteAt.set(state.absPath, Date.now());
|
|
255
|
+
results.push(restore(state));
|
|
256
|
+
this.lastKnownContent.set(state.absPath, state.content);
|
|
257
|
+
}
|
|
258
|
+
return summarize(results, "Reapplied");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// "latin1" maps each byte 0-255 to the same code point, so it round-trips
|
|
262
|
+
// arbitrary binary content (docx/xlsx/pptx/pdf) losslessly, unlike "utf8"
|
|
263
|
+
// which would corrupt byte sequences that aren't valid UTF-8.
|
|
264
|
+
function readOrNull(absPath) {
|
|
265
|
+
try {
|
|
266
|
+
return fs.readFileSync(absPath, "latin1");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function restore(state) {
|
|
273
|
+
if (state.content === null) {
|
|
274
|
+
try {
|
|
275
|
+
fs.unlinkSync(state.absPath);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return `Nothing to remove: ${state.relPath}`;
|
|
279
|
+
}
|
|
280
|
+
return `Removed ${state.relPath}`;
|
|
281
|
+
}
|
|
282
|
+
fs.mkdirSync(path.dirname(state.absPath), { recursive: true });
|
|
283
|
+
// Atomic for the same reason the write tools are: a crash while restoring
|
|
284
|
+
// would leave the file half-reverted, with the original now gone from both
|
|
285
|
+
// the disk and the stack entry we just popped.
|
|
286
|
+
writeFileAtomicSync(state.absPath, state.content, { encoding: "latin1" });
|
|
287
|
+
return `Restored ${state.relPath}`;
|
|
288
|
+
}
|
|
289
|
+
function summarize(results, verb) {
|
|
290
|
+
return results.length === 1
|
|
291
|
+
? results[0]
|
|
292
|
+
: `${verb} ${results.length} files:\n ${results.join("\n ")}`;
|
|
293
|
+
}
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kritya",
|
|
3
|
+
"version": "0.8.2-beta",
|
|
4
|
+
"description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kritya": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"!dist/test/**",
|
|
12
|
+
"!dist/electron/**"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsx src/index.tsx",
|
|
20
|
+
"test": "npm run build && node scripts/run-tests.mjs",
|
|
21
|
+
"test:watch": "tsx --test --watch \"src/test/*.test.ts\"",
|
|
22
|
+
"test:coverage": "npm run build && c8 node scripts/run-tests.mjs",
|
|
23
|
+
"lint": "eslint .",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"format:check": "prettier --check .",
|
|
26
|
+
"check:audit": "node scripts/check-audit.mjs",
|
|
27
|
+
"check:install-scripts": "node scripts/check-install-scripts.mjs",
|
|
28
|
+
"link": "npm run build && npm link",
|
|
29
|
+
"prepublishOnly": "npm run build",
|
|
30
|
+
"prepare": "husky",
|
|
31
|
+
"electron:dev": "npm run build && electron .",
|
|
32
|
+
"electron:build": "npm run build && electron-builder"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"cli",
|
|
36
|
+
"coding-agent",
|
|
37
|
+
"terminal",
|
|
38
|
+
"ai",
|
|
39
|
+
"llm",
|
|
40
|
+
"nvidia",
|
|
41
|
+
"openai-compatible",
|
|
42
|
+
"mcp"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"docx": "^9.7.1",
|
|
47
|
+
"exceljs": "^4.4.0",
|
|
48
|
+
"fast-glob": "^3.3.2",
|
|
49
|
+
"ink": "^7.1.1",
|
|
50
|
+
"ink-text-input": "^6.0.0",
|
|
51
|
+
"jszip": "^3.10.1",
|
|
52
|
+
"mammoth": "^1.12.0",
|
|
53
|
+
"openai": "^7.4.0",
|
|
54
|
+
"pdf-lib": "^1.17.1",
|
|
55
|
+
"pdfjs-dist": "^6.1.200",
|
|
56
|
+
"pptxgenjs": "^4.0.1",
|
|
57
|
+
"react": "^19.2.8",
|
|
58
|
+
"string-width": "^8.2.2"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^10.0.1",
|
|
62
|
+
"@types/node": "^26.2.0",
|
|
63
|
+
"@types/react": "^19.2.18",
|
|
64
|
+
"c8": "^12.0.0",
|
|
65
|
+
"electron": "^43.3.0",
|
|
66
|
+
"electron-builder": "^26.15.3",
|
|
67
|
+
"eslint": "^10.8.1",
|
|
68
|
+
"globals": "^17.11.0",
|
|
69
|
+
"husky": "^9.1.7",
|
|
70
|
+
"ink-testing-library": "^4.0.0",
|
|
71
|
+
"lint-staged": "^17.3.0",
|
|
72
|
+
"prettier": "^3.3.0",
|
|
73
|
+
"tsx": "^4.23.9",
|
|
74
|
+
"typescript": "^6.0.3",
|
|
75
|
+
"typescript-eslint": "^8.0.0"
|
|
76
|
+
},
|
|
77
|
+
"overrides": {
|
|
78
|
+
"brace-expansion": "^5.0.8",
|
|
79
|
+
"exceljs": {
|
|
80
|
+
"uuid": "^11.1.1",
|
|
81
|
+
"unzipper": "^0.12.3",
|
|
82
|
+
"archiver": "^7.0.1",
|
|
83
|
+
"readdir-glob": "^3.0.0",
|
|
84
|
+
"glob": "^13.0.0",
|
|
85
|
+
"fast-csv": "^5.0.7"
|
|
86
|
+
},
|
|
87
|
+
"glob": "^13.0.0"
|
|
88
|
+
},
|
|
89
|
+
"lint-staged": {
|
|
90
|
+
"*.{ts,tsx}": [
|
|
91
|
+
"eslint --fix",
|
|
92
|
+
"prettier --write"
|
|
93
|
+
],
|
|
94
|
+
"*.{js,mjs,json,md}": [
|
|
95
|
+
"prettier --write"
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"build": {
|
|
99
|
+
"appId": "com.kritya.desktop",
|
|
100
|
+
"productName": "Kritya",
|
|
101
|
+
"directories": {
|
|
102
|
+
"output": "release"
|
|
103
|
+
},
|
|
104
|
+
"publish": null,
|
|
105
|
+
"files": [
|
|
106
|
+
"dist/**/*",
|
|
107
|
+
"electron/**/*",
|
|
108
|
+
"package.json",
|
|
109
|
+
"!src/**/*",
|
|
110
|
+
"!scripts/**/*"
|
|
111
|
+
],
|
|
112
|
+
"win": {
|
|
113
|
+
"target": "nsis"
|
|
114
|
+
},
|
|
115
|
+
"mac": {
|
|
116
|
+
"target": "dmg"
|
|
117
|
+
},
|
|
118
|
+
"linux": {
|
|
119
|
+
"target": "AppImage"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|