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