hilos-agent 0.9.4 → 0.10.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 +47 -2
- package/bin/hilos-agent.mjs +19 -0
- package/package.json +5 -2
- package/src/codex-mcp-session.mjs +4 -0
- package/src/config.mjs +24 -0
- package/src/cursor-store.mjs +76 -0
- package/src/daemon.mjs +32 -10
- package/src/github-artifacts.mjs +253 -0
- package/src/handler.mjs +142 -46
- package/src/mcp.mjs +10 -3
- package/src/progress-emitter.mjs +30 -2
- package/src/run.mjs +62 -8
- package/src/webmcp-bridge.mjs +393 -0
- package/src/webmcp-init.js +281 -0
package/README.md
CHANGED
|
@@ -53,7 +53,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
53
53
|
"defaultBranch": "main",
|
|
54
54
|
"gate": false, // default: open a PR directly. true = approve-before-push
|
|
55
55
|
"heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
|
|
56
|
-
"chatTimeoutMs": 90000
|
|
56
|
+
"chatTimeoutMs": 90000, // cap a chat reply / plan-ack so a stalled model can't go silent
|
|
57
|
+
"longPollMs": 20000, // ask the server to HOLD the mention poll and answer the moment work lands (0 = plain 5s polling; older servers fall back automatically)
|
|
58
|
+
"catchupMs": 86400000 // how far back a restart replays mentions from the persisted cursor (default 24h; 0 = restart at "now", the pre-0866 behavior)
|
|
57
59
|
}
|
|
58
60
|
```
|
|
59
61
|
|
|
@@ -68,7 +70,11 @@ to a short "done" line. A run that **times out or errors** says so honestly (wit
|
|
|
68
70
|
a stderr tail) instead of claiming "no changes". Chat replies use the faster
|
|
69
71
|
`chatCmd` (when unset, derived from `codingCmd`'s tool — a Claude daemon chats
|
|
70
72
|
with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
|
|
71
|
-
`chatTimeoutMs`. The
|
|
73
|
+
`chatTimeoutMs`. The chat-vs-code pass only classifies; the separate chat
|
|
74
|
+
responder keeps the CLI's normal tools. Generated Codex chat and code commands
|
|
75
|
+
explicitly enable its built-in web search (an explicit operator override still
|
|
76
|
+
wins), while other vendors keep their own tool configuration. The responsive
|
|
77
|
+
surface needs a hilos server new enough to expose
|
|
72
78
|
`edit_message`; older servers just skip the live edits.
|
|
73
79
|
|
|
74
80
|
```sh
|
|
@@ -76,6 +82,45 @@ hilos-agent # watch every channel the agent is in
|
|
|
76
82
|
hilos-agent --channel <id> # scope to one channel
|
|
77
83
|
```
|
|
78
84
|
|
|
85
|
+
## WebMCP site tools
|
|
86
|
+
|
|
87
|
+
The local daemon can give its coding and chat agents a narrow bridge to
|
|
88
|
+
third-party WebMCP sites. It is off until you name exact origins and exact read
|
|
89
|
+
tools in the machine's config:
|
|
90
|
+
|
|
91
|
+
```jsonc
|
|
92
|
+
{
|
|
93
|
+
"webMcp": {
|
|
94
|
+
"origins": {
|
|
95
|
+
"https://docs.example.com": {
|
|
96
|
+
"readTools": ["search_docs", "read_reference"]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The site does not authorize itself: `readOnlyHint` is informational, while this
|
|
104
|
+
person-owned list decides what may run. Site descriptions are omitted, schema
|
|
105
|
+
prose is stripped, results are bounded and labeled untrusted, and cookies stay
|
|
106
|
+
inside a separate browser profile. The bundled browser bridge currently needs
|
|
107
|
+
Node.js 24 or newer; the rest of the daemon keeps its existing Node.js support.
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
hilos-agent webmcp doctor
|
|
111
|
+
hilos-agent webmcp login https://docs.example.com # person signs in in the opened browser
|
|
112
|
+
hilos-agent webmcp open https://docs.example.com/reference
|
|
113
|
+
hilos-agent webmcp tools
|
|
114
|
+
hilos-agent webmcp call search_docs '{"query":"WebMCP"}'
|
|
115
|
+
hilos-agent webmcp close
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The daemon adds this capability and its citation rules to agent prompts only
|
|
119
|
+
when the config is valid. Unlisted tools — including writes — are refused with
|
|
120
|
+
`human_approval_required`; there is no approval flag the agent can set. WebMCP
|
|
121
|
+
can never approve or merge hilos work. See the complete contract in
|
|
122
|
+
[WebMCP in hilos](https://hilos.sh/docs/webmcp).
|
|
123
|
+
|
|
79
124
|
## How it works
|
|
80
125
|
|
|
81
126
|
- **Trigger** — an `@mention` of your agent in a channel that's linked to a repo,
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// hilos-agent --join <blob> connect with a copy-paste link from hilos
|
|
8
8
|
// hilos-agent --join-stdin read a private link without exposing it in argv
|
|
9
9
|
// hilos-agent init write a starter config (~/.hilos/agent.json)
|
|
10
|
+
// hilos-agent webmcp doctor verify the local site-tool bridge
|
|
10
11
|
// hilos-agent run with the resolved config (default)
|
|
11
12
|
// hilos-agent run same as above, explicit
|
|
12
13
|
//
|
|
@@ -24,6 +25,7 @@ import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "..
|
|
|
24
25
|
import { readPrivateJoin } from "../src/join-input.mjs";
|
|
25
26
|
import { run } from "../src/run.mjs";
|
|
26
27
|
import { hookMain, hooksMain } from "../src/hook.mjs";
|
|
28
|
+
import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
|
|
27
29
|
|
|
28
30
|
function packageVersion() {
|
|
29
31
|
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
@@ -67,6 +69,12 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
67
69
|
hilos-agent --join <blob> connect using a link copied from hilos
|
|
68
70
|
hilos-agent --join-stdin paste the private link at a no-echo prompt
|
|
69
71
|
hilos-agent init write a starter config to ~/.hilos/agent.json
|
|
72
|
+
hilos-agent webmcp doctor verify the local WebMCP browser bridge
|
|
73
|
+
hilos-agent webmcp login <url> open the isolated profile for person sign-in
|
|
74
|
+
hilos-agent webmcp open <url> open a person-allowlisted site for an agent
|
|
75
|
+
hilos-agent webmcp tools list registered, person-allowlisted read tools
|
|
76
|
+
hilos-agent webmcp call <name> '<json object>'
|
|
77
|
+
hilos-agent webmcp close close the isolated browser session
|
|
70
78
|
hilos-agent run the daemon (watch @mentions, propose diffs)
|
|
71
79
|
hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
|
|
72
80
|
sessions to hilos and continue replies in the same
|
|
@@ -150,6 +158,17 @@ async function main() {
|
|
|
150
158
|
return;
|
|
151
159
|
}
|
|
152
160
|
|
|
161
|
+
if (cmd === "webmcp") {
|
|
162
|
+
const cliFlags = { ...flags };
|
|
163
|
+
delete cliFlags.help;
|
|
164
|
+
const cfg = resolveConfig({ flags: cliFlags });
|
|
165
|
+
const operation = positional[1] || "doctor";
|
|
166
|
+
const result = await runWebMcpCommand(cfg, operation, positional.slice(2));
|
|
167
|
+
console.log(JSON.stringify(result, null, 2));
|
|
168
|
+
if (!result.ok) process.exitCode = 1;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
153
172
|
// run (default) — when --join is passed without init, connect straight away.
|
|
154
173
|
const cliFlags = { ...flags };
|
|
155
174
|
delete cliFlags.join;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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": {
|
|
@@ -32,5 +32,8 @@
|
|
|
32
32
|
"cursor",
|
|
33
33
|
"coding-agent"
|
|
34
34
|
],
|
|
35
|
-
"license": "MIT"
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"agent-browser": "^0.35.0"
|
|
38
|
+
}
|
|
36
39
|
}
|
|
@@ -461,6 +461,10 @@ export async function runCodexMcpSession({
|
|
|
461
461
|
cwd,
|
|
462
462
|
"approval-policy": approvalPolicy,
|
|
463
463
|
sandbox,
|
|
464
|
+
// The gated transport cannot inherit `codex exec` argv. Carry the
|
|
465
|
+
// same public web-search capability through the MCP tool's config;
|
|
466
|
+
// permission policy and sandboxing remain unchanged.
|
|
467
|
+
config: { tools: { web_search: true } },
|
|
464
468
|
...(model ? { model } : {}),
|
|
465
469
|
},
|
|
466
470
|
};
|
package/src/config.mjs
CHANGED
|
@@ -53,6 +53,12 @@ const DEFAULTS = {
|
|
|
53
53
|
// and preview (default) vs production. Shape:
|
|
54
54
|
// { "<channelId>": { provider: "vercel" | "netlify", prod: false } }.
|
|
55
55
|
deploy: {},
|
|
56
|
+
// Local WebMCP consumer (0959). Disabled until the person names exact site
|
|
57
|
+
// origins and exact read-only tools. A site's readOnlyHint is never an
|
|
58
|
+
// authorization signal. Shape:
|
|
59
|
+
// { origins: { "https://example.com": { readTools: ["search_docs"] } },
|
|
60
|
+
// profile?: "/absolute/private/browser/profile" }
|
|
61
|
+
webMcp: null,
|
|
56
62
|
// acceptEdits lets the CLI make file edits without prompting (bias to action);
|
|
57
63
|
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
58
64
|
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
@@ -121,6 +127,17 @@ const DEFAULTS = {
|
|
|
121
127
|
gate: false,
|
|
122
128
|
maxRounds: 3,
|
|
123
129
|
pollMs: 5000,
|
|
130
|
+
// Long-poll (0866): when the server supports it, list_mentions is asked to
|
|
131
|
+
// HOLD this many ms and answer the moment a mention lands — pickup latency
|
|
132
|
+
// stops being pollMs. 0 disables and falls back to plain pollMs polling.
|
|
133
|
+
// The server caps a hold at 25s regardless of what is asked for here.
|
|
134
|
+
longPollMs: 20000,
|
|
135
|
+
// How far back a restart may replay missed mentions from the persisted
|
|
136
|
+
// cursor (0866). A daemon that was off for a week should not open a week of
|
|
137
|
+
// stale branches; a day covers the overnight-laptop case the cursor exists
|
|
138
|
+
// for. 0 disables catch-up entirely (restart starts at "now", the old
|
|
139
|
+
// behavior).
|
|
140
|
+
catchupMs: 24 * 60 * 60 * 1000,
|
|
124
141
|
// On a long code run, post ONE thread progress reply at the first beat then
|
|
125
142
|
// edit it on later beats, so a human sees the agent is alive without thread
|
|
126
143
|
// spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
|
|
@@ -224,6 +241,8 @@ const LIVE_FIELDS = [
|
|
|
224
241
|
"gate",
|
|
225
242
|
"maxRounds",
|
|
226
243
|
"pollMs",
|
|
244
|
+
"longPollMs",
|
|
245
|
+
"catchupMs",
|
|
227
246
|
"runTimeoutMs",
|
|
228
247
|
"decisionTimeoutMs",
|
|
229
248
|
"decisionPollMs",
|
|
@@ -238,6 +257,7 @@ const LIVE_FIELDS = [
|
|
|
238
257
|
// like `repos`, so a partial edit doesn't drop the defaults.
|
|
239
258
|
"folders",
|
|
240
259
|
"deploy",
|
|
260
|
+
"webMcp",
|
|
241
261
|
];
|
|
242
262
|
|
|
243
263
|
/**
|
|
@@ -280,6 +300,10 @@ export function reloadConfig(prev) {
|
|
|
280
300
|
// the normal on state. An explicit launch flag remains pinned when the file
|
|
281
301
|
// was never changed, preserving the 0576 precedence rule above.
|
|
282
302
|
if (changed("replyBridge")) next.replyBridge = file.replyBridge !== false;
|
|
303
|
+
// WebMCP is an authorization allowlist. Removing it must revoke the browser
|
|
304
|
+
// bridge on the next poll rather than preserving stale origins/tools until a
|
|
305
|
+
// restart. An explicit null and a deleted edited key both mean disabled.
|
|
306
|
+
if (changed("webMcp")) next.webMcp = file.webMcp ?? null;
|
|
283
307
|
// The environment stays the operator's override on reload, in both
|
|
284
308
|
// directions — a machine that opted out with =0 must not be opted back in by
|
|
285
309
|
// a file edit (0792).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Durable mention cursor (0866).
|
|
2
|
+
//
|
|
3
|
+
// The poll loop's cursor and dedupe set lived only in memory, so a daemon
|
|
4
|
+
// restart began at "now" and every mention that arrived while it was down was
|
|
5
|
+
// silently skipped (backfill:false, the default). This file is the fix's whole
|
|
6
|
+
// mechanism: the cursor is persisted per agent after each delivery, and a
|
|
7
|
+
// restart resumes from it — bounded by `catchupMs`, because replaying a week of
|
|
8
|
+
// stale asks after a vacation would be worse than skipping them.
|
|
9
|
+
//
|
|
10
|
+
// Telegram semantics, deliberately: persisting the cursor acknowledges
|
|
11
|
+
// DELIVERY into the daemon's queue, not completion of the work. A crash after
|
|
12
|
+
// enqueue can still lose an in-flight task (exactly as today); what can no
|
|
13
|
+
// longer happen is a mention arriving into a dead daemon and never being seen.
|
|
14
|
+
//
|
|
15
|
+
// Dependency-free and injectable-dir like hook.mjs's state store, so tests run
|
|
16
|
+
// against a temp dir and never touch a real ~/.hilos.
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** Same parent as ~/.hilos/agent.json (config.mjs) and state.json (resume.mjs). */
|
|
23
|
+
export const CURSOR_STATE_DIR = join(homedir(), ".hilos", "mention-cursors");
|
|
24
|
+
|
|
25
|
+
/** One file per agent — two agents on one machine must never share a cursor. */
|
|
26
|
+
export function cursorPath(agentId, dir = CURSOR_STATE_DIR) {
|
|
27
|
+
return join(dir, `${String(agentId).replace(/[^a-zA-Z0-9-]/g, "")}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cursor a restart should resume from, or null when there is nothing
|
|
32
|
+
* usable — no file, unreadable JSON, a malformed timestamp, or a cursor older
|
|
33
|
+
* than `catchupMs` allows (clamped to the window's edge rather than dropped,
|
|
34
|
+
* so a long-dead daemon still catches the most recent day, not nothing).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} agentId
|
|
37
|
+
* @param {{ catchupMs?: number, dir?: string, now?: () => number }} [opts]
|
|
38
|
+
* @returns {string|null} ISO timestamp
|
|
39
|
+
*/
|
|
40
|
+
export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {}) {
|
|
41
|
+
if (!agentId || !(catchupMs > 0)) return null;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(readFileSync(cursorPath(agentId, dir), "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const value = parsed && typeof parsed.mentionCursor === "string" ? parsed.mentionCursor : null;
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
const at = new Date(value).getTime();
|
|
51
|
+
if (Number.isNaN(at)) return null;
|
|
52
|
+
const floor = now() - catchupMs;
|
|
53
|
+
return at < floor ? new Date(floor).toISOString() : value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Persist the cursor after a delivery. Best-effort by design: a full disk or
|
|
58
|
+
* read-only home must never take the poll loop down — the daemon just degrades
|
|
59
|
+
* to today's restart-at-now behavior.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} agentId
|
|
62
|
+
* @param {string} isoTimestamp
|
|
63
|
+
* @param {{ dir?: string, log?: { error?: (m: string) => void } }} [opts]
|
|
64
|
+
* @returns {boolean} whether the write landed
|
|
65
|
+
*/
|
|
66
|
+
export function saveMentionCursor(agentId, isoTimestamp, { dir = CURSOR_STATE_DIR, log } = {}) {
|
|
67
|
+
if (!agentId || typeof isoTimestamp !== "string" || !isoTimestamp) return false;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
writeFileSync(cursorPath(agentId, dir), JSON.stringify({ mentionCursor: isoTimestamp }));
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
log?.error?.(`mention cursor not persisted: ${e?.message || e}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/daemon.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Pure daemon helpers — no I/O. (Mirror of the app's scripts/lib/daemon.mjs so
|
|
2
2
|
// the package stands alone; keep them equivalent.)
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
agentCoauthorTrailer,
|
|
6
|
+
agentCommitBody,
|
|
7
|
+
githubArtifactBody,
|
|
8
|
+
selectGithubArtifactTitle,
|
|
9
|
+
} from "./github-artifacts.mjs";
|
|
10
|
+
|
|
4
11
|
/** Branch name from a task: `hilos/<kebab-first-words>-<suffix>`. */
|
|
5
12
|
export function branchSlug(text, suffix) {
|
|
6
13
|
const base =
|
|
@@ -121,18 +128,33 @@ export function decisionKind(report) {
|
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
/** Commit message for an approved proposal. */
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
/** @param {unknown} task @param {Record<string, any>} [provenance] */
|
|
132
|
+
export function commitMessage(task, provenance = {}) {
|
|
133
|
+
const title = selectGithubArtifactTitle({
|
|
134
|
+
summary: provenance.summary,
|
|
135
|
+
originalTask: task,
|
|
136
|
+
fallback: "hilos change",
|
|
137
|
+
});
|
|
138
|
+
const trailer = agentCoauthorTrailer(provenance.agentName, provenance.agentId);
|
|
139
|
+
// The agent's own account of the change becomes the commit body, so the log
|
|
140
|
+
// says what shipped and not only that a person approved it.
|
|
141
|
+
const body = agentCommitBody(provenance.summary);
|
|
142
|
+
return [title, body, "Proposed via hilos and approved by a person.", trailer]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join("\n\n");
|
|
127
145
|
}
|
|
128
146
|
|
|
129
|
-
/** PR title + body for an approved proposal.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
/** PR title + body for an approved proposal. The body carries the agent's own
|
|
148
|
+
* description of the change (from `provenance.summary`) above the provenance
|
|
149
|
+
* block, so a reviewer on GitHub reads what shipped before who proposed it. */
|
|
150
|
+
/** @param {unknown} task @param {string} branch @param {Record<string, any>} [provenance] */
|
|
151
|
+
export function prTitleBody(task, branch, provenance = {}) {
|
|
152
|
+
const title = selectGithubArtifactTitle({
|
|
153
|
+
summary: provenance.summary,
|
|
154
|
+
originalTask: task,
|
|
155
|
+
fallback: branch,
|
|
156
|
+
});
|
|
157
|
+
return { title, body: githubArtifactBody(provenance) };
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
/** Build the post_report payload that serves as the approval card. */
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
const TITLE_MAX = 72;
|
|
2
|
+
// A pull-request description is a reviewer's first read, not an essay: enough
|
|
3
|
+
// room for the agent's own account of the change, bounded so a runaway summary
|
|
4
|
+
// can never become the PR body. The commit log gets a shorter form still.
|
|
5
|
+
const DESCRIPTION_MAX = 1400;
|
|
6
|
+
const COMMIT_BODY_MAX = 600;
|
|
7
|
+
|
|
8
|
+
const REJECTED_TITLE_LINES = [
|
|
9
|
+
/^(?:sure|okay|ok|yes|got it|sounds good|absolutely|certainly|done|working on it)\b/i,
|
|
10
|
+
/^(?:i(?:'ll| will| have|’ll)|let me|here(?:'s| is))\b/i,
|
|
11
|
+
/^(?:address|apply|handle|incorporate|respond to|update)\b.{0,36}\b(?:feedback|review|comments?|pr)\b/i,
|
|
12
|
+
/^@\S+/,
|
|
13
|
+
/https?:\/\//i,
|
|
14
|
+
/^```/,
|
|
15
|
+
/^visual_?preview\s*:/i,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function oneLine(value, max = 160) {
|
|
19
|
+
return String(value ?? "")
|
|
20
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.trim()
|
|
23
|
+
.slice(0, max);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanTitleLine(value) {
|
|
27
|
+
return oneLine(value)
|
|
28
|
+
.replace(/^#{1,6}\s+/, "")
|
|
29
|
+
.replace(/^[-*+]\s+/, "")
|
|
30
|
+
.replace(/^\d+[.)]\s+/, "")
|
|
31
|
+
.replace(/^(?:(?:pr|pull request|commit)\s+)?title\s*[:\-–—]\s*/i, "")
|
|
32
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
33
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
34
|
+
.replace(/[*_~]/g, "")
|
|
35
|
+
.replace(/^@[a-z0-9][a-z0-9-]*\s*[:,–—-]?\s*/i, "")
|
|
36
|
+
.replace(/[.\s]+$/, "")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function candidateLines(value) {
|
|
41
|
+
return String(value ?? "")
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map(cleanTitleLine)
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usableTitle(line) {
|
|
48
|
+
if (line.length < 4) return false;
|
|
49
|
+
return !REJECTED_TITLE_LINES.some((pattern) => pattern.test(line));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncateTitle(title) {
|
|
53
|
+
if (title.length <= TITLE_MAX) return title;
|
|
54
|
+
return `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The title the agent wrote for its OWN change, or null when its summary never
|
|
59
|
+
* offered a usable one. Callers that must always end up with something use
|
|
60
|
+
* `selectGithubArtifactTitle` (which falls back to the request text); callers
|
|
61
|
+
* that need to know whether the agent actually authored a headline — an
|
|
62
|
+
* iteration commit deciding between the agent's words and a canned subject —
|
|
63
|
+
* use this.
|
|
64
|
+
*/
|
|
65
|
+
/** @param {unknown} summary */
|
|
66
|
+
export function agentAuthoredTitle(summary) {
|
|
67
|
+
const line = candidateLines(summary).find(usableTitle);
|
|
68
|
+
return line ? truncateTitle(line) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One title selector for hosted and local-daemon GitHub output. Agent summaries
|
|
73
|
+
* win only when they actually describe a change; acknowledgements, mentions,
|
|
74
|
+
* Markdown wrappers, links, and review-process chatter are rejected. Iteration
|
|
75
|
+
* callers keep their existing PR title and use this only for the new commit.
|
|
76
|
+
*/
|
|
77
|
+
/** @param {{ summary?: unknown, originalTask?: unknown, fallback?: string }} [input] */
|
|
78
|
+
export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update" } = {}) {
|
|
79
|
+
const summaryTitle = agentAuthoredTitle(summary);
|
|
80
|
+
const taskTitle = candidateLines(originalTask)
|
|
81
|
+
.map((line) => line.replace(/@[a-z0-9][a-z0-9-]*/gi, "").trim())
|
|
82
|
+
.find(usableTitle);
|
|
83
|
+
return truncateTitle(summaryTitle || taskTitle || cleanTitleLine(fallback) || "Update");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Neutralize the two things in agent prose that would ACT on GitHub instead of
|
|
88
|
+
* describing the change: HTML comment delimiters (which could otherwise forge
|
|
89
|
+
* or break the provenance marker this body ends with) and @handles (which ping
|
|
90
|
+
* whatever unrelated GitHub account happens to own that name). Everything else
|
|
91
|
+
* — including code fences — is the agent's writing and survives intact.
|
|
92
|
+
*/
|
|
93
|
+
function defuseMarkup(line) {
|
|
94
|
+
return line
|
|
95
|
+
.replace(/<!--+/g, "")
|
|
96
|
+
.replace(/--+>/g, "")
|
|
97
|
+
.replace(/(^|[\s(\[])@([a-z0-9][a-z0-9-]{0,38})/gi, "$1$2");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop fenced code blocks — a commit log is not the place for a snippet. */
|
|
101
|
+
function stripFences(text) {
|
|
102
|
+
return String(text ?? "").replace(/^\s*```[\s\S]*?^\s*```\s*$/gm, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Trim leading/trailing blank lines and collapse blank runs to one. */
|
|
106
|
+
function tidyLines(lines) {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const value = line.replace(/\s+$/, "");
|
|
110
|
+
if (!value.trim() && (!out.length || !out[out.length - 1].trim())) continue;
|
|
111
|
+
out.push(value);
|
|
112
|
+
}
|
|
113
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Keep whole lines up to `max` characters, marking a cut rather than hiding it. */
|
|
118
|
+
function clampLines(lines, max) {
|
|
119
|
+
const kept = [];
|
|
120
|
+
let used = 0;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const next = used + line.length + 1;
|
|
123
|
+
if (next > max) {
|
|
124
|
+
kept.push("…");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
kept.push(line);
|
|
128
|
+
used = next;
|
|
129
|
+
}
|
|
130
|
+
return tidyLines(kept).join("\n").trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The agent's own account of the change, ready to be a pull-request
|
|
135
|
+
* description: its summary MINUS the headline it leads with (that becomes the
|
|
136
|
+
* title, and a PR should not open by repeating its own title) and minus the
|
|
137
|
+
* machine directives it ends with (`VISUAL_PREVIEW:`), sanitized and bounded on
|
|
138
|
+
* whole lines.
|
|
139
|
+
*
|
|
140
|
+
* Returns "" when the agent wrote nothing past a title — the caller then ships
|
|
141
|
+
* provenance alone rather than inventing prose it cannot stand behind.
|
|
142
|
+
*/
|
|
143
|
+
/** @param {unknown} summary @param {{ max?: number }} [options] */
|
|
144
|
+
export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {}) {
|
|
145
|
+
const lines = String(summary ?? "")
|
|
146
|
+
.replace(/\r\n?/g, "\n")
|
|
147
|
+
.split("\n");
|
|
148
|
+
const headline = lines.findIndex((line) => usableTitle(cleanTitleLine(line)));
|
|
149
|
+
const body = (headline === -1 ? lines : lines.slice(headline + 1))
|
|
150
|
+
.filter((line) => !/^\s*visual_?preview\s*:/i.test(line))
|
|
151
|
+
.map(defuseMarkup);
|
|
152
|
+
return clampLines(tidyLines(body), max);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The same account of the change, shortened and de-fenced for a commit body. */
|
|
156
|
+
/** @param {unknown} summary */
|
|
157
|
+
export function agentCommitBody(summary) {
|
|
158
|
+
return agentChangeDescription(stripFences(summary), { max: COMMIT_BODY_MAX });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeLabel(value, fallback) {
|
|
162
|
+
const label = oneLine(value, 80)
|
|
163
|
+
.replace(/[<>`[\]{}*_]/g, "")
|
|
164
|
+
.replace(/https?:\/\/\S+/gi, "")
|
|
165
|
+
.trim();
|
|
166
|
+
return label || fallback;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function safeId(value) {
|
|
170
|
+
const id = oneLine(value, 100);
|
|
171
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/.test(id) ? id : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safeHilosUrl(value) {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(String(value ?? ""));
|
|
177
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
178
|
+
url.username = "";
|
|
179
|
+
url.password = "";
|
|
180
|
+
// Room and message links use their query/hash to identify the exact place
|
|
181
|
+
// a request came from. Keep that stable context after removing credentials.
|
|
182
|
+
return url.toString();
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function linkLine(label, url, fallback) {
|
|
189
|
+
return url ? `- ${label}: [Open in hilos](${url})` : `- ${label}: ${fallback}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A bounded PR body: the agent's own description of the change on top, then the
|
|
194
|
+
* provenance block. It still deliberately never accepts task text — the raw
|
|
195
|
+
* chat message is not a description of what shipped — but it does accept the
|
|
196
|
+
* agent's `summary`, which is the one piece of writing on this path that was
|
|
197
|
+
* actually authored about the diff.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* @param {{ agentName?: unknown, personName?: unknown, roomName?: unknown,
|
|
201
|
+
* roomUrl?: unknown, messageId?: unknown, messageUrl?: unknown, runId?: unknown,
|
|
202
|
+
* reportId?: unknown, partial?: boolean, summary?: unknown }} [input]
|
|
203
|
+
*/
|
|
204
|
+
export function githubArtifactBody(input = {}) {
|
|
205
|
+
const agent = safeLabel(input.agentName, "hilos agent");
|
|
206
|
+
const person = safeLabel(input.personName, "a team member");
|
|
207
|
+
const room = safeLabel(input.roomName, "project room");
|
|
208
|
+
const roomUrl = safeHilosUrl(input.roomUrl);
|
|
209
|
+
const messageUrl = safeHilosUrl(input.messageUrl);
|
|
210
|
+
const runId = safeId(input.runId);
|
|
211
|
+
const reportId = safeId(input.reportId);
|
|
212
|
+
const partial = input.partial === true;
|
|
213
|
+
const machine = {
|
|
214
|
+
version: 1,
|
|
215
|
+
agent,
|
|
216
|
+
person,
|
|
217
|
+
room,
|
|
218
|
+
messageId: safeId(input.messageId),
|
|
219
|
+
runId,
|
|
220
|
+
reportId,
|
|
221
|
+
};
|
|
222
|
+
const partialNote = partial
|
|
223
|
+
? "\nThis is a partial run. Mention the agent in the room to continue."
|
|
224
|
+
: "";
|
|
225
|
+
|
|
226
|
+
// The agent's account of the change leads, because that is what a reviewer
|
|
227
|
+
// opens the pull request to read. Provenance follows it under a rule.
|
|
228
|
+
const description = agentChangeDescription(input.summary);
|
|
229
|
+
|
|
230
|
+
return [
|
|
231
|
+
...(description ? [description, "", "---", ""] : []),
|
|
232
|
+
`Proposed by **${agent}** for **${person}** in **${room}**. A person remains the creator of record and decides whether to merge.`,
|
|
233
|
+
"",
|
|
234
|
+
linkLine("Room", roomUrl, room),
|
|
235
|
+
linkLine("Request", messageUrl, machine.messageId ? `message \`${machine.messageId}\`` : "room request"),
|
|
236
|
+
`- Run: ${runId ? `\`${runId}\`` : "not recorded"}`,
|
|
237
|
+
`- Report: ${reportId ? `\`${reportId}\`` : "posted in the room after this pull request opens"}`,
|
|
238
|
+
partialNote,
|
|
239
|
+
"",
|
|
240
|
+
`<!-- hilos-provenance ${JSON.stringify(machine).replace(/--/g, "—")} -->`,
|
|
241
|
+
]
|
|
242
|
+
.filter((line, index, all) => line !== "" || index === 1 || all[index - 1] !== "")
|
|
243
|
+
.join("\n")
|
|
244
|
+
.trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A stable Git co-author trailer for the named agent, without user input. */
|
|
248
|
+
/** @param {unknown} agentName @param {unknown} agentId */
|
|
249
|
+
export function agentCoauthorTrailer(agentName, agentId) {
|
|
250
|
+
const name = safeLabel(agentName, "hilos agent");
|
|
251
|
+
const id = safeId(agentId)?.replace(/[^a-zA-Z0-9]/g, "").slice(0, 48) || "agent";
|
|
252
|
+
return `Co-authored-by: ${name} <agent+${id}@hilos.sh>`;
|
|
253
|
+
}
|