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/watches.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
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) => {
|
|
18
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
19
|
+
const mid = sorted.length >> 1;
|
|
20
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
21
|
+
};
|
|
22
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The number a check reads from output: how many lines hold a phrase (count), seconds since a named field's last
|
|
26
|
+
* timestamp (age: unix seconds, milliseconds or an ISO date), a named field's last value (field), or the last number in
|
|
27
|
+
* it. null when absent.
|
|
28
|
+
*/
|
|
29
|
+
export function readNumber(output, measure, now = Date.now()) {
|
|
30
|
+
const text = String(output ?? "");
|
|
31
|
+
if (measure.count)
|
|
32
|
+
return text
|
|
33
|
+
.split("\n")
|
|
34
|
+
.filter((line) =>
|
|
35
|
+
line.toLowerCase().includes(String(measure.count).toLowerCase())
|
|
36
|
+
).length;
|
|
37
|
+
if (measure.age) {
|
|
38
|
+
const found = [
|
|
39
|
+
...text.matchAll(
|
|
40
|
+
new RegExp(
|
|
41
|
+
`["']?${escape(measure.age)}["']?\\s*[:=]\\s*"?([0-9][0-9:.TZ+-]*)`,
|
|
42
|
+
"g"
|
|
43
|
+
)
|
|
44
|
+
),
|
|
45
|
+
].at(-1)?.[1];
|
|
46
|
+
if (!found) return null;
|
|
47
|
+
const at = /^\d+(?:\.\d+)?$/.test(found)
|
|
48
|
+
? Number(found) * (Number(found) < 1e12 ? 1000 : 1)
|
|
49
|
+
: Date.parse(found);
|
|
50
|
+
return Number.isFinite(at)
|
|
51
|
+
? Math.max(0, Math.round((now - at) / 1000))
|
|
52
|
+
: null;
|
|
53
|
+
}
|
|
54
|
+
if (measure.field) {
|
|
55
|
+
const found = [
|
|
56
|
+
...text.matchAll(
|
|
57
|
+
new RegExp(
|
|
58
|
+
`["']?${escape(measure.field)}["']?\\s*[:=]\\s*"?(-?\\d+(?:\\.\\d+)?)`,
|
|
59
|
+
"g"
|
|
60
|
+
)
|
|
61
|
+
),
|
|
62
|
+
].at(-1);
|
|
63
|
+
return found ? Number(found[1]) : null;
|
|
64
|
+
}
|
|
65
|
+
const found = text.match(/-?\d+(?:\.\d+)?(?=\D*$)/);
|
|
66
|
+
return found ? Number(found[0]) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the newest sample is off, and why: past a limit the watch names, outside its usual range (median and spread of
|
|
71
|
+
* its history, once it has some), or, falling toward its floor, due to cross it within a day. null when it is fine.
|
|
72
|
+
*/
|
|
73
|
+
export function offPattern(samples, { above = null, below = null } = {}) {
|
|
74
|
+
const values = samples
|
|
75
|
+
.map((sample) => sample.value)
|
|
76
|
+
.filter((value) => value !== null && Number.isFinite(value));
|
|
77
|
+
const latest = samples.at(-1);
|
|
78
|
+
if (!latest || latest.value === null)
|
|
79
|
+
return latest?.error
|
|
80
|
+
? { kind: "failed", text: `its check failed: ${latest.error}` }
|
|
81
|
+
: null;
|
|
82
|
+
if (above !== null && latest.value > above)
|
|
83
|
+
return { kind: "limit", text: `is ${latest.value}, above ${above}` };
|
|
84
|
+
if (below !== null && latest.value < below)
|
|
85
|
+
return { kind: "limit", text: `is ${latest.value}, below ${below}` };
|
|
86
|
+
if (below !== null && samples.length >= 4) {
|
|
87
|
+
// Falling toward the floor: a straight line through the recent samples, and when it reaches it.
|
|
88
|
+
const recent = samples.slice(-12).filter((sample) => sample.value !== null);
|
|
89
|
+
const t0 = recent[0].at;
|
|
90
|
+
const xs = recent.map((sample) => (sample.at - t0) / 3_600_000),
|
|
91
|
+
ys = recent.map((sample) => sample.value);
|
|
92
|
+
const mx = xs.reduce((a, b) => a + b, 0) / xs.length,
|
|
93
|
+
my = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
94
|
+
const slope =
|
|
95
|
+
xs.reduce((sum, x, i) => sum + (x - mx) * (ys[i] - my), 0) /
|
|
96
|
+
(xs.reduce((sum, x) => sum + (x - mx) ** 2, 0) || 1);
|
|
97
|
+
if (slope < 0) {
|
|
98
|
+
const hours = (latest.value - below) / -slope;
|
|
99
|
+
if (hours <= RUN_OUT_HOURS)
|
|
100
|
+
return {
|
|
101
|
+
kind: "run-out",
|
|
102
|
+
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"}`}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (above === null && below === null && values.length > USUAL_AFTER) {
|
|
107
|
+
const history = values.slice(0, -1).slice(-100);
|
|
108
|
+
const middle = median(history);
|
|
109
|
+
// A number that takes two values (a sawtooth, a flag) has no median deviation; the mean deviation stands in.
|
|
110
|
+
const deviations = history.map((value) => Math.abs(value - middle));
|
|
111
|
+
const spread =
|
|
112
|
+
median(deviations) ||
|
|
113
|
+
deviations.reduce((a, b) => a + b, 0) / deviations.length ||
|
|
114
|
+
Math.max(1e-9, Math.abs(middle) * 0.05);
|
|
115
|
+
if (Math.abs(latest.value - middle) > 6 * spread)
|
|
116
|
+
return {
|
|
117
|
+
kind: "unusual",
|
|
118
|
+
text: `is ${latest.value}; it is usually about ${Number(middle.toPrecision(4))}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Runs a check's command in its folder; its output, or an error. A check never runs longer than its own interval. */
|
|
125
|
+
function runCheck(watch, shell = process.env.SHELL || "/bin/sh") {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const started = performance.now();
|
|
128
|
+
let out = "",
|
|
129
|
+
err = "",
|
|
130
|
+
done = false;
|
|
131
|
+
const child = spawn(shell, ["-lc", watch.command], {
|
|
132
|
+
cwd: watch.folder || undefined,
|
|
133
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
134
|
+
});
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
child.kill("SIGKILL");
|
|
137
|
+
finish(null, `it took longer than its ${watch.everySeconds} s interval`);
|
|
138
|
+
}, watch.everySeconds * 1000);
|
|
139
|
+
const finish = (output, error) => {
|
|
140
|
+
if (done) return;
|
|
141
|
+
done = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve({ output, error, ms: Math.round(performance.now() - started) });
|
|
144
|
+
};
|
|
145
|
+
child.stdout.on("data", (chunk) => {
|
|
146
|
+
out = (out + chunk).slice(-200_000);
|
|
147
|
+
});
|
|
148
|
+
child.stderr.on("data", (chunk) => {
|
|
149
|
+
err = (err + chunk).slice(-4_000);
|
|
150
|
+
});
|
|
151
|
+
child.on("error", (error) => finish(null, error.message));
|
|
152
|
+
child.on("close", (code) =>
|
|
153
|
+
finish(
|
|
154
|
+
code === 0 || out ? out : null,
|
|
155
|
+
code === 0 || out
|
|
156
|
+
? null
|
|
157
|
+
: err.trim().split("\n").at(-1) || `it exited with code ${code}`
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createWatches({
|
|
164
|
+
store,
|
|
165
|
+
notify = () => {},
|
|
166
|
+
log = () => {},
|
|
167
|
+
run = runCheck,
|
|
168
|
+
}) {
|
|
169
|
+
const list = () => {
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(store.meta("watches") || "[]");
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const save = (watches) => store.meta("watches", JSON.stringify(watches));
|
|
177
|
+
const samplesOf = (id) => {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(store.meta(`watch-samples:${id}`) || "[]");
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const timers = new Map();
|
|
185
|
+
|
|
186
|
+
const check = async (id) => {
|
|
187
|
+
const watch = list().find((entry) => entry.id === id);
|
|
188
|
+
if (!watch || watch.state !== "on") return null;
|
|
189
|
+
const result = await run(watch);
|
|
190
|
+
const value =
|
|
191
|
+
result.output === null ? null : readNumber(result.output, watch.measure);
|
|
192
|
+
const samples = [
|
|
193
|
+
...samplesOf(id),
|
|
194
|
+
{
|
|
195
|
+
at: Date.now(),
|
|
196
|
+
value,
|
|
197
|
+
ms: result.ms,
|
|
198
|
+
...(result.error
|
|
199
|
+
? { error: result.error.slice(0, 300) }
|
|
200
|
+
: value === null
|
|
201
|
+
? { error: "its output had no number to read" }
|
|
202
|
+
: {}),
|
|
203
|
+
},
|
|
204
|
+
].slice(-HISTORY);
|
|
205
|
+
store.meta(`watch-samples:${id}`, JSON.stringify(samples));
|
|
206
|
+
const off = offPattern(samples, watch.measure);
|
|
207
|
+
const current = list().find((entry) => entry.id === id);
|
|
208
|
+
if (!current) return null;
|
|
209
|
+
// Said once when it goes off and once when it is back; not on every check while it stays off.
|
|
210
|
+
if (off && !current.off) {
|
|
211
|
+
const alert = {
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
text: `${current.name} ${off.text}.`,
|
|
214
|
+
kind: off.kind,
|
|
215
|
+
};
|
|
216
|
+
save(
|
|
217
|
+
list().map((entry) =>
|
|
218
|
+
entry.id === id ? { ...entry, off: alert } : entry
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
store.meta(
|
|
222
|
+
"watch-alerts",
|
|
223
|
+
JSON.stringify(
|
|
224
|
+
[
|
|
225
|
+
...JSON.parse(store.meta("watch-alerts") || "[]"),
|
|
226
|
+
{ watch: id, ...alert },
|
|
227
|
+
].slice(-200)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
notify(alert.text, { watch: current, alert });
|
|
231
|
+
log(`watch ${current.name}: ${off.text}`);
|
|
232
|
+
} else if (!off && current.off) {
|
|
233
|
+
save(
|
|
234
|
+
list().map((entry) =>
|
|
235
|
+
entry.id === id ? { ...entry, off: null } : entry
|
|
236
|
+
)
|
|
237
|
+
);
|
|
238
|
+
notify(`${current.name} is back to normal.`, {
|
|
239
|
+
watch: current,
|
|
240
|
+
alert: null,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return { value, off, ms: result.ms };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const schedule = (watch) => {
|
|
247
|
+
clearTimeout(timers.get(watch.id));
|
|
248
|
+
if (watch.state !== "on") {
|
|
249
|
+
timers.delete(watch.id);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const tick = () => {
|
|
253
|
+
void check(watch.id)
|
|
254
|
+
.catch((error) => log(`watch ${watch.name}: ${error.message}`))
|
|
255
|
+
.finally(() => {
|
|
256
|
+
if (timers.has(watch.id))
|
|
257
|
+
timers.set(
|
|
258
|
+
watch.id,
|
|
259
|
+
setTimeout(tick, watch.everySeconds * 1000).unref?.() ?? null
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
timers.set(watch.id, setTimeout(tick, 0));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
list: () =>
|
|
268
|
+
list().map((watch) => ({
|
|
269
|
+
...watch,
|
|
270
|
+
samples: samplesOf(watch.id).slice(-20),
|
|
271
|
+
})),
|
|
272
|
+
alerts: (since = "") => {
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(store.meta("watch-alerts") || "[]").filter(
|
|
275
|
+
(alert) => alert.at > since
|
|
276
|
+
);
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
/** A new watch: the person's starts at once; an agent's waits for the person's start. */
|
|
282
|
+
add({
|
|
283
|
+
name,
|
|
284
|
+
command,
|
|
285
|
+
folder = null,
|
|
286
|
+
everySeconds = 300,
|
|
287
|
+
measure = {},
|
|
288
|
+
by = "user",
|
|
289
|
+
}) {
|
|
290
|
+
if (!String(name || "").trim() || !String(command || "").trim())
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: "a watch needs a name and a command to read",
|
|
294
|
+
};
|
|
295
|
+
const every = Math.max(
|
|
296
|
+
MIN_EVERY_SECONDS,
|
|
297
|
+
Math.round(Number(everySeconds) || 300)
|
|
298
|
+
);
|
|
299
|
+
const clean = {
|
|
300
|
+
...(measure.field
|
|
301
|
+
? { field: String(measure.field).slice(0, 100) }
|
|
302
|
+
: {}),
|
|
303
|
+
...(measure.count
|
|
304
|
+
? { count: String(measure.count).slice(0, 100) }
|
|
305
|
+
: {}),
|
|
306
|
+
...(measure.age ? { age: String(measure.age).slice(0, 100) } : {}),
|
|
307
|
+
above:
|
|
308
|
+
Number.isFinite(Number(measure.above)) &&
|
|
309
|
+
measure.above !== null &&
|
|
310
|
+
measure.above !== ""
|
|
311
|
+
? Number(measure.above)
|
|
312
|
+
: null,
|
|
313
|
+
below:
|
|
314
|
+
Number.isFinite(Number(measure.below)) &&
|
|
315
|
+
measure.below !== null &&
|
|
316
|
+
measure.below !== ""
|
|
317
|
+
? Number(measure.below)
|
|
318
|
+
: null,
|
|
319
|
+
};
|
|
320
|
+
const watch = {
|
|
321
|
+
id: randomUUID(),
|
|
322
|
+
name: String(name).trim().slice(0, 120),
|
|
323
|
+
command: String(command).slice(0, 2_000),
|
|
324
|
+
folder,
|
|
325
|
+
everySeconds: every,
|
|
326
|
+
measure: clean,
|
|
327
|
+
by,
|
|
328
|
+
state: by === "user" ? "on" : "suggested",
|
|
329
|
+
off: null,
|
|
330
|
+
createdAt: new Date().toISOString(),
|
|
331
|
+
};
|
|
332
|
+
save([...list(), watch]);
|
|
333
|
+
if (watch.state === "on") schedule(watch);
|
|
334
|
+
return { ok: true, watch };
|
|
335
|
+
},
|
|
336
|
+
start(id) {
|
|
337
|
+
const watch = list().find((entry) => entry.id === id);
|
|
338
|
+
if (!watch) return { ok: false, error: "no such watch" };
|
|
339
|
+
const on = { ...watch, state: "on" };
|
|
340
|
+
save(list().map((entry) => (entry.id === id ? on : entry)));
|
|
341
|
+
schedule(on);
|
|
342
|
+
return { ok: true, watch: on };
|
|
343
|
+
},
|
|
344
|
+
remove(id) {
|
|
345
|
+
if (!list().some((entry) => entry.id === id))
|
|
346
|
+
return { ok: false, error: "no such watch" };
|
|
347
|
+
clearTimeout(timers.get(id));
|
|
348
|
+
timers.delete(id);
|
|
349
|
+
save(list().filter((entry) => entry.id !== id));
|
|
350
|
+
store.meta(`watch-samples:${id}`, "[]");
|
|
351
|
+
return { ok: true };
|
|
352
|
+
},
|
|
353
|
+
check,
|
|
354
|
+
resume() {
|
|
355
|
+
for (const watch of list()) if (watch.state === "on") schedule(watch);
|
|
356
|
+
},
|
|
357
|
+
stop() {
|
|
358
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
359
|
+
timers.clear();
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|