tryharness 0.7.22 → 0.7.23
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 +1 -0
- package/assets/pi-extension.ts +1 -0
- package/cli.mjs +85 -9
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +5 -1
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +10 -2
- package/lib/daemon.mjs +569 -41
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +60 -37
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +97 -5
- package/lib/media.mjs +118 -0
- package/lib/nudge.mjs +7 -0
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +26 -1
- package/lib/places.mjs +16 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +2 -0
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +36 -6
- package/lib/swarm.mjs +8 -6
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/trails.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
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 = /(?:\.{0,2}\/)?[\w@+-][\w@+.-]*(?:\/[\w@+.-]+)+|[\w@+-][\w@+-]*\.[A-Za-z][A-Za-z0-9]{0,7}\b/g;
|
|
11
|
+
const PATCH_FILE = /\*\*\* (?:Update|Add|Delete) File: ([^\n"\\]+)/g;
|
|
12
|
+
const REDIRECT_FILE = /(?:^|[\s;&|])(?:>>?|tee(?:\s+-a)?)\s+(["']?)([^\s"'|;&<>]+)\1/g;
|
|
13
|
+
const SED_INPLACE = /\bsed\s+(?:-[a-zA-Z]*i[a-zA-Z]*(?:\s+''|\s+"")?|--in-place)\b[^|;&]*?\s(["']?)([^\s"'|;&]+)\1\s*(?:$|[|;&])/gm;
|
|
14
|
+
// Test runners by their command, for receipts: a command's own exit code is the evidence, never the agent's words.
|
|
15
|
+
const TEST_COMMAND = /\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|$))/;
|
|
16
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "edit", "write", "apply_patch"]);
|
|
17
|
+
const BRIEF_SKIP = /^<(?:command-|local-command|system-reminder|task-notification|user-prompt-submit-hook)|^Caveat:/;
|
|
18
|
+
|
|
19
|
+
const parseJson = (line) => { try { const value = JSON.parse(line); return value && typeof value === "object" ? value : null; } catch { return null; } };
|
|
20
|
+
const textOf = (content) => typeof content === "string" ? content
|
|
21
|
+
: Array.isArray(content) ? content.map((part) => (typeof part?.text === "string" ? part.text : "")).filter(Boolean).join("\n") : "";
|
|
22
|
+
const ms = (value) => { const at = typeof value === "number" ? value * (value < 1e12 ? 1000 : 1) : Date.parse(value); return Number.isFinite(at) ? at : null; };
|
|
23
|
+
|
|
24
|
+
/** Path-like words in a tool call's input or output, as written (relative or absolute). */
|
|
25
|
+
export function pathTokens(text) {
|
|
26
|
+
return [...new Set(String(text).match(PATH_TOKEN) || [])].map((token) => token.replace(/[.,:;)\]}'"`]+$/, "")).filter((token) => token.length > 2 && token.length < 300);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Files a tool call changed: edit tools' own paths, patch headers, sed -i, redirects and tee. */
|
|
30
|
+
export function editedPaths(tool, input) {
|
|
31
|
+
const out = new Set();
|
|
32
|
+
if (EDIT_TOOLS.has(tool) && typeof input?.file_path === "string") out.add(input.file_path);
|
|
33
|
+
if (EDIT_TOOLS.has(tool) && typeof input === "string") { const found = input.match(/"(?:file_path|path)":"([^"]+)"/); if (found && !found[1].endsWith("…")) out.add(found[1]); }
|
|
34
|
+
if (EDIT_TOOLS.has(tool) && typeof input?.path === "string") out.add(input.path);
|
|
35
|
+
if (tool === "NotebookEdit" && typeof input?.notebook_path === "string") out.add(input.notebook_path);
|
|
36
|
+
const text = typeof input === "string" ? input : JSON.stringify(input ?? "").replace(/\\n/g, "\n").replace(/\\"/g, "\"");
|
|
37
|
+
for (const match of text.matchAll(PATCH_FILE)) out.add(match[1].trim());
|
|
38
|
+
for (const match of text.matchAll(SED_INPLACE)) out.add(match[2]);
|
|
39
|
+
for (const match of text.matchAll(REDIRECT_FILE)) if (!/^\/dev\/|^&\d$/.test(match[2])) out.add(match[2]);
|
|
40
|
+
return [...out];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const isTestCommand = (command) => TEST_COMMAND.test(String(command));
|
|
44
|
+
|
|
45
|
+
function exitCodeOf(text) {
|
|
46
|
+
const found = String(text).match(/"?exit_code"?\s*[:=]\s*(-?\d+)|Exit code:?\s*(-?\d+)|Process exited with code (-?\d+)/i);
|
|
47
|
+
return found ? Number(found[1] ?? found[2] ?? found[3]) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A session's trail: { agent, sessionId, cwd, brief, startedAt, endedAt, calls, usage, turns }. A call is
|
|
52
|
+
* { at, tool, input (clipped text), named (path words), edited (paths), command, exitCode, error }; usage is one row
|
|
53
|
+
* 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.
|
|
54
|
+
*/
|
|
55
|
+
export async function readTrail(agent, file) {
|
|
56
|
+
if (!["claude-code", "codex"].includes(agent) || !fs.existsSync(file)) return null;
|
|
57
|
+
const trail = { agent, sessionId: null, cwd: null, brief: null, startedAt: null, endedAt: null, calls: [], usage: [], turns: [] };
|
|
58
|
+
const byId = new Map();
|
|
59
|
+
const add = (at, tool, input, id) => {
|
|
60
|
+
const text = typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
61
|
+
const command = typeof input?.command === "string" ? input.command : Array.isArray(input?.command) ? input.command.join(" ") : typeof input?.cmd === "string" ? input.cmd
|
|
62
|
+
: [...text.matchAll(/cmd\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1]).join("\n") || null;
|
|
63
|
+
const call = { at, tool, input: text.slice(0, 4_000), named: pathTokens(text), edited: editedPaths(tool, input), command, exitCode: null, error: false, output: "" };
|
|
64
|
+
trail.calls.push(call);
|
|
65
|
+
if (id) byId.set(id, call);
|
|
66
|
+
};
|
|
67
|
+
const result = (id, text, error) => {
|
|
68
|
+
const call = byId.get(id);
|
|
69
|
+
if (!call) return;
|
|
70
|
+
call.output = String(text).slice(0, 4_000);
|
|
71
|
+
call.named = [...new Set([...call.named, ...pathTokens(String(text).slice(0, 20_000))])];
|
|
72
|
+
call.exitCode = exitCodeOf(text);
|
|
73
|
+
call.error = Boolean(error) || (call.exitCode !== null && call.exitCode !== 0);
|
|
74
|
+
};
|
|
75
|
+
const lines = readline.createInterface({ input: fs.createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
76
|
+
for await (const line of lines) {
|
|
77
|
+
const row = parseJson(line);
|
|
78
|
+
if (!row) continue;
|
|
79
|
+
const at = ms(row.timestamp ?? row.payload?.timestamp);
|
|
80
|
+
if (at) { trail.startedAt ??= at; trail.endedAt = at; }
|
|
81
|
+
if (agent === "claude-code") {
|
|
82
|
+
trail.sessionId ??= row.sessionId ?? null;
|
|
83
|
+
trail.cwd ??= row.cwd ?? null;
|
|
84
|
+
const content = row.message?.content;
|
|
85
|
+
if (row.type === "user" && !row.isMeta) {
|
|
86
|
+
const words = (typeof content === "string" ? content : Array.isArray(content) && !content.some((part) => part?.type === "tool_result") ? textOf(content) : "").trim();
|
|
87
|
+
// The person's turns: each message starts one; Esc leaves "[Request interrupted by user...]" where it stopped one.
|
|
88
|
+
if (words.startsWith("[Request interrupted by user")) trail.turns.push({ at, interrupted: true });
|
|
89
|
+
else if (words && !BRIEF_SKIP.test(words)) { trail.turns.push({ at, interrupted: false }); trail.brief ??= words; }
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(content)) for (const part of content) {
|
|
92
|
+
if (part?.type === "tool_use") add(at, part.name, part.input, part.id);
|
|
93
|
+
if (part?.type === "tool_result") result(part.tool_use_id, textOf(part.content) || JSON.stringify(row.toolUseResult ?? ""), part.is_error);
|
|
94
|
+
}
|
|
95
|
+
const usage = row.type === "assistant" ? row.message?.usage : null;
|
|
96
|
+
if (usage) trail.usage.push({ at, input: (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0), cached: usage.cache_read_input_tokens || 0, output: usage.output_tokens || 0 });
|
|
97
|
+
} else {
|
|
98
|
+
const payload = row.payload ?? {};
|
|
99
|
+
if (row.type === "session_meta") { trail.sessionId ??= payload.id ?? null; trail.cwd ??= payload.cwd ?? null; }
|
|
100
|
+
if (row.type === "response_item" && payload.type === "message" && payload.role === "user") {
|
|
101
|
+
const words = textOf(payload.content).trim();
|
|
102
|
+
if (words && !/^<(?:environment_context|user_instructions|permissions|turn_aborted)/.test(words) && !words.startsWith("# AGENTS.md")) { trail.turns.push({ at, interrupted: false }); trail.brief ??= words; }
|
|
103
|
+
}
|
|
104
|
+
if (row.type === "event_msg" && payload.type === "turn_aborted" && payload.reason === "interrupted") trail.turns.push({ at, interrupted: true });
|
|
105
|
+
if (row.type === "response_item" && ["function_call", "custom_tool_call", "local_shell_call"].includes(payload.type)) {
|
|
106
|
+
const input = payload.input ?? parseJson(payload.arguments) ?? payload.arguments ?? payload.action;
|
|
107
|
+
add(at, payload.name ?? payload.type, input, payload.call_id);
|
|
108
|
+
}
|
|
109
|
+
if (row.type === "response_item" && ["function_call_output", "custom_tool_call_output"].includes(payload.type)) {
|
|
110
|
+
const text = typeof payload.output === "string" ? payload.output : textOf(payload.output) || JSON.stringify(payload.output ?? "");
|
|
111
|
+
result(payload.call_id, text, false);
|
|
112
|
+
}
|
|
113
|
+
if (row.type === "token_usage_record" && payload.usage) trail.usage.push({ at, input: payload.usage.input_tokens || 0, cached: payload.usage.cached_input_tokens || 0, output: payload.usage.output_tokens || 0 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return trail;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const RESULT_EDIT = /(?:The file (\S+) has been (?:updated|created)|File created successfully at: (\S+?)[\s)\]])/g;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A trail rebuilt from the relay's own copy of a session (store.mjs keeps every message with tool calls clipped), for
|
|
123
|
+
* sessions whose source file the agent has since deleted. It has the brief, the calls with the paths they named and the
|
|
124
|
+
* files they changed (the edit tools' own confirmations carry the whole path), but no token counts or timestamps.
|
|
125
|
+
*/
|
|
126
|
+
export function trailFromTranscript(agent, transcript, { cwd = null, startedAt = null } = {}) {
|
|
127
|
+
const trail = { agent, sessionId: null, cwd, brief: null, startedAt, endedAt: startedAt, calls: [], usage: [] };
|
|
128
|
+
for (const block of String(transcript).split(/\n\n(?=(?:User|Agent): )/)) {
|
|
129
|
+
const user = block.startsWith("User: ");
|
|
130
|
+
const text = block.replace(/^(?:User|Agent): /, "");
|
|
131
|
+
if (user && !trail.brief && !text.startsWith("[tool result") && !BRIEF_SKIP.test(text.trim()) && !text.includes("<local-command-caveat>")) trail.brief = text.trim();
|
|
132
|
+
for (const match of text.matchAll(/\[tool: (\S+) ([\s\S]*?)\](?=\n|$)/g)) {
|
|
133
|
+
trail.calls.push({ at: null, tool: match[1], input: match[2], named: pathTokens(match[2]), edited: editedPaths(match[1], match[2]).filter((file) => !file.endsWith("…")), command: null, exitCode: null, error: false, output: "" });
|
|
134
|
+
}
|
|
135
|
+
if (user && text.startsWith("[tool result")) {
|
|
136
|
+
const last = trail.calls.at(-1);
|
|
137
|
+
if (!last) continue;
|
|
138
|
+
last.named = [...new Set([...last.named, ...pathTokens(text)])];
|
|
139
|
+
for (const match of text.matchAll(RESULT_EDIT)) last.edited = [...new Set([...last.edited, match[1] || match[2]])];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return trail;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A path as written in a call, made relative to root when it lies inside it; null when it points elsewhere. */
|
|
146
|
+
export function insideRoot(token, root, cwd = root) {
|
|
147
|
+
const absolute = path.isAbsolute(token) ? token : path.resolve(cwd || root, token);
|
|
148
|
+
const rel = path.relative(root, absolute);
|
|
149
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What a trail shows a run needed, against the files that existed: { edited, read, firstEditAt, beforeFirstEdit }.
|
|
154
|
+
* `read` is every existing file the run named before its first edit (opened, listed, grepped into); `beforeFirstEdit`
|
|
155
|
+
* counts the calls and tokens spent before that edit, which is what a better brief can save.
|
|
156
|
+
*/
|
|
157
|
+
export function needs(trail, root, files) {
|
|
158
|
+
const known = (token) => { const rel = insideRoot(token, root, trail.cwd); return rel && files.has(rel) ? rel : null; };
|
|
159
|
+
const edited = new Set();
|
|
160
|
+
const read = new Set();
|
|
161
|
+
let firstEditAt = null;
|
|
162
|
+
let editing = false;
|
|
163
|
+
let callsBefore = 0;
|
|
164
|
+
for (const call of trail.calls) {
|
|
165
|
+
const changed = call.edited.map(known).filter(Boolean);
|
|
166
|
+
if (changed.length && !editing) { editing = true; firstEditAt = call.at; }
|
|
167
|
+
for (const file of changed) edited.add(file);
|
|
168
|
+
if (!editing) { callsBefore += 1; for (const token of call.named) { const file = known(token); if (file) read.add(file); } }
|
|
169
|
+
}
|
|
170
|
+
const before = trail.usage.filter((row) => !editing || firstEditAt === null || (row.at ?? 0) < firstEditAt);
|
|
171
|
+
return {
|
|
172
|
+
edited: [...edited], read: [...read], firstEditAt,
|
|
173
|
+
beforeFirstEdit: { calls: callsBefore, modelCalls: before.length, inputTokens: before.reduce((sum, row) => sum + row.input, 0),
|
|
174
|
+
uncachedInputTokens: before.reduce((sum, row) => sum + Math.max(0, row.input - row.cached), 0), outputTokens: before.reduce((sum, row) => sum + row.output, 0),
|
|
175
|
+
ms: firstEditAt !== null && trail.startedAt !== null ? firstEditAt - trail.startedAt : null },
|
|
176
|
+
};
|
|
177
|
+
}
|
package/lib/watches.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// Watches: something the person wants kept an eye on, read by a command on this computer on a schedule (a log, a status
|
|
5
|
+
// command, a balance), and said out loud only when it is off. Each check reads one number from the command's output (a
|
|
6
|
+
// field like "ageSeconds" or the count of lines matching a pattern like "critical") and compares it with a limit the
|
|
7
|
+
// watch names, or, without one, with its own usual range once it has enough history. A falling number with a floor gets
|
|
8
|
+
// a run-out estimate. Plain arithmetic, no model. An agent may suggest a watch; it runs only once the person starts it.
|
|
9
|
+
// The October 13 keeper outage is why: its own health job logged "no recent successful keeper run" every 5 minutes for
|
|
10
|
+
// 41 hours and nobody saw it.
|
|
11
|
+
|
|
12
|
+
const HISTORY = 500;
|
|
13
|
+
const USUAL_AFTER = 12;
|
|
14
|
+
const MIN_EVERY_SECONDS = 5;
|
|
15
|
+
const RUN_OUT_HOURS = 24;
|
|
16
|
+
|
|
17
|
+
const median = (values) => { const sorted = [...values].sort((a, b) => a - b); const mid = sorted.length >> 1; return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; };
|
|
18
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The number a check reads from output: how many lines hold a phrase (count), seconds since a named field's last
|
|
22
|
+
* timestamp (age: unix seconds, milliseconds or an ISO date), a named field's last value (field), or the last number in
|
|
23
|
+
* it. null when absent.
|
|
24
|
+
*/
|
|
25
|
+
export function readNumber(output, measure, now = Date.now()) {
|
|
26
|
+
const text = String(output ?? "");
|
|
27
|
+
if (measure.count) return text.split("\n").filter((line) => line.toLowerCase().includes(String(measure.count).toLowerCase())).length;
|
|
28
|
+
if (measure.age) {
|
|
29
|
+
const found = [...text.matchAll(new RegExp(`["']?${escape(measure.age)}["']?\\s*[:=]\\s*"?([0-9][0-9:.TZ+-]*)`, "g"))].at(-1)?.[1];
|
|
30
|
+
if (!found) return null;
|
|
31
|
+
const at = /^\d+(?:\.\d+)?$/.test(found) ? Number(found) * (Number(found) < 1e12 ? 1000 : 1) : Date.parse(found);
|
|
32
|
+
return Number.isFinite(at) ? Math.max(0, Math.round((now - at) / 1000)) : null;
|
|
33
|
+
}
|
|
34
|
+
if (measure.field) {
|
|
35
|
+
const found = [...text.matchAll(new RegExp(`["']?${escape(measure.field)}["']?\\s*[:=]\\s*"?(-?\\d+(?:\\.\\d+)?)`, "g"))].at(-1);
|
|
36
|
+
return found ? Number(found[1]) : null;
|
|
37
|
+
}
|
|
38
|
+
const found = text.match(/-?\d+(?:\.\d+)?(?=\D*$)/);
|
|
39
|
+
return found ? Number(found[0]) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the newest sample is off, and why: past a limit the watch names, outside its usual range (median and spread of
|
|
44
|
+
* its history, once it has some), or, falling toward its floor, due to cross it within a day. null when it is fine.
|
|
45
|
+
*/
|
|
46
|
+
export function offPattern(samples, { above = null, below = null } = {}) {
|
|
47
|
+
const values = samples.map((sample) => sample.value).filter((value) => value !== null && Number.isFinite(value));
|
|
48
|
+
const latest = samples.at(-1);
|
|
49
|
+
if (!latest || latest.value === null) return latest?.error ? { kind: "failed", text: `its check failed: ${latest.error}` } : null;
|
|
50
|
+
if (above !== null && latest.value > above) return { kind: "limit", text: `is ${latest.value}, above ${above}` };
|
|
51
|
+
if (below !== null && latest.value < below) return { kind: "limit", text: `is ${latest.value}, below ${below}` };
|
|
52
|
+
if (below !== null && samples.length >= 4) {
|
|
53
|
+
// Falling toward the floor: a straight line through the recent samples, and when it reaches it.
|
|
54
|
+
const recent = samples.slice(-12).filter((sample) => sample.value !== null);
|
|
55
|
+
const t0 = recent[0].at;
|
|
56
|
+
const xs = recent.map((sample) => (sample.at - t0) / 3_600_000), ys = recent.map((sample) => sample.value);
|
|
57
|
+
const mx = xs.reduce((a, b) => a + b, 0) / xs.length, my = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
58
|
+
const slope = xs.reduce((sum, x, i) => sum + (x - mx) * (ys[i] - my), 0) / (xs.reduce((sum, x) => sum + (x - mx) ** 2, 0) || 1);
|
|
59
|
+
if (slope < 0) {
|
|
60
|
+
const hours = (latest.value - below) / -slope;
|
|
61
|
+
if (hours <= RUN_OUT_HOURS) return { kind: "run-out", text: `is falling by ${Math.abs(slope).toPrecision(2)} an hour and reaches ${below} in about ${hours < 1 ? `${Math.max(1, Math.round(hours * 60))} minutes` : `${Math.round(hours)} hour${Math.round(hours) === 1 ? "" : "s"}`}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (above === null && below === null && values.length > USUAL_AFTER) {
|
|
65
|
+
const history = values.slice(0, -1).slice(-100);
|
|
66
|
+
const middle = median(history);
|
|
67
|
+
// A number that takes two values (a sawtooth, a flag) has no median deviation; the mean deviation stands in.
|
|
68
|
+
const deviations = history.map((value) => Math.abs(value - middle));
|
|
69
|
+
const spread = median(deviations) || deviations.reduce((a, b) => a + b, 0) / deviations.length || Math.max(1e-9, Math.abs(middle) * 0.05);
|
|
70
|
+
if (Math.abs(latest.value - middle) > 6 * spread) return { kind: "unusual", text: `is ${latest.value}; it is usually about ${Number(middle.toPrecision(4))}` };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Runs a check's command in its folder; its output, or an error. A check never runs longer than its own interval. */
|
|
76
|
+
function runCheck(watch, shell = process.env.SHELL || "/bin/sh") {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const started = performance.now();
|
|
79
|
+
let out = "", err = "", done = false;
|
|
80
|
+
const child = spawn(shell, ["-lc", watch.command], { cwd: watch.folder || undefined, stdio: ["ignore", "pipe", "pipe"] });
|
|
81
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); finish(null, `it took longer than its ${watch.everySeconds} s interval`); }, watch.everySeconds * 1000);
|
|
82
|
+
const finish = (output, error) => { if (done) return; done = true; clearTimeout(timer); resolve({ output, error, ms: Math.round(performance.now() - started) }); };
|
|
83
|
+
child.stdout.on("data", (chunk) => { out = (out + chunk).slice(-200_000); });
|
|
84
|
+
child.stderr.on("data", (chunk) => { err = (err + chunk).slice(-4_000); });
|
|
85
|
+
child.on("error", (error) => finish(null, error.message));
|
|
86
|
+
child.on("close", (code) => finish(code === 0 || out ? out : null, code === 0 || out ? null : (err.trim().split("\n").at(-1) || `it exited with code ${code}`)));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createWatches({ store, notify = () => {}, log = () => {}, run = runCheck }) {
|
|
91
|
+
const list = () => { try { return JSON.parse(store.meta("watches") || "[]"); } catch { return []; } };
|
|
92
|
+
const save = (watches) => store.meta("watches", JSON.stringify(watches));
|
|
93
|
+
const samplesOf = (id) => { try { return JSON.parse(store.meta(`watch-samples:${id}`) || "[]"); } catch { return []; } };
|
|
94
|
+
const timers = new Map();
|
|
95
|
+
|
|
96
|
+
const check = async (id) => {
|
|
97
|
+
const watch = list().find((entry) => entry.id === id);
|
|
98
|
+
if (!watch || watch.state !== "on") return null;
|
|
99
|
+
const result = await run(watch);
|
|
100
|
+
const value = result.output === null ? null : readNumber(result.output, watch.measure);
|
|
101
|
+
const samples = [...samplesOf(id), { at: Date.now(), value, ms: result.ms, ...(result.error ? { error: result.error.slice(0, 300) } : value === null ? { error: "its output had no number to read" } : {}) }].slice(-HISTORY);
|
|
102
|
+
store.meta(`watch-samples:${id}`, JSON.stringify(samples));
|
|
103
|
+
const off = offPattern(samples, watch.measure);
|
|
104
|
+
const current = list().find((entry) => entry.id === id);
|
|
105
|
+
if (!current) return null;
|
|
106
|
+
// Said once when it goes off and once when it is back; not on every check while it stays off.
|
|
107
|
+
if (off && !current.off) {
|
|
108
|
+
const alert = { at: new Date().toISOString(), text: `${current.name} ${off.text}.`, kind: off.kind };
|
|
109
|
+
save(list().map((entry) => (entry.id === id ? { ...entry, off: alert } : entry)));
|
|
110
|
+
store.meta("watch-alerts", JSON.stringify([...(JSON.parse(store.meta("watch-alerts") || "[]")), { watch: id, ...alert }].slice(-200)));
|
|
111
|
+
notify(alert.text, { watch: current, alert });
|
|
112
|
+
log(`watch ${current.name}: ${off.text}`);
|
|
113
|
+
} else if (!off && current.off) {
|
|
114
|
+
save(list().map((entry) => (entry.id === id ? { ...entry, off: null } : entry)));
|
|
115
|
+
notify(`${current.name} is back to normal.`, { watch: current, alert: null });
|
|
116
|
+
}
|
|
117
|
+
return { value, off, ms: result.ms };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const schedule = (watch) => {
|
|
121
|
+
clearTimeout(timers.get(watch.id));
|
|
122
|
+
if (watch.state !== "on") { timers.delete(watch.id); return; }
|
|
123
|
+
const tick = () => { void check(watch.id).catch((error) => log(`watch ${watch.name}: ${error.message}`)).finally(() => { if (timers.has(watch.id)) timers.set(watch.id, setTimeout(tick, watch.everySeconds * 1000).unref?.() ?? null); }); };
|
|
124
|
+
timers.set(watch.id, setTimeout(tick, 0));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
list: () => list().map((watch) => ({ ...watch, samples: samplesOf(watch.id).slice(-20) })),
|
|
129
|
+
alerts: (since = "") => { try { return JSON.parse(store.meta("watch-alerts") || "[]").filter((alert) => alert.at > since); } catch { return []; } },
|
|
130
|
+
/** A new watch: the person's starts at once; an agent's waits for the person's start. */
|
|
131
|
+
add({ name, command, folder = null, everySeconds = 300, measure = {}, by = "user" }) {
|
|
132
|
+
if (!String(name || "").trim() || !String(command || "").trim()) return { ok: false, error: "a watch needs a name and a command to read" };
|
|
133
|
+
const every = Math.max(MIN_EVERY_SECONDS, Math.round(Number(everySeconds) || 300));
|
|
134
|
+
const clean = {
|
|
135
|
+
...(measure.field ? { field: String(measure.field).slice(0, 100) } : {}), ...(measure.count ? { count: String(measure.count).slice(0, 100) } : {}), ...(measure.age ? { age: String(measure.age).slice(0, 100) } : {}),
|
|
136
|
+
above: Number.isFinite(Number(measure.above)) && measure.above !== null && measure.above !== "" ? Number(measure.above) : null,
|
|
137
|
+
below: Number.isFinite(Number(measure.below)) && measure.below !== null && measure.below !== "" ? Number(measure.below) : null,
|
|
138
|
+
};
|
|
139
|
+
const watch = { id: randomUUID(), name: String(name).trim().slice(0, 120), command: String(command).slice(0, 2_000), folder, everySeconds: every, measure: clean, by, state: by === "user" ? "on" : "suggested", off: null, createdAt: new Date().toISOString() };
|
|
140
|
+
save([...list(), watch]);
|
|
141
|
+
if (watch.state === "on") schedule(watch);
|
|
142
|
+
return { ok: true, watch };
|
|
143
|
+
},
|
|
144
|
+
start(id) {
|
|
145
|
+
const watch = list().find((entry) => entry.id === id);
|
|
146
|
+
if (!watch) return { ok: false, error: "no such watch" };
|
|
147
|
+
const on = { ...watch, state: "on" };
|
|
148
|
+
save(list().map((entry) => (entry.id === id ? on : entry)));
|
|
149
|
+
schedule(on);
|
|
150
|
+
return { ok: true, watch: on };
|
|
151
|
+
},
|
|
152
|
+
remove(id) {
|
|
153
|
+
if (!list().some((entry) => entry.id === id)) return { ok: false, error: "no such watch" };
|
|
154
|
+
clearTimeout(timers.get(id)); timers.delete(id);
|
|
155
|
+
save(list().filter((entry) => entry.id !== id));
|
|
156
|
+
store.meta(`watch-samples:${id}`, "[]");
|
|
157
|
+
return { ok: true };
|
|
158
|
+
},
|
|
159
|
+
check,
|
|
160
|
+
resume() { for (const watch of list()) if (watch.state === "on") schedule(watch); },
|
|
161
|
+
stop() { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); },
|
|
162
|
+
};
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.23",
|
|
4
4
|
"description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
@@ -32,15 +32,17 @@
|
|
|
32
32
|
"@clack/core": "0.5.0",
|
|
33
33
|
"@clack/prompts": "0.11.0",
|
|
34
34
|
"@huggingface/tokenizers": "^0.1.3",
|
|
35
|
+
"jpeg-js": "0.4.4",
|
|
35
36
|
"node-sqlite3-wasm": "0.8.60",
|
|
36
|
-
"onnxruntime-
|
|
37
|
+
"onnxruntime-node": "1.24.3",
|
|
37
38
|
"picocolors": "^1.1.1",
|
|
39
|
+
"pngjs": "7.0.0",
|
|
38
40
|
"qr": "0.4.2",
|
|
39
41
|
"ws": "^8.18.3"
|
|
40
42
|
},
|
|
41
43
|
"optionalDependencies": {
|
|
42
|
-
"tryharness-desktop-darwin-arm64": "0.7.
|
|
43
|
-
"tryharness-desktop-darwin-x64": "0.7.
|
|
44
|
+
"tryharness-desktop-darwin-arm64": "0.7.23",
|
|
45
|
+
"tryharness-desktop-darwin-x64": "0.7.23",
|
|
44
46
|
"tryharness-desktop-linux-x64": "0.7.4",
|
|
45
47
|
"tryharness-desktop-win32-x64": "0.7.4"
|
|
46
48
|
}
|
package/lib/embedder.mjs
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ensureDir } from "./files.mjs";
|
|
4
|
-
|
|
5
|
-
// The daemon's own embedder for the context space: the same model, quantization, pooling
|
|
6
|
-
// and normalization as the browser (src/shared/memory/embeddingService.ts), so the two
|
|
7
|
-
// sides describe the same space under the same version key. It runs on onnxruntime-web's
|
|
8
|
-
// Node build (wasm, no native addon) with the same tokenizer library transformers.js uses,
|
|
9
|
-
// and mirrors transformers' feature-extraction pipeline step by step: tokenize with special
|
|
10
|
-
// tokens, truncate to the model's 512, pad to the batch, take the [CLS] vector, L2-normalize.
|
|
11
|
-
// Weights are downloaded into the Harness home during setup, never fetched from a browser.
|
|
12
|
-
|
|
13
|
-
export const EMBEDDING_MODEL = "Xenova/bge-base-en-v1.5";
|
|
14
|
-
export const EMBEDDING_DIMENSIONS = 768;
|
|
15
|
-
export const EMBEDDING_VERSION = "bge-base-en-v1.5:q8:cls:l2:asymmetric:v1";
|
|
16
|
-
const QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
|
|
17
|
-
const MODEL_FILES = ["config.json", "tokenizer.json", "tokenizer_config.json", "onnx/model_quantized.onnx"];
|
|
18
|
-
const REMOTE = `https://huggingface.co/${EMBEDDING_MODEL}/resolve/main/`;
|
|
19
|
-
// One text per run: a q8 model quantizes per batch tensor, so the same text in different company
|
|
20
|
-
// differs in the last decimals (transformers.js shows the same, cosine 0.992 alone versus batched).
|
|
21
|
-
// Entries arrive one at a time and a vector must not depend on its neighbours, so no batching.
|
|
22
|
-
const BATCH = 1;
|
|
23
|
-
|
|
24
|
-
export function modelDirectory(modelsDir) {
|
|
25
|
-
return path.join(modelsDir, ...EMBEDDING_MODEL.split("/"));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** True when every model file is present and non-empty. */
|
|
29
|
-
export function modelPresent(modelsDir) {
|
|
30
|
-
const dir = modelDirectory(modelsDir);
|
|
31
|
-
return MODEL_FILES.every((file) => {
|
|
32
|
-
try { return fs.statSync(path.join(dir, file)).size > 0; } catch { return false; }
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function download(url, target, { fetchImpl, log }) {
|
|
37
|
-
const partial = `${target}.partial`;
|
|
38
|
-
ensureDir(path.dirname(target));
|
|
39
|
-
let have = 0;
|
|
40
|
-
try { have = fs.statSync(partial).size; } catch {}
|
|
41
|
-
const headers = have > 0 ? { range: `bytes=${have}-` } : {};
|
|
42
|
-
const response = await fetchImpl(url, { headers, redirect: "follow" });
|
|
43
|
-
if (response.status === 416) { fs.renameSync(partial, target); return; }
|
|
44
|
-
if (!response.ok) throw new Error(`download of ${path.basename(target)} failed: HTTP ${response.status}`);
|
|
45
|
-
const resumed = response.status === 206;
|
|
46
|
-
const total = Number(response.headers.get("content-length") || 0) + (resumed ? have : 0);
|
|
47
|
-
const out = fs.createWriteStream(partial, { flags: resumed ? "a" : "w" });
|
|
48
|
-
let done = resumed ? have : 0;
|
|
49
|
-
let shown = -1;
|
|
50
|
-
const name = path.basename(target);
|
|
51
|
-
for await (const chunk of response.body) {
|
|
52
|
-
if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", resolve));
|
|
53
|
-
done += chunk.length;
|
|
54
|
-
const percent = total ? Math.floor((done / total) * 10) * 10 : -1;
|
|
55
|
-
if (percent !== shown && percent >= 0) { shown = percent; log?.(`embedder: ${name} ${percent}%`); }
|
|
56
|
-
}
|
|
57
|
-
await new Promise((resolve, reject) => { out.end(); out.on("finish", resolve); out.on("error", reject); });
|
|
58
|
-
if (total && done !== total) throw new Error(`download of ${name} stopped at ${done} of ${total} bytes; run setup again to resume`);
|
|
59
|
-
fs.renameSync(partial, target);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Downloads any missing model file into modelsDir. Idempotent; a partial file resumes. */
|
|
63
|
-
export async function ensureModel({ modelsDir, log = () => {}, fetchImpl = fetch } = {}) {
|
|
64
|
-
const dir = modelDirectory(modelsDir);
|
|
65
|
-
const missing = MODEL_FILES.filter((file) => { try { return fs.statSync(path.join(dir, file)).size === 0; } catch { return true; } });
|
|
66
|
-
if (!missing.length) return { downloaded: [], present: true };
|
|
67
|
-
log(`embedder: downloading ${EMBEDDING_MODEL} (${missing.length} file${missing.length === 1 ? "" : "s"}, about 110 MB) into ${modelsDir}`);
|
|
68
|
-
for (const file of missing) await download(REMOTE + file, path.join(dir, file), { fetchImpl, log });
|
|
69
|
-
log("embedder: ready");
|
|
70
|
-
return { downloaded: missing, present: true };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function truncate(encoded, maxLength) {
|
|
74
|
-
for (const key of Object.keys(encoded)) encoded[key] = encoded[key].slice(0, maxLength);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function pad(encoded, maxLength, padId) {
|
|
78
|
-
for (const key of Object.keys(encoded)) {
|
|
79
|
-
const fill = key === "input_ids" ? padId : 0;
|
|
80
|
-
while (encoded[key].length < maxLength) encoded[key].push(fill);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A lazily loaded embedder over local files only. Loading the session takes about half a
|
|
86
|
-
* second and inference runs on the calling thread (onnxruntime-web's Node build has no
|
|
87
|
-
* worker), so each short text holds the event loop for a few tens of milliseconds;
|
|
88
|
-
* callers embed in the background, never inside a request the person is waiting on.
|
|
89
|
-
*/
|
|
90
|
-
export function createEmbedder({ modelsDir }) {
|
|
91
|
-
const dir = modelDirectory(modelsDir);
|
|
92
|
-
let loading = null;
|
|
93
|
-
const load = () => {
|
|
94
|
-
if (loading) return loading;
|
|
95
|
-
loading = (async () => {
|
|
96
|
-
if (!modelPresent(modelsDir)) throw new Error(`the embedding model is not present in ${modelsDir}; run npx tryharness to download it`);
|
|
97
|
-
const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("onnxruntime-web")]);
|
|
98
|
-
const tokenizerJson = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer.json"), "utf8"));
|
|
99
|
-
const tokenizerConfig = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer_config.json"), "utf8"));
|
|
100
|
-
const modelConfig = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
|
|
101
|
-
const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig);
|
|
102
|
-
ort.env.wasm.numThreads = 1;
|
|
103
|
-
const session = await ort.InferenceSession.create(path.join(dir, "onnx", "model_quantized.onnx"), { executionProviders: ["wasm"] });
|
|
104
|
-
return {
|
|
105
|
-
ort, tokenizer, session,
|
|
106
|
-
maxLength: Number(tokenizerConfig.model_max_length) || 512,
|
|
107
|
-
padId: Number(modelConfig.pad_token_id ?? 0),
|
|
108
|
-
wantsTypeIds: session.inputNames.includes("token_type_ids"),
|
|
109
|
-
};
|
|
110
|
-
})();
|
|
111
|
-
loading.catch(() => { loading = null; });
|
|
112
|
-
return loading;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const embedBatch = async (texts) => {
|
|
116
|
-
const { ort, tokenizer, session, maxLength, padId, wantsTypeIds } = await load();
|
|
117
|
-
const encoded = texts.map((text) => {
|
|
118
|
-
const { ids, attention_mask, token_type_ids } = tokenizer.encode(text, { add_special_tokens: true, return_token_type_ids: true });
|
|
119
|
-
return { input_ids: [...ids], attention_mask: [...attention_mask], ...(wantsTypeIds ? { token_type_ids: [...(token_type_ids || ids.map(() => 0))] } : {}) };
|
|
120
|
-
});
|
|
121
|
-
const width = Math.min(Math.max(...encoded.map((e) => e.input_ids.length)), maxLength);
|
|
122
|
-
for (const e of encoded) {
|
|
123
|
-
if (e.input_ids.length > width) truncate(e, width);
|
|
124
|
-
else if (e.input_ids.length < width) pad(e, width, padId);
|
|
125
|
-
}
|
|
126
|
-
const tensor = (key) => new ort.Tensor("int64", BigInt64Array.from(encoded.flatMap((e) => e[key].map((v) => BigInt(v)))), [texts.length, width]);
|
|
127
|
-
const feeds = { input_ids: tensor("input_ids"), attention_mask: tensor("attention_mask"), ...(wantsTypeIds ? { token_type_ids: tensor("token_type_ids") } : {}) };
|
|
128
|
-
const outputs = await session.run(feeds);
|
|
129
|
-
const hidden = outputs.last_hidden_state;
|
|
130
|
-
const [, seq, dims] = hidden.dims;
|
|
131
|
-
const data = hidden.data;
|
|
132
|
-
return texts.map((_, row) => {
|
|
133
|
-
const start = row * seq * dims;
|
|
134
|
-
const vector = new Float32Array(dims);
|
|
135
|
-
let norm = 0;
|
|
136
|
-
for (let i = 0; i < dims; i += 1) { vector[i] = data[start + i]; norm += vector[i] * vector[i]; }
|
|
137
|
-
norm = Math.sqrt(norm) || 1;
|
|
138
|
-
for (let i = 0; i < dims; i += 1) vector[i] /= norm;
|
|
139
|
-
return vector;
|
|
140
|
-
});
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
version: EMBEDDING_VERSION,
|
|
145
|
-
dimensions: EMBEDDING_DIMENSIONS,
|
|
146
|
-
ready: () => load().then(() => true),
|
|
147
|
-
async embedDocuments(texts) {
|
|
148
|
-
const out = [];
|
|
149
|
-
for (let i = 0; i < texts.length; i += BATCH) out.push(...(await embedBatch(texts.slice(i, i + BATCH))));
|
|
150
|
-
return out;
|
|
151
|
-
},
|
|
152
|
-
async embedQuery(text) {
|
|
153
|
-
return (await embedBatch([QUERY_PREFIX + text]))[0];
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|