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/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
+ }
@@ -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
+ }