oakode 0.0.0-stage → 0.5.1
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/CHANGELOG.md +93 -0
- package/LICENSE +9 -0
- package/README.md +245 -2
- package/SECURITY.md +13 -0
- package/dist/account.js +34 -0
- package/dist/agent.js +584 -0
- package/dist/agents/approval.js +116 -0
- package/dist/agents/budget.js +293 -0
- package/dist/agents/child.js +103 -0
- package/dist/agents/host.js +289 -0
- package/dist/agents/orchestrator.js +286 -0
- package/dist/agents/plan.js +143 -0
- package/dist/agents/types.js +20 -0
- package/dist/agents/verify.js +94 -0
- package/dist/app.js +619 -0
- package/dist/auth.js +456 -0
- package/dist/brief.js +97 -0
- package/dist/commands.js +588 -0
- package/dist/compact.js +231 -0
- package/dist/config.js +87 -0
- package/dist/diff.js +98 -0
- package/dist/doctor.js +106 -0
- package/dist/errors.js +15 -0
- package/dist/fileio.js +688 -0
- package/dist/input.js +244 -0
- package/dist/jobs.js +262 -0
- package/dist/main.js +560 -0
- package/dist/mask.js +63 -0
- package/dist/mcp.js +721 -0
- package/dist/models.js +106 -0
- package/dist/modes.js +114 -0
- package/dist/oicki.js +475 -0
- package/dist/output.js +101 -0
- package/dist/permissions.js +389 -0
- package/dist/permissions_ui.js +134 -0
- package/dist/print.js +76 -0
- package/dist/proc.js +63 -0
- package/dist/runners.js +264 -0
- package/dist/serial.js +9 -0
- package/dist/session.js +452 -0
- package/dist/shell.js +40 -0
- package/dist/skills.js +335 -0
- package/dist/syntax.js +80 -0
- package/dist/tools.js +480 -0
- package/dist/tui/editor.js +279 -0
- package/dist/tui/highlight.js +78 -0
- package/dist/tui/markdown.js +254 -0
- package/dist/tui/screen.js +146 -0
- package/dist/tui/tui.js +505 -0
- package/dist/tui/view.js +337 -0
- package/dist/ui.js +105 -0
- package/dist/update.js +64 -0
- package/package.json +57 -4
package/dist/tui/view.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { INTERRUPTED } from "../session.js";
|
|
2
|
+
import { bold, cyan, dim, gray, green, red, safeText, truncate, visible, width, wrap, yellow } from "../ui.js";
|
|
3
|
+
import { MarkdownStream } from "./markdown.js";
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
export function fmtDuration(ms) {
|
|
6
|
+
const s = ms / 1000;
|
|
7
|
+
if (s < 10)
|
|
8
|
+
return `${s.toFixed(1)}s`;
|
|
9
|
+
if (s < 60)
|
|
10
|
+
return `${Math.round(s)}s`;
|
|
11
|
+
return `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s`;
|
|
12
|
+
}
|
|
13
|
+
/** One line of live command output as shown: only the last redraw of a \r progress line, control characters visible. */
|
|
14
|
+
/** A one-line summary of a read, grep, glob or ls result ("58 lines", "12 matches in 3 files"), or undefined to show it as before. */
|
|
15
|
+
export function explored(name, result) {
|
|
16
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : w.endsWith("ch") ? "es" : "s"}`;
|
|
17
|
+
if (name === "grep") {
|
|
18
|
+
if (/^No matches/.test(result))
|
|
19
|
+
return "no matches";
|
|
20
|
+
const m = /\[(\d+) match(?:es)? in (\d+) files?/.exec(result);
|
|
21
|
+
return m ? `${plural(Number(m[1]), "match")} in ${plural(Number(m[2]), "file")}` : undefined;
|
|
22
|
+
}
|
|
23
|
+
if (name === "read") {
|
|
24
|
+
const lines = (result.match(/^ *\d+\t/gm) ?? []).length;
|
|
25
|
+
if (!lines)
|
|
26
|
+
return undefined; // an image, an empty file or a note: shown as before
|
|
27
|
+
const files = (result.match(/^==> .* <==$/gm) ?? []).length;
|
|
28
|
+
return files > 1 ? `${plural(files, "file")}, ${plural(lines, "line")}` : plural(lines, "line");
|
|
29
|
+
}
|
|
30
|
+
if (name === "glob")
|
|
31
|
+
return /^No files match/.test(result) ? "no matches" : plural(result.split("\n").filter((l) => l.trim() && !/^[[(]/.test(l)).length, "file");
|
|
32
|
+
if (name === "ls")
|
|
33
|
+
return plural(result.split("\n").filter((l) => l.trim() && !/^[[(]/.test(l)).length, "entry").replace("entrys", "entries");
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const liveLine = (l) => visible(l.includes("\r") ? l.slice(l.lastIndexOf("\r", l.length - 2) + 1).replace(/\r$/, "") : l);
|
|
37
|
+
export class ChatView {
|
|
38
|
+
host;
|
|
39
|
+
stream = new MarkdownStream();
|
|
40
|
+
liveStream = [];
|
|
41
|
+
/** Text arrived since the last render: re-render once per painted frame, not once per token. */
|
|
42
|
+
streamDirty = false;
|
|
43
|
+
tools = new Map();
|
|
44
|
+
turnStart = 0;
|
|
45
|
+
label = "";
|
|
46
|
+
active = false;
|
|
47
|
+
waitingFor = "";
|
|
48
|
+
ticker;
|
|
49
|
+
lastKind = "none";
|
|
50
|
+
/** While restoring history, output is collected here and committed in one piece. */
|
|
51
|
+
sink = null;
|
|
52
|
+
/** Extra status lines shown above the spinner while a turn runs (subagents). */
|
|
53
|
+
extraLive;
|
|
54
|
+
constructor(host) {
|
|
55
|
+
this.host = host;
|
|
56
|
+
}
|
|
57
|
+
out(lines) {
|
|
58
|
+
if (this.sink)
|
|
59
|
+
this.sink.push(...lines);
|
|
60
|
+
else
|
|
61
|
+
this.host.print(lines);
|
|
62
|
+
}
|
|
63
|
+
get busy() {
|
|
64
|
+
return this.active;
|
|
65
|
+
}
|
|
66
|
+
beginTurn() {
|
|
67
|
+
this.active = true;
|
|
68
|
+
this.turnStart = Date.now();
|
|
69
|
+
this.label = "Thinking…";
|
|
70
|
+
this.ticker = setInterval(() => this.host.invalidate(), 100);
|
|
71
|
+
this.ticker.unref(); // the spinner never keeps the process alive
|
|
72
|
+
this.host.invalidate();
|
|
73
|
+
}
|
|
74
|
+
endTurn() {
|
|
75
|
+
this.finishStream();
|
|
76
|
+
this.tools.clear();
|
|
77
|
+
this.active = false;
|
|
78
|
+
clearInterval(this.ticker);
|
|
79
|
+
this.ticker = undefined;
|
|
80
|
+
this.host.invalidate();
|
|
81
|
+
}
|
|
82
|
+
gap(kind) {
|
|
83
|
+
if (this.lastKind !== "none" && (this.lastKind !== kind || kind === "user"))
|
|
84
|
+
this.out([""]);
|
|
85
|
+
this.lastKind = kind;
|
|
86
|
+
}
|
|
87
|
+
/** While a menu is open the agent is idle, waiting for the user: say so instead of animating. */
|
|
88
|
+
setWaiting(what) {
|
|
89
|
+
this.waitingFor = what;
|
|
90
|
+
this.host.invalidate();
|
|
91
|
+
}
|
|
92
|
+
/** Echo of what the user sent. */
|
|
93
|
+
user(text) {
|
|
94
|
+
this.finishStream(); // a queued message delivered mid-turn comes after the answer it followed
|
|
95
|
+
this.gap("user");
|
|
96
|
+
const cols = this.host.cols();
|
|
97
|
+
const lines = text.split("\n").flatMap((l) => wrap(l, cols - 2));
|
|
98
|
+
this.out(lines.map((l, i) => (i === 0 ? bold(cyan("› ")) : " ") + bold(l)));
|
|
99
|
+
}
|
|
100
|
+
note(text, kind = "info") {
|
|
101
|
+
this.gap("note");
|
|
102
|
+
const style = kind === "warn" ? yellow : dim;
|
|
103
|
+
this.out(text.split("\n").flatMap((l) => wrap(l, this.host.cols())).map((l) => style(l)));
|
|
104
|
+
}
|
|
105
|
+
/** Lines printed as they are (diffs, listings), with a leading gap. */
|
|
106
|
+
block(lines) {
|
|
107
|
+
this.gap("note");
|
|
108
|
+
this.out(lines);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Draw a saved conversation with the same components as live output. Nothing is executed: tool calls and results are
|
|
112
|
+
* only displayed. `summarize` turns a tool call into the one-line description (unknown tools get a generic one).
|
|
113
|
+
*/
|
|
114
|
+
replay(messages, summarize) {
|
|
115
|
+
this.sink = [];
|
|
116
|
+
let shown = 0;
|
|
117
|
+
const pending = new Map();
|
|
118
|
+
const flushPending = () => {
|
|
119
|
+
for (const [, summary] of pending)
|
|
120
|
+
this.commitTool(summary, INTERRUPTED, true, 0);
|
|
121
|
+
pending.clear();
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
for (const m of messages) {
|
|
125
|
+
if (m.role === "tool") {
|
|
126
|
+
const summary = pending.get(m.tool_call_id);
|
|
127
|
+
if (summary === undefined)
|
|
128
|
+
continue; // orphan (its call was compacted away or never saved)
|
|
129
|
+
pending.delete(m.tool_call_id);
|
|
130
|
+
const text = String(m.content ?? "");
|
|
131
|
+
this.commitTool(summary, text, text === INTERRUPTED || /^Error: /.test(text), 0);
|
|
132
|
+
shown++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
flushPending();
|
|
136
|
+
if (m.role === "user") {
|
|
137
|
+
const text = String(m.content ?? "");
|
|
138
|
+
if (text.startsWith("[OAKODE notice]"))
|
|
139
|
+
this.note(text.replace("[OAKODE notice] ", "↳ "), "info");
|
|
140
|
+
else if (text.startsWith("[Summary of the earlier conversation"))
|
|
141
|
+
this.note("↳ (a compaction summary stands in for earlier history here)");
|
|
142
|
+
else
|
|
143
|
+
this.user(text);
|
|
144
|
+
shown++;
|
|
145
|
+
}
|
|
146
|
+
else if (m.role === "assistant") {
|
|
147
|
+
if (m.content) {
|
|
148
|
+
this.gap("text");
|
|
149
|
+
const md = new MarkdownStream();
|
|
150
|
+
md.push(safeText(String(m.content)));
|
|
151
|
+
this.out(md.finish(this.host.cols()));
|
|
152
|
+
}
|
|
153
|
+
for (const c of m.tool_calls ?? []) {
|
|
154
|
+
let args = {};
|
|
155
|
+
try {
|
|
156
|
+
args = JSON.parse(c.function.arguments);
|
|
157
|
+
}
|
|
158
|
+
catch { }
|
|
159
|
+
let summary;
|
|
160
|
+
try {
|
|
161
|
+
summary = summarize(c.function.name, args);
|
|
162
|
+
}
|
|
163
|
+
catch { }
|
|
164
|
+
pending.set(c.id, summary ?? `${c.function.name} ${truncate(visible(c.function.arguments), 80)}`);
|
|
165
|
+
}
|
|
166
|
+
shown++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
flushPending();
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
const lines = this.sink;
|
|
173
|
+
this.sink = null;
|
|
174
|
+
if (lines.length)
|
|
175
|
+
this.host.print(lines);
|
|
176
|
+
}
|
|
177
|
+
return shown;
|
|
178
|
+
}
|
|
179
|
+
// ------------------------------------------------------------------ events
|
|
180
|
+
onEvent(e) {
|
|
181
|
+
switch (e.type) {
|
|
182
|
+
case "text": {
|
|
183
|
+
if (this.stream.empty && !this.liveStream.length)
|
|
184
|
+
this.gap("text");
|
|
185
|
+
this.label = "Writing…";
|
|
186
|
+
this.stream.push(safeText(e.delta));
|
|
187
|
+
this.streamDirty = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "tool_start": {
|
|
191
|
+
this.finishStream();
|
|
192
|
+
this.tools.set(e.call.id, { summary: e.summary, start: Date.now(), tail: [], out: [], partial: "" });
|
|
193
|
+
this.label = "Working…";
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "tool_output": {
|
|
197
|
+
// output chunks are not tied to a call id: they belong to the tool running right now (tools run one at a time)
|
|
198
|
+
const t = [...this.tools.values()].at(-1);
|
|
199
|
+
if (t) {
|
|
200
|
+
// raw text first, cleaned when shown: a progress bar's \r redraws keep only the last one (instead of
|
|
201
|
+
// turning into \uFFFD marks), and a line that never ends is capped instead of growing without bound
|
|
202
|
+
const parts = (t.partial + e.chunk.replace(/\r\n/g, "\n")).split("\n");
|
|
203
|
+
t.partial = (parts.pop() ?? "").slice(-4096);
|
|
204
|
+
t.out.push(...parts.map(liveLine));
|
|
205
|
+
if (t.out.length > 400)
|
|
206
|
+
t.out.splice(0, t.out.length - 400);
|
|
207
|
+
t.tail = [...t.out, liveLine(t.partial)].filter((l) => l.trim()).slice(-4);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "tool_end": {
|
|
212
|
+
const t = this.tools.get(e.call.id);
|
|
213
|
+
this.tools.delete(e.call.id);
|
|
214
|
+
this.commitTool(t?.summary ?? e.call.function.name, e.result, e.isError, t ? Date.now() - t.start : 0);
|
|
215
|
+
this.label = "Thinking…";
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "compacting":
|
|
219
|
+
this.finishStream();
|
|
220
|
+
this.label = "Compacting the conversation…";
|
|
221
|
+
break;
|
|
222
|
+
case "compacted":
|
|
223
|
+
this.note(`✓ Compacted: ~${e.before} → ~${e.after} tokens.${e.split ? " A long turn was split; its request is kept verbatim." : ""} The full history stays in the session file.`);
|
|
224
|
+
this.label = "Thinking…";
|
|
225
|
+
break;
|
|
226
|
+
case "notice":
|
|
227
|
+
this.finishStream();
|
|
228
|
+
this.note(e.text, "warn");
|
|
229
|
+
break;
|
|
230
|
+
case "reasoning":
|
|
231
|
+
this.label = "Reasoning…";
|
|
232
|
+
break;
|
|
233
|
+
case "usage":
|
|
234
|
+
this.label = "Thinking…";
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
this.host.invalidate();
|
|
238
|
+
}
|
|
239
|
+
// ------------------------------------------------------------------ streaming markdown
|
|
240
|
+
budget() {
|
|
241
|
+
return Math.max(3, this.host.maxLive() - 14);
|
|
242
|
+
}
|
|
243
|
+
pullStream() {
|
|
244
|
+
this.streamDirty = false;
|
|
245
|
+
const { commit, live } = this.stream.pull(this.host.cols(), this.budget());
|
|
246
|
+
if (commit.length)
|
|
247
|
+
this.out(commit);
|
|
248
|
+
this.liveStream = live;
|
|
249
|
+
}
|
|
250
|
+
/** The assistant message (or the part of it before a tool call) is complete: commit whatever is pending. */
|
|
251
|
+
finishStream() {
|
|
252
|
+
this.streamDirty = false;
|
|
253
|
+
if (this.stream.empty && !this.liveStream.length)
|
|
254
|
+
return;
|
|
255
|
+
const rest = this.stream.finish(this.host.cols());
|
|
256
|
+
if (rest.length)
|
|
257
|
+
this.out(rest);
|
|
258
|
+
this.liveStream = [];
|
|
259
|
+
}
|
|
260
|
+
// ------------------------------------------------------------------ tools
|
|
261
|
+
commitTool(summary, result, isError, ms) {
|
|
262
|
+
this.gap("tool");
|
|
263
|
+
const cols = this.host.cols();
|
|
264
|
+
const sp = summary.indexOf(" ");
|
|
265
|
+
const name = sp < 0 ? summary : summary.slice(0, sp);
|
|
266
|
+
const rest = sp < 0 ? "" : summary.slice(sp + 1);
|
|
267
|
+
const dur = ms >= 1000 ? dim(` (${fmtDuration(ms)})`) : "";
|
|
268
|
+
const isShell = name === "$";
|
|
269
|
+
// a command's outcome belongs in its header: a failed one must not look like a success
|
|
270
|
+
let body = visible(result.replace(/\s+$/, "")).split("\n");
|
|
271
|
+
let outcome = "";
|
|
272
|
+
let failed = isError;
|
|
273
|
+
if (isShell && !isError) {
|
|
274
|
+
const last = body.at(-1) ?? "";
|
|
275
|
+
const code = /\[exit code ([^\]]+)\]/.exec(last)?.[1];
|
|
276
|
+
const stopped = /\[(timed out after [^\]]+|interrupted by user)\]/.exec(last)?.[1];
|
|
277
|
+
if (/^\[failed to start: /.test(last))
|
|
278
|
+
(outcome = red(" failed to start")), (failed = true);
|
|
279
|
+
else if (code !== undefined || stopped) {
|
|
280
|
+
const tail = last.replace(/\s*\[exit code [^\]]+\]\s*/, " ").replace(/\[(timed out after [^\]]+|interrupted by user)\]/, "").trim();
|
|
281
|
+
body = body.slice(0, -1).concat(tail ? [tail] : []);
|
|
282
|
+
if (stopped)
|
|
283
|
+
outcome = yellow(` ${stopped}`);
|
|
284
|
+
else if (code !== "0")
|
|
285
|
+
(outcome = red(` exit ${code}`)), (failed = true);
|
|
286
|
+
if (!body.some((l) => l.trim()))
|
|
287
|
+
body = [dim("(no output)")];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const head = `${failed ? red("●") : outcome ? yellow("●") : green("●")} ${bold(name)} ${rest ? gray(visible(rest)) : ""}${outcome}${dur}`;
|
|
291
|
+
// reads and searches: what was looked at and how much, not the contents (the model has them; the transcript stays short)
|
|
292
|
+
const seen = isError ? undefined : explored(name, result);
|
|
293
|
+
if (seen !== undefined)
|
|
294
|
+
return void this.out([truncate(`${head}${dim(` (${seen})`)}`, cols)]);
|
|
295
|
+
const lines = [truncate(head, cols)];
|
|
296
|
+
const keep = isError ? 4 : isShell ? 12 : name === "edit" || name === "write" ? 1 : 4;
|
|
297
|
+
const shown = isShell || isError ? (body.length > keep ? body.slice(-keep) : body) : body.slice(0, keep);
|
|
298
|
+
const hidden = body.length - shown.length;
|
|
299
|
+
if (hidden > 0 && isShell && !isError)
|
|
300
|
+
lines.push(dim(` … ${hidden} earlier line${hidden > 1 ? "s" : ""}`));
|
|
301
|
+
for (const l of shown)
|
|
302
|
+
lines.push(" " + (isError ? red(truncate(l, cols - 2)) : dim(truncate(l, cols - 2))));
|
|
303
|
+
if (hidden > 0 && !(isShell && !isError))
|
|
304
|
+
lines.push(dim(` … ${hidden} more line${hidden > 1 ? "s" : ""} (sent to the model)`));
|
|
305
|
+
this.out(lines);
|
|
306
|
+
}
|
|
307
|
+
// ------------------------------------------------------------------ live area
|
|
308
|
+
/** Streaming answer, running tools and the status line. */
|
|
309
|
+
live(cols, maxRows) {
|
|
310
|
+
if (!this.active)
|
|
311
|
+
return [];
|
|
312
|
+
if (this.streamDirty)
|
|
313
|
+
this.pullStream(); // its finished lines print above this frame (Screen writes them first)
|
|
314
|
+
const out = [...this.liveStream.map((l) => truncate(l, cols))];
|
|
315
|
+
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
if (this.waitingFor) {
|
|
318
|
+
out.push(truncate(`${yellow("●")} ${bold("Waiting for you")} ${dim(this.waitingFor)}`, cols));
|
|
319
|
+
return out.length > maxRows ? out.slice(out.length - maxRows) : out;
|
|
320
|
+
}
|
|
321
|
+
if (this.tools.size) {
|
|
322
|
+
out.push("");
|
|
323
|
+
for (const t of this.tools.values()) {
|
|
324
|
+
out.push(truncate(`${cyan(frame)} ${bold("Running")} ${gray(t.summary)} ${dim(fmtDuration(now - t.start))}`, cols));
|
|
325
|
+
for (const l of t.tail.slice(-3))
|
|
326
|
+
out.push(truncate(dim(" " + l), cols));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const extra = this.extraLive?.(cols) ?? [];
|
|
330
|
+
if (extra.length)
|
|
331
|
+
out.push("", ...extra);
|
|
332
|
+
out.push(truncate(`${cyan(frame)} ${this.label} ${dim(`(${fmtDuration(now - this.turnStart)} · esc to interrupt)`)}`, cols));
|
|
333
|
+
// keep the status line and tools; if over budget the oldest streaming lines scroll out of the live area
|
|
334
|
+
return out.length > maxRows ? out.slice(out.length - maxRows) : out;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export { width };
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Colors and small formatting helpers shared by the terminal UI. */
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
import wrapAnsi from "wrap-ansi";
|
|
4
|
+
/** Colors are on for a TTY unless NO_COLOR is set; FORCE_COLOR overrides (used by tests that inspect styling). */
|
|
5
|
+
const color = !process.env.NO_COLOR && (!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
|
|
6
|
+
const sgr = (open, close = "0") => (s) => (color ? `\x1b[${open}m${s}\x1b[${close}m` : s);
|
|
7
|
+
export const dim = sgr("2", "22");
|
|
8
|
+
export const bold = sgr("1", "22");
|
|
9
|
+
export const italic = sgr("3", "23");
|
|
10
|
+
export const underline = sgr("4", "24");
|
|
11
|
+
export const strike = sgr("9", "29");
|
|
12
|
+
export const red = sgr("31", "39");
|
|
13
|
+
export const green = sgr("32", "39");
|
|
14
|
+
export const yellow = sgr("33", "39");
|
|
15
|
+
export const blue = sgr("34", "39");
|
|
16
|
+
export const magenta = sgr("35", "39");
|
|
17
|
+
export const cyan = sgr("36", "39");
|
|
18
|
+
export const gray = sgr("90", "39");
|
|
19
|
+
/** 256-color foreground (falls back to plain text without color). */
|
|
20
|
+
export const fg = (n) => sgr(`38;5;${n}`, "39");
|
|
21
|
+
export const colorEnabled = color;
|
|
22
|
+
export const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n));
|
|
23
|
+
export function clip(text, maxLines, maxCols = 160) {
|
|
24
|
+
const lines = text.replace(/\s+$/, "").split("\n");
|
|
25
|
+
const shown = lines.slice(0, maxLines).map((l) => (l.length > maxCols ? l.slice(0, maxCols - 1) + "…" : l));
|
|
26
|
+
return shown.join("\n") + (lines.length > maxLines ? `\n… (+${lines.length - maxLines} more lines)` : "");
|
|
27
|
+
}
|
|
28
|
+
export function ago(d, now = Date.now()) {
|
|
29
|
+
const s = Math.max(0, Math.round((now - d.getTime()) / 1000));
|
|
30
|
+
if (s < 60)
|
|
31
|
+
return "just now";
|
|
32
|
+
if (s < 3600)
|
|
33
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
34
|
+
if (s < 86400)
|
|
35
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
36
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
37
|
+
}
|
|
38
|
+
// ---- display width (ANSI-aware; Hangul/CJK/emoji are double width) ----
|
|
39
|
+
export const width = (s) => stringWidth(s);
|
|
40
|
+
const URL_RE = /^(?:\x1b\[[0-9;]*m)*(?:https?|file):\/\/\S/;
|
|
41
|
+
/**
|
|
42
|
+
* Wrap styled text to `cols`, breaking a word only when it is wider than a line. A URL is never broken: it stays one
|
|
43
|
+
* run of characters (the terminal soft-wraps it), so the terminal can still open it.
|
|
44
|
+
*/
|
|
45
|
+
export const wrap = (s, cols) => {
|
|
46
|
+
if (s === "")
|
|
47
|
+
return [""];
|
|
48
|
+
const n = Math.max(1, cols);
|
|
49
|
+
const hard = !s.split(/\s+/).some((w) => URL_RE.test(w) && width(w) > n);
|
|
50
|
+
return wrapAnsi(s, n, { hard, trim: false }).split("\n");
|
|
51
|
+
};
|
|
52
|
+
/** Cut styled text to at most `cols` display columns, adding … when it was cut. */
|
|
53
|
+
export function truncate(s, cols) {
|
|
54
|
+
if (width(s) <= cols)
|
|
55
|
+
return s;
|
|
56
|
+
let out = "";
|
|
57
|
+
let w = 0;
|
|
58
|
+
// walk code points, copying ANSI sequences without counting them
|
|
59
|
+
const re = /\x1b\[[0-9;]*m|[^\x1b]/gu;
|
|
60
|
+
for (const m of s.matchAll(re)) {
|
|
61
|
+
const tok = m[0];
|
|
62
|
+
if (tok.startsWith("\x1b")) {
|
|
63
|
+
out += tok;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const cw = width(tok);
|
|
67
|
+
if (w + cw > cols - 1)
|
|
68
|
+
break;
|
|
69
|
+
out += tok;
|
|
70
|
+
w += cw;
|
|
71
|
+
}
|
|
72
|
+
return out + (colorEnabled ? "\x1b[0m" : "") + "…";
|
|
73
|
+
}
|
|
74
|
+
export const padEnd = (s, cols) => s + " ".repeat(Math.max(0, cols - width(s)));
|
|
75
|
+
export const stripAnsi = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
|
76
|
+
/** Untrusted text (model output, file contents, tool results) must never act on the terminal: escapes become visible symbols. */
|
|
77
|
+
export const visible = (s) => s.replace(/\t/g, " ").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => (c === "\x1b" ? "␛" : "�"));
|
|
78
|
+
/**
|
|
79
|
+
* Invisible or reordering characters ("Trojan Source"): every format character (zero-width, bidi controls and marks,
|
|
80
|
+
* soft hyphen, the BOM, tag characters), line and paragraph separators, blank-looking fillers and variation selectors.
|
|
81
|
+
*/
|
|
82
|
+
const HIDDEN = /[\p{Cf}\p{Zl}\p{Zp}\u115f\u1160\u3164\uffa0\u2800\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu;
|
|
83
|
+
const mark = (c) => `⟨U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}⟩`;
|
|
84
|
+
/** Untrusted text made fully visible: control characters as symbols (visible) and invisible characters as ⟨U+XXXX⟩. */
|
|
85
|
+
export const reveal = (s) => visible(s).replace(HIDDEN, mark);
|
|
86
|
+
/** True when text to be approved holds characters that change how it looks (controls, bidi, zero-width). */
|
|
87
|
+
export const hasHiddenChars = (s) => /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(s.replace(/\r\n/g, "\n")) || new RegExp(HIDDEN.source, "u").test(s);
|
|
88
|
+
/**
|
|
89
|
+
* Text the user is asked to approve (a command, MCP arguments), shown completely: every line wrapped to `cols`, never
|
|
90
|
+
* cut at the width; control, zero-width and bidi characters shown as visible marks; runs of 8+ spaces collapsed to a
|
|
91
|
+
* visible count, so nothing can hide after a wall of blanks.
|
|
92
|
+
*/
|
|
93
|
+
export function approvalText(s, cols) {
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const line of s.replace(/\r\n/g, "\n").split("\n")) {
|
|
96
|
+
// any blank run, including no-break and ideographic spaces, so nothing can be pushed out of view
|
|
97
|
+
const shown = reveal(line).replace(/\s{8,}/g, (m) => `⟨${m.length} blanks⟩`);
|
|
98
|
+
out.push(...wrap(shown, Math.max(10, cols)));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/** Same for streamed text, where newlines and tabs are content (a lone \r would let text overwrite earlier output). */
|
|
103
|
+
export const safeText = (s) => s.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, (c) => (c === "\x1b" ? "␛" : "�"));
|
|
104
|
+
/** Credits with sensible precision (OICKI reports fractions of a credit). */
|
|
105
|
+
export const fmtCredits = (c) => (c >= 100 ? Math.round(c).toLocaleString() : c >= 1 ? c.toFixed(1) : c.toFixed(3));
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update notice: at most once a day, a background request to the npm registry for OAKODE's published versions. The
|
|
3
|
+
* answer is cached and shown at the next start, so startup never waits for the network. Nothing about the user is
|
|
4
|
+
* sent beyond what any HTTP request carries. Off with OAKODE_NO_UPDATE_CHECK=1, in CI, and when not on a terminal.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { VERSION, home, readJson, writeJson } from "./config.js";
|
|
8
|
+
const DAY = 24 * 3600_000;
|
|
9
|
+
const file = () => join(home(), "update-check.json");
|
|
10
|
+
/** -1, 0 or 1. Semver order, including prereleases (1.0.0-rc.1 < 1.0.0-rc.2 < 1.0.0). */
|
|
11
|
+
export function compareVersions(a, b) {
|
|
12
|
+
const parse = (v) => {
|
|
13
|
+
const [core, pre] = v.replace(/^v/, "").split("-", 2);
|
|
14
|
+
return { nums: core.split(".").map((x) => Number(x) || 0), pre: pre?.split(".") };
|
|
15
|
+
};
|
|
16
|
+
const x = parse(a), y = parse(b);
|
|
17
|
+
for (let i = 0; i < 3; i++)
|
|
18
|
+
if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0))
|
|
19
|
+
return (x.nums[i] ?? 0) < (y.nums[i] ?? 0) ? -1 : 1;
|
|
20
|
+
if (!x.pre || !y.pre)
|
|
21
|
+
return x.pre ? -1 : y.pre ? 1 : 0;
|
|
22
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
23
|
+
const p = x.pre[i], q = y.pre[i];
|
|
24
|
+
if (p === undefined || q === undefined)
|
|
25
|
+
return p === undefined ? -1 : 1;
|
|
26
|
+
if (p === q)
|
|
27
|
+
continue;
|
|
28
|
+
const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
|
|
29
|
+
if (pn && qn)
|
|
30
|
+
return Number(p) < Number(q) ? -1 : 1;
|
|
31
|
+
if (pn !== qn)
|
|
32
|
+
return pn ? -1 : 1;
|
|
33
|
+
return p < q ? -1 : 1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
export const updateCheckEnabled = (env = process.env, tty = process.stdout.isTTY === true) => tty && !env.CI && !env.OAKODE_NO_UPDATE_CHECK;
|
|
38
|
+
/** A one-line notice if a newer version is known (from the cache), else undefined. Prereleases follow the `next` tag. */
|
|
39
|
+
export function updateNotice(current = VERSION) {
|
|
40
|
+
const c = readJson(file());
|
|
41
|
+
// the registry's answer is printed and offered as a command: only an exact, short version string is accepted
|
|
42
|
+
const candidates = [c?.latest, current.includes("-") ? c?.next : undefined].filter((v) => typeof v === "string" && v.length <= 40 && /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(v));
|
|
43
|
+
const newest = candidates.sort(compareVersions).at(-1);
|
|
44
|
+
if (!newest || compareVersions(newest, current) <= 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
return `OAKODE ${newest} is available (you have ${current}). Update: npm install -g oakode@${newest}`;
|
|
47
|
+
}
|
|
48
|
+
/** Refreshes the cache in the background when it is older than a day. Never throws, never blocks. */
|
|
49
|
+
export function refreshUpdateCache(fetchImpl = fetch) {
|
|
50
|
+
const c = readJson(file());
|
|
51
|
+
if (c && Date.now() - c.checkedAt < DAY)
|
|
52
|
+
return Promise.resolve();
|
|
53
|
+
return fetchImpl(process.env.OAKODE_REGISTRY_URL ?? "https://registry.npmjs.org/-/package/oakode/dist-tags", { signal: AbortSignal.timeout(5000) })
|
|
54
|
+
.then(async (r) => {
|
|
55
|
+
const tags = r.ok ? (await r.json()) : {};
|
|
56
|
+
writeJson(file(), { checkedAt: Date.now(), latest: tags.latest, next: tags.next });
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
try {
|
|
60
|
+
writeJson(file(), { ...c, checkedAt: Date.now() }); // offline: try again tomorrow
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
});
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oakode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "A coding agent for your terminal, powered by OICKI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"ai",
|
|
8
|
+
"cli",
|
|
9
|
+
"coding-agent",
|
|
10
|
+
"oicki",
|
|
11
|
+
"terminal"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/hjun1052/oakode#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/hjun1052/oakode/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/hjun1052/oakode.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"oakode": "dist/main.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"SECURITY.md"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('dist/main.js', 0o755)\"",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "node --test test/*.test.ts",
|
|
43
|
+
"start": "node src/main.ts",
|
|
44
|
+
"prepack": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"diff": "^9.0.0",
|
|
48
|
+
"highlight.js": "^11.12.0",
|
|
49
|
+
"marked": "^18.1.1",
|
|
50
|
+
"marked-cjk-friendly": "^0.1.3",
|
|
51
|
+
"string-width": "^8.3.0",
|
|
52
|
+
"wrap-ansi": "^10.0.2",
|
|
53
|
+
"yaml": "^2.9.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.0.0",
|
|
57
|
+
"typescript": "^5.9.0"
|
|
58
|
+
}
|
|
59
|
+
}
|