hilos-agent 0.1.8 → 0.1.10
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 +4 -0
- package/package.json +1 -1
- package/src/handler.mjs +98 -65
package/README.md
CHANGED
|
@@ -72,6 +72,10 @@ hilos-agent --channel <id> # scope to one channel
|
|
|
72
72
|
## How it works
|
|
73
73
|
|
|
74
74
|
- **Trigger** — an `@mention` of your agent in a channel that's linked to a repo.
|
|
75
|
+
- **Chat or code?** — the agent reads the conversation and decides with the model
|
|
76
|
+
(via `chatCmd`), not a keyword list: a question/greeting/"let's just discuss" →
|
|
77
|
+
a chat reply; anything asking for a change — including "just code it", "finish
|
|
78
|
+
it", "approved", or "go for it" after a request, in any language → a code run.
|
|
75
79
|
- **Repo resolution** — the channel's linked repo is mapped to a local path via
|
|
76
80
|
`repos`. No mapping → the agent says so and stops.
|
|
77
81
|
- **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": {
|
package/src/handler.mjs
CHANGED
|
@@ -165,64 +165,72 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
165
165
|
return { status: "timeout", branch };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// Sentinel the router model emits when the latest message is a request to change
|
|
169
|
+
// code. Unusual on purpose so it can't be confused with a real chat reply.
|
|
170
|
+
const CODE_SIGNAL = "__CODE__";
|
|
171
|
+
|
|
168
172
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
+
* Decide — with the LLM, not a word list — whether the latest message wants a
|
|
174
|
+
* code change or a conversational reply, and (for chat) produce that reply in the
|
|
175
|
+
* SAME call. The model reads the whole conversation, so it judges by intent and
|
|
176
|
+
* context, in any language: "just code it", "dale, hazlo", "yeah go for it" after
|
|
177
|
+
* a request → code; "how does this work?", "thoughts?", "thanks" → chat.
|
|
178
|
+
*
|
|
179
|
+
* Returns one of:
|
|
180
|
+
* { aborted: true }
|
|
181
|
+
* { code: true } → run the coding flow
|
|
182
|
+
* { code: false, reply, error } → post `reply` as a chat message
|
|
183
|
+
*
|
|
184
|
+
* `error` is set when the model produced nothing (so the caller can be honest
|
|
185
|
+
* about a timeout vs a missing binary instead of inventing a reply).
|
|
173
186
|
*/
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
|
|
188
|
+
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
189
|
+
const parts = cmd.split(" ").filter(Boolean);
|
|
190
|
+
const prompt =
|
|
191
|
+
`You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
|
|
192
|
+
`${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
|
|
193
|
+
`If it is asking you to make a code or repository change — implement/fix/refactor/adjust ` +
|
|
194
|
+
`something, continue or finish work discussed above, or give a go-ahead to act ("yeah, do ` +
|
|
195
|
+
`it", "go for it", "just code it", "approved", "dale", in ANY language) — respond with ` +
|
|
196
|
+
`EXACTLY this and nothing else:\n${CODE_SIGNAL}\n\n` +
|
|
197
|
+
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
198
|
+
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
199
|
+
`headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.\n\n` +
|
|
200
|
+
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
201
|
+
const run = await runCli({
|
|
202
|
+
cmd: parts[0],
|
|
203
|
+
args: [...parts.slice(1), prompt],
|
|
204
|
+
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
205
|
+
label: "thinking",
|
|
206
|
+
signal,
|
|
207
|
+
});
|
|
208
|
+
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
209
|
+
const out = (run.stdout || "").trim();
|
|
210
|
+
if (!out) return { code: false, reply: null, error: run.error || new Error("no output") };
|
|
211
|
+
if (new RegExp(`^${CODE_SIGNAL}\\b`).test(out)) return { code: true };
|
|
212
|
+
return { code: false, reply: out };
|
|
191
213
|
}
|
|
192
214
|
|
|
193
215
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
216
|
+
* The task text handed to the coding CLI. The mention is always the instruction,
|
|
217
|
+
* but it's almost never self-contained: a reply like "yeah, do it" only means
|
|
218
|
+
* something against what was just said, and even a direct request is clearer with
|
|
219
|
+
* the surrounding discussion. So we ALWAYS prepend the recent conversation when we
|
|
220
|
+
* have it — the same context the chat path already gets. Falls back to the bare
|
|
221
|
+
* mention only when there's no transcript at all.
|
|
199
222
|
*/
|
|
200
|
-
export function
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (/\b(go for it|go ahead|ship it|send it|send a pr|open a pr|make it so|lfg|let'?s go|fire away)\b/.test(t)) {
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
// Otherwise only a SHORT message counts — a long one carries its own intent.
|
|
215
|
-
if (t.length > 40) return false;
|
|
216
|
-
return /^(go|do it|do that|yes|yep|yeah|yup|sure|ship|proceed|sounds good|sgtm|please do|please)\b/.test(t);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Does the agent's most recent message read like a plan/proposal it offered? */
|
|
220
|
-
export function agentProposedWork(rows, agentName) {
|
|
221
|
-
const mine = (rows || []).filter((m) => m && m.author === agentName);
|
|
222
|
-
const last = mine[mine.length - 1];
|
|
223
|
-
if (!last) return false;
|
|
224
|
-
return /\b(i'?ll|i will|plan|propose|pr\b|pull request|branch|here'?s|let me|on it|i'?d|i can)\b/i.test(
|
|
225
|
-
String(last.body || ""),
|
|
223
|
+
export function codeTaskPrompt({ message, context }) {
|
|
224
|
+
const body = String(message?.body || "").trim();
|
|
225
|
+
const transcript = context?.transcript?.trim();
|
|
226
|
+
if (!transcript) return body;
|
|
227
|
+
return (
|
|
228
|
+
`You're acting on a request in a team chat. Recent conversation (oldest first):\n` +
|
|
229
|
+
`${transcript}\n\n` +
|
|
230
|
+
`The latest message is your instruction: "${body}". Do what it asks, using the ` +
|
|
231
|
+
`conversation above for context — it often carries the actual spec (e.g. a reply ` +
|
|
232
|
+
`like "go for it" refers to what was just discussed). Make the change directly; ` +
|
|
233
|
+
`it will be opened as a PR for review.`
|
|
226
234
|
);
|
|
227
235
|
}
|
|
228
236
|
|
|
@@ -393,23 +401,48 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
393
401
|
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
394
402
|
memory: null,
|
|
395
403
|
}));
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (!
|
|
404
|
-
context = await fetchContext({ channelId, tool, parentId });
|
|
405
|
-
if (agentProposedWork(context.rows, me?.agentName)) isCode = true;
|
|
406
|
-
}
|
|
407
|
-
if (!repoLink || !isCode) {
|
|
404
|
+
// The conversation the agent is replying within — fetched ONCE and used to
|
|
405
|
+
// route AND (for a code run) handed to the coding prompt. Context is always
|
|
406
|
+
// crucial: a reply like "yeah, do it" only means something against what was
|
|
407
|
+
// just said.
|
|
408
|
+
const context = await fetchContext({ channelId, tool, parentId });
|
|
409
|
+
|
|
410
|
+
// No repo linked → nothing to build; just reply.
|
|
411
|
+
if (!repoLink) {
|
|
408
412
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
409
413
|
return { status: "chat" };
|
|
410
414
|
}
|
|
411
415
|
const repoFullName = repoLink.repo_full_name;
|
|
412
416
|
|
|
417
|
+
// Chat vs code is the LLM's call, not a word list: it reads the whole
|
|
418
|
+
// conversation and either returns a chat reply or signals a code run. Handles
|
|
419
|
+
// any phrasing, any language, and "go for it" obviously means "do the thing we
|
|
420
|
+
// just discussed". When it replies (chat), post that and we're done.
|
|
421
|
+
const routed = await routeIntent({
|
|
422
|
+
name: me?.agentName || "an assistant",
|
|
423
|
+
repoFullName,
|
|
424
|
+
transcript: context.transcript,
|
|
425
|
+
workspaceMemory,
|
|
426
|
+
cfg,
|
|
427
|
+
signal,
|
|
428
|
+
});
|
|
429
|
+
if (routed.aborted || signal?.aborted) {
|
|
430
|
+
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
431
|
+
return { status: "chat" };
|
|
432
|
+
}
|
|
433
|
+
if (!routed.code) {
|
|
434
|
+
let body = routed.reply;
|
|
435
|
+
if (!body) {
|
|
436
|
+
body =
|
|
437
|
+
routed.error?.code === "ENOENT"
|
|
438
|
+
? `(my chat command \`${cfg.chatCmd || cfg.codingCmd}\` isn't installed or on PATH.)`
|
|
439
|
+
: `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
|
|
440
|
+
}
|
|
441
|
+
await tool("post_message", { channelId, parentId, body });
|
|
442
|
+
return { status: "chat" };
|
|
443
|
+
}
|
|
444
|
+
// routed.code → fall through to the coding flow below.
|
|
445
|
+
|
|
413
446
|
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
414
447
|
if (!repoPath) {
|
|
415
448
|
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
@@ -630,7 +663,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
630
663
|
return { status: "cancelled", branch };
|
|
631
664
|
};
|
|
632
665
|
|
|
633
|
-
const staged = await runAndStage(message
|
|
666
|
+
const staged = await runAndStage(codeTaskPrompt({ message, context }));
|
|
634
667
|
if (staged.aborted) return await postStopped();
|
|
635
668
|
if (staged.empty) {
|
|
636
669
|
let body;
|