carmoji 0.3.9 → 0.3.12

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 +76 -28
  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,7 +629,7 @@ function sessionTokens(payload) {
608
629
  writeFileSync(statePath, JSON.stringify(state));
609
630
  }
610
631
  }
611
- return state.total;
632
+ return { total: state.total, texts };
612
633
  }
613
634
 
614
635
  /**
@@ -633,16 +654,8 @@ function lastAssistantText(payload) {
633
654
  const line = lines[i];
634
655
  if (!line.includes('"assistant"') && !line.includes('agent_message')) continue;
635
656
  try {
636
- const record = JSON.parse(line);
637
- if (record.type === 'assistant') {
638
- const texts = (record.message?.content ?? [])
639
- .filter((block) => block.type === 'text' && block.text)
640
- .map((block) => block.text);
641
- if (texts.length) return texts.join(' ');
642
- }
643
- if (record.payload?.type === 'agent_message' && record.payload.message) {
644
- return String(record.payload.message);
645
- }
657
+ const text = assistantTextOf(JSON.parse(line));
658
+ if (text) return text;
646
659
  } catch { /* the window's first line may be cut mid-record */ }
647
660
  }
648
661
  } catch { /* no transcript, no say */ }
@@ -655,6 +668,9 @@ const TEST_EVENTS = {
655
668
  say: { event: 'turn_done',
656
669
  detail: 'Done — corners now release on yaw alone with an 8s cap, '
657
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.' },
658
674
  edit: { event: 'tool_start', tool: 'Edit' },
659
675
  read: { event: 'tool_start', tool: 'Read' },
660
676
  run: { event: 'tool_start', tool: 'Bash' },
@@ -1418,28 +1434,60 @@ async function main() {
1418
1434
  // sum across them instead of showing whichever reported last.
1419
1435
  const message = { source, session: payload.session_id,
1420
1436
  host: hostname(), ...event };
1421
- // Claude and Codex transcripts power the tok/s readout; other formats
1422
- // quietly produce no token changes.
1423
- const tokens = sessionTokens(payload);
1424
- 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;
1425
1441
  // Gemini and Trae hooks carry no cwd — the hook itself runs in the
1426
1442
  // project directory, so fall back to that.
1427
1443
  const cwd = payload.cwd || process.cwd();
1428
1444
  if (cwd) message.project = basename(cwd);
1445
+
1446
+ // Prose ships whole and verbatim — markdown indentation, code
1447
+ // blocks, and tables all matter to the phone's renderer, and the
1448
+ // history is a review surface, so no length cap either. Only
1449
+ // trailing per-line whitespace goes. Captions are one-liners, so
1450
+ // they still collapse and clip.
1451
+ const clipProse = (text) =>
1452
+ String(text).replace(/[ \t]+$/gm, '').trim();
1453
+ const clipCaption = (text, cap) =>
1454
+ String(text).replace(/\s+/g, ' ').trim().slice(0, cap);
1455
+
1456
+ // On Stop the newest text block is the turn's reply; anything
1457
+ // before it — and everything on other hooks — is mid-procedure
1458
+ // narration, shipped as agent_note for the live play-by-play.
1459
+ const newTexts = news?.texts ?? [];
1460
+ const isTurnDone = event.event === 'turn_done';
1461
+ const noteTexts = isTurnDone ? newTexts.slice(0, -1) : newTexts;
1462
+ for (const text of noteTexts.slice(-4)) {
1463
+ await sendAll(devices, { source, session: payload.session_id,
1464
+ host: hostname(), project: message.project,
1465
+ event: 'agent_note',
1466
+ detail: clipProse(text) });
1467
+ }
1468
+
1429
1469
  // A snippet of human-readable context for the phone to show. The
1430
- // turn's reply gets more room than a toast line — it feeds the
1431
- // reviewable chat history, not just a caption.
1470
+ // turn's reply gets room — it feeds the reviewable chat history,
1471
+ // not just a caption — with the tail-window read as fallback when
1472
+ // the incremental scan saw nothing new.
1473
+ // tool_start also names what the tool is chewing on (the command,
1474
+ // the file) so the phone's history can say more than "Bash".
1475
+ const toolInfo = event.event === 'tool_start' && payload.tool_input
1476
+ ? summarizeToolInput(payload) : undefined;
1432
1477
  const detail = event.event === 'prompt'
1433
1478
  ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
1434
1479
  payload.message, payload.input, payload.text)
1435
1480
  : event.event === 'permission'
1436
1481
  ? firstString(payload.message, payload.detail,
1437
1482
  payload.tool_input ? summarizeToolInput(payload) : undefined)
1438
- : event.event === 'turn_done'
1439
- ? lastAssistantText(payload)
1440
- : undefined;
1441
- const detailCap = event.event === 'turn_done' ? 400 : 140;
1442
- if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, detailCap);
1483
+ : isTurnDone
1484
+ ? (newTexts[newTexts.length - 1] ?? lastAssistantText(payload))
1485
+ : (toolInfo !== '{}' ? toolInfo : undefined);
1486
+ if (detail) {
1487
+ message.detail = isTurnDone || event.event === 'prompt'
1488
+ ? clipProse(detail)
1489
+ : clipCaption(detail, 140);
1490
+ }
1443
1491
  // Plan-window usage comes from Claude Code's local transcripts;
1444
1492
  // recompute only at turn boundaries.
1445
1493
  if (source === 'claude') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.9",
3
+ "version": "0.3.12",
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": {