carmoji 0.3.8 → 0.3.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/carmoji.js +98 -15
  2. package/package.json +1 -1
package/carmoji.js CHANGED
@@ -570,12 +570,27 @@ function applyTranscriptTokenRecord(state, record) {
570
570
  return state;
571
571
  }
572
572
 
573
+ /** One transcript record's assistant prose, if it carries any. */
574
+ function assistantTextOf(record) {
575
+ if (record.type === 'assistant') {
576
+ const texts = (record.message?.content ?? [])
577
+ .filter((block) => block.type === 'text' && block.text)
578
+ .map((block) => block.text);
579
+ if (texts.length) return texts.join('\n');
580
+ }
581
+ if (record.payload?.type === 'agent_message' && record.payload.message) {
582
+ return String(record.payload.message);
583
+ }
584
+ return undefined;
585
+ }
586
+
573
587
  /**
574
- * Cumulative output tokens for this session, counted incrementally from the
575
- * Claude or Codex transcript JSONL. A per-session byte offset is cached so
576
- * each hook only parses lines appended since the previous one.
588
+ * What the transcript gained since the last hook: the session's cumulative
589
+ * output tokens, plus any assistant prose that appeared the play-by-play
590
+ * an agent narrates between tools, and eventually the turn's reply. A
591
+ * per-session byte offset is cached so each hook only parses new lines.
577
592
  */
578
- function sessionTokens(payload) {
593
+ function sessionTranscriptNews(payload) {
579
594
  const transcript = payload.transcript_path;
580
595
  const session = payload.session_id;
581
596
  if (!transcript || !session) return undefined;
@@ -584,6 +599,7 @@ function sessionTokens(payload) {
584
599
  try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
585
600
  if (!Number.isFinite(state.offset) || state.offset < 0) state.offset = 0;
586
601
  if (!Number.isFinite(state.total) || state.total < 0) state.total = 0;
602
+ const texts = [];
587
603
  let size;
588
604
  try { size = statSync(transcript).size; } catch { return undefined; }
589
605
  if (size < state.offset) state = { offset: 0, total: 0 }; // truncated/rotated
@@ -598,9 +614,14 @@ function sessionTokens(payload) {
598
614
  const lastNewline = chunk.lastIndexOf(0x0a);
599
615
  if (lastNewline >= 0) {
600
616
  for (const line of chunk.subarray(0, lastNewline + 1).toString('utf8').split('\n')) {
601
- if (!line.includes('"usage"') && !line.includes('"token_count"')) continue;
617
+ const wantsTokens = line.includes('"usage"') || line.includes('"token_count"');
618
+ const wantsText = line.includes('"assistant"') || line.includes('agent_message');
619
+ if (!wantsTokens && !wantsText) continue;
602
620
  try {
603
- applyTranscriptTokenRecord(state, JSON.parse(line));
621
+ const record = JSON.parse(line);
622
+ if (wantsTokens) applyTranscriptTokenRecord(state, record);
623
+ const text = assistantTextOf(record);
624
+ if (text && text.trim().length >= 20) texts.push(text);
604
625
  } catch { /* ignore malformed lines */ }
605
626
  }
606
627
  state.offset += lastNewline + 1;
@@ -608,12 +629,48 @@ function sessionTokens(payload) {
608
629
  writeFileSync(statePath, JSON.stringify(state));
609
630
  }
610
631
  }
611
- return state.total;
632
+ return { total: state.total, texts };
633
+ }
634
+
635
+ /**
636
+ * The agent's final say for the turn. Hooks never carry assistant text,
637
+ * but Stop hands over transcript_path, and the JSONL's tail holds the
638
+ * reply — Claude records {type:"assistant", message:{content:[{type:
639
+ * "text",text}]}}, Codex rollouts {payload:{type:"agent_message",message}}.
640
+ */
641
+ function lastAssistantText(payload) {
642
+ try {
643
+ const transcript = payload.transcript_path;
644
+ if (!transcript) return undefined;
645
+ const size = statSync(transcript).size;
646
+ const window = Math.min(size, 256 * 1024);
647
+ if (window === 0) return undefined;
648
+ const fd = openSync(transcript, 'r');
649
+ const buffer = Buffer.alloc(window);
650
+ const bytesRead = readSync(fd, buffer, 0, window, size - window);
651
+ closeSync(fd);
652
+ const lines = buffer.subarray(0, bytesRead).toString('utf8').split('\n');
653
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
654
+ const line = lines[i];
655
+ if (!line.includes('"assistant"') && !line.includes('agent_message')) continue;
656
+ try {
657
+ const text = assistantTextOf(JSON.parse(line));
658
+ if (text) return text;
659
+ } catch { /* the window's first line may be cut mid-record */ }
660
+ }
661
+ } catch { /* no transcript, no say */ }
662
+ return undefined;
612
663
  }
613
664
 
614
665
  const TEST_EVENTS = {
615
666
  start: { event: 'session_start' },
616
- prompt: { event: 'prompt' },
667
+ prompt: { event: 'prompt', detail: 'Fix the corner release bug' },
668
+ say: { event: 'turn_done',
669
+ detail: 'Done — corners now release on yaw alone with an 8s cap, '
670
+ + 'and cornerEnded bypasses every gate so the lean never sticks.' },
671
+ note: { event: 'agent_note',
672
+ detail: 'Found it — the release threshold on lateral was 0.078g, '
673
+ + 'which a hand never settles under. Trying a yaw-only exit next.' },
617
674
  edit: { event: 'tool_start', tool: 'Edit' },
618
675
  read: { event: 'tool_start', tool: 'Read' },
619
676
  run: { event: 'tool_start', tool: 'Bash' },
@@ -1377,23 +1434,49 @@ async function main() {
1377
1434
  // sum across them instead of showing whichever reported last.
1378
1435
  const message = { source, session: payload.session_id,
1379
1436
  host: hostname(), ...event };
1380
- // Claude and Codex transcripts power the tok/s readout; other formats
1381
- // quietly produce no token changes.
1382
- const tokens = sessionTokens(payload);
1383
- if (tokens !== undefined) message.tokens = tokens;
1437
+ // Claude and Codex transcripts power the tok/s readout and the
1438
+ // play-by-play; other formats quietly produce neither.
1439
+ const news = sessionTranscriptNews(payload);
1440
+ if (news?.total !== undefined) message.tokens = news.total;
1384
1441
  // Gemini and Trae hooks carry no cwd — the hook itself runs in the
1385
1442
  // project directory, so fall back to that.
1386
1443
  const cwd = payload.cwd || process.cwd();
1387
1444
  if (cwd) message.project = basename(cwd);
1388
- // A snippet of human-readable context for the phone to show.
1445
+
1446
+ // Prose keeps its newlines and is clipped only when abnormally
1447
+ // long; the one-line permission caption stays terse.
1448
+ const clip = (text, cap) =>
1449
+ String(text).replace(/[ \t]+/g, ' ').trim().slice(0, cap);
1450
+
1451
+ // On Stop the newest text block is the turn's reply; anything
1452
+ // before it — and everything on other hooks — is mid-procedure
1453
+ // narration, shipped as agent_note for the live play-by-play.
1454
+ const newTexts = news?.texts ?? [];
1455
+ const isTurnDone = event.event === 'turn_done';
1456
+ const noteTexts = isTurnDone ? newTexts.slice(0, -1) : newTexts;
1457
+ for (const text of noteTexts.slice(-4)) {
1458
+ await sendAll(devices, { source, session: payload.session_id,
1459
+ host: hostname(), project: message.project,
1460
+ event: 'agent_note',
1461
+ detail: clip(text, 2000) });
1462
+ }
1463
+
1464
+ // A snippet of human-readable context for the phone to show. The
1465
+ // turn's reply gets room — it feeds the reviewable chat history,
1466
+ // not just a caption — with the tail-window read as fallback when
1467
+ // the incremental scan saw nothing new.
1389
1468
  const detail = event.event === 'prompt'
1390
1469
  ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
1391
1470
  payload.message, payload.input, payload.text)
1392
1471
  : event.event === 'permission'
1393
1472
  ? firstString(payload.message, payload.detail,
1394
1473
  payload.tool_input ? summarizeToolInput(payload) : undefined)
1395
- : undefined;
1396
- if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
1474
+ : isTurnDone
1475
+ ? (newTexts[newTexts.length - 1] ?? lastAssistantText(payload))
1476
+ : undefined;
1477
+ const detailCap = isTurnDone ? 2000
1478
+ : event.event === 'prompt' ? 1000 : 140;
1479
+ if (detail) message.detail = clip(detail, detailCap);
1397
1480
  // Plan-window usage comes from Claude Code's local transcripts;
1398
1481
  // recompute only at turn boundaries.
1399
1482
  if (source === 'claude') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Bring your CarMoji pet to life from Claude Code / Codex — a LAN bridge that streams coding events to the CarMoji iOS app",
5
5
  "type": "module",
6
6
  "bin": {