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/hook.mjs ADDED
@@ -0,0 +1,427 @@
1
+ // Claude Code hook → hilos live progress (0448). This is the "watch your
2
+ // agent's hands move" path for a RAW CLI session (no daemon): install the
3
+ // hilos hooks in a repo (`hilos-agent hooks install`) and every tool call your
4
+ // Claude Code session makes streams a step into the agent's live status card
5
+ // in its hilos channel, via the `post_progress` MCP tool's channelId mode.
6
+ //
7
+ // Design rules:
8
+ // - NEVER block or break the user's CLI. Every entry point swallows every
9
+ // error and exits 0; the network send is capped by a hard timeout.
10
+ // - Cheap by default: a state file per Claude session carries the message id,
11
+ // step ring and last-send time, so most invocations are one small read +
12
+ // at most one bounded fetch. Sends are throttled (default 2s) with pending
13
+ // steps carried over — a burst of tool calls becomes one coalesced update.
14
+ // - Privacy is the install default: `hooks install` writes to the PROJECT's
15
+ // .claude/settings.json, so only repos you opt in ever stream. HILOS_HOOKS=off
16
+ // is the global kill switch.
17
+ // - Pure helpers (event parsing, step labels, throttling decisions) are
18
+ // exported for offline unit tests; I/O lives only in runHook/main.
19
+
20
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join, dirname, delimiter } from "node:path";
23
+ import { sanitizeText } from "./agent-events.mjs";
24
+ import { resolveConfig } from "./config.mjs";
25
+
26
+ export const HOOK_STATE_DIR = join(homedir(), ".hilos", "hook-state");
27
+ /** Coalesce window between sends; Stop/SessionEnd always flush. */
28
+ const MIN_SEND_MS = 2000;
29
+ /** A hook must never hang the CLI on a slow network. */
30
+ const FETCH_TIMEOUT_MS = 3000;
31
+ /** Step/file bounds — mirror the server's caps (lib/agent-progress.ts). */
32
+ const MAX_STEPS = 8;
33
+ const MAX_FILES = 20;
34
+ /** Session state older than this is dead — GC'd opportunistically. */
35
+ const STATE_TTL_MS = 48 * 60 * 60 * 1000;
36
+
37
+ /**
38
+ * Parse a Claude Code hook event from stdin text. Returns a normalized
39
+ * { event, sessionId, toolName, toolInput, cwd } or null when unusable.
40
+ * PURE — never throws.
41
+ */
42
+ export function parseHookEvent(raw) {
43
+ try {
44
+ const o = JSON.parse(String(raw));
45
+ if (!o || typeof o !== "object") return null;
46
+ const event = typeof o.hook_event_name === "string" ? o.hook_event_name : "";
47
+ const sessionId = typeof o.session_id === "string" ? o.session_id : "";
48
+ if (!event || !sessionId) return null;
49
+ return {
50
+ event,
51
+ sessionId,
52
+ toolName: typeof o.tool_name === "string" ? o.tool_name : "",
53
+ toolInput: o.tool_input && typeof o.tool_input === "object" ? o.tool_input : {},
54
+ cwd: typeof o.cwd === "string" ? o.cwd : "",
55
+ };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Strip the session cwd prefix so step labels read as repo-relative paths. */
62
+ function relPath(p, cwd) {
63
+ const s = String(p || "");
64
+ if (cwd && s.startsWith(cwd + "/")) return s.slice(cwd.length + 1);
65
+ return s;
66
+ }
67
+
68
+ /** First line of a shell command, trimmed to a readable label. */
69
+ function cmdLabel(cmd) {
70
+ const first = String(cmd || "").split("\n")[0].trim();
71
+ return first.length > 60 ? first.slice(0, 59) + "…" : first;
72
+ }
73
+
74
+ /**
75
+ * Map a PostToolUse event to a human step label (and the file it touched, if
76
+ * any), or null for tools too noisy to narrate (TodoWrite etc). PURE.
77
+ * Mirrors the daemon's vocabulary (agent-events stepLabel) so a hook-streamed
78
+ * card and a daemon-streamed card read the same.
79
+ */
80
+ export function hookStep(toolName, toolInput, cwd = "") {
81
+ const name = String(toolName || "");
82
+ const input = toolInput && typeof toolInput === "object" ? toolInput : {};
83
+ const file = typeof input.file_path === "string" ? relPath(input.file_path, cwd) : "";
84
+ // MCP tools arrive as `mcp__<server>__<tool>`. Skip hilos's own tools — a
85
+ // session posting to hilos narrating "posting to hilos" is self-referential
86
+ // noise — and label other servers by their short tool name.
87
+ const mcp = name.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
88
+ if (mcp) {
89
+ if (/hilos/i.test(mcp[1])) return null;
90
+ return { label: `Using ${mcp[2].replace(/_/g, " ")} (${mcp[1]})` };
91
+ }
92
+ switch (name) {
93
+ case "Edit":
94
+ case "Write":
95
+ case "MultiEdit":
96
+ case "NotebookEdit":
97
+ return file ? { label: `Editing ${file}`, file } : { label: "Editing files" };
98
+ case "Read":
99
+ return file ? { label: `Reading ${file}`, file } : { label: "Reading files" };
100
+ case "Bash": {
101
+ const c = cmdLabel(input.command);
102
+ return { label: c ? `Running ${c}` : "Running a command" };
103
+ }
104
+ case "Grep":
105
+ case "Glob": {
106
+ const q = typeof input.pattern === "string" ? input.pattern : "";
107
+ return { label: q ? `Searching for ${q.slice(0, 60)}` : "Searching the repo" };
108
+ }
109
+ case "Task": {
110
+ const d = typeof input.description === "string" ? input.description : "";
111
+ return { label: d ? `Delegating: ${d.slice(0, 60)}` : "Delegating a task" };
112
+ }
113
+ case "WebFetch": {
114
+ let host = "";
115
+ try {
116
+ host = new URL(String(input.url || "")).host;
117
+ } catch {
118
+ /* not a url */
119
+ }
120
+ return { label: host ? `Fetching ${host}` : "Fetching a page" };
121
+ }
122
+ case "WebSearch": {
123
+ const q = typeof input.query === "string" ? input.query : "";
124
+ return { label: q ? `Searching the web: ${q.slice(0, 60)}` : "Searching the web" };
125
+ }
126
+ case "TodoWrite":
127
+ return null; // planning chatter — too noisy to narrate
128
+ default:
129
+ return { label: `Using ${name || "a tool"}` };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Fold a step into the session state: dedupe an immediate repeat (a run of
135
+ * edits to one file stays one step), ring-bound steps, set-bound files. PURE —
136
+ * mutates and returns `state`.
137
+ */
138
+ export function foldStep(state, step) {
139
+ if (!step) return state;
140
+ const label = sanitizeText(step.label);
141
+ if (!label) return state;
142
+ const steps = Array.isArray(state.steps) ? state.steps : [];
143
+ if (steps[steps.length - 1] !== label) steps.push(label);
144
+ while (steps.length > MAX_STEPS) steps.shift();
145
+ state.steps = steps;
146
+ if (step.file) {
147
+ const file = sanitizeText(step.file);
148
+ const files = Array.isArray(state.files) ? state.files : [];
149
+ if (file && !files.includes(file)) {
150
+ files.push(file);
151
+ while (files.length > MAX_FILES) files.shift();
152
+ }
153
+ state.files = files;
154
+ }
155
+ return state;
156
+ }
157
+
158
+ /** Throttle decision: send now, or fold and wait for a later event? PURE. */
159
+ export function shouldSendNow(state, now, minSendMs = MIN_SEND_MS) {
160
+ const last = typeof state.lastSentAt === "number" ? state.lastSentAt : 0;
161
+ return now - last >= minSendMs;
162
+ }
163
+
164
+ /** Filename-safe session key (a session_id is a uuid, but never trust input). */
165
+ function sessionKey(sessionId) {
166
+ return String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
167
+ }
168
+
169
+ export function statePath(sessionId, dir = HOOK_STATE_DIR) {
170
+ return join(dir, sessionKey(sessionId) + ".json");
171
+ }
172
+
173
+ export function readState(sessionId, dir = HOOK_STATE_DIR) {
174
+ try {
175
+ return JSON.parse(readFileSync(statePath(sessionId, dir), "utf8"));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ export function writeState(sessionId, state, dir = HOOK_STATE_DIR) {
182
+ try {
183
+ mkdirSync(dir, { recursive: true });
184
+ writeFileSync(statePath(sessionId, dir), JSON.stringify(state));
185
+ } catch {
186
+ /* best-effort */
187
+ }
188
+ }
189
+
190
+ export function deleteState(sessionId, dir = HOOK_STATE_DIR) {
191
+ try {
192
+ unlinkSync(statePath(sessionId, dir));
193
+ } catch {
194
+ /* already gone */
195
+ }
196
+ }
197
+
198
+ /** Drop state files from long-dead sessions. Best-effort, bounded, silent. */
199
+ export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
200
+ try {
201
+ for (const f of readdirSync(dir).slice(0, 200)) {
202
+ const p = join(dir, f);
203
+ try {
204
+ if (now - statSync(p).mtimeMs > STATE_TTL_MS) unlinkSync(p);
205
+ } catch {
206
+ /* skip */
207
+ }
208
+ }
209
+ } catch {
210
+ /* no dir yet */
211
+ }
212
+ }
213
+
214
+ /** One bounded post_progress call. Returns the server's messageId, or null. */
215
+ async function sendProgress({ url, token, messageId, channelId, progress }) {
216
+ const controller = new AbortController();
217
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
218
+ try {
219
+ const args = messageId ? { messageId, progress } : { channelId, progress };
220
+ const res = await fetch(url, {
221
+ method: "POST",
222
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
223
+ body: JSON.stringify({
224
+ jsonrpc: "2.0",
225
+ id: 1,
226
+ method: "tools/call",
227
+ params: { name: "post_progress", arguments: args },
228
+ }),
229
+ signal: controller.signal,
230
+ });
231
+ if (!res.ok) return null;
232
+ const json = await res.json();
233
+ const text = json?.result?.content?.[0]?.text;
234
+ const parsed = typeof text === "string" ? JSON.parse(text) : null;
235
+ return parsed && typeof parsed.messageId === "string" ? parsed.messageId : null;
236
+ } catch {
237
+ return null;
238
+ } finally {
239
+ clearTimeout(timer);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Handle one hook invocation end to end. Reads nothing from process.* so the
245
+ * caller (bin) owns stdin/env; returns quietly on every failure path.
246
+ */
247
+ export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_DIR }) {
248
+ const ev = parseHookEvent(raw);
249
+ if (!ev) return;
250
+ const { url, token, channelId } = cfg;
251
+ if (!url || !token || !channelId) return; // not connected to hilos — no-op
252
+
253
+ if (ev.event === "Stop" || ev.event === "SessionEnd") {
254
+ // End of a turn (or the session): settle the card honestly — "not doing
255
+ // anything right now". The next tool call revives the SAME card to working
256
+ // via its stored messageId, so a session stays one card, not one per turn.
257
+ const state = readState(ev.sessionId, stateDir);
258
+ if (!state || !state.messageId) return;
259
+ await sendProgress({
260
+ url,
261
+ token,
262
+ messageId: state.messageId,
263
+ progress: {
264
+ state: "done",
265
+ elapsedMs: Math.max(0, now() - (state.startedAt || now())),
266
+ },
267
+ });
268
+ if (ev.event === "SessionEnd") deleteState(ev.sessionId, stateDir);
269
+ else {
270
+ state.lastSentAt = now();
271
+ writeState(ev.sessionId, state, stateDir);
272
+ }
273
+ return;
274
+ }
275
+
276
+ if (ev.event !== "PostToolUse") return;
277
+
278
+ const step = hookStep(ev.toolName, ev.toolInput, ev.cwd);
279
+ if (!step) return;
280
+
281
+ let state = readState(ev.sessionId, stateDir);
282
+ if (!state) {
283
+ gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
284
+ state = { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
285
+ }
286
+ foldStep(state, step);
287
+
288
+ if (!shouldSendNow(state, now())) {
289
+ // Inside the coalesce window: fold only. The next event (or Stop) flushes.
290
+ writeState(ev.sessionId, state, stateDir);
291
+ return;
292
+ }
293
+
294
+ const progress = {
295
+ state: "working",
296
+ step: state.steps[state.steps.length - 1],
297
+ steps: state.steps,
298
+ filesTouched: state.files,
299
+ elapsedMs: Math.max(0, now() - (state.startedAt || now())),
300
+ };
301
+ const messageId = await sendProgress({
302
+ url,
303
+ token,
304
+ messageId: state.messageId || undefined,
305
+ channelId,
306
+ progress,
307
+ });
308
+ if (messageId) state.messageId = messageId;
309
+ state.lastSentAt = now();
310
+ writeState(ev.sessionId, state, stateDir);
311
+ }
312
+
313
+ /** Read all of stdin (the hook event JSON Claude Code pipes in). */
314
+ function readStdin() {
315
+ return new Promise((resolve) => {
316
+ let data = "";
317
+ const timer = setTimeout(() => resolve(data), 2000); // stdin must never hang the hook
318
+ process.stdin.on("data", (c) => (data += c));
319
+ process.stdin.on("end", () => {
320
+ clearTimeout(timer);
321
+ resolve(data);
322
+ });
323
+ process.stdin.on("error", () => {
324
+ clearTimeout(timer);
325
+ resolve(data);
326
+ });
327
+ });
328
+ }
329
+
330
+ /** `hilos-agent hook` — the command Claude Code invokes. Always exits 0. */
331
+ export async function hookMain() {
332
+ try {
333
+ if (/^(off|0|false)$/i.test(process.env.HILOS_HOOKS || "")) return;
334
+ const raw = await readStdin();
335
+ const cfg = resolveConfig({});
336
+ await runHook({ raw, cfg });
337
+ } catch {
338
+ /* a hook must never fail the user's CLI */
339
+ }
340
+ }
341
+
342
+ // --- `hilos-agent hooks print|install [--global]` ---------------------------
343
+
344
+ const HOOK_COMMAND = "hilos-agent hook";
345
+
346
+ /** The hooks block hilos needs inside a Claude Code settings.json. */
347
+ export function hilosHooksBlock() {
348
+ const entry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
349
+ return {
350
+ PostToolUse: [{ matcher: "*", ...entry }],
351
+ Stop: [entry],
352
+ SessionEnd: [entry],
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Idempotently merge the hilos hooks into a settings object (parsed
358
+ * settings.json). Existing user hooks are preserved; a second install is a
359
+ * no-op. PURE — returns { settings, changed }.
360
+ */
361
+ export function mergeHooksIntoSettings(settings) {
362
+ const out = settings && typeof settings === "object" ? settings : {};
363
+ const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
364
+ let changed = false;
365
+ for (const [event, entries] of Object.entries(hilosHooksBlock())) {
366
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
367
+ const already = existing.some((e) =>
368
+ (e?.hooks || []).some((h) => h?.command === HOOK_COMMAND),
369
+ );
370
+ if (!already) {
371
+ hooks[event] = [...existing, ...entries];
372
+ changed = true;
373
+ }
374
+ }
375
+ return { settings: out, changed };
376
+ }
377
+
378
+ /** `hilos-agent hooks install [--global]` / `hilos-agent hooks print`. */
379
+ export function hooksMain(sub, { global: isGlobal = false } = {}) {
380
+ if (sub === "print") {
381
+ console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
382
+ console.log("\nMerge this into .claude/settings.json in the repo you want to stream.");
383
+ return;
384
+ }
385
+ if (sub !== "install") {
386
+ console.log("Usage: hilos-agent hooks <install|print> [--global]");
387
+ return;
388
+ }
389
+ const target = isGlobal
390
+ ? join(homedir(), ".claude", "settings.json")
391
+ : join(process.cwd(), ".claude", "settings.json");
392
+ let current = {};
393
+ if (existsSync(target)) {
394
+ try {
395
+ current = JSON.parse(readFileSync(target, "utf8"));
396
+ } catch {
397
+ console.error(`${target} exists but isn't valid JSON — fix it first (nothing written).`);
398
+ process.exitCode = 1;
399
+ return;
400
+ }
401
+ }
402
+ const { settings, changed } = mergeHooksIntoSettings(current);
403
+ if (!changed) {
404
+ console.log(`hilos hooks already installed in ${target}.`);
405
+ return;
406
+ }
407
+ if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
408
+ mkdirSync(dirname(target), { recursive: true });
409
+ writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
410
+ console.log(`Installed hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
411
+ console.log(
412
+ isGlobal
413
+ ? "Every Claude Code session on this machine will now stream its steps to your hilos channel."
414
+ : "Claude Code sessions in THIS repo will now stream their steps to your hilos channel.",
415
+ );
416
+ console.log("Kill switch: HILOS_HOOKS=off.");
417
+ // The installed entry runs `hilos-agent hook` directly (npx per tool call
418
+ // would add cold-start latency) — warn now if that won't resolve.
419
+ const onPath = (process.env.PATH || "")
420
+ .split(delimiter)
421
+ .some((dir) => dir && existsSync(join(dir, "hilos-agent")));
422
+ if (!onPath) {
423
+ console.log(
424
+ "NOTE: hilos-agent isn't on your PATH — the hooks won't fire until you run: npm i -g hilos-agent",
425
+ );
426
+ }
427
+ }
package/src/memory.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // Pure helper for building the team memory block injected into coding-agent
2
+ // prompts before a task. Mirrors the shape of lib/memory.ts (server-side TS)
3
+ // but as plain JS so the daemon package has no TypeScript/Next.js dependencies.
4
+ // No I/O — pure function, exportable and unit-testable without a running server.
5
+
6
+ const HEADER = "Team memory (accumulated learnings):";
7
+ const REMEMBER_NOTE =
8
+ "To record durable new learnings from this task, use the `remember` MCP tool " +
9
+ "(call it if your MCP config includes the hilos server).";
10
+
11
+ // Budgets are deliberately TIGHTER than lib/memory.ts formatMemoryBlock
12
+ // (25/6000/400): the daemon injects this into a coding-agent prompt that already
13
+ // carries the repo + task context, so it keeps a smaller footprint (25/4000/300)
14
+ // to leave headroom for the code.
15
+ const DEFAULT_MAX_ENTRIES = 25;
16
+ const DEFAULT_MAX_CHARS = 4000;
17
+ const DEFAULT_BODY_CHARS = 300;
18
+ // Reserve up to this many entry slots for workspace-wide learnings when both
19
+ // scopes have entries, so channel-first ordering can't starve team-wide
20
+ // conventions out of the cap. Mirrors lib/memory.ts. Channel entries still lead.
21
+ const RESERVED_WS_SLOTS = 5;
22
+
23
+ /** Truncate `text` to `max` chars, appending "…" when clipped. */
24
+ function clip(text, max) {
25
+ const t = String(text || "")
26
+ .replace(/\s+/g, " ")
27
+ .trim();
28
+ return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t;
29
+ }
30
+
31
+ /**
32
+ * Build a compact "Team memory" block from recall() entries for injection into
33
+ * a coding-agent prompt. Channel-scoped entries come first (they are the most
34
+ * project-specific); newest first within each scope group. Each entry is one
35
+ * line: `- [kind] title: body`. Bodies are truncated to ~300 chars. The whole
36
+ * block is capped at ~4000 chars (entries that would overflow are dropped, but
37
+ * at least one entry is always kept). Ends with a note reminding the agent to
38
+ * record new learnings via the `remember` MCP tool. Saved memory is active
39
+ * immediately; people can correct or hide it later in hilos.
40
+ *
41
+ * Returns "" when entries is empty or null so callers can concatenate
42
+ * unconditionally (`block ? block + "\n\n" : ""`).
43
+ *
44
+ * @param {Array<{kind?: string, title: string, body: string, scope: string, updated_at?: string}>} entries
45
+ * @param {{ maxEntries?: number, maxChars?: number, bodyChars?: number }} [opts]
46
+ * @returns {string}
47
+ */
48
+ export function buildMemoryBlock(entries, opts = {}) {
49
+ if (!Array.isArray(entries) || entries.length === 0) return "";
50
+ const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
51
+ const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
52
+ const bodyChars = opts.bodyChars ?? DEFAULT_BODY_CHARS;
53
+
54
+ // Channel-scoped first (most relevant to the current project), then
55
+ // workspace-wide. Newest first within each scope group.
56
+ const scopeRank = (s) => (s === "channel" ? 0 : 1);
57
+ const ordered = [...entries].sort((a, b) => {
58
+ const byScope = scopeRank(a.scope) - scopeRank(b.scope);
59
+ if (byScope !== 0) return byScope;
60
+ return (b.updated_at || "").localeCompare(a.updated_at || "");
61
+ });
62
+
63
+ // Entry-count selection with a reserved workspace quota (see RESERVED_WS_SLOTS):
64
+ // hold back a few slots for workspace-wide entries when both scopes are present
65
+ // so channel entries can't claim every slot. Channel entries still lead.
66
+ const channelEntries = ordered.filter((e) => e.scope === "channel");
67
+ const workspaceEntries = ordered.filter((e) => e.scope !== "channel");
68
+ let picked;
69
+ if (channelEntries.length > 0 && workspaceEntries.length > 0) {
70
+ const wsReserve = Math.min(RESERVED_WS_SLOTS, workspaceEntries.length);
71
+ const chosenChannel = channelEntries.slice(0, Math.max(0, maxEntries - wsReserve));
72
+ const chosenWs = workspaceEntries.slice(0, maxEntries - chosenChannel.length);
73
+ picked = [...chosenChannel, ...chosenWs];
74
+ } else {
75
+ picked = ordered.slice(0, maxEntries);
76
+ }
77
+
78
+ const lines = [];
79
+ // Reserve chars for: header line + newline + 2 newlines before footer + footer.
80
+ let total = HEADER.length + 1 + 2 + REMEMBER_NOTE.length;
81
+ for (const e of picked) {
82
+ const kind = (e.kind || "note").trim();
83
+ const title = (e.title || "").replace(/\s+/g, " ").trim();
84
+ const body = clip(e.body || "", bodyChars);
85
+ const line = `- [${kind}] ${title}${body ? `: ${body}` : ""}`;
86
+ // Always keep the first entry even if it's large; drop later ones that
87
+ // would push the block past the budget.
88
+ if (lines.length > 0 && total + line.length + 1 > maxChars) break;
89
+ lines.push(line);
90
+ total += line.length + 1;
91
+ }
92
+
93
+ if (lines.length === 0) return "";
94
+ return `${HEADER}\n${lines.join("\n")}\n\n${REMEMBER_NOTE}`;
95
+ }