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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. 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
+ }
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const pkg = require("../package.json");
4
+ export const VERSION = pkg.version;
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
+ }