hilos-agent 0.1.15 → 0.4.0
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 +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +371 -0
- package/src/config.mjs +25 -0
- package/src/daemon.mjs +67 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +1351 -70
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +95 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +133 -12
package/src/daemon.mjs
CHANGED
|
@@ -31,6 +31,55 @@ export function resolveRepoPath(config, repoFullName) {
|
|
|
31
31
|
return (config && config.repos && config.repos[repoFullName]) || null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Local folder path for a channel from config (folder mode, 0322), or null.
|
|
35
|
+
* Mirrors resolveRepoPath — a channel with no linked repo can still map to a
|
|
36
|
+
* plain folder on this machine where the agent works directly. */
|
|
37
|
+
export function resolveFolderPath(config, channelId) {
|
|
38
|
+
return (config && config.folders && config.folders[channelId]) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse `git status --porcelain` (v1) into a Map(path → "XY" status code).
|
|
42
|
+
* Handles renames ("old -> new" → keeps the new name) and quoted paths. */
|
|
43
|
+
function parsePorcelain(text) {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
for (const raw of String(text || "").split("\n")) {
|
|
46
|
+
if (!raw.trim()) continue;
|
|
47
|
+
const code = raw.slice(0, 2); // two status columns
|
|
48
|
+
let path = raw.slice(3); // skip the single separating space
|
|
49
|
+
const arrow = path.indexOf(" -> ");
|
|
50
|
+
if (arrow >= 0) path = path.slice(arrow + 4); // a rename reports old -> new
|
|
51
|
+
path = path.replace(/^"(.*)"$/, "$1"); // unquote paths with special chars
|
|
52
|
+
if (path) map.set(path, code);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What changed between two `git status --porcelain` snapshots taken before and
|
|
59
|
+
* after a run: files the run created (new/untracked/added), modified, or deleted.
|
|
60
|
+
* Pure + unit-testable. A path whose status is identical in both snapshots is a
|
|
61
|
+
* PRE-EXISTING local change (dirty before the run) and is excluded. Returns
|
|
62
|
+
* `{ modified, created, deleted }`, each a sorted string[].
|
|
63
|
+
*/
|
|
64
|
+
export function diffStatusSets(before, after) {
|
|
65
|
+
const b = parsePorcelain(before);
|
|
66
|
+
const a = parsePorcelain(after);
|
|
67
|
+
const created = [];
|
|
68
|
+
const modified = [];
|
|
69
|
+
const deleted = [];
|
|
70
|
+
for (const [path, code] of a) {
|
|
71
|
+
if (b.get(path) === code) continue; // unchanged since before the run
|
|
72
|
+
const c = code.trim();
|
|
73
|
+
if (code.includes("?") || c === "A" || c === "AM") created.push(path);
|
|
74
|
+
else if (c.startsWith("D")) deleted.push(path);
|
|
75
|
+
else modified.push(path);
|
|
76
|
+
}
|
|
77
|
+
created.sort();
|
|
78
|
+
modified.sort();
|
|
79
|
+
deleted.sort();
|
|
80
|
+
return { modified, created, deleted };
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
/** Parse `git diff --shortstat` output into counts. */
|
|
35
84
|
export function parseShortstat(s) {
|
|
36
85
|
const files = /(\d+) files? changed/.exec(s || "");
|
|
@@ -89,3 +138,21 @@ export function mentionHandle(name) {
|
|
|
89
138
|
.replace(/[^a-z0-9]+/g, "-")
|
|
90
139
|
.replace(/^-+|-+$/g, "");
|
|
91
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A GitHub PR reference embedded in `text` for `repoFullName`, or null. The
|
|
144
|
+
* server tags a "request changes" rework ping with the existing PR's URL
|
|
145
|
+
* ("…update the PR <url>…"), so the daemon can update THAT PR's branch
|
|
146
|
+
* (same-branch iteration) instead of opening a brand-new one.
|
|
147
|
+
*
|
|
148
|
+
* Anchored on a "PR <url>" lead-in (and scoped to the channel's repo) so only an
|
|
149
|
+
* explicit "update the PR <url>" — the server ping, or a person naming a PR —
|
|
150
|
+
* triggers continuation. A stray reference link to some other PR (same or other
|
|
151
|
+
* repo, quoted for context) must NOT hijack the branch.
|
|
152
|
+
*/
|
|
153
|
+
export function detectPrContinuation(text, repoFullName) {
|
|
154
|
+
const m = /\bPR\s+(https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+))/i.exec(String(text || ""));
|
|
155
|
+
if (!m) return null;
|
|
156
|
+
if (repoFullName && m[2].toLowerCase() !== String(repoFullName).toLowerCase()) return null;
|
|
157
|
+
return { url: m[1], repoFullName: m[2], number: Number(m[3]) };
|
|
158
|
+
}
|
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
|
+
}
|