tryharness 0.7.22 → 0.7.25
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/README.md +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/trails.mjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
|
|
5
|
+
// What a coding session actually did, read from the agent's own session file: the person's first request, every tool
|
|
6
|
+
// call in order (the paths it named, the files it changed, commands with their exit codes) and the tokens each model
|
|
7
|
+
// call used. This is the record the context engine learns from (what a run needed against what its brief carried) and
|
|
8
|
+
// the evidence receipts check claims against. Read only; the parsers never decide anything about intent.
|
|
9
|
+
|
|
10
|
+
const PATH_TOKEN =
|
|
11
|
+
/(?:\.{0,2}\/)?[\w@+-][\w@+.-]*(?:\/[\w@+.-]+)+|[\w@+-][\w@+-]*\.[A-Za-z][A-Za-z0-9]{0,7}\b/g;
|
|
12
|
+
const PATCH_FILE = /\*\*\* (?:Update|Add|Delete) File: ([^\n"\\]+)/g;
|
|
13
|
+
const REDIRECT_FILE =
|
|
14
|
+
/(?:^|[\s;&|])(?:>>?|tee(?:\s+-a)?)\s+(["']?)([^\s"'|;&<>]+)\1/g;
|
|
15
|
+
const SED_INPLACE =
|
|
16
|
+
/\bsed\s+(?:-[a-zA-Z]*i[a-zA-Z]*(?:\s+''|\s+"")?|--in-place)\b[^|;&]*?\s(["']?)([^\s"'|;&]+)\1\s*(?:$|[|;&])/gm;
|
|
17
|
+
// Test runners by their command, for receipts: a command's own exit code is the evidence, never the agent's words.
|
|
18
|
+
const TEST_COMMAND =
|
|
19
|
+
/\b(?:npm(?:\s+run)?\s+(?:test|smoke|e2e|eval|lint|typecheck)|npx\s+(?:jest|vitest|tsx\s+tests\/|playwright\s+test)|jest|vitest|pytest|cargo\s+(?:test|check|clippy)|go\s+test|forge\s+test|bun\s+test|deno\s+test|swift\s+test|mvn\s+test|gradlew?\s+test|make\s+(?:test|check)|node\s+(?:--test\b|[\w./-]*\btests?\/)|tsc(?:\s|$))/;
|
|
20
|
+
const EDIT_TOOLS = new Set([
|
|
21
|
+
"Edit",
|
|
22
|
+
"Write",
|
|
23
|
+
"MultiEdit",
|
|
24
|
+
"NotebookEdit",
|
|
25
|
+
"edit",
|
|
26
|
+
"write",
|
|
27
|
+
"apply_patch",
|
|
28
|
+
]);
|
|
29
|
+
const BRIEF_SKIP =
|
|
30
|
+
/^<(?:command-|local-command|system-reminder|task-notification|user-prompt-submit-hook)|^Caveat:/;
|
|
31
|
+
|
|
32
|
+
const parseJson = (line) => {
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(line);
|
|
35
|
+
return value && typeof value === "object" ? value : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const textOf = (content) =>
|
|
41
|
+
typeof content === "string"
|
|
42
|
+
? content
|
|
43
|
+
: Array.isArray(content)
|
|
44
|
+
? content
|
|
45
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join("\n")
|
|
48
|
+
: "";
|
|
49
|
+
const ms = (value) => {
|
|
50
|
+
const at =
|
|
51
|
+
typeof value === "number"
|
|
52
|
+
? value * (value < 1e12 ? 1000 : 1)
|
|
53
|
+
: Date.parse(value);
|
|
54
|
+
return Number.isFinite(at) ? at : null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Path-like words in a tool call's input or output, as written (relative or absolute). */
|
|
58
|
+
export function pathTokens(text) {
|
|
59
|
+
return [...new Set(String(text).match(PATH_TOKEN) || [])]
|
|
60
|
+
.map((token) => token.replace(/[.,:;)\]}'"`]+$/, ""))
|
|
61
|
+
.filter((token) => token.length > 2 && token.length < 300);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Files a tool call changed: edit tools' own paths, patch headers, sed -i, redirects and tee. */
|
|
65
|
+
export function editedPaths(tool, input) {
|
|
66
|
+
const out = new Set();
|
|
67
|
+
if (EDIT_TOOLS.has(tool) && typeof input?.file_path === "string")
|
|
68
|
+
out.add(input.file_path);
|
|
69
|
+
if (EDIT_TOOLS.has(tool) && typeof input === "string") {
|
|
70
|
+
const found = input.match(/"(?:file_path|path)":"([^"]+)"/);
|
|
71
|
+
if (found && !found[1].endsWith("…")) out.add(found[1]);
|
|
72
|
+
}
|
|
73
|
+
if (EDIT_TOOLS.has(tool) && typeof input?.path === "string")
|
|
74
|
+
out.add(input.path);
|
|
75
|
+
if (tool === "NotebookEdit" && typeof input?.notebook_path === "string")
|
|
76
|
+
out.add(input.notebook_path);
|
|
77
|
+
const text =
|
|
78
|
+
typeof input === "string"
|
|
79
|
+
? input
|
|
80
|
+
: JSON.stringify(input ?? "")
|
|
81
|
+
.replace(/\\n/g, "\n")
|
|
82
|
+
.replace(/\\"/g, '"');
|
|
83
|
+
for (const match of text.matchAll(PATCH_FILE)) out.add(match[1].trim());
|
|
84
|
+
for (const match of text.matchAll(SED_INPLACE)) out.add(match[2]);
|
|
85
|
+
for (const match of text.matchAll(REDIRECT_FILE))
|
|
86
|
+
if (!/^\/dev\/|^&\d$/.test(match[2])) out.add(match[2]);
|
|
87
|
+
return [...out];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const isTestCommand = (command) => TEST_COMMAND.test(String(command));
|
|
91
|
+
|
|
92
|
+
function exitCodeOf(text) {
|
|
93
|
+
const found = String(text).match(
|
|
94
|
+
/"?exit_code"?\s*[:=]\s*(-?\d+)|Exit code:?\s*(-?\d+)|Process exited with code (-?\d+)/i
|
|
95
|
+
);
|
|
96
|
+
return found ? Number(found[1] ?? found[2] ?? found[3]) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A session's trail: { agent, sessionId, cwd, brief, startedAt, endedAt, calls, usage, turns }. A call is
|
|
101
|
+
* { at, tool, input (clipped text), named (path words), edited (paths), command, exitCode, error }; usage is one row
|
|
102
|
+
* per model call { at, input, cached, output }; turns are the person's messages and stops, { at, interrupted }. Claude Code and Codex are read in full; other agents fall back to none.
|
|
103
|
+
*/
|
|
104
|
+
export async function readTrail(agent, file) {
|
|
105
|
+
if (!["claude-code", "codex"].includes(agent) || !fs.existsSync(file))
|
|
106
|
+
return null;
|
|
107
|
+
const trail = {
|
|
108
|
+
agent,
|
|
109
|
+
sessionId: null,
|
|
110
|
+
cwd: null,
|
|
111
|
+
brief: null,
|
|
112
|
+
startedAt: null,
|
|
113
|
+
endedAt: null,
|
|
114
|
+
calls: [],
|
|
115
|
+
usage: [],
|
|
116
|
+
turns: [],
|
|
117
|
+
};
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
const add = (at, tool, input, id) => {
|
|
120
|
+
const text =
|
|
121
|
+
typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
122
|
+
const command =
|
|
123
|
+
typeof input?.command === "string"
|
|
124
|
+
? input.command
|
|
125
|
+
: Array.isArray(input?.command)
|
|
126
|
+
? input.command.join(" ")
|
|
127
|
+
: typeof input?.cmd === "string"
|
|
128
|
+
? input.cmd
|
|
129
|
+
: [...text.matchAll(/cmd\s*:\s*"((?:[^"\\]|\\.)*)"/g)]
|
|
130
|
+
.map((match) => match[1])
|
|
131
|
+
.join("\n") || null;
|
|
132
|
+
const call = {
|
|
133
|
+
at,
|
|
134
|
+
tool,
|
|
135
|
+
input: text.slice(0, 4_000),
|
|
136
|
+
named: pathTokens(text),
|
|
137
|
+
edited: editedPaths(tool, input),
|
|
138
|
+
command,
|
|
139
|
+
exitCode: null,
|
|
140
|
+
error: false,
|
|
141
|
+
output: "",
|
|
142
|
+
};
|
|
143
|
+
trail.calls.push(call);
|
|
144
|
+
if (id) byId.set(id, call);
|
|
145
|
+
};
|
|
146
|
+
const result = (id, text, error) => {
|
|
147
|
+
const call = byId.get(id);
|
|
148
|
+
if (!call) return;
|
|
149
|
+
call.output = String(text).slice(0, 4_000);
|
|
150
|
+
call.named = [
|
|
151
|
+
...new Set([...call.named, ...pathTokens(String(text).slice(0, 20_000))]),
|
|
152
|
+
];
|
|
153
|
+
call.exitCode = exitCodeOf(text);
|
|
154
|
+
call.error =
|
|
155
|
+
Boolean(error) || (call.exitCode !== null && call.exitCode !== 0);
|
|
156
|
+
};
|
|
157
|
+
const lines = readline.createInterface({
|
|
158
|
+
input: fs.createReadStream(file, { encoding: "utf8" }),
|
|
159
|
+
crlfDelay: Infinity,
|
|
160
|
+
});
|
|
161
|
+
for await (const line of lines) {
|
|
162
|
+
const row = parseJson(line);
|
|
163
|
+
if (!row) continue;
|
|
164
|
+
const at = ms(row.timestamp ?? row.payload?.timestamp);
|
|
165
|
+
if (at) {
|
|
166
|
+
trail.startedAt ??= at;
|
|
167
|
+
trail.endedAt = at;
|
|
168
|
+
}
|
|
169
|
+
if (agent === "claude-code") {
|
|
170
|
+
trail.sessionId ??= row.sessionId ?? null;
|
|
171
|
+
trail.cwd ??= row.cwd ?? null;
|
|
172
|
+
const content = row.message?.content;
|
|
173
|
+
if (row.type === "user" && !row.isMeta) {
|
|
174
|
+
const words = (
|
|
175
|
+
typeof content === "string"
|
|
176
|
+
? content
|
|
177
|
+
: Array.isArray(content) &&
|
|
178
|
+
!content.some((part) => part?.type === "tool_result")
|
|
179
|
+
? textOf(content)
|
|
180
|
+
: ""
|
|
181
|
+
).trim();
|
|
182
|
+
// The person's turns: each message starts one; Esc leaves "[Request interrupted by user...]" where it stopped one.
|
|
183
|
+
if (words.startsWith("[Request interrupted by user"))
|
|
184
|
+
trail.turns.push({ at, interrupted: true });
|
|
185
|
+
else if (words && !BRIEF_SKIP.test(words)) {
|
|
186
|
+
trail.turns.push({ at, interrupted: false });
|
|
187
|
+
trail.brief ??= words;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(content))
|
|
191
|
+
for (const part of content) {
|
|
192
|
+
if (part?.type === "tool_use")
|
|
193
|
+
add(at, part.name, part.input, part.id);
|
|
194
|
+
if (part?.type === "tool_result")
|
|
195
|
+
result(
|
|
196
|
+
part.tool_use_id,
|
|
197
|
+
textOf(part.content) || JSON.stringify(row.toolUseResult ?? ""),
|
|
198
|
+
part.is_error
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const usage = row.type === "assistant" ? row.message?.usage : null;
|
|
202
|
+
if (usage)
|
|
203
|
+
trail.usage.push({
|
|
204
|
+
at,
|
|
205
|
+
input:
|
|
206
|
+
(usage.input_tokens || 0) +
|
|
207
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
208
|
+
(usage.cache_read_input_tokens || 0),
|
|
209
|
+
cached: usage.cache_read_input_tokens || 0,
|
|
210
|
+
output: usage.output_tokens || 0,
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
const payload = row.payload ?? {};
|
|
214
|
+
if (row.type === "session_meta") {
|
|
215
|
+
trail.sessionId ??= payload.id ?? null;
|
|
216
|
+
trail.cwd ??= payload.cwd ?? null;
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
row.type === "response_item" &&
|
|
220
|
+
payload.type === "message" &&
|
|
221
|
+
payload.role === "user"
|
|
222
|
+
) {
|
|
223
|
+
const words = textOf(payload.content).trim();
|
|
224
|
+
if (
|
|
225
|
+
words &&
|
|
226
|
+
!/^<(?:environment_context|user_instructions|permissions|turn_aborted)/.test(
|
|
227
|
+
words
|
|
228
|
+
) &&
|
|
229
|
+
!words.startsWith("# AGENTS.md")
|
|
230
|
+
) {
|
|
231
|
+
trail.turns.push({ at, interrupted: false });
|
|
232
|
+
trail.brief ??= words;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
row.type === "event_msg" &&
|
|
237
|
+
payload.type === "turn_aborted" &&
|
|
238
|
+
payload.reason === "interrupted"
|
|
239
|
+
)
|
|
240
|
+
trail.turns.push({ at, interrupted: true });
|
|
241
|
+
if (
|
|
242
|
+
row.type === "response_item" &&
|
|
243
|
+
["function_call", "custom_tool_call", "local_shell_call"].includes(
|
|
244
|
+
payload.type
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
const input =
|
|
248
|
+
payload.input ??
|
|
249
|
+
parseJson(payload.arguments) ??
|
|
250
|
+
payload.arguments ??
|
|
251
|
+
payload.action;
|
|
252
|
+
add(at, payload.name ?? payload.type, input, payload.call_id);
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
row.type === "response_item" &&
|
|
256
|
+
["function_call_output", "custom_tool_call_output"].includes(
|
|
257
|
+
payload.type
|
|
258
|
+
)
|
|
259
|
+
) {
|
|
260
|
+
const text =
|
|
261
|
+
typeof payload.output === "string"
|
|
262
|
+
? payload.output
|
|
263
|
+
: textOf(payload.output) || JSON.stringify(payload.output ?? "");
|
|
264
|
+
result(payload.call_id, text, false);
|
|
265
|
+
}
|
|
266
|
+
if (row.type === "token_usage_record" && payload.usage)
|
|
267
|
+
trail.usage.push({
|
|
268
|
+
at,
|
|
269
|
+
input: payload.usage.input_tokens || 0,
|
|
270
|
+
cached: payload.usage.cached_input_tokens || 0,
|
|
271
|
+
output: payload.usage.output_tokens || 0,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return trail;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const RESULT_EDIT =
|
|
279
|
+
/(?:The file (\S+) has been (?:updated|created)|File created successfully at: (\S+?)[\s)\]])/g;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A trail rebuilt from the relay's own copy of a session (store.mjs keeps every message with tool calls clipped), for
|
|
283
|
+
* sessions whose source file the agent has since deleted. It has the brief, the calls with the paths they named and the
|
|
284
|
+
* files they changed (the edit tools' own confirmations carry the whole path), but no token counts or timestamps.
|
|
285
|
+
*/
|
|
286
|
+
export function trailFromTranscript(
|
|
287
|
+
agent,
|
|
288
|
+
transcript,
|
|
289
|
+
{ cwd = null, startedAt = null } = {}
|
|
290
|
+
) {
|
|
291
|
+
const trail = {
|
|
292
|
+
agent,
|
|
293
|
+
sessionId: null,
|
|
294
|
+
cwd,
|
|
295
|
+
brief: null,
|
|
296
|
+
startedAt,
|
|
297
|
+
endedAt: startedAt,
|
|
298
|
+
calls: [],
|
|
299
|
+
usage: [],
|
|
300
|
+
};
|
|
301
|
+
for (const block of String(transcript).split(/\n\n(?=(?:User|Agent): )/)) {
|
|
302
|
+
const user = block.startsWith("User: ");
|
|
303
|
+
const text = block.replace(/^(?:User|Agent): /, "");
|
|
304
|
+
if (
|
|
305
|
+
user &&
|
|
306
|
+
!trail.brief &&
|
|
307
|
+
!text.startsWith("[tool result") &&
|
|
308
|
+
!BRIEF_SKIP.test(text.trim()) &&
|
|
309
|
+
!text.includes("<local-command-caveat>")
|
|
310
|
+
)
|
|
311
|
+
trail.brief = text.trim();
|
|
312
|
+
for (const match of text.matchAll(/\[tool: (\S+) ([\s\S]*?)\](?=\n|$)/g)) {
|
|
313
|
+
trail.calls.push({
|
|
314
|
+
at: null,
|
|
315
|
+
tool: match[1],
|
|
316
|
+
input: match[2],
|
|
317
|
+
named: pathTokens(match[2]),
|
|
318
|
+
edited: editedPaths(match[1], match[2]).filter(
|
|
319
|
+
(file) => !file.endsWith("…")
|
|
320
|
+
),
|
|
321
|
+
command: null,
|
|
322
|
+
exitCode: null,
|
|
323
|
+
error: false,
|
|
324
|
+
output: "",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (user && text.startsWith("[tool result")) {
|
|
328
|
+
const last = trail.calls.at(-1);
|
|
329
|
+
if (!last) continue;
|
|
330
|
+
last.named = [...new Set([...last.named, ...pathTokens(text)])];
|
|
331
|
+
for (const match of text.matchAll(RESULT_EDIT))
|
|
332
|
+
last.edited = [...new Set([...last.edited, match[1] || match[2]])];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return trail;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A path as written in a call, made relative to root when it lies inside it; null when it points elsewhere. */
|
|
339
|
+
export function insideRoot(token, root, cwd = root) {
|
|
340
|
+
const absolute = path.isAbsolute(token)
|
|
341
|
+
? token
|
|
342
|
+
: path.resolve(cwd || root, token);
|
|
343
|
+
const rel = path.relative(root, absolute);
|
|
344
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
345
|
+
? rel.split(path.sep).join("/")
|
|
346
|
+
: null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What a trail shows a run needed, against the files that existed: { edited, read, firstEditAt, beforeFirstEdit }.
|
|
351
|
+
* `read` is every existing file the run named before its first edit (opened, listed, grepped into); `beforeFirstEdit`
|
|
352
|
+
* counts the calls and tokens spent before that edit, which is what a better brief can save.
|
|
353
|
+
*/
|
|
354
|
+
export function needs(trail, root, files) {
|
|
355
|
+
const known = (token) => {
|
|
356
|
+
const rel = insideRoot(token, root, trail.cwd);
|
|
357
|
+
return rel && files.has(rel) ? rel : null;
|
|
358
|
+
};
|
|
359
|
+
const edited = new Set();
|
|
360
|
+
const read = new Set();
|
|
361
|
+
let firstEditAt = null;
|
|
362
|
+
let editing = false;
|
|
363
|
+
let callsBefore = 0;
|
|
364
|
+
for (const call of trail.calls) {
|
|
365
|
+
const changed = call.edited.map(known).filter(Boolean);
|
|
366
|
+
if (changed.length && !editing) {
|
|
367
|
+
editing = true;
|
|
368
|
+
firstEditAt = call.at;
|
|
369
|
+
}
|
|
370
|
+
for (const file of changed) edited.add(file);
|
|
371
|
+
if (!editing) {
|
|
372
|
+
callsBefore += 1;
|
|
373
|
+
for (const token of call.named) {
|
|
374
|
+
const file = known(token);
|
|
375
|
+
if (file) read.add(file);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const before = trail.usage.filter(
|
|
380
|
+
(row) => !editing || firstEditAt === null || (row.at ?? 0) < firstEditAt
|
|
381
|
+
);
|
|
382
|
+
return {
|
|
383
|
+
edited: [...edited],
|
|
384
|
+
read: [...read],
|
|
385
|
+
firstEditAt,
|
|
386
|
+
beforeFirstEdit: {
|
|
387
|
+
calls: callsBefore,
|
|
388
|
+
modelCalls: before.length,
|
|
389
|
+
inputTokens: before.reduce((sum, row) => sum + row.input, 0),
|
|
390
|
+
uncachedInputTokens: before.reduce(
|
|
391
|
+
(sum, row) => sum + Math.max(0, row.input - row.cached),
|
|
392
|
+
0
|
|
393
|
+
),
|
|
394
|
+
outputTokens: before.reduce((sum, row) => sum + row.output, 0),
|
|
395
|
+
ms:
|
|
396
|
+
firstEditAt !== null && trail.startedAt !== null
|
|
397
|
+
? firstEditAt - trail.startedAt
|
|
398
|
+
: null,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
package/lib/ui.mjs
CHANGED
|
@@ -6,9 +6,17 @@ import pc from "picocolors";
|
|
|
6
6
|
// menus, spinners while waiting. createUi draws it; scriptedUi answers from a list and keeps what a person would have
|
|
7
7
|
// seen, for tests. Prompts return null when the person cancels (Ctrl-C), so setup can skip that part and go on.
|
|
8
8
|
|
|
9
|
-
const unicode =
|
|
9
|
+
const unicode =
|
|
10
|
+
process.platform !== "win32" ||
|
|
11
|
+
Boolean(process.env.WT_SESSION) ||
|
|
12
|
+
process.env.TERM_PROGRAM === "vscode";
|
|
10
13
|
const glyph = (fancy, plain) => (unicode ? fancy : plain);
|
|
11
|
-
const BAR = glyph("│", "|"),
|
|
14
|
+
const BAR = glyph("│", "|"),
|
|
15
|
+
END = glyph("└", "—"),
|
|
16
|
+
ACTIVE = glyph("◆", "*"),
|
|
17
|
+
DONE = glyph("◇", "o"),
|
|
18
|
+
STOP = glyph("■", "x"),
|
|
19
|
+
WARN = glyph("▲", "!");
|
|
12
20
|
|
|
13
21
|
const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
14
22
|
|
|
@@ -16,10 +24,15 @@ const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
|
16
24
|
export function wrap(text, width) {
|
|
17
25
|
return text.split("\n").flatMap((line) => {
|
|
18
26
|
if (!line.trim()) return [""];
|
|
19
|
-
const indent = " ".repeat(line.length - line.trimStart().length),
|
|
27
|
+
const indent = " ".repeat(line.length - line.trimStart().length),
|
|
28
|
+
room = Math.max(20, width - indent.length),
|
|
29
|
+
rows = [];
|
|
20
30
|
let row = "";
|
|
21
31
|
for (const word of line.trim().split(" ")) {
|
|
22
|
-
if (row && visible(row) + 1 + visible(word) > room) {
|
|
32
|
+
if (row && visible(row) + 1 + visible(word) > room) {
|
|
33
|
+
rows.push(row);
|
|
34
|
+
row = word;
|
|
35
|
+
} else row = row ? `${row} ${word}` : word;
|
|
23
36
|
}
|
|
24
37
|
return [...rows, row].map((part) => `${indent}${part}`);
|
|
25
38
|
});
|
|
@@ -33,16 +46,24 @@ export const columns = () => process.stdout.columns || 100;
|
|
|
33
46
|
async function waitOrType({ message, placeholder, until }) {
|
|
34
47
|
const stop = new AbortController();
|
|
35
48
|
let done = null;
|
|
36
|
-
void until.then((result) => {
|
|
49
|
+
void until.then((result) => {
|
|
50
|
+
done = result;
|
|
51
|
+
stop.abort();
|
|
52
|
+
});
|
|
37
53
|
const value = await new TextPrompt({
|
|
38
54
|
placeholder,
|
|
39
55
|
signal: stop.signal,
|
|
40
56
|
render() {
|
|
41
57
|
const head = (symbol) => `${pc.gray(BAR)}\n${symbol} ${message}\n`;
|
|
42
|
-
if (this.state === "submit")
|
|
43
|
-
|
|
44
|
-
if (this.state === "cancel"
|
|
45
|
-
|
|
58
|
+
if (this.state === "submit")
|
|
59
|
+
return `${head(pc.green(DONE))}${pc.gray(BAR)} ${pc.dim(this.value || "keep waiting")}`;
|
|
60
|
+
if (this.state === "cancel" && done)
|
|
61
|
+
return `${pc.gray(BAR)}\n${done.ok ? pc.green(DONE) : pc.yellow(WARN)} ${done.line}`;
|
|
62
|
+
if (this.state === "cancel")
|
|
63
|
+
return `${head(pc.red(STOP))}${pc.gray(BAR)} ${pc.dim("stopped")}`;
|
|
64
|
+
const shown = this.value
|
|
65
|
+
? this.valueWithCursor
|
|
66
|
+
: `${pc.inverse(placeholder[0])}${pc.dim(placeholder.slice(1))}`;
|
|
46
67
|
return `${head(pc.cyan(ACTIVE))}${pc.cyan(BAR)} ${shown}\n${pc.cyan(END)}\n`;
|
|
47
68
|
},
|
|
48
69
|
}).prompt();
|
|
@@ -51,7 +72,9 @@ async function waitOrType({ message, placeholder, until }) {
|
|
|
51
72
|
return { value: String(value ?? "").trim() };
|
|
52
73
|
}
|
|
53
74
|
|
|
54
|
-
export function createUi({
|
|
75
|
+
export function createUi({
|
|
76
|
+
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
77
|
+
} = {}) {
|
|
55
78
|
const answer = (value) => (p.isCancel(value) ? null : value);
|
|
56
79
|
// clack does not wrap; a long line would run back to the left edge, off the rail.
|
|
57
80
|
const fit = (text) => wrap(text, columns() - 4).join("\n");
|
|
@@ -72,12 +95,24 @@ export function createUi({ interactive = Boolean(process.stdin.isTTY && process.
|
|
|
72
95
|
// clack's spinner stops on Ctrl-C but keeps the process alive; setup ends there instead. Without a terminal (piped
|
|
73
96
|
// or logged) it is two plain lines, not animation frames.
|
|
74
97
|
spinner: () => {
|
|
75
|
-
if (!process.stdout.isTTY)
|
|
76
|
-
|
|
98
|
+
if (!process.stdout.isTTY)
|
|
99
|
+
return {
|
|
100
|
+
start: (text) => p.log.message(fit(text)),
|
|
101
|
+
message: () => {},
|
|
102
|
+
stop: (text, code) => (code ? p.log.warn : p.log.step)(fit(text)),
|
|
103
|
+
};
|
|
104
|
+
const spin = p.spinner(),
|
|
105
|
+
quit = () => process.exit(130);
|
|
77
106
|
return {
|
|
78
|
-
start: (text) => {
|
|
107
|
+
start: (text) => {
|
|
108
|
+
spin.start(text);
|
|
109
|
+
process.once("SIGINT", quit);
|
|
110
|
+
},
|
|
79
111
|
message: (text) => spin.message(text),
|
|
80
|
-
stop: (text, code) => {
|
|
112
|
+
stop: (text, code) => {
|
|
113
|
+
process.removeListener("SIGINT", quit);
|
|
114
|
+
spin.stop(text, code);
|
|
115
|
+
},
|
|
81
116
|
};
|
|
82
117
|
},
|
|
83
118
|
waitOrType,
|
|
@@ -86,19 +121,56 @@ export function createUi({ interactive = Boolean(process.stdin.isTTY && process.
|
|
|
86
121
|
|
|
87
122
|
/** The same interface answered from `answers` in order (undefined takes the default); `lines` is what was shown. */
|
|
88
123
|
export function scriptedUi(answers = [], lines = []) {
|
|
89
|
-
const say = (symbol) => (text) => {
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
const say = (symbol) => (text) => {
|
|
125
|
+
lines.push(...`${symbol} ${text}`.split("\n"));
|
|
126
|
+
};
|
|
127
|
+
const next = (fallback) => {
|
|
128
|
+
const value = answers.shift();
|
|
129
|
+
return value === undefined ? fallback : value;
|
|
130
|
+
};
|
|
131
|
+
const ask = async (opts) => {
|
|
132
|
+
lines.push(`? ${opts.message}`);
|
|
133
|
+
const value = next(null);
|
|
134
|
+
if (value !== null) lines.push(` ${value}`);
|
|
135
|
+
return value;
|
|
136
|
+
};
|
|
92
137
|
return {
|
|
93
138
|
interactive: true,
|
|
94
139
|
lines,
|
|
95
|
-
intro: say("┌"),
|
|
96
|
-
|
|
97
|
-
|
|
140
|
+
intro: say("┌"),
|
|
141
|
+
outro: say("└"),
|
|
142
|
+
step: say("◇"),
|
|
143
|
+
success: say("◆"),
|
|
144
|
+
info: say("●"),
|
|
145
|
+
warn: say("▲"),
|
|
146
|
+
message: say("│"),
|
|
147
|
+
note: (text, title) => {
|
|
148
|
+
lines.push(`[${title}]`, ...text.split("\n"));
|
|
149
|
+
},
|
|
150
|
+
select: async (opts) => {
|
|
151
|
+
lines.push(
|
|
152
|
+
`? ${opts.message}`,
|
|
153
|
+
...opts.options.map(
|
|
154
|
+
(option) =>
|
|
155
|
+
` ${option.label}${option.hint ? ` (${option.hint})` : ""}`
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
return next(opts.initialValue ?? opts.options[0].value);
|
|
159
|
+
},
|
|
98
160
|
text: ask,
|
|
99
|
-
password: async (opts) => {
|
|
100
|
-
|
|
101
|
-
|
|
161
|
+
password: async (opts) => {
|
|
162
|
+
lines.push(`? ${opts.message}`);
|
|
163
|
+
return next(null);
|
|
164
|
+
},
|
|
165
|
+
confirm: async (opts) => {
|
|
166
|
+
lines.push(`? ${opts.message}`);
|
|
167
|
+
return next(opts.initialValue ?? true);
|
|
168
|
+
},
|
|
169
|
+
spinner: () => ({
|
|
170
|
+
start: say("◒"),
|
|
171
|
+
message: () => {},
|
|
172
|
+
stop: (text, code = 0) => say(code ? "▲" : "◇")(text),
|
|
173
|
+
}),
|
|
102
174
|
waitOrType: async ({ message, until }) => {
|
|
103
175
|
lines.push(`? ${message}`);
|
|
104
176
|
const value = next("");
|
package/lib/vectors.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// entries, not millions, so a scan is the right index until one outgrows it.
|
|
4
4
|
|
|
5
5
|
export function cosine(a, b) {
|
|
6
|
-
if (a.length !== b.length)
|
|
6
|
+
if (a.length !== b.length)
|
|
7
|
+
throw new Error(`vector lengths differ: ${a.length} and ${b.length}`);
|
|
7
8
|
let dot = 0;
|
|
8
9
|
let na = 0;
|
|
9
10
|
let nb = 0;
|
|
@@ -36,7 +37,9 @@ export function toBlob(vector) {
|
|
|
36
37
|
|
|
37
38
|
/** Buffer (or Uint8Array) from SQLite -> Float32Array (a copy). */
|
|
38
39
|
export function fromBlob(blob) {
|
|
39
|
-
const bytes = Buffer.isBuffer(blob)
|
|
40
|
+
const bytes = Buffer.isBuffer(blob)
|
|
41
|
+
? blob
|
|
42
|
+
: Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
40
43
|
const out = new Float32Array(bytes.length / 4);
|
|
41
44
|
for (let i = 0; i < out.length; i += 1) out[i] = bytes.readFloatLE(i * 4);
|
|
42
45
|
return out;
|