tryharness 0.7.25 → 0.7.28
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/cli.mjs +41 -3
- package/lib/asks.mjs +260 -0
- package/lib/cards.mjs +2 -1
- package/lib/coordinator.mjs +444 -68
- package/lib/daemon.mjs +535 -88
- package/lib/goals.mjs +199 -0
- package/lib/lane.mjs +187 -0
- package/lib/listen.mjs +674 -0
- package/lib/mcp-stdio.mjs +107 -11
- package/lib/members.json +2 -2
- package/lib/nudge.mjs +3 -1
- package/lib/phones.mjs +21 -0
- package/lib/places.mjs +38 -0
- package/lib/runner.mjs +11 -1
- package/lib/store.mjs +73 -1
- package/lib/swarm.mjs +128 -3
- package/lib/t3.mjs +16 -2
- package/lib/video.mjs +332 -0
- package/package.json +1 -1
package/lib/goals.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// A thread's goal (owner, October 15: "very little plumbing on evaluating whether that goal has been reached… no
|
|
2
|
+
// mechanism to update the goal by whoever is leading the swarm"). The thread's title is only its first line; the goal is
|
|
3
|
+
// the whole aim in the person's terms, the checks that show it is reached, and where it stands. The lead drafts it from
|
|
4
|
+
// the person's words, writes a new version when the person changes what they want, and marks each check reached with
|
|
5
|
+
// the posts that show it. Every version stays, with the post that caused it. A version can say what the work is for, never
|
|
6
|
+
// what it may spend or do: those stay the person's own limits.
|
|
7
|
+
|
|
8
|
+
const KEY = (threadId) => `goal-record:${threadId}`;
|
|
9
|
+
const MAX_CRITERIA = 12;
|
|
10
|
+
const STATES = ["working", "ready_for_review", "met"];
|
|
11
|
+
|
|
12
|
+
/** The record as stored, or null. */
|
|
13
|
+
const readRecord = (store, threadId) => {
|
|
14
|
+
try {
|
|
15
|
+
const record = JSON.parse(store.meta(KEY(threadId)) || "null");
|
|
16
|
+
return record && typeof record.statement === "string" ? record : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const listed = (criteria) =>
|
|
23
|
+
criteria.map((criterion, index) => `${index + 1}. ${criterion.text}`);
|
|
24
|
+
|
|
25
|
+
export function createGoals({ store, notifyBoard = () => {} }) {
|
|
26
|
+
const save = (threadId, record) =>
|
|
27
|
+
store.meta(KEY(threadId), JSON.stringify(record));
|
|
28
|
+
// The lead's goal posts are the coordinator's, not Harness the agent's (coordinator.mjs speakerOf); the person's are theirs.
|
|
29
|
+
const post = (threadId, author, text) => {
|
|
30
|
+
const posted = store.roomPost({
|
|
31
|
+
goalId: threadId,
|
|
32
|
+
author,
|
|
33
|
+
kind: "goal_update",
|
|
34
|
+
text,
|
|
35
|
+
});
|
|
36
|
+
if (posted && author !== "user")
|
|
37
|
+
store.meta(`coordinator-post:${posted.id}`, "1");
|
|
38
|
+
return posted;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The goal as a coordinator round reads it, or null when the thread has none yet. */
|
|
42
|
+
const view = (threadId) => {
|
|
43
|
+
const record = readRecord(store, threadId);
|
|
44
|
+
return record
|
|
45
|
+
? {
|
|
46
|
+
version: record.version,
|
|
47
|
+
statement: record.statement,
|
|
48
|
+
criteria: record.criteria.map(({ id, text, status, evidence }) => ({
|
|
49
|
+
id,
|
|
50
|
+
text,
|
|
51
|
+
status,
|
|
52
|
+
evidence,
|
|
53
|
+
})),
|
|
54
|
+
state: record.state,
|
|
55
|
+
}
|
|
56
|
+
: null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A new version, from the lead's round or the person. A criterion kept word for word keeps what it reached; one left
|
|
61
|
+
* out is dropped, so the record still shows it. Posted in the thread with the post it follows.
|
|
62
|
+
*/
|
|
63
|
+
const set = (threadId, { statement, criteria }, { by, seq = null }) => {
|
|
64
|
+
const text = String(statement || "")
|
|
65
|
+
.trim()
|
|
66
|
+
.slice(0, 4_000);
|
|
67
|
+
const wanted = [
|
|
68
|
+
...new Set(
|
|
69
|
+
(Array.isArray(criteria) ? criteria : [])
|
|
70
|
+
.map((entry) =>
|
|
71
|
+
String(entry || "")
|
|
72
|
+
.trim()
|
|
73
|
+
.slice(0, 500)
|
|
74
|
+
)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
),
|
|
77
|
+
];
|
|
78
|
+
if (!text || !wanted.length) return null;
|
|
79
|
+
const before = readRecord(store, threadId);
|
|
80
|
+
const kept = new Map(
|
|
81
|
+
(before?.criteria ?? []).map((criterion) => [criterion.text, criterion])
|
|
82
|
+
);
|
|
83
|
+
let next = before?.nextId ?? 1;
|
|
84
|
+
const current = wanted.map((wording) => {
|
|
85
|
+
const same = kept.get(wording);
|
|
86
|
+
if (same)
|
|
87
|
+
return same.status === "dropped" ? { ...same, status: "open" } : same;
|
|
88
|
+
return { id: `c${next++}`, text: wording, status: "open", evidence: [] };
|
|
89
|
+
});
|
|
90
|
+
const dropped = (before?.criteria ?? [])
|
|
91
|
+
.filter((criterion) => !wanted.includes(criterion.text))
|
|
92
|
+
.map((criterion) => ({ ...criterion, status: "dropped" }));
|
|
93
|
+
const version = (before?.version ?? 0) + 1;
|
|
94
|
+
const at = new Date().toISOString();
|
|
95
|
+
const record = {
|
|
96
|
+
version,
|
|
97
|
+
statement: text,
|
|
98
|
+
criteria: [...current, ...dropped].slice(0, MAX_CRITERIA),
|
|
99
|
+
state: "working",
|
|
100
|
+
nextId: next,
|
|
101
|
+
versions: [
|
|
102
|
+
...(before?.versions ?? []),
|
|
103
|
+
{ version, statement: text, criteria: wanted, by, seq, at },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
save(threadId, record);
|
|
107
|
+
const cause =
|
|
108
|
+
version === 1
|
|
109
|
+
? ""
|
|
110
|
+
: seq === null
|
|
111
|
+
? ` (version ${version})`
|
|
112
|
+
: ` (version ${version}, after post ${seq})`;
|
|
113
|
+
post(
|
|
114
|
+
threadId,
|
|
115
|
+
by,
|
|
116
|
+
[`Goal${cause}: ${text}`, "Done when:", ...listed(current)].join("\n")
|
|
117
|
+
);
|
|
118
|
+
notifyBoard(store.getTask(threadId));
|
|
119
|
+
return record;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a round found: checks reached with the posts that show them, and where the goal stands. Says so in the thread
|
|
124
|
+
* when it becomes ready for the person's review; returns "met" when every check is reached, for the lead to close it.
|
|
125
|
+
*/
|
|
126
|
+
const progress = (threadId, { met = [], goalState = null }, { by }) => {
|
|
127
|
+
const record = readRecord(store, threadId);
|
|
128
|
+
if (!record) return { became: null };
|
|
129
|
+
const reached = new Map(
|
|
130
|
+
(Array.isArray(met) ? met : []).map((entry) => [entry?.id, entry])
|
|
131
|
+
);
|
|
132
|
+
const criteria = record.criteria.map((criterion) => {
|
|
133
|
+
const entry = reached.get(criterion.id);
|
|
134
|
+
if (!entry || criterion.status !== "open") return criterion;
|
|
135
|
+
const evidence = (Array.isArray(entry.evidence) ? entry.evidence : [])
|
|
136
|
+
.map(Number)
|
|
137
|
+
.filter(Number.isInteger)
|
|
138
|
+
.slice(0, 10);
|
|
139
|
+
return { ...criterion, status: "met", evidence };
|
|
140
|
+
});
|
|
141
|
+
const allMet = criteria.every((criterion) => criterion.status !== "open");
|
|
142
|
+
// Met only when every check is reached: a round that says so early leaves it working.
|
|
143
|
+
const state = STATES.includes(goalState)
|
|
144
|
+
? goalState === "met" && !allMet
|
|
145
|
+
? "working"
|
|
146
|
+
: goalState
|
|
147
|
+
: record.state;
|
|
148
|
+
save(threadId, { ...record, criteria, state });
|
|
149
|
+
const became = state !== record.state ? state : null;
|
|
150
|
+
if (became === "ready_for_review") {
|
|
151
|
+
const done = criteria.filter((criterion) => criterion.status === "met");
|
|
152
|
+
post(
|
|
153
|
+
threadId,
|
|
154
|
+
by,
|
|
155
|
+
[
|
|
156
|
+
"Ready for your review.",
|
|
157
|
+
...done.map(
|
|
158
|
+
(criterion) =>
|
|
159
|
+
`Reached: ${criterion.text}${criterion.evidence.length ? ` (post ${criterion.evidence.join(", ")})` : ""}`
|
|
160
|
+
),
|
|
161
|
+
].join("\n")
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (became) notifyBoard(store.getTask(threadId));
|
|
165
|
+
return { became };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** The person wrote in a thread whose goal was met: it is being worked again. */
|
|
169
|
+
const reopen = (threadId) => {
|
|
170
|
+
const record = readRecord(store, threadId);
|
|
171
|
+
if (record && record.state !== "working")
|
|
172
|
+
save(threadId, { ...record, state: "working" });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The whole record, every version included, for the thread's read. */
|
|
176
|
+
const record = (threadId) => {
|
|
177
|
+
const stored = readRecord(store, threadId);
|
|
178
|
+
if (!stored) return null;
|
|
179
|
+
const { nextId, ...shown } = stored;
|
|
180
|
+
return shown;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** What the thread's answer says when its goal is met: the goal and each check with the posts that show it. */
|
|
184
|
+
const summary = (threadId) => {
|
|
185
|
+
const stored = readRecord(store, threadId);
|
|
186
|
+
if (!stored) return "";
|
|
187
|
+
return [
|
|
188
|
+
`The goal is met: ${stored.statement}`,
|
|
189
|
+
...stored.criteria
|
|
190
|
+
.filter((criterion) => criterion.status === "met")
|
|
191
|
+
.map(
|
|
192
|
+
(criterion) =>
|
|
193
|
+
`Reached: ${criterion.text}${criterion.evidence.length ? ` (post ${criterion.evidence.join(", ")})` : ""}`
|
|
194
|
+
),
|
|
195
|
+
].join("\n");
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { view, set, progress, reopen, record, summary };
|
|
199
|
+
}
|
package/lib/lane.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readJson, writeJsonAtomic } from "./files.mjs";
|
|
4
|
+
import { paths } from "./paths.mjs";
|
|
5
|
+
|
|
6
|
+
// The person's own model lane, kept on this computer once they let Harness Relay use it (owner, October 15): the API key
|
|
7
|
+
// or model endpoint their Harness window runs on, so Harness can answer in their swarm's threads while that window is
|
|
8
|
+
// closed. Only their linked Harness window gives it (sealed, phones.mjs), only they can take it back, and it leaves this
|
|
9
|
+
// computer only with a task given to Harness, the way the window sends it with each of its own turns: Harness's server
|
|
10
|
+
// uses it for that task and keeps nothing (src/server/swarm/harnessTask.ts). An endpoint only this computer reaches
|
|
11
|
+
// (localhost or the person's network) is called from here for Harness's server (`fetch`), and only that endpoint.
|
|
12
|
+
|
|
13
|
+
const PROVIDERS = new Set(["venice", "bankr", "openai", "dot"]);
|
|
14
|
+
export const laneFile = () => path.join(paths.home, "harness-lane.json");
|
|
15
|
+
|
|
16
|
+
// The shape Harness's server takes (src/schemas/credentials.ts), checked here so a malformed lane is refused when given.
|
|
17
|
+
function credentialOf(value) {
|
|
18
|
+
if (!value || typeof value !== "object") return null;
|
|
19
|
+
if (value.type === "byok" && PROVIDERS.has(value.provider)) {
|
|
20
|
+
const keys = Object.fromEntries(
|
|
21
|
+
Object.entries(value.keys ?? {}).filter(
|
|
22
|
+
([provider, key]) =>
|
|
23
|
+
PROVIDERS.has(provider) && typeof key === "string" && key.trim()
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
return keys[value.provider]
|
|
27
|
+
? { type: "byok", provider: value.provider, keys }
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
const endpoint = value.endpoint;
|
|
31
|
+
if (
|
|
32
|
+
value.type === "custom" &&
|
|
33
|
+
typeof value.selectedModelId === "string" &&
|
|
34
|
+
endpoint &&
|
|
35
|
+
typeof endpoint === "object" &&
|
|
36
|
+
typeof endpoint.baseUrl === "string" &&
|
|
37
|
+
/^https?:\/\//.test(endpoint.baseUrl) &&
|
|
38
|
+
["server", "relay"].includes(endpoint.transport)
|
|
39
|
+
)
|
|
40
|
+
return {
|
|
41
|
+
type: "custom",
|
|
42
|
+
endpoint,
|
|
43
|
+
selectedModelId: value.selectedModelId,
|
|
44
|
+
};
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// What may be said of the lane anywhere: never a key.
|
|
49
|
+
function described(lane) {
|
|
50
|
+
if (!lane) return { shared: false };
|
|
51
|
+
const credential = lane.credentialSource;
|
|
52
|
+
return {
|
|
53
|
+
shared: true,
|
|
54
|
+
modelId: lane.modelId,
|
|
55
|
+
sharedAt: lane.sharedAt,
|
|
56
|
+
...(credential.type === "byok"
|
|
57
|
+
? { kind: "key", provider: credential.provider }
|
|
58
|
+
: {
|
|
59
|
+
kind: "endpoint",
|
|
60
|
+
label: String(credential.endpoint.label || ""),
|
|
61
|
+
onThisComputer: credential.endpoint.transport === "relay",
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `changed`: the lane was given or taken back, so the tasks for Harness that wait can be offered again.
|
|
67
|
+
export function createHarnessLane({
|
|
68
|
+
file = laneFile(),
|
|
69
|
+
log = () => {},
|
|
70
|
+
changed = () => {},
|
|
71
|
+
fetchImpl = fetch,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const held = () => {
|
|
74
|
+
const saved = readJson(file, null);
|
|
75
|
+
const credentialSource = credentialOf(saved?.credentialSource);
|
|
76
|
+
return credentialSource && typeof saved.modelId === "string"
|
|
77
|
+
? { ...saved, credentialSource }
|
|
78
|
+
: null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The person's window gives its lane: the model it runs and the key or endpoint that serves it. */
|
|
82
|
+
const share = ({ modelId, credentialSource }) => {
|
|
83
|
+
const credential = credentialOf(credentialSource);
|
|
84
|
+
if (!credential || typeof modelId !== "string" || !modelId.trim())
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "only your own API key or model endpoint can be given",
|
|
88
|
+
};
|
|
89
|
+
if (credential.type === "custom" && credential.selectedModelId !== modelId)
|
|
90
|
+
return { ok: false, error: "the model is not the endpoint's" };
|
|
91
|
+
writeJsonAtomic(file, {
|
|
92
|
+
modelId: modelId.slice(0, 200),
|
|
93
|
+
credentialSource: credential,
|
|
94
|
+
sharedAt: new Date().toISOString(),
|
|
95
|
+
});
|
|
96
|
+
log(`harness lane: kept the person's ${described(held()).kind}`);
|
|
97
|
+
changed();
|
|
98
|
+
return { ok: true, lane: described(held()) };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The person takes it back: nothing of it stays here. */
|
|
102
|
+
const forget = () => {
|
|
103
|
+
try {
|
|
104
|
+
fs.unlinkSync(file);
|
|
105
|
+
} catch {}
|
|
106
|
+
changed();
|
|
107
|
+
return { ok: true, lane: described(null) };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One call Harness's server makes to an endpoint only this computer reaches, for a task given to Harness: the
|
|
112
|
+
* endpoint's own address only. Its answer goes back as it comes (`say`): the status and headers, the body in parts,
|
|
113
|
+
* then the end. A refusal is a 403 and an endpoint that cannot be reached a 502, each with an OpenAI-style error the
|
|
114
|
+
* server's model call reports. No deadline of its own: the server's stream guards bound each part.
|
|
115
|
+
*/
|
|
116
|
+
const call = async (
|
|
117
|
+
{ url, method = "POST", headers = {}, body = null },
|
|
118
|
+
say
|
|
119
|
+
) => {
|
|
120
|
+
const failed = (status, message) => {
|
|
121
|
+
say({
|
|
122
|
+
type: "lane_head",
|
|
123
|
+
status,
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
});
|
|
126
|
+
say({ type: "lane_chunk", data: JSON.stringify({ error: { message } }) });
|
|
127
|
+
say({ type: "lane_end" });
|
|
128
|
+
};
|
|
129
|
+
const credential = held()?.credentialSource;
|
|
130
|
+
const base =
|
|
131
|
+
credential?.type === "custom" && credential.endpoint.transport === "relay"
|
|
132
|
+
? credential.endpoint.baseUrl.replace(/\/+$/, "")
|
|
133
|
+
: null;
|
|
134
|
+
const target = String(url || "");
|
|
135
|
+
if (!base || (target !== base && !target.startsWith(`${base}/`)))
|
|
136
|
+
return failed(
|
|
137
|
+
403,
|
|
138
|
+
"that address is not the model endpoint the person let Harness Relay use"
|
|
139
|
+
);
|
|
140
|
+
if (!["GET", "POST"].includes(method))
|
|
141
|
+
return failed(403, `${method} is not a model call`);
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetchImpl(target, {
|
|
145
|
+
method,
|
|
146
|
+
headers: Object.fromEntries(
|
|
147
|
+
Object.entries(headers).map(([name, value]) => [name, String(value)])
|
|
148
|
+
),
|
|
149
|
+
...(body != null && method === "POST" ? { body: String(body) } : {}),
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return failed(
|
|
153
|
+
502,
|
|
154
|
+
`the person's model endpoint did not answer: ${error.message}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
say({
|
|
158
|
+
type: "lane_head",
|
|
159
|
+
status: response.status,
|
|
160
|
+
headers: { "content-type": response.headers.get("content-type") ?? "" },
|
|
161
|
+
});
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
try {
|
|
164
|
+
for await (const bytes of response.body ?? [])
|
|
165
|
+
say({
|
|
166
|
+
type: "lane_chunk",
|
|
167
|
+
data: decoder.decode(bytes, { stream: true }),
|
|
168
|
+
});
|
|
169
|
+
const rest = decoder.decode();
|
|
170
|
+
if (rest) say({ type: "lane_chunk", data: rest });
|
|
171
|
+
say({ type: "lane_end" });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
say({
|
|
174
|
+
type: "lane_end",
|
|
175
|
+
error: `the person's model endpoint broke off: ${error.message}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
share,
|
|
182
|
+
forget,
|
|
183
|
+
status: () => ({ ok: true, lane: described(held()) }),
|
|
184
|
+
held,
|
|
185
|
+
call,
|
|
186
|
+
};
|
|
187
|
+
}
|