oakode 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +9 -0
  3. package/README.md +243 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +570 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +102 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +587 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +82 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +322 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
@@ -0,0 +1,231 @@
1
+ import { OickiError } from "./oicki.js";
2
+ import { INTERRUPTED, estimateTokens, repair } from "./session.js";
3
+ export const RESERVE_TOKENS = 16_384;
4
+ /** OICKI rejects requests over 2000 messages or 4,000,000 bytes, which 1M-token models reach before their window fills. */
5
+ export const MAX_MESSAGES = 1900;
6
+ export const MAX_BODY_BYTES = 3_000_000;
7
+ const HARD_MESSAGES = 2000;
8
+ const HARD_BYTES = 4_000_000;
9
+ /** Text size of a request. Images are left out: compaction keeps the newest message, so it cannot shrink them (the client leaves out old images to fit instead). */
10
+ export const bodyBytes = (ctx) => Buffer.byteLength(JSON.stringify(ctx, (k, v) => (k === "images" ? undefined : v)));
11
+ /** Headroom kept free for the reply. Scaled down for small windows: models with 4k-16k windows exist, and a fixed 16k reserve would mean compacting on every request. */
12
+ export const reserveFor = (window) => Math.min(RESERVE_TOKENS, Math.floor(window * 0.25));
13
+ export function shouldCompact(used, window, ctx = []) {
14
+ if (ctx.length > MAX_MESSAGES || bodyBytes(ctx) > MAX_BODY_BYTES)
15
+ return true;
16
+ return !!window && used > window - reserveFor(window);
17
+ }
18
+ /** The request cannot be sent at all as it is (as opposed to merely being large). */
19
+ export function overHardLimits(used, window, ctx) {
20
+ return ctx.length >= HARD_MESSAGES || bodyBytes(ctx) >= HARD_BYTES || (!!window && used > window - 2_000);
21
+ }
22
+ // ---------------------------------------------------------------- deterministic file tracking
23
+ /** Files read / modified by tool calls that succeeded, accumulated over `prev`. Modified wins over read. */
24
+ export function trackFiles(messages, prev = { read: [], modified: [] }) {
25
+ const read = new Set(prev.read);
26
+ const modified = new Set(prev.modified);
27
+ const ok = new Map();
28
+ for (const m of messages)
29
+ if (m.role === "tool")
30
+ ok.set(m.tool_call_id, !m.content.startsWith("Error:") && m.content !== INTERRUPTED && !m.content.includes("[interrupted by user]"));
31
+ for (const m of messages) {
32
+ if (m.role !== "assistant")
33
+ continue;
34
+ for (const c of m.tool_calls ?? []) {
35
+ if (ok.get(c.id) === false)
36
+ continue;
37
+ let path;
38
+ try {
39
+ path = JSON.parse(c.function.arguments).path;
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ if (typeof path !== "string" || !path)
45
+ continue;
46
+ const p = path.replace(/^\.\//, "");
47
+ if (c.function.name === "read")
48
+ read.add(p);
49
+ else if (c.function.name === "write" || c.function.name === "edit")
50
+ modified.add(p);
51
+ }
52
+ }
53
+ for (const p of modified)
54
+ read.delete(p);
55
+ return { read: [...read].sort(), modified: [...modified].sort() };
56
+ }
57
+ const msgBytes = (m) => Buffer.byteLength(JSON.stringify(m));
58
+ /** Index in `raw` where the verbatim tail starts, or undefined if there is nothing worth compacting. */
59
+ export function findCut(session, window, aggressive = false) {
60
+ const raw = session.raw;
61
+ const keep = aggressive ? Math.floor(window * 0.08) : Math.min(20_000, Math.floor(window * 0.25));
62
+ const lo = session.keepFrom + 1;
63
+ const tailStats = (from) => {
64
+ let tokens = 0, bytes = 0;
65
+ for (let i = from; i < raw.length; i++) {
66
+ tokens += estimateTokens(raw[i]);
67
+ bytes += msgBytes(raw[i]);
68
+ }
69
+ return { tokens, bytes, count: raw.length - from };
70
+ };
71
+ // 1. walk back until the token / count / byte budget of the tail is used up
72
+ let cut = raw.length;
73
+ let tokens = 0, bytes = 0;
74
+ for (let i = raw.length - 1; i >= lo; i--) {
75
+ tokens += estimateTokens(raw[i]);
76
+ bytes += msgBytes(raw[i]);
77
+ cut = i;
78
+ if (tokens >= keep || raw.length - i >= 400 || bytes >= 1_500_000)
79
+ break;
80
+ }
81
+ // never separate a tool call from its results. If the budget ran out inside an exchange: summarize the exchange when
82
+ // something follows it (it is stale), keep it whole only when it is the latest thing the agent still needs.
83
+ if (raw[cut].role === "tool") {
84
+ let fwd = cut, back = cut;
85
+ while (fwd < raw.length && raw[fwd].role === "tool")
86
+ fwd++;
87
+ while (back > lo && raw[back].role === "tool")
88
+ back--;
89
+ cut = fwd < raw.length ? fwd : back;
90
+ }
91
+ if (!(cut >= lo && cut < raw.length))
92
+ return undefined;
93
+ // 2. prefer a turn boundary: move back to the user message that started this turn if keeping it whole stays within 2x budget
94
+ let turnStart = -1;
95
+ for (let i = cut; i >= lo; i--)
96
+ if (raw[i].role === "user") {
97
+ turnStart = i;
98
+ break;
99
+ }
100
+ if (raw[cut].role === "user")
101
+ return { index: cut, split: false };
102
+ if (turnStart >= lo) {
103
+ const t = tailStats(turnStart);
104
+ if (t.tokens <= keep * 2 && t.count <= 800 && t.bytes <= 2_500_000)
105
+ return { index: turnStart, split: false };
106
+ }
107
+ // 2b. otherwise the next user message, if the tail from there fits the budget (no turn is split; that message stays verbatim)
108
+ let nextUser = -1;
109
+ for (let i = cut + 1; i < raw.length; i++)
110
+ if (raw[i].role === "user") {
111
+ nextUser = i;
112
+ break;
113
+ }
114
+ if (nextUser > 0) {
115
+ const t = tailStats(nextUser);
116
+ if (t.tokens <= keep && t.count <= 400 && t.bytes <= 1_500_000)
117
+ return { index: nextUser, split: false };
118
+ }
119
+ // 3. one huge turn: cut inside it and keep the request that started it
120
+ let request;
121
+ for (let i = cut - 1; i >= session.keepFrom; i--) {
122
+ const m = raw[i];
123
+ if (m.role === "user" && !String(m.content).startsWith("[OAKODE notice]")) {
124
+ request = m.content;
125
+ break;
126
+ }
127
+ }
128
+ request ??= session.request; // the turn began before an earlier compaction
129
+ return { index: cut, split: true, request: request ? clipMiddle(request, 6000) : undefined };
130
+ }
131
+ const clipMiddle = (s, n) => (s.length <= n ? s : s.slice(0, n / 2) + "\n…[middle omitted]…\n" + s.slice(-n / 2));
132
+ // ---------------------------------------------------------------- summarizing
133
+ const SECTIONS = `Use these sections:
134
+ ## Goal
135
+ ## Constraints & preferences
136
+ ## Progress (done / in progress)
137
+ ## Key decisions
138
+ ## Important tool results (errors, test output, facts that matter)
139
+ ## Next steps
140
+ Be concrete and preserve exact paths, names, commands and error messages, but stay concise (under 600 words in total). Do not list files read/modified (tracked separately). Output only the summary.`;
141
+ const INITIAL = `Summarize the conversation above so a coding agent can continue the work without it.\n${SECTIONS}`;
142
+ const UPDATE = `Update the previous summary using the new conversation above. Keep everything in the previous summary that still matters, add new progress, decisions and results, and remove what is now obsolete or finished. Do not drop open tasks or constraints.\n${SECTIONS}`;
143
+ function serialize(messages, maxChars) {
144
+ const parts = messages.map((m) => {
145
+ if (m.role === "tool")
146
+ return `[tool result]\n${m.content.length > 3000 ? m.content.slice(0, 1500) + "\n…[trimmed]…\n" + m.content.slice(-1500) : m.content}`;
147
+ if (m.role === "assistant") {
148
+ const calls = (m.tool_calls ?? []).map((c) => `[call ${c.function.name}(${c.function.arguments.slice(0, 600)})]`).join("\n");
149
+ return `[assistant]\n${m.content ?? ""}\n${calls}`.trim();
150
+ }
151
+ return `[${m.role}]\n${m.content}`;
152
+ });
153
+ return clipMiddle(parts.join("\n\n"), maxChars);
154
+ }
155
+ /**
156
+ * The instruction appended to the agent's own request for a cached compaction: the model sees the conversation as it
157
+ * is (not a clipped copy), and the boundary is named by how many recent messages stay.
158
+ */
159
+ export function cachedInstruction(update, firstKept, split) {
160
+ const quote = firstKept?.replace(/\s+/g, " ").trim().slice(0, 120);
161
+ const boundary = quote
162
+ ? `Everything above before the ${split ? "message" : "user message"} that begins «${quote}» is about to be replaced by your summary; that message and everything after it stay verbatim (if unsure where it is, include it in the summary).${split ? " The user's request that started this turn is kept verbatim too." : ""}`
163
+ : "Everything above is about to be replaced by your summary.";
164
+ return (`Context compaction (from OAKODE, not the user). ${boundary} ` +
165
+ `That includes every tool result in it: what you learned from files and commands is lost unless you write it down. ` +
166
+ (update ? "The summary at the start of the conversation is the earlier one: carry over everything in it that still matters, then add what happened since. " : "") +
167
+ `Write a handoff summary for yourself to continue the user's work: the overall goal from the user's first request on (not only the latest question), ` +
168
+ `every request that is still open (a request is done only if its answer was actually given to the user), constraints and preferences, what was done and decided, ` +
169
+ `what each file you read or command you ran showed that you will still need (exact paths, names, signatures, values, commands, errors), and the next steps. ` +
170
+ `Be thorough rather than brief: use the word budget when there is much to keep. Do not call any tools and do not continue the task: reply with the summary only.\n` +
171
+ SECTIONS);
172
+ }
173
+ /** The text of a message, for naming it. */
174
+ const textOf = (m) => (!m ? undefined : typeof m.content === "string" && m.content.trim() ? m.content : m.role === "assistant" && m.tool_calls?.length ? `[call ${m.tool_calls[0].function.name}(${m.tool_calls[0].function.arguments.slice(0, 80)})]` : undefined);
175
+ async function summarize(client, model, previous, old, maxChars, maxTokens, signal) {
176
+ const prompt = previous
177
+ ? `<previous-summary>\n${previous}\n</previous-summary>\n\n<new-conversation>\n${serialize(old, maxChars)}\n</new-conversation>\n\n${UPDATE}`
178
+ : `<conversation>\n${serialize(old, maxChars)}\n</conversation>\n\n${INITIAL}`;
179
+ return client.chat({
180
+ model,
181
+ maxTokens: maxTokens ? Math.min(4096, maxTokens) : undefined,
182
+ signal,
183
+ messages: [
184
+ { role: "system", content: "You write precise summaries of coding sessions." },
185
+ { role: "user", content: prompt },
186
+ ],
187
+ });
188
+ }
189
+ export async function compact(client, session, model, window, signal, aggressive = false, maxTokens,
190
+ /** Sends the agent's next request plus this instruction (a cache hit); undefined when that request would not fit. */
191
+ viaCache, onUsage) {
192
+ const cut = findCut(session, window, aggressive);
193
+ if (!cut)
194
+ return undefined;
195
+ const before = session.estimateTokens();
196
+ const old = repair(session.raw.slice(session.keepFrom, cut.index));
197
+ const counted = (r) => (r.usage && onUsage?.(r.usage, r.model ?? model), r);
198
+ let cached;
199
+ let refused = false;
200
+ if (viaCache) {
201
+ cached = await viaCache(cachedInstruction(!!session.summary, textOf(session.raw[cut.index]), cut.split)).then(counted, (e) => {
202
+ if (signal?.aborted)
203
+ throw e;
204
+ return undefined; // the separate request below still works
205
+ });
206
+ if (cached?.aborted)
207
+ return undefined;
208
+ // a reply that is not a whole summary (a tool call, nothing, cut off) is not used
209
+ if (cached && (cached.tool_calls.length || !cached.content.trim() || cached.finish_reason === "length"))
210
+ (cached = undefined), (refused = true);
211
+ }
212
+ if (cached) {
213
+ session.compact(cached.content.trim(), cut.index, trackFiles(old, session.files), cut.split ? cut.request : undefined);
214
+ return { before, after: session.estimateTokens(), split: cut.split, cached: true };
215
+ }
216
+ let maxChars = Math.min(1_500_000, Math.max(20_000, (window - 20_000) * 3));
217
+ let res = await summarize(client, model, session.summary, old, maxChars, maxTokens, signal).then(counted).catch(async (e) => {
218
+ // Refused as too long: documented as unbilled, so shrinking the input and asking once more is safe.
219
+ if (e instanceof OickiError && e.code === "context_length_exceeded") {
220
+ maxChars = Math.floor(maxChars / 3);
221
+ return summarize(client, model, session.summary, old, maxChars, maxTokens, signal).then(counted);
222
+ }
223
+ throw e;
224
+ });
225
+ if (res.aborted)
226
+ return undefined;
227
+ if (!res.content.trim() || res.finish_reason === "length")
228
+ throw new Error(res.finish_reason === "length" ? "Summarization hit its output limit before finishing; history left untouched." : "Summarization produced no usable summary; history left untouched.");
229
+ session.compact(res.content.trim(), cut.index, trackFiles(old, session.files), cut.split ? cut.request : undefined);
230
+ return { before, after: session.estimateTokens(), split: cut.split, cached: false, refused };
231
+ }
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const VERSION = "0.5.0";
5
+ export const DEFAULT_BASE_URL = "https://oicki.vercel.app";
6
+ /**
7
+ * The OAKODE service's id in OICKI ("Sign in with OICKI" client id; public, not a secret). Its redirect URIs must be
8
+ * http://localhost:8976/callback, :8977/callback and :8978/callback (OICKI rewrites 127.0.0.1 to localhost; the
9
+ * 127.0.0.1 forms are registered too). Registered 2026-10-29; with it,
10
+ * `oakode login` needs no flags. OAKODE_CLIENT_ID or `clientId` in the config override it.
11
+ */
12
+ export const DEFAULT_CLIENT_ID = "74fa0e931cea469f";
13
+ /** First loopback port for the sign-in callback (OICKI matches redirect URIs exactly; the next two are fallbacks). */
14
+ export const DEFAULT_REDIRECT_PORT = 8976;
15
+ export const home = () => process.env.OAKODE_HOME || join(homedir(), ".oakode");
16
+ export function readJson(file) {
17
+ try {
18
+ return JSON.parse(readFileSync(file, "utf8"));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** Atomic write; `secret` files are 0600. */
25
+ export function writeJson(file, data, secret = false) {
26
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
27
+ const tmp = `${file}.${process.pid}.tmp`;
28
+ try {
29
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: secret ? 0o600 : 0o644 });
30
+ if (secret)
31
+ chmodSync(tmp, 0o600);
32
+ renameRetry(tmp, file);
33
+ }
34
+ catch (e) {
35
+ rmSync(tmp, { force: true }); // never leave a copy (of credentials, say) behind
36
+ throw e;
37
+ }
38
+ }
39
+ /**
40
+ * rename(), retried for about a second on Windows, where a virus scanner or indexer briefly holding the new file makes
41
+ * it fail with EPERM/EACCES/EBUSY.
42
+ */
43
+ export function renameRetry(from, to) {
44
+ for (let i = 0;; i++) {
45
+ try {
46
+ return renameSync(from, to);
47
+ }
48
+ catch (e) {
49
+ const code = e.code;
50
+ if (process.platform !== "win32" || i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(code ?? ""))
51
+ throw e;
52
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
53
+ }
54
+ }
55
+ }
56
+ export const configFile = () => join(home(), "config.json");
57
+ export const loadConfig = () => readJson(configFile()) ?? {};
58
+ /** Why config.json is being ignored (it exists but is not a JSON object), or undefined. It is edited by hand. */
59
+ export function configProblem() {
60
+ let text;
61
+ try {
62
+ text = readFileSync(configFile(), "utf8");
63
+ }
64
+ catch {
65
+ return undefined; // no file: defaults
66
+ }
67
+ try {
68
+ const v = JSON.parse(text);
69
+ if (v && typeof v === "object" && !Array.isArray(v))
70
+ return undefined;
71
+ }
72
+ catch { }
73
+ return `${configFile()} is not a valid JSON object, so it is ignored and default settings are used. Fix it (e.g. a trailing comma) or delete it.`;
74
+ }
75
+ /** Saving never replaces a hand-edited file that does not parse: that would silently drop the person's settings. */
76
+ export const saveConfig = (patch) => {
77
+ const problem = configProblem();
78
+ if (problem)
79
+ throw new Error(`Not saved: ${problem}`);
80
+ writeJson(configFile(), { ...loadConfig(), ...patch }, true);
81
+ };
82
+ export const baseUrl = () => (process.env.OAKODE_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
package/dist/diff.js ADDED
@@ -0,0 +1,98 @@
1
+ /** Real unified diffs of proposed file changes (computed from the actual before/after text, never from the model's description). */
2
+ import { structuredPatch } from "diff";
3
+ import { bold, dim, green, red, reveal, truncate, width, wrap } from "./ui.js";
4
+ const CONTEXT = 3;
5
+ /** `oldText` is null for a file that does not exist yet. Both texts use \n line endings. */
6
+ export function makeDiff(path, oldText, newText) {
7
+ const oldStr = oldText ?? "";
8
+ let hunks;
9
+ let coarse = false;
10
+ if (oldStr.length + newText.length < 2_000_000) {
11
+ try {
12
+ const p = structuredPatch(path, path, oldStr, newText, "", "", { context: CONTEXT, timeout: 3000 });
13
+ if (p)
14
+ hunks = p.hunks.map(toHunk);
15
+ }
16
+ catch { }
17
+ }
18
+ if (!hunks) {
19
+ coarse = true;
20
+ hunks = [coarseHunk(oldStr, newText)];
21
+ }
22
+ let added = 0, removed = 0;
23
+ for (const h of hunks)
24
+ for (const l of h.lines)
25
+ l.t === "+" ? added++ : l.t === "-" ? removed++ : 0;
26
+ return { path, isNew: oldText === null, added, removed, hunks, ...(coarse ? { coarse } : {}) };
27
+ }
28
+ function toHunk(h) {
29
+ let o = h.oldStart, n = h.newStart;
30
+ const lines = [];
31
+ for (const raw of h.lines) {
32
+ const c = raw[0];
33
+ if (c === "\\")
34
+ continue; // ""
35
+ const text = raw.slice(1);
36
+ if (c === "+")
37
+ lines.push({ t: "+", text, newNo: n++ });
38
+ else if (c === "-")
39
+ lines.push({ t: "-", text, oldNo: o++ });
40
+ else
41
+ lines.push({ t: " ", text, oldNo: o++, newNo: n++ });
42
+ }
43
+ // unified-diff convention: an empty side starts at the line BEFORE the change (jsdiff reports 1 for an empty file)
44
+ return { oldStart: h.oldLines === 0 ? h.oldStart - 1 : h.oldStart, oldLines: h.oldLines, newStart: h.newLines === 0 ? h.newStart - 1 : h.newStart, newLines: h.newLines, lines };
45
+ }
46
+ function coarseHunk(a, b) {
47
+ const x = a.split("\n"), y = b.split("\n");
48
+ let s = 0;
49
+ while (s < x.length && s < y.length && x[s] === y[s])
50
+ s++;
51
+ let e = 0;
52
+ while (e < x.length - s && e < y.length - s && x[x.length - 1 - e] === y[y.length - 1 - e])
53
+ e++;
54
+ const lines = [];
55
+ x.slice(s, x.length - e).forEach((t, i) => lines.push({ t: "-", text: t, oldNo: s + i + 1 }));
56
+ y.slice(s, y.length - e).forEach((t, i) => lines.push({ t: "+", text: t, newNo: s + i + 1 }));
57
+ return { oldStart: s + 1, oldLines: x.length - s - e, newStart: s + 1, newLines: y.length - s - e, lines };
58
+ }
59
+ /** Colored unified diff for `cols` columns. At most `maxLines` lines are returned; `hidden` counts what was cut. */
60
+ export function renderDiff(d, cols, maxLines = Infinity) {
61
+ const maxNo = Math.max(1, ...d.hunks.flatMap((h) => h.lines.map((l) => l.newNo ?? l.oldNo ?? 0)));
62
+ const gw = String(maxNo).length;
63
+ const out = [];
64
+ out.push(`${bold(reveal(d.path))} ${green("+" + d.added)} ${red("−" + d.removed)}${d.isNew ? dim(" (new file)") : ""}${d.coarse ? dim(" (large file: approximate diff)") : ""}`);
65
+ for (const h of d.hunks) {
66
+ out.push(dim(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`));
67
+ for (const l of h.lines) {
68
+ const no = String(l.t === "-" ? l.oldNo : l.newNo).padStart(gw);
69
+ const sign = l.t === "+" ? "+" : l.t === "-" ? "−" : " ";
70
+ const style = l.t === "+" ? green : l.t === "-" ? red : dim;
71
+ const bodyW = Math.max(10, cols - gw - 4);
72
+ const pieces = wrap(reveal(l.text), bodyW); // invisible and bidi characters shown, so a diff cannot hide code
73
+ pieces.forEach((p, i) => out.push(style(`${i === 0 ? no : " ".repeat(gw)} ${i === 0 ? sign : " "} `) + (l.t === " " ? dim(p) : style(p))));
74
+ }
75
+ }
76
+ if (d.dropped)
77
+ out.push(dim(`… ${d.dropped} more changed lines not stored`));
78
+ if (out.length > maxLines)
79
+ return { lines: out.slice(0, maxLines).map((l) => truncate(l, cols)), hidden: out.length - maxLines };
80
+ return { lines: out.map((l) => (width(l) > cols ? truncate(l, cols) : l)), hidden: 0 };
81
+ }
82
+ /** Bound what is stored in the session file: at most `max` diff lines per change. */
83
+ export function capDiff(d, max = 2000) {
84
+ let left = max;
85
+ let dropped = 0;
86
+ const hunks = [];
87
+ for (const h of d.hunks) {
88
+ if (left <= 0) {
89
+ dropped += h.lines.length;
90
+ continue;
91
+ }
92
+ const keep = h.lines.slice(0, left);
93
+ dropped += h.lines.length - keep.length;
94
+ left -= keep.length;
95
+ hunks.push({ ...h, lines: keep });
96
+ }
97
+ return dropped ? { ...d, hunks, dropped } : d;
98
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,106 @@
1
+ /** `oakode doctor`: checks the installation, credentials and connection. Every request it makes is free. */
2
+ import { allowedModel, experimentalModels } from "./models.js";
3
+ import { configProblem } from "./config.js";
4
+ import { statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { billingLines } from "./account.js";
7
+ import { clientIdSetting, isRemoteSession } from "./auth.js";
8
+ import { VERSION, baseUrl, home, loadConfig } from "./config.js";
9
+ import { trustedExecutable } from "./fileio.js";
10
+ import { shell } from "./shell.js";
11
+ import { bold, dim, green, red, yellow } from "./ui.js";
12
+ import { updateCheckEnabled, updateNotice } from "./update.js";
13
+ const mark = { ok: green("✓"), warn: yellow("!"), fail: red("✗") };
14
+ export async function doctor(auth, client, cwd) {
15
+ let failed = false;
16
+ const line = (r, what, detail = "") => {
17
+ if (r === "fail")
18
+ failed = true;
19
+ console.log(`${mark[r]} ${what}${detail ? dim(` ${detail}`) : ""}`);
20
+ };
21
+ console.log(bold(`OAKODE ${VERSION} doctor`));
22
+ const major = Number(process.versions.node.split(".")[0]);
23
+ line(major >= 22 ? "ok" : "fail", `Node.js ${process.versions.node}`, major >= 22 ? "" : "OAKODE needs Node.js 22 or newer");
24
+ line("ok", `Platform ${process.platform} ${process.arch}`, process.stdout.isTTY ? `terminal ${process.stdout.columns}x${process.stdout.rows}, TERM=${process.env.TERM ?? "?"}` : "not a terminal");
25
+ // data directory and credentials file
26
+ const dir = home();
27
+ try {
28
+ const st = statSync(dir);
29
+ const loose = process.platform !== "win32" && (st.mode & 0o077) !== 0;
30
+ line(loose ? "warn" : "ok", `Data directory ${dir}`, loose ? `readable by other users (mode ${(st.mode & 0o777).toString(8)}); run: chmod 700 "${dir}"` : "");
31
+ }
32
+ catch {
33
+ line("ok", `Data directory ${dir}`, "not created yet");
34
+ }
35
+ const cp = configProblem();
36
+ if (cp)
37
+ line("fail", "config.json cannot be read", cp);
38
+ try {
39
+ const st = statSync(join(dir, "auth.json"));
40
+ if (process.platform !== "win32" && (st.mode & 0o077))
41
+ line("fail", "Credentials file is readable by other users", `run: chmod 600 "${join(dir, "auth.json")}"`);
42
+ }
43
+ catch { }
44
+ // credentials
45
+ const src = auth.source();
46
+ if (src === "none")
47
+ line("fail", "Not signed in", "run `oakode login` (or `oakode login --api-key`)");
48
+ else if (src === "oauth") {
49
+ const exp = auth.expiresAt() ?? 0;
50
+ line("ok", `Signed in with OICKI`, `scopes: ${auth.scopes().join(" ")}; access token ${exp > Date.now() ? "valid" : "expired (refreshed on next use)"}`);
51
+ if (!auth.scopes().includes("billing:read"))
52
+ line("warn", "Credits are not visible", "the sign-in did not include billing:read; run `oakode login` again to grant it");
53
+ }
54
+ else
55
+ line("ok", `API key ${src === "env" ? "from OICKI_API_KEY" : "stored"}`, "credits are not visible with an API key");
56
+ const server = auth.storedServer();
57
+ if (src !== "env" && server && server.replace(/\/+$/, "") !== baseUrl())
58
+ line("fail", `Saved sign-in is for ${server}`, `but OAKODE is using ${baseUrl()}`);
59
+ // connection: health is unauthenticated, models proves the credentials
60
+ const t0 = Date.now();
61
+ try {
62
+ const r = await fetch(`${baseUrl()}/api/health`, { signal: AbortSignal.timeout(10_000) });
63
+ line(r.ok ? "ok" : "fail", `OICKI reachable at ${baseUrl()}`, `HTTP ${r.status}, ${Date.now() - t0} ms`);
64
+ }
65
+ catch (e) {
66
+ line("fail", `Cannot reach ${baseUrl()}`, e.message);
67
+ }
68
+ if (src !== "none") {
69
+ try {
70
+ const models = await client.listModels(AbortSignal.timeout(20_000));
71
+ const want = loadConfig().model ?? "auto";
72
+ line("ok", `Credentials accepted; ${models.length} models available`);
73
+ if (!models.some((m) => m.id === want))
74
+ line("warn", `Default model "${want}" is not in the list`, "choose another with /model");
75
+ else if (!allowedModel(want))
76
+ line("warn", `Default model "${want}" is not a supported model; new sessions use auto`, "choose one with /model, or allow it with \"experimentalModels\": true in config.json");
77
+ if (experimentalModels())
78
+ line("warn", "Experimental models are on", "models outside the supported list can be chosen; they are unsupported");
79
+ }
80
+ catch (e) {
81
+ line("fail", "Credentials not accepted or model list unavailable", e.message);
82
+ }
83
+ if (auth.kind() === "oauth") {
84
+ try {
85
+ const b = await client.billing(AbortSignal.timeout(10_000));
86
+ for (const l of billingLines(b, "oauth"))
87
+ console.log(` ${l}`);
88
+ if (b && (b.state === "exhausted" || b.state === "no_plan" || b.state === "past_due"))
89
+ line("fail", "The account cannot pay for requests", b.headline);
90
+ }
91
+ catch (e) {
92
+ line("warn", "Could not read credits", e.message);
93
+ }
94
+ }
95
+ }
96
+ // sign-in and tools
97
+ if (src !== "env")
98
+ line(clientIdSetting() ? "ok" : "warn", "Browser sign-in", clientIdSetting() ? (isRemoteSession() ? "remote session: `oakode login` prints the link and an ssh -L hint" : "available") : "no OICKI client id in this build; use `oakode login --api-key`");
99
+ line(trustedExecutable("git", cwd) ? "ok" : "warn", "git", trustedExecutable("git", cwd) ? "" : "not found: search cannot apply .gitignore");
100
+ line(trustedExecutable("python3", cwd) ? "ok" : "warn", "python3", trustedExecutable("python3", cwd) ? "" : "not found: Python files are not syntax-checked after edits");
101
+ const sh = shell();
102
+ line(sh.posix ? "ok" : "warn", `Commands run with ${sh.name}`, sh.posix ? "" : "models write POSIX shell commands; install Git for Windows for bash");
103
+ const n = updateCheckEnabled() ? updateNotice() : undefined;
104
+ line(n ? "warn" : "ok", n ?? "No newer version known", updateCheckEnabled() ? "" : "update check off (CI, not a terminal, or OAKODE_NO_UPDATE_CHECK)");
105
+ return !failed;
106
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ import { OickiError } from "./oicki.js";
2
+ import { dim, red, yellow } from "./ui.js";
3
+ /** One user-facing description of any error: what happened, what to do, and the request id for support. */
4
+ export function explain(e) {
5
+ if (e instanceof OickiError) {
6
+ return [red(`Error (${e.code}${e.status ? `, HTTP ${e.status}` : ""}): ${e.message}`), e.hint && yellow(e.hint), e.requestId && dim(`request id: ${e.requestId}`)]
7
+ .filter(Boolean)
8
+ .join("\n");
9
+ }
10
+ // fetch() only says "fetch failed"; the reason (DNS, refused, TLS, proxy) is in `cause`
11
+ const cause = e?.cause;
12
+ if (e?.message === "fetch failed")
13
+ return [red(`Error: could not reach the server${cause ? ` (${cause.code ?? cause.message})` : ""}.`), yellow("Check your connection or proxy; `oakode doctor` tests it.")].join("\n");
14
+ return red(`Error: ${e.message}`);
15
+ }