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.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. 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 = process.platform !== "win32" || Boolean(process.env.WT_SESSION) || process.env.TERM_PROGRAM === "vscode";
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("│", "|"), END = glyph("└", "—"), ACTIVE = glyph("◆", "*"), DONE = glyph("◇", "o"), STOP = glyph("■", "x"), WARN = 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), room = Math.max(20, width - indent.length), rows = [];
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) { rows.push(row); row = word; } else row = row ? `${row} ${word}` : word;
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) => { done = result; stop.abort(); });
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") return `${head(pc.green(DONE))}${pc.gray(BAR)} ${pc.dim(this.value || "keep waiting")}`;
43
- if (this.state === "cancel" && done) return `${pc.gray(BAR)}\n${done.ok ? pc.green(DONE) : pc.yellow(WARN)} ${done.line}`;
44
- if (this.state === "cancel") return `${head(pc.red(STOP))}${pc.gray(BAR)} ${pc.dim("stopped")}`;
45
- const shown = this.value ? this.valueWithCursor : `${pc.inverse(placeholder[0])}${pc.dim(placeholder.slice(1))}`;
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({ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) } = {}) {
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) return { start: (text) => p.log.message(fit(text)), message: () => {}, stop: (text, code) => (code ? p.log.warn : p.log.step)(fit(text)) };
76
- const spin = p.spinner(), quit = () => process.exit(130);
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) => { spin.start(text); process.once("SIGINT", quit); },
107
+ start: (text) => {
108
+ spin.start(text);
109
+ process.once("SIGINT", quit);
110
+ },
79
111
  message: (text) => spin.message(text),
80
- stop: (text, code) => { process.removeListener("SIGINT", quit); spin.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) => { lines.push(...`${symbol} ${text}`.split("\n")); };
90
- const next = (fallback) => { const value = answers.shift(); return value === undefined ? fallback : value; };
91
- const ask = async (opts) => { lines.push(`? ${opts.message}`); const value = next(null); if (value !== null) lines.push(` ${value}`); return value; };
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("┌"), outro: say("└"), step: say("◇"), success: say("◆"), info: say("●"), warn: say("▲"), message: say("│"),
96
- note: (text, title) => { lines.push(`[${title}]`, ...text.split("\n")); },
97
- select: async (opts) => { lines.push(`? ${opts.message}`, ...opts.options.map((option) => ` ${option.label}${option.hint ? ` (${option.hint})` : ""}`)); return next(opts.initialValue ?? opts.options[0].value); },
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) => { lines.push(`? ${opts.message}`); return next(null); },
100
- confirm: async (opts) => { lines.push(`? ${opts.message}`); return next(opts.initialValue ?? true); },
101
- spinner: () => ({ start: say("◒"), message: () => {}, stop: (text, code = 0) => say(code ? "▲" : "◇")(text) }),
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) throw new Error(`vector lengths differ: ${a.length} and ${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) ? blob : Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
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;