hilos-agent 0.1.15 → 0.1.16
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/package.json +1 -1
- package/src/agent-events.mjs +371 -0
- package/src/config.mjs +11 -0
- package/src/daemon.mjs +18 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +801 -51
- package/src/memory.mjs +71 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Normalized vendor stream parser (0272). The KEYSTONE of the "Agents feel
|
|
2
|
+
// alive" epic: coding CLIs each narrate their work in a different, unstable
|
|
3
|
+
// wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
|
|
4
|
+
// emits `--json` item events, Cursor emits unstructured text). This module turns
|
|
5
|
+
// any of them into ONE small, typed `AgentEvent` stream the UI can render as a
|
|
6
|
+
// live "what the agent is doing right now" card.
|
|
7
|
+
//
|
|
8
|
+
// Design rules that make this safe to point at an untrusted, evolving CLI:
|
|
9
|
+
// - PURE + dependency-free (node builtins only) so it stands alone and is
|
|
10
|
+
// unit-tested without a sandbox — mirror of the .mjs siblings' style.
|
|
11
|
+
// - NEVER throws. A vendor changing its format, a non-JSON log line, or an
|
|
12
|
+
// unknown event type must DEGRADE (emit nothing), never crash a run.
|
|
13
|
+
// - Every string that reaches the UI is sanitized (token-shaped secrets
|
|
14
|
+
// stripped, length capped) — a chatty CLI can echo a `Bearer …` header.
|
|
15
|
+
//
|
|
16
|
+
// Claude shapes here MATCH `lib/hosted-worklog.ts` / `lib/hosted-agent.ts`
|
|
17
|
+
// (`formatClaudeEvent`, `parseStreamResult`) so ticket 0274 can swap the daemon's
|
|
18
|
+
// hand-rolled `lastLine` capture (see handler.mjs onData ~:787) for this parser
|
|
19
|
+
// with zero behavior change. NOTHING imports this yet except the test — wiring is
|
|
20
|
+
// 0274.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} AgentEvent
|
|
24
|
+
* A normalized, render-ready step. Exactly one of these per meaningful thing the
|
|
25
|
+
* coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
|
|
26
|
+
* @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'} t
|
|
27
|
+
* - 'session' → { sessionId } the CLI's resumable session id (init only)
|
|
28
|
+
* - 'edit' → { path } wrote/edited a file
|
|
29
|
+
* - 'read' → { path } read a file
|
|
30
|
+
* - 'run' → { cmd } ran a shell command
|
|
31
|
+
* - 'note' → { text } the agent's narration / thinking (think→note)
|
|
32
|
+
* - 'result' → { ok, summary? } the run finished (ok=false on error)
|
|
33
|
+
* - 'phase' → { name } reserved lifecycle marker (unused by v1
|
|
34
|
+
* parsers; kept so 0274 can add start/end
|
|
35
|
+
* phases without widening the type)
|
|
36
|
+
* @property {string} [sessionId]
|
|
37
|
+
* @property {string} [path]
|
|
38
|
+
* @property {string} [cmd]
|
|
39
|
+
* @property {string} [text]
|
|
40
|
+
* @property {string} [name]
|
|
41
|
+
* @property {boolean} [ok]
|
|
42
|
+
* @property {string} [summary]
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// --- Security: sanitize everything that reaches the UI ---------------------
|
|
46
|
+
|
|
47
|
+
const MAX_LEN = 300;
|
|
48
|
+
const REDACTED = "[redacted]";
|
|
49
|
+
|
|
50
|
+
// Token-shaped strings a CLI might echo (auth headers, keys it was handed, hex/
|
|
51
|
+
// base64 blobs). Order matters — specific prefixes before the generic blobs so a
|
|
52
|
+
// `ghp_…` is labelled, not swallowed by the base64 rule.
|
|
53
|
+
const SECRET_PATTERNS = [
|
|
54
|
+
/\bBearer\s+[A-Za-z0-9._\-]+/gi, // "Bearer <token>"
|
|
55
|
+
/\bhilos_[A-Za-z0-9]{8,}/g, // hilos MCP bearer tokens
|
|
56
|
+
/\bsbp_[A-Za-z0-9]{16,}/g, // Supabase access/management tokens (SUPABASE_ACCESS_TOKEN)
|
|
57
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g, // fine-grained GitHub PAT
|
|
58
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // ghp_/gho_/ghu_/ghs_/ghr_ GitHub tokens
|
|
59
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
|
|
60
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI/Anthropic-style secret keys
|
|
61
|
+
/\b[A-Fa-f0-9]{32,}\b/g, // long hex blobs
|
|
62
|
+
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, // long base64 blobs
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strip token-shaped secrets and cap length. Exported so the server (0274+) can
|
|
67
|
+
* reuse the exact same redaction on anything it echoes. Never throws; non-string
|
|
68
|
+
* input → "".
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeText(value) {
|
|
73
|
+
if (typeof value !== "string") return "";
|
|
74
|
+
let out = value;
|
|
75
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
|
|
76
|
+
if (out.length > MAX_LEN) out = out.slice(0, MAX_LEN - 1) + "…";
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Line parsing per vendor ----------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** JSON.parse that returns null instead of throwing on a non-JSON line. */
|
|
83
|
+
function tryParse(line) {
|
|
84
|
+
const trimmed = String(line || "").trim();
|
|
85
|
+
if (!trimmed) return null;
|
|
86
|
+
try {
|
|
87
|
+
const obj = JSON.parse(trimmed);
|
|
88
|
+
return obj && typeof obj === "object" ? obj : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null; // log noise / partial line / plain text → ignore
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Map a Claude/agent tool name to a normalized event kind (or null to skip). */
|
|
95
|
+
function toolKind(name) {
|
|
96
|
+
switch (name) {
|
|
97
|
+
case "Edit":
|
|
98
|
+
case "MultiEdit":
|
|
99
|
+
case "Write":
|
|
100
|
+
case "NotebookEdit":
|
|
101
|
+
return "edit";
|
|
102
|
+
case "Read":
|
|
103
|
+
return "read";
|
|
104
|
+
case "Bash":
|
|
105
|
+
return "run";
|
|
106
|
+
default:
|
|
107
|
+
// Grep/Glob/WebFetch/Task/etc. carry less "alive" signal; ignore in v1 so
|
|
108
|
+
// an unknown tool can never crash the parser. 0274 may promote some.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One assistant content block → an AgentEvent, or null. */
|
|
114
|
+
function blockToEvent(block) {
|
|
115
|
+
if (!block || typeof block !== "object") return null;
|
|
116
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
117
|
+
const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
|
|
118
|
+
return text ? { t: "note", text } : null;
|
|
119
|
+
}
|
|
120
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
121
|
+
const kind = toolKind(block.name);
|
|
122
|
+
if (!kind) return null;
|
|
123
|
+
const input = block.input && typeof block.input === "object" ? block.input : {};
|
|
124
|
+
if (kind === "run") {
|
|
125
|
+
const cmd = typeof input.command === "string" ? input.command.replace(/\s+/g, " ").trim() : "";
|
|
126
|
+
return { t: "run", cmd: sanitizeText(cmd) };
|
|
127
|
+
}
|
|
128
|
+
const raw = input.file_path ?? input.path ?? input.notebook_path;
|
|
129
|
+
const path = typeof raw === "string" ? sanitizeText(raw) : "";
|
|
130
|
+
return { t: kind, path };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse ONE Claude Code `stream-json` NDJSON line → AgentEvent[]. Shapes mirror
|
|
137
|
+
* `lib/hosted-agent.ts`: system init (session_id), assistant turns (text +
|
|
138
|
+
* tool_use), and the final result. Everything else (user tool_result, unknown
|
|
139
|
+
* types) → [].
|
|
140
|
+
*/
|
|
141
|
+
function parseClaudeLine(line) {
|
|
142
|
+
const obj = tryParse(line);
|
|
143
|
+
if (!obj) return [];
|
|
144
|
+
if (obj.type === "system" && typeof obj.session_id === "string") {
|
|
145
|
+
return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
|
|
146
|
+
}
|
|
147
|
+
if (obj.type === "result") {
|
|
148
|
+
const ok = obj.is_error !== true && obj.subtype !== "error";
|
|
149
|
+
const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
|
|
150
|
+
return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
|
|
151
|
+
}
|
|
152
|
+
if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const block of obj.message.content) {
|
|
155
|
+
const ev = blockToEvent(block);
|
|
156
|
+
if (ev) out.push(ev);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse ONE Codex `--json` item line → AgentEvent[]. Best-effort / experimental:
|
|
165
|
+
* Codex's format is younger and less documented, so we read the kind from either
|
|
166
|
+
* `item.type` (the `{type:"item.completed", item:{…}}` envelope) or a bare
|
|
167
|
+
* top-level `type`, and pull fields from whichever object carries them. Unknown
|
|
168
|
+
* kinds → [].
|
|
169
|
+
*/
|
|
170
|
+
function parseCodexLine(line) {
|
|
171
|
+
const obj = tryParse(line);
|
|
172
|
+
if (!obj) return [];
|
|
173
|
+
const item = obj.item && typeof obj.item === "object" ? obj.item : obj;
|
|
174
|
+
const kind = item.type ?? obj.type;
|
|
175
|
+
switch (kind) {
|
|
176
|
+
case "command_execution": {
|
|
177
|
+
const raw = item.command ?? item.cmd;
|
|
178
|
+
const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
179
|
+
return cmd ? [{ t: "run", cmd: sanitizeText(cmd) }] : [];
|
|
180
|
+
}
|
|
181
|
+
case "file_change": {
|
|
182
|
+
const changes = Array.isArray(item.changes)
|
|
183
|
+
? item.changes
|
|
184
|
+
: item.path
|
|
185
|
+
? [{ path: item.path }]
|
|
186
|
+
: [];
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const c of changes) {
|
|
189
|
+
const p = c && (c.path ?? c.file_path);
|
|
190
|
+
if (typeof p === "string" && p) out.push({ t: "edit", path: sanitizeText(p) });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
case "agent_message": {
|
|
195
|
+
const raw = item.text ?? item.message;
|
|
196
|
+
const text = typeof raw === "string" ? sanitizeText(raw.replace(/\s+/g, " ").trim()) : "";
|
|
197
|
+
return text ? [{ t: "note", text }] : [];
|
|
198
|
+
}
|
|
199
|
+
case "task_complete":
|
|
200
|
+
case "turn.completed": {
|
|
201
|
+
const raw = item.text ?? item.message ?? obj.text;
|
|
202
|
+
const summary = typeof raw === "string" ? sanitizeText(raw) : undefined;
|
|
203
|
+
return [summary ? { t: "result", ok: true, summary } : { t: "result", ok: true }];
|
|
204
|
+
}
|
|
205
|
+
default:
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** claude / claude_code / claude-code all mean the Claude parser. */
|
|
211
|
+
function normalizeVendor(vendor) {
|
|
212
|
+
const v = String(vendor || "").toLowerCase();
|
|
213
|
+
if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
|
|
214
|
+
if (v === "codex") return "codex";
|
|
215
|
+
return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- Streaming parsers -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The daemon pushes raw stdout chunks (see handler.mjs onData ~:787) that can
|
|
222
|
+
* split a JSON object mid-line. A structured parser buffers the partial tail and
|
|
223
|
+
* only parses COMPLETE lines; flush() drains a trailing unterminated line.
|
|
224
|
+
* @param {(line: string) => AgentEvent[]} parseLine
|
|
225
|
+
*/
|
|
226
|
+
function makeLineBufferedParser(parseLine) {
|
|
227
|
+
let buf = "";
|
|
228
|
+
return {
|
|
229
|
+
/** @param {string} chunk @returns {AgentEvent[]} */
|
|
230
|
+
push(chunk) {
|
|
231
|
+
buf += String(chunk ?? "");
|
|
232
|
+
const out = [];
|
|
233
|
+
let nl;
|
|
234
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
235
|
+
const line = buf.slice(0, nl);
|
|
236
|
+
buf = buf.slice(nl + 1);
|
|
237
|
+
for (const ev of parseLine(line)) out.push(ev);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
241
|
+
/** @returns {AgentEvent[]} */
|
|
242
|
+
flush() {
|
|
243
|
+
const out = [];
|
|
244
|
+
if (buf.trim()) for (const ev of parseLine(buf)) out.push(ev);
|
|
245
|
+
buf = "";
|
|
246
|
+
return out;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fallback for Cursor and any vendor with no structured stream. There's nothing
|
|
253
|
+
* to parse, so we only remember the last non-empty line seen — matching exactly
|
|
254
|
+
* what the daemon does today (handler.mjs onData) so we NEVER regress below it —
|
|
255
|
+
* and emit at most one sparse note on flush.
|
|
256
|
+
*/
|
|
257
|
+
function makeCursorParser() {
|
|
258
|
+
let lastLine = "";
|
|
259
|
+
return {
|
|
260
|
+
/** @returns {AgentEvent[]} */
|
|
261
|
+
push(chunk) {
|
|
262
|
+
const lines = String(chunk ?? "")
|
|
263
|
+
.split("\n")
|
|
264
|
+
.map((s) => s.trim())
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
267
|
+
return []; // no structured events mid-stream
|
|
268
|
+
},
|
|
269
|
+
/** @returns {AgentEvent[]} */
|
|
270
|
+
flush() {
|
|
271
|
+
if (!lastLine) return [];
|
|
272
|
+
const text = sanitizeText(lastLine);
|
|
273
|
+
lastLine = "";
|
|
274
|
+
return text ? [{ t: "note", text }] : [];
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build a stateful stream parser for `vendor`.
|
|
281
|
+
* @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
|
|
282
|
+
* @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
|
|
283
|
+
*/
|
|
284
|
+
export function makeStreamParser(vendor) {
|
|
285
|
+
const v = normalizeVendor(vendor);
|
|
286
|
+
if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
|
|
287
|
+
if (v === "codex") return makeLineBufferedParser(parseCodexLine);
|
|
288
|
+
return makeCursorParser();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Human step labels (what the LiveRunCard shows) ------------------------
|
|
292
|
+
|
|
293
|
+
/** Describe a shell command in plain words (design rule: no emoji, calm copy). */
|
|
294
|
+
function describeRun(cmd) {
|
|
295
|
+
const c = String(cmd || "").trim();
|
|
296
|
+
if (!c) return "Running a command";
|
|
297
|
+
if (/\b(test|tests)\b|vitest|jest|pytest|\bgo test\b/i.test(c)) return "Running tests";
|
|
298
|
+
const short = c.length > 60 ? c.slice(0, 59) + "…" : c;
|
|
299
|
+
return `Running ${short}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One AgentEvent → a human step label, or null for events that aren't shown as
|
|
304
|
+
* steps (session/phase are metadata). Pure.
|
|
305
|
+
* @param {AgentEvent} event
|
|
306
|
+
* @returns {string | null}
|
|
307
|
+
*/
|
|
308
|
+
export function stepLabel(event) {
|
|
309
|
+
switch (event && event.t) {
|
|
310
|
+
case "edit":
|
|
311
|
+
return event.path ? `Editing ${event.path}` : "Editing files";
|
|
312
|
+
case "read":
|
|
313
|
+
return event.path ? `Reading ${event.path}` : "Reading files";
|
|
314
|
+
case "run":
|
|
315
|
+
return describeRun(event.cmd);
|
|
316
|
+
case "note":
|
|
317
|
+
return event.text ? event.text : null;
|
|
318
|
+
case "result":
|
|
319
|
+
return event.ok ? "Done" : "Finished with errors";
|
|
320
|
+
default:
|
|
321
|
+
return null; // session / phase / unknown → not a step
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A bounded ring buffer of the most recent human step labels. Coalesces
|
|
327
|
+
* sequential edits to the SAME file into one step (a run that touches one file
|
|
328
|
+
* ten times reads as one "Editing …" line) and drops immediate duplicate
|
|
329
|
+
* labels. `limit` steps kept (default 8) — the card shows a short rolling window.
|
|
330
|
+
* @param {number} [limit]
|
|
331
|
+
*/
|
|
332
|
+
export function createStepRing(limit = 8) {
|
|
333
|
+
const steps = [];
|
|
334
|
+
let lastEditPath = null; // for collapsing consecutive same-file edits
|
|
335
|
+
|
|
336
|
+
/** @param {AgentEvent} event */
|
|
337
|
+
function push(event) {
|
|
338
|
+
const label = stepLabel(event);
|
|
339
|
+
if (!label) return;
|
|
340
|
+
if (event.t === "edit") {
|
|
341
|
+
if (lastEditPath === event.path) return; // same file again → collapse
|
|
342
|
+
lastEditPath = event.path;
|
|
343
|
+
} else {
|
|
344
|
+
lastEditPath = null; // any other step breaks an edit run
|
|
345
|
+
}
|
|
346
|
+
if (steps.length && steps[steps.length - 1] === label) return; // dup guard
|
|
347
|
+
steps.push(label);
|
|
348
|
+
if (steps.length > limit) steps.shift();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
push,
|
|
353
|
+
/** @returns {string[]} */
|
|
354
|
+
labels: () => steps.slice(),
|
|
355
|
+
/** @returns {string | null} */
|
|
356
|
+
latest: () => (steps.length ? steps[steps.length - 1] : null),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Fold a batch of events through a fresh ring → the coalesced step labels. Pure
|
|
362
|
+
* convenience over createStepRing for callers that have the whole list.
|
|
363
|
+
* @param {AgentEvent[]} events
|
|
364
|
+
* @param {number} [limit]
|
|
365
|
+
* @returns {string[]}
|
|
366
|
+
*/
|
|
367
|
+
export function summarizeSteps(events, limit = 8) {
|
|
368
|
+
const ring = createStepRing(limit);
|
|
369
|
+
for (const ev of events || []) ring.push(ev);
|
|
370
|
+
return ring.labels();
|
|
371
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -63,6 +63,11 @@ const DEFAULTS = {
|
|
|
63
63
|
// spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
|
|
64
64
|
// UPDATEs. (Distinct from cli.mjs's 15s LOCAL terminal heartbeat.)
|
|
65
65
|
heartbeatMs: 180000,
|
|
66
|
+
// Live-progress throttle (0274): when the server supports post_progress, the
|
|
67
|
+
// code run streams a coalesced "what I'm doing right now" card instead of the
|
|
68
|
+
// 3-min heartbeat. This caps how often it pushes an UPDATE (leading+trailing).
|
|
69
|
+
// Clamped to >=750ms so a misconfig can't hammer realtime.
|
|
70
|
+
progressMs: 2000,
|
|
66
71
|
// Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
|
|
67
72
|
// on timeout we post an honest "taking longer than expected" line.
|
|
68
73
|
chatTimeoutMs: 90000,
|
|
@@ -88,6 +93,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
88
93
|
codingCmd: process.env.CODING_CMD,
|
|
89
94
|
chatCmd: process.env.HILOS_CHAT_CMD,
|
|
90
95
|
heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
|
|
96
|
+
progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
|
|
91
97
|
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
92
98
|
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
93
99
|
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
@@ -96,6 +102,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
96
102
|
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
97
103
|
// Clamp the chat heartbeat: 0 (off) stays off, otherwise never below 15s.
|
|
98
104
|
if (merged.heartbeatMs > 0) merged.heartbeatMs = Math.max(15000, Number(merged.heartbeatMs) || 0);
|
|
105
|
+
// Clamp the live-progress throttle to a >=750ms floor (a bad value → the default).
|
|
106
|
+
merged.progressMs = Math.max(750, Number(merged.progressMs) || 2000);
|
|
99
107
|
if (joinPayload) {
|
|
100
108
|
merged.url = joinPayload.url;
|
|
101
109
|
merged.token = joinPayload.token;
|
|
@@ -122,6 +130,7 @@ const LIVE_FIELDS = [
|
|
|
122
130
|
"decisionTimeoutMs",
|
|
123
131
|
"decisionPollMs",
|
|
124
132
|
"heartbeatMs",
|
|
133
|
+
"progressMs",
|
|
125
134
|
"chatTimeoutMs",
|
|
126
135
|
// NOT queueConcurrency: the queue is built once at startup, so it can't change
|
|
127
136
|
// live. queueAcks IS live (intake() reads it per-mention from liveCfg).
|
|
@@ -146,8 +155,10 @@ export function reloadConfig(prev) {
|
|
|
146
155
|
if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
|
|
147
156
|
if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
|
|
148
157
|
if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
|
|
158
|
+
if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
|
|
149
159
|
if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
|
|
150
160
|
if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
|
|
161
|
+
next.progressMs = Math.max(750, Number(next.progressMs) || 2000);
|
|
151
162
|
if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
|
|
152
163
|
return next;
|
|
153
164
|
}
|
package/src/daemon.mjs
CHANGED
|
@@ -89,3 +89,21 @@ export function mentionHandle(name) {
|
|
|
89
89
|
.replace(/[^a-z0-9]+/g, "-")
|
|
90
90
|
.replace(/^-+|-+$/g, "");
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A GitHub PR reference embedded in `text` for `repoFullName`, or null. The
|
|
95
|
+
* server tags a "request changes" rework ping with the existing PR's URL
|
|
96
|
+
* ("…update the PR <url>…"), so the daemon can update THAT PR's branch
|
|
97
|
+
* (same-branch iteration) instead of opening a brand-new one.
|
|
98
|
+
*
|
|
99
|
+
* Anchored on a "PR <url>" lead-in (and scoped to the channel's repo) so only an
|
|
100
|
+
* explicit "update the PR <url>" — the server ping, or a person naming a PR —
|
|
101
|
+
* triggers continuation. A stray reference link to some other PR (same or other
|
|
102
|
+
* repo, quoted for context) must NOT hijack the branch.
|
|
103
|
+
*/
|
|
104
|
+
export function detectPrContinuation(text, repoFullName) {
|
|
105
|
+
const m = /\bPR\s+(https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+))/i.exec(String(text || ""));
|
|
106
|
+
if (!m) return null;
|
|
107
|
+
if (repoFullName && m[2].toLowerCase() !== String(repoFullName).toLowerCase()) return null;
|
|
108
|
+
return { url: m[1], repoFullName: m[2], number: Number(m[3]) };
|
|
109
|
+
}
|
package/src/followup.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Same-PR follow-up resolver (0281) — the decision core for "a human replied in a
|
|
2
|
+
// thread asking for a change; do we continue the PR this thread already owns, or
|
|
3
|
+
// fork a new one?" Pure + dependency-free (it needs no imports at all) so it
|
|
4
|
+
// unit-tests without any I/O and is shared by the daemon (handler.mjs) and the
|
|
5
|
+
// hosted path (lib/, via TS import).
|
|
6
|
+
//
|
|
7
|
+
// The PRIMARY classifier is the LLM (the daemon's routeIntent reads intent in any
|
|
8
|
+
// language, in context); the cue lists below are ONLY a hint for that prompt and a
|
|
9
|
+
// deterministic FALLBACK when the model is unavailable. Never let the cue lists be
|
|
10
|
+
// the main brain — they're the safety net.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The three reads of a follow-up message, relative to the run its thread owns:
|
|
14
|
+
* 'change' — continue/adjust that work ("also make it blue", "actually revert")
|
|
15
|
+
* 'new-scope' — explicitly wants a SEPARATE PR ("do that in a new PR")
|
|
16
|
+
* 'ambiguous' — unclear; a plain follow-up with no explicit fresh-PR cue
|
|
17
|
+
* @typedef {'change' | 'new-scope' | 'ambiguous'} FollowupSignal
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The routing decision:
|
|
22
|
+
* 'iterate' — continue the SAME branch/PR the thread already owns
|
|
23
|
+
* 'redirect' — supersede the old run and start a fresh, separate PR
|
|
24
|
+
* 'new' — today's default: a brand-new branch + run (no run to continue)
|
|
25
|
+
* @typedef {'iterate' | 'new' | 'redirect'} FollowupMode
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Illustrative "continue this work" cues, multi-language friendly. NOT exhaustive
|
|
30
|
+
* and NOT the primary classifier — a hint for the LLM prompt and the deterministic
|
|
31
|
+
* fallback. Matched as substrings against a normalized (lowercased,
|
|
32
|
+
* punctuation-collapsed) message.
|
|
33
|
+
*/
|
|
34
|
+
export const CHANGE_CUES = [
|
|
35
|
+
// English
|
|
36
|
+
"also",
|
|
37
|
+
"actually",
|
|
38
|
+
"instead",
|
|
39
|
+
"revert",
|
|
40
|
+
"undo",
|
|
41
|
+
"roll back",
|
|
42
|
+
"change it",
|
|
43
|
+
"make it",
|
|
44
|
+
"tweak",
|
|
45
|
+
"adjust",
|
|
46
|
+
"one more",
|
|
47
|
+
"on second thought",
|
|
48
|
+
"same pr",
|
|
49
|
+
"keep going",
|
|
50
|
+
// Spanish
|
|
51
|
+
"también",
|
|
52
|
+
"en vez",
|
|
53
|
+
"revierte",
|
|
54
|
+
"deshaz",
|
|
55
|
+
"cámbialo",
|
|
56
|
+
"ajusta",
|
|
57
|
+
// Portuguese / French / German (small illustrative set)
|
|
58
|
+
"ao invés",
|
|
59
|
+
"au lieu",
|
|
60
|
+
"annule",
|
|
61
|
+
"stattdessen",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Illustrative "make it a SEPARATE PR" cues — an explicit request to fork rather
|
|
66
|
+
* than continue. Same role: LLM hint + deterministic fallback.
|
|
67
|
+
*/
|
|
68
|
+
export const NEW_SCOPE_CUES = [
|
|
69
|
+
// English
|
|
70
|
+
"separate pr",
|
|
71
|
+
"separate branch",
|
|
72
|
+
"new pr",
|
|
73
|
+
"another pr",
|
|
74
|
+
"different pr",
|
|
75
|
+
"fresh pr",
|
|
76
|
+
"its own pr",
|
|
77
|
+
"own pr",
|
|
78
|
+
"new branch",
|
|
79
|
+
"as a new",
|
|
80
|
+
"in a new",
|
|
81
|
+
// Spanish
|
|
82
|
+
"pr aparte",
|
|
83
|
+
"pr separado",
|
|
84
|
+
"otro pr",
|
|
85
|
+
"nuevo pr",
|
|
86
|
+
"rama aparte",
|
|
87
|
+
// Portuguese / French
|
|
88
|
+
"nova pr",
|
|
89
|
+
"pr séparée",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** Lowercase + collapse any run of non-alphanumeric (unicode letters kept) to a
|
|
93
|
+
* single space, so matching is case- AND punctuation-insensitive. */
|
|
94
|
+
function normalize(text) {
|
|
95
|
+
return String(text || "")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
98
|
+
.replace(/\s+/g, " ")
|
|
99
|
+
.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when the normalized haystack contains the normalized cue, so "new PR!"
|
|
103
|
+
* matches "new pr". */
|
|
104
|
+
function hasCue(normalizedHaystack, cue) {
|
|
105
|
+
const c = normalize(cue);
|
|
106
|
+
if (!c) return false;
|
|
107
|
+
return normalizedHaystack === c || normalizedHaystack.includes(c);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deterministic cue-based classifier — the FALLBACK when the LLM produced no
|
|
112
|
+
* signal. Explicit new-scope cues win over change cues (an explicit "in a new PR"
|
|
113
|
+
* is a stronger intent than a stray "also"); nothing matched → 'ambiguous'.
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @returns {FollowupSignal}
|
|
116
|
+
*/
|
|
117
|
+
export function classifyFollowupCue(text) {
|
|
118
|
+
const h = normalize(text);
|
|
119
|
+
if (!h) return "ambiguous";
|
|
120
|
+
if (NEW_SCOPE_CUES.some((c) => hasCue(h, c))) return "new-scope";
|
|
121
|
+
if (CHANGE_CUES.some((c) => hasCue(h, c))) return "change";
|
|
122
|
+
return "ambiguous";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Coerce an arbitrary model/string signal into the enum; unknown → 'ambiguous'. */
|
|
126
|
+
export function normalizeSignal(raw) {
|
|
127
|
+
const s = normalize(raw);
|
|
128
|
+
if (s === "new scope" || s === "newscope" || s === "new" || s === "separate") return "new-scope";
|
|
129
|
+
if (s === "change" || s === "iterate" || s === "continue") return "change";
|
|
130
|
+
if (s === "ambiguous" || s === "unsure" || s === "unknown") return "ambiguous";
|
|
131
|
+
return "ambiguous";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The truth table. Deliberately small + exhaustive over
|
|
136
|
+
* (hasActiveRun × prOpen × signal):
|
|
137
|
+
*
|
|
138
|
+
* hasActiveRun | prOpen | signal -> mode
|
|
139
|
+
* -------------|--------|-------------|----------
|
|
140
|
+
* false | * | * -> 'new' (today's behavior — nothing to continue)
|
|
141
|
+
* true | true | 'change' -> 'iterate' (continue the same PR)
|
|
142
|
+
* true | true | 'ambiguous' -> 'iterate' (default a plain reply to the open PR it belongs to)
|
|
143
|
+
* true | true | 'new-scope' -> 'redirect' (explicit "separate PR" — supersede + fork)
|
|
144
|
+
* true | false | * -> 'new' (PR merged/closed — NEVER iterate onto it)
|
|
145
|
+
*
|
|
146
|
+
* Safety: iterate REQUIRES an open PR, so a false 'iterate' can't push a genuinely
|
|
147
|
+
* new task onto a merged/closed PR; an explicit new-scope always forks even when a
|
|
148
|
+
* PR is open.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ hasActiveRun: boolean, prOpen: boolean, signal: FollowupSignal }} o
|
|
151
|
+
* @returns {FollowupMode}
|
|
152
|
+
*/
|
|
153
|
+
export function resolveFollowupMode({ hasActiveRun, prOpen, signal } = {}) {
|
|
154
|
+
if (!hasActiveRun) return "new";
|
|
155
|
+
// Recency guard: only ever continue an OPEN PR. A run whose PR is merged/closed
|
|
156
|
+
// (or that never opened one) starts fresh — a follow-up there is new work.
|
|
157
|
+
if (!prOpen) return "new";
|
|
158
|
+
if (signal === "new-scope") return "redirect";
|
|
159
|
+
// 'change' or 'ambiguous' on an open PR → continue it.
|
|
160
|
+
return "iterate";
|
|
161
|
+
}
|