@ariso-ai/ari-hooks 0.1.9 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/hooks.js +242 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ariso-ai/ari-hooks",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Set up Claude Code hooks that share your requests and their outcomes with Ari",
5
5
  "type": "module",
6
6
  "bin": {
package/src/hooks.js CHANGED
@@ -1,4 +1,5 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
2
3
  import {
3
4
  mkdirSync,
4
5
  readFileSync,
@@ -101,6 +102,13 @@ function taskNotificationStandIn(prompt) {
101
102
  : 'The coding agent ran a background task.';
102
103
  }
103
104
 
105
+ // The IDE integration injects the user's current editor selection into the
106
+ // prompt as an <ide_selection> block — host-added context, not text the user
107
+ // typed. Strip it before recording so the activity feed shows only what the
108
+ // user actually said.
109
+ const IDE_SELECTION_RE = /<ide_selection>[\s\S]*?<\/ide_selection>/g;
110
+ const stripIdeSelection = (prompt) => prompt.replace(IDE_SELECTION_RE, '').trim();
111
+
104
112
  /**
105
113
  * UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor): remember the
106
114
  * prompt so the Stop hook can pair it with the turn's outcome. Both hosts
@@ -109,11 +117,18 @@ function taskNotificationStandIn(prompt) {
109
117
  async function onUserPromptSubmit(input) {
110
118
  const sessionId = sessionIdOf(input);
111
119
  if (!sessionId || typeof input.prompt !== 'string') return;
120
+ const prompt = stripIdeSelection(input.prompt);
121
+ if (!prompt) return;
112
122
  const session = loadSession(sessionId);
113
- session.prompts.push(taskNotificationStandIn(input.prompt) ?? input.prompt);
123
+ session.prompts.push(taskNotificationStandIn(prompt) ?? prompt);
114
124
  saveSession(sessionId, session);
115
125
  }
116
126
 
127
+ // A model name from a hook payload, or null — hosts that include one send a
128
+ // plain non-empty string.
129
+ const modelOf = (value) =>
130
+ typeof value === 'string' && value.trim() ? value : null;
131
+
117
132
  /**
118
133
  * afterAgentResponse (Cursor only): Cursor's transcript is not the Claude
119
134
  * Code JSONL that extractOutcome can parse, so capture the final assistant
@@ -125,6 +140,8 @@ async function onAgentResponse(input) {
125
140
  if (!sessionId || typeof input.text !== 'string' || !input.text.trim()) return;
126
141
  const session = loadSession(sessionId);
127
142
  session.outcome = input.text;
143
+ const model = modelOf(input.model);
144
+ if (model) session.model = model;
128
145
  saveSession(sessionId, session);
129
146
  }
130
147
 
@@ -139,17 +156,7 @@ function assistantText(entry) {
139
156
  .trim();
140
157
  }
141
158
 
142
- /**
143
- * Pull the final assistant text out of the transcript (JSONL). This is the
144
- * "outcome" — we deliberately skip the intermediate steps/tool calls.
145
- *
146
- * `settled` reports whether the exchange actually ends in assistant text.
147
- * When the transcript instead ends at a tool call/result or a half-written
148
- * line, the final message hasn't been flushed yet and `text` is only the
149
- * last narration before a tool ran — the caller should re-read rather than
150
- * ship that as the outcome.
151
- */
152
- function extractOutcome(transcriptPath) {
159
+ function parseTranscript(transcriptPath) {
153
160
  const entries = [];
154
161
  let tailPartial = false;
155
162
  for (const line of readFileSync(transcriptPath, 'utf8').split('\n')) {
@@ -161,7 +168,20 @@ function extractOutcome(transcriptPath) {
161
168
  tailPartial = true; // a line still being written
162
169
  }
163
170
  }
171
+ return { entries, tailPartial };
172
+ }
164
173
 
174
+ /**
175
+ * Pull the final assistant text out of the transcript entries. This is the
176
+ * "outcome" — we deliberately skip the intermediate steps/tool calls.
177
+ *
178
+ * `settled` reports whether the exchange actually ends in assistant text.
179
+ * When the transcript instead ends at a tool call/result or a half-written
180
+ * line, the final message hasn't been flushed yet and `text` is only the
181
+ * last narration before a tool ran — the caller should re-read rather than
182
+ * ship that as the outcome.
183
+ */
184
+ function extractOutcome(entries, tailPartial) {
165
185
  let settled = tailPartial ? false : null;
166
186
  for (let i = entries.length - 1; i >= 0; i--) {
167
187
  const entry = entries[i];
@@ -186,12 +206,72 @@ function extractOutcome(transcriptPath) {
186
206
  return { text: null, settled: false };
187
207
  }
188
208
 
209
+ // A user entry that is an actual typed prompt — not a tool result, not host
210
+ // bookkeeping (isMeta), not a subagent's inner conversation (isSidechain).
211
+ function isUserPrompt(entry) {
212
+ if (entry.type !== 'user' || entry.isSidechain || entry.isMeta) return false;
213
+ const content = entry.message?.content;
214
+ if (typeof content === 'string') return content.trim().length > 0;
215
+ return (
216
+ Array.isArray(content) &&
217
+ content.some((block) => block.type === 'text') &&
218
+ !content.some((block) => block.type === 'tool_result')
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Model and token usage for the turn being reported. The transcript holds
224
+ * the whole session, so walk back past `promptCount` user prompts (this
225
+ * send covers that many queued prompts) and only count assistant entries
226
+ * after that point. Usage is keyed by message id — a message split across
227
+ * several JSONL entries (one per content block) repeats the same usage on
228
+ * each, and the last entry wins — then totalled over everything the API
229
+ * metered: input, cache writes/reads, and output. The model is named by
230
+ * the turn's last top-level assistant message; sidechain (subagent)
231
+ * entries still count toward tokens.
232
+ */
233
+ function extractTurnStats(entries, promptCount) {
234
+ let boundary = -1;
235
+ let remaining = Math.max(1, promptCount);
236
+ for (let i = entries.length - 1; i >= 0 && remaining > 0; i--) {
237
+ if (isUserPrompt(entries[i])) {
238
+ boundary = i;
239
+ remaining--;
240
+ }
241
+ }
242
+
243
+ let model = null;
244
+ const usageById = new Map();
245
+ for (let i = boundary + 1; i < entries.length; i++) {
246
+ const entry = entries[i];
247
+ if (entry.type !== 'assistant') continue;
248
+ const message = entry.message ?? {};
249
+ // '<synthetic>' marks host-injected error messages, not a real model.
250
+ if (!entry.isSidechain && modelOf(message.model) && message.model !== '<synthetic>') {
251
+ model = message.model;
252
+ }
253
+ if (message.usage) usageById.set(message.id ?? `entry-${i}`, message.usage);
254
+ }
255
+
256
+ let tokens = null;
257
+ for (const usage of usageById.values()) {
258
+ tokens =
259
+ (tokens ?? 0) +
260
+ (usage.input_tokens ?? 0) +
261
+ (usage.cache_creation_input_tokens ?? 0) +
262
+ (usage.cache_read_input_tokens ?? 0) +
263
+ (usage.output_tokens ?? 0);
264
+ }
265
+ return { model, tokens };
266
+ }
267
+
189
268
  const clamp = (text) =>
190
269
  text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
191
270
 
192
271
  /**
193
272
  * Stop: the turn is over — send the accumulated request(s) plus the final
194
- * assistant message to Ari, then clear the per-session state.
273
+ * assistant message (and, when the host exposes them, the model used and
274
+ * the turn's token count) to Ari, then clear the per-session state.
195
275
  */
196
276
  async function onStop(input, agent) {
197
277
  // stop_hook_active means a stop hook already forced Claude to continue;
@@ -208,13 +288,36 @@ async function onStop(input, agent) {
208
288
  // last_assistant_message; Claude Code sessions read it from the transcript,
209
289
  // waiting for the final assistant message to land there (on timeout, fall
210
290
  // back to the last text we did find — best effort).
211
- let outcome = session.outcome ?? input.last_assistant_message ?? null;
212
- if (!outcome && input.transcript_path) {
291
+ const payloadOutcome = session.outcome ?? input.last_assistant_message ?? null;
292
+ let outcome = payloadOutcome;
293
+ // Hosts that hand us the outcome directly may also name the model on their
294
+ // payloads; Claude Code's model and token usage live only in the
295
+ // transcript. Token counts are transcript-only — the other hosts don't
296
+ // report usage. Recent Claude Code also sends last_assistant_message, so
297
+ // the transcript is read for stats even when the outcome is already in
298
+ // hand — but a stats failure must never cost us the activity itself.
299
+ let model = modelOf(input.model) ?? session.model ?? null;
300
+ let tokens = null;
301
+ if (input.transcript_path) {
213
302
  const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
214
303
  for (;;) {
215
- const { text, settled } = extractOutcome(input.transcript_path);
216
- outcome = text;
217
- if (settled || Date.now() >= deadline) break;
304
+ let entries, tailPartial;
305
+ try {
306
+ ({ entries, tailPartial } = parseTranscript(input.transcript_path));
307
+ } catch (err) {
308
+ logError(err);
309
+ break;
310
+ }
311
+ const { text, settled } = extractOutcome(entries, tailPartial);
312
+ outcome = payloadOutcome ?? text;
313
+ // Even with the outcome in hand, wait for the final message to flush
314
+ // so its usage makes it into the token count.
315
+ if (settled || Date.now() >= deadline) {
316
+ const stats = extractTurnStats(entries, session.prompts.length);
317
+ model = stats.model ?? model;
318
+ tokens = stats.tokens;
319
+ break;
320
+ }
218
321
  await sleep(OUTCOME_POLL_INTERVAL_MS);
219
322
  }
220
323
  }
@@ -236,6 +339,9 @@ async function onStop(input, agent) {
236
339
  agent_type: agentTypeOf(input, agent),
237
340
  // Cursor sends workspace_roots instead of cwd.
238
341
  cwd: input.cwd ?? input.workspace_roots?.[0] ?? process.cwd(),
342
+ // Only sent when known — the API treats absent and unknown alike.
343
+ ...(model && { primary_model: model }),
344
+ ...(tokens != null && { token_count: tokens }),
239
345
  }),
240
346
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
241
347
  });
@@ -252,23 +358,7 @@ const MAX_TASK_NAME_LENGTH = 200;
252
358
  const oneLine = (text) =>
253
359
  text.replace(/\s+/g, ' ').trim().slice(0, MAX_TASK_NAME_LENGTH);
254
360
 
255
- /**
256
- * SessionStart: ask Ari for the top tasks Claude can take care of right now
257
- * and surface them at boot — a visible list for the user (systemMessage)
258
- * plus the full prompts for Claude (additionalContext) so it can run
259
- * whichever one the user picks.
260
- */
261
- async function onSessionStart(input) {
262
- // Compaction restarts the session mid-conversation; the tasks were
263
- // already offered, so don't show (or inject) them again.
264
- if (input.source === 'compact') return;
265
- // Cursor also fires sessionStart for headless background agents — there is
266
- // no user watching who could pick a task.
267
- if (input.is_background_agent) return;
268
-
269
- const config = loadConfig();
270
- if (!config.token) return;
271
-
361
+ async function fetchTasks(config) {
272
362
  const response = await fetch(new URL('/agent-tasks', getApiUrl(config)), {
273
363
  headers: { Authorization: `Bearer ${config.token}` },
274
364
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
@@ -280,7 +370,7 @@ async function onSessionStart(input) {
280
370
  const body = await response.json();
281
371
  // The API wraps the list ({ tasks: [...] }); accept a bare array too.
282
372
  const list = Array.isArray(body) ? body : Array.isArray(body?.tasks) ? body.tasks : [];
283
- const tasks = list
373
+ return list
284
374
  .filter(
285
375
  (t) =>
286
376
  t &&
@@ -290,33 +380,115 @@ async function onSessionStart(input) {
290
380
  t.prompt.trim()
291
381
  )
292
382
  .slice(0, MAX_TASKS);
293
- if (tasks.length === 0) return;
294
-
295
- const additionalContext =
296
- `The user has Ari connected via ari-hooks. At session start the user was ` +
297
- `shown this list of suggested tasks:\n\n` +
298
- tasks
299
- .map(
300
- (t, i) =>
301
- `Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
302
- )
303
- .join('\n\n') +
304
- `\n\nIf the user asks to run one of these tasks (by number or name), ` +
305
- `carry out that task's prompt as if the user had typed it. Do not start ` +
306
- `any of these tasks unless the user asks.`;
383
+ }
384
+
385
+ const NPM_PACKAGE = '@ariso-ai/ari-hooks';
386
+ const VERSION_CHECK_TIMEOUT_MS = 3_000;
387
+
388
+ const installedVersion = () =>
389
+ JSON.parse(
390
+ readFileSync(
391
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
392
+ 'utf8'
393
+ )
394
+ ).version;
395
+
396
+ // Plain x.y.z releases only no prerelease/build-metadata tags to worry about.
397
+ const isNewer = (latest, current) => {
398
+ const a = latest.split('.').map(Number);
399
+ const b = current.split('.').map(Number);
400
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
401
+ const x = a[i] ?? 0;
402
+ const y = b[i] ?? 0;
403
+ if (x !== y) return x > y;
404
+ }
405
+ return false;
406
+ };
407
+
408
+ /**
409
+ * Compare the installed version against what npm has published. A stale or
410
+ * unreachable registry must never break session start, so any failure just
411
+ * skips the check (returns null, same as "up to date").
412
+ */
413
+ async function checkForUpdate() {
414
+ try {
415
+ const registryUrl = process.env.ARI_HOOKS_REGISTRY_URL || 'https://registry.npmjs.org';
416
+ const response = await fetch(new URL(`/${NPM_PACKAGE}/latest`, registryUrl), {
417
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
418
+ });
419
+ if (!response.ok) return null;
420
+ const { version: latest } = await response.json();
421
+ const current = installedVersion();
422
+ return typeof latest === 'string' && isNewer(latest, current)
423
+ ? { current, latest }
424
+ : null;
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * SessionStart: ask Ari for the top tasks Claude can take care of right now
432
+ * and surface them at boot — a visible list for the user (systemMessage)
433
+ * plus the full prompts for Claude (additionalContext) so it can run
434
+ * whichever one the user picks. Also checks whether a newer ari-hooks is
435
+ * published, since this is the one moment we know a user is watching.
436
+ */
437
+ async function onSessionStart(input) {
438
+ // Compaction restarts the session mid-conversation; the tasks were
439
+ // already offered, so don't show (or inject) them again.
440
+ if (input.source === 'compact') return;
441
+ // Cursor also fires sessionStart for headless background agents — there is
442
+ // no user watching who could pick a task.
443
+ if (input.is_background_agent) return;
444
+
445
+ const config = loadConfig();
446
+ if (!config.token) return;
447
+
448
+ const [tasks, update] = await Promise.all([fetchTasks(config), checkForUpdate()]);
449
+ if (tasks.length === 0 && !update) return;
450
+
451
+ const updateNotice = update
452
+ ? `ari-hooks ${update.current} is out of date (latest: ${update.latest}). ` +
453
+ `Update it: npm install -g ${NPM_PACKAGE}@latest`
454
+ : null;
455
+
456
+ const taskContext =
457
+ tasks.length > 0
458
+ ? `The user has Ari connected via ari-hooks. At session start the user was ` +
459
+ `shown this list of suggested tasks:\n\n` +
460
+ tasks
461
+ .map(
462
+ (t, i) =>
463
+ `Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
464
+ )
465
+ .join('\n\n') +
466
+ `\n\nIf the user asks to run one of these tasks (by number or name), ` +
467
+ `carry out that task's prompt as if the user had typed it. Do not start ` +
468
+ `any of these tasks unless the user asks.`
469
+ : null;
470
+
471
+ const additionalContext = [
472
+ updateNotice ? `Note for the user: ${updateNotice}` : null,
473
+ taskContext,
474
+ ]
475
+ .filter(Boolean)
476
+ .join('\n\n');
307
477
 
308
478
  // Cursor's sessionStart output is a flat { additional_context } and it has
309
479
  // no user-visible systemMessage channel, so the agent itself must surface
310
- // the list.
480
+ // the list (and the update notice).
311
481
  if (isCursorInput(input)) {
312
482
  writeSync(
313
483
  1,
314
484
  JSON.stringify({
315
485
  additional_context:
316
486
  additionalContext +
317
- `\n\nNote: unlike Claude Code, Cursor did NOT show the user this ` +
318
- `list briefly offer these tasks by name at the start of your ` +
319
- `first reply.`,
487
+ (tasks.length > 0
488
+ ? `\n\nNote: unlike Claude Code, Cursor did NOT show the user this ` +
489
+ `list — briefly offer these tasks by name at the start of your ` +
490
+ `first reply.`
491
+ : ''),
320
492
  }) + '\n'
321
493
  );
322
494
  return;
@@ -326,15 +498,23 @@ async function onSessionStart(input) {
326
498
  // pushes our block below the fixed "SessionStart:<source> says:" prefix.
327
499
  const BOLD = '\x1b[1m';
328
500
  const CYAN = '\x1b[36m';
501
+ const YELLOW = '\x1b[33m';
329
502
  const GREY = '\x1b[37m';
330
503
  const RESET = '\x1b[0m';
331
- const visibleList = tasks
332
- .map((t, i) => ` ${BOLD}${i + 1}.${RESET} ${oneLine(t.taskName)}`)
333
- .join('\n');
334
- const systemMessage =
335
- `\n${BOLD}${CYAN}✻ Ari things Claude can take care of for you right now${RESET}\n` +
336
- `${visibleList}\n` +
337
- `${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`;
504
+
505
+ const messageBlocks = [];
506
+ if (updateNotice) messageBlocks.push(`${YELLOW}⚠ ${updateNotice}${RESET}`);
507
+ if (tasks.length > 0) {
508
+ const visibleList = tasks
509
+ .map((t, i) => ` ${BOLD}${i + 1}.${RESET} ${oneLine(t.taskName)}`)
510
+ .join('\n');
511
+ messageBlocks.push(
512
+ `${BOLD}${CYAN}✻ Ari — things Claude can take care of for you right now${RESET}\n` +
513
+ `${visibleList}\n` +
514
+ `${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`
515
+ );
516
+ }
517
+ const systemMessage = `\n${messageBlocks.join('\n')}`;
338
518
 
339
519
  // writeSync: process.exit(0) in runHook would race an async stdout write.
340
520
  writeSync(