neuvikon 0.2.9 → 0.2.11

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/dist/index.mjs +145 -134
  2. package/package.json +4 -1
package/dist/index.mjs CHANGED
@@ -238,7 +238,7 @@ function transcriptSize(path) {
238
238
  closeSync(fd);
239
239
  }
240
240
  }
241
- function readUsageSince(path, offset) {
241
+ function readUsageSince(path, offset, since) {
242
242
  let fd;
243
243
  try {
244
244
  fd = openSync(path, "r");
@@ -251,7 +251,8 @@ function readUsageSince(path, offset) {
251
251
  if (size === from) return {
252
252
  tokensIn: 0,
253
253
  tokensOut: 0,
254
- offset: size
254
+ offset: size,
255
+ lastMessageId: since
255
256
  };
256
257
  const buffer = Buffer.alloc(size - from);
257
258
  readSync(fd, buffer, 0, buffer.length, from);
@@ -260,10 +261,12 @@ function readUsageSince(path, offset) {
260
261
  if (lastNewline === -1) return {
261
262
  tokensIn: 0,
262
263
  tokensOut: 0,
263
- offset: from
264
+ offset: from,
265
+ lastMessageId: since
264
266
  };
265
267
  let tokensIn = 0;
266
268
  let tokensOut = 0;
269
+ let lastMessageId = since;
267
270
  for (const line of text.slice(0, lastNewline).split("\n")) {
268
271
  if (!line.trim()) continue;
269
272
  let row;
@@ -274,13 +277,16 @@ function readUsageSince(path, offset) {
274
277
  }
275
278
  const usage = extractUsage(row);
276
279
  if (!usage) continue;
280
+ if (usage.messageId !== void 0 && usage.messageId === lastMessageId) continue;
281
+ lastMessageId = usage.messageId ?? lastMessageId;
277
282
  tokensIn += usage.tokensIn;
278
283
  tokensOut += usage.tokensOut;
279
284
  }
280
285
  return {
281
286
  tokensIn,
282
287
  tokensOut,
283
- offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1))
288
+ offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1)),
289
+ lastMessageId
284
290
  };
285
291
  } catch {
286
292
  return null;
@@ -297,9 +303,11 @@ function extractUsage(row) {
297
303
  if (typeof message !== "object" || message === null) return null;
298
304
  const usage = message.usage;
299
305
  if (typeof usage !== "object" || usage === null) return null;
306
+ const id = message.id;
300
307
  const fields = usage;
301
308
  return {
302
- tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_read_input_tokens) + toCount$1(fields.cache_creation_input_tokens),
309
+ messageId: typeof id === "string" && id ? id : void 0,
310
+ tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_creation_input_tokens),
303
311
  tokensOut: toCount$1(fields.output_tokens)
304
312
  };
305
313
  }
@@ -358,6 +366,133 @@ function fail(message, hint) {
358
366
  process.exit(1);
359
367
  }
360
368
  //#endregion
369
+ //#region src/commands/report.ts
370
+ /**
371
+ * Agent hooks hand their context to the command on stdin as JSON. Reading the
372
+ * transcript path from there means a hook is a bare `npx neuvikon report` with
373
+ * nothing to wire up, and nothing to keep in sync when a tool renames a flag.
374
+ */
375
+ async function transcriptFromHookPayload() {
376
+ const raw = await readStdin();
377
+ if (!raw.trim()) return void 0;
378
+ try {
379
+ const payload = JSON.parse(raw);
380
+ if (typeof payload !== "object" || payload === null) return void 0;
381
+ const path = payload.transcript_path;
382
+ return typeof path === "string" && path ? path : void 0;
383
+ } catch {
384
+ return;
385
+ }
386
+ }
387
+ function toCount(raw) {
388
+ if (raw === void 0) return void 0;
389
+ const value = Number(raw);
390
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
391
+ }
392
+ /**
393
+ * What the transcript has accumulated since this session's baseline.
394
+ *
395
+ * A session with no baseline yet establishes one here rather than counting
396
+ * from the top: everything before the ticket started belongs to whatever the
397
+ * agent was doing beforehand, not to this ticket.
398
+ */
399
+ function pendingUsage(session, transcript) {
400
+ if (!transcript) return {
401
+ tokensIn: 0,
402
+ tokensOut: 0
403
+ };
404
+ let baseline = session.transcriptOffset;
405
+ if (baseline === void 0) {
406
+ baseline = transcriptSize(transcript) ?? void 0;
407
+ if (baseline !== void 0) writeSession({
408
+ ...session,
409
+ transcriptOffset: baseline
410
+ });
411
+ }
412
+ if (baseline === void 0) return {
413
+ tokensIn: 0,
414
+ tokensOut: 0
415
+ };
416
+ const usage = readUsageSince(transcript, baseline, session.lastMessageId);
417
+ if (!usage) return {
418
+ tokensIn: 0,
419
+ tokensOut: 0
420
+ };
421
+ return {
422
+ tokensIn: usage.tokensIn,
423
+ tokensOut: usage.tokensOut,
424
+ nextOffset: usage.offset,
425
+ nextMessageId: usage.lastMessageId
426
+ };
427
+ }
428
+ /**
429
+ * Hands a delta to the server, advancing the local offset only once it is
430
+ * accepted — a dropped request leaves the window to be retried by the next
431
+ * call instead of silently losing those tokens.
432
+ */
433
+ async function postUsage(credentials, session, delta) {
434
+ try {
435
+ await post(credentials.url, "/api/agent/events", {
436
+ token: credentials.token,
437
+ event: "report",
438
+ sessionKey: session.sessionKey,
439
+ tokensIn: delta.tokensIn,
440
+ tokensOut: delta.tokensOut
441
+ });
442
+ } catch {
443
+ return false;
444
+ }
445
+ if (delta.nextOffset !== void 0) writeSession({
446
+ ...session,
447
+ transcriptOffset: delta.nextOffset,
448
+ lastMessageId: delta.nextMessageId
449
+ });
450
+ return true;
451
+ }
452
+ /**
453
+ * Closes a session, carrying everything it accumulated since its last report.
454
+ *
455
+ * Switching tickets used to stop the outgoing session bare, which dropped every
456
+ * token it had earned since its last turn ended — and a ticket that was started
457
+ * and left inside a single turn recorded exactly nothing.
458
+ */
459
+ async function stopSession(credentials, session, transcript) {
460
+ const usage = pendingUsage(session, transcript);
461
+ await post(credentials.url, "/api/agent/events", {
462
+ token: credentials.token,
463
+ event: "stop",
464
+ sessionKey: session.sessionKey,
465
+ tokensIn: usage.tokensIn,
466
+ tokensOut: usage.tokensOut
467
+ }).catch(() => void 0);
468
+ }
469
+ /**
470
+ * Called from an agent hook on every turn, so it is deliberately silent and
471
+ * never exits non-zero: a token count is not worth interrupting someone's work
472
+ * over, and a hook that fails loudly is a hook people uninstall.
473
+ */
474
+ async function report(flags) {
475
+ const credentials = readCredentials();
476
+ const session = readSession();
477
+ if (!credentials || !session) {
478
+ if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
479
+ return;
480
+ }
481
+ const fromTranscript = pendingUsage(session, flags.get("transcript") || await transcriptFromHookPayload() || claudeTranscriptPath());
482
+ const delta = {
483
+ tokensIn: (toCount(flags.get("tokens-in")) ?? 0) + fromTranscript.tokensIn,
484
+ tokensOut: (toCount(flags.get("tokens-out")) ?? 0) + fromTranscript.tokensOut,
485
+ nextOffset: fromTranscript.nextOffset,
486
+ nextMessageId: fromTranscript.nextMessageId
487
+ };
488
+ if (!delta.tokensIn && !delta.tokensOut && !flags.has("heartbeat")) {
489
+ if (flags.has("verbose")) info(dim("No new usage to report."));
490
+ return;
491
+ }
492
+ await postUsage(credentials, session, delta);
493
+ if (flags.has("verbose")) info(dim(`Reported ${delta.tokensIn} in / ${delta.tokensOut} out`));
494
+ }
495
+ //#endregion
361
496
  //#region src/commands/hook-start.ts
362
497
  /**
363
498
  * `UserPromptSubmit` fires before the turn runs, which is what lets it capture
@@ -396,18 +531,14 @@ async function hookStart() {
396
531
  if (!ref) return;
397
532
  const existing = readSession();
398
533
  if (existing?.ticket === ref) return;
534
+ const transcript = transcriptPath ?? claudeTranscriptPath();
399
535
  const result = await post(credentials.url, "/api/agent/events", {
400
536
  token: credentials.token,
401
537
  event: "start",
402
538
  ticketRef: ref,
403
539
  tool: "claude-code"
404
540
  });
405
- if (existing) await post(credentials.url, "/api/agent/events", {
406
- token: credentials.token,
407
- event: "stop",
408
- sessionKey: existing.sessionKey
409
- }).catch(() => void 0);
410
- const transcript = transcriptPath ?? claudeTranscriptPath();
541
+ if (existing) await stopSession(credentials, existing, transcript);
411
542
  const transcriptOffset = transcript ? transcriptSize(transcript) ?? void 0 : void 0;
412
543
  writeSession({
413
544
  sessionKey: result.sessionKey,
@@ -770,113 +901,6 @@ async function logout() {
770
901
  info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
771
902
  }
772
903
  //#endregion
773
- //#region src/commands/report.ts
774
- /**
775
- * Agent hooks hand their context to the command on stdin as JSON. Reading the
776
- * transcript path from there means a hook is a bare `npx neuvikon report` with
777
- * nothing to wire up, and nothing to keep in sync when a tool renames a flag.
778
- */
779
- async function transcriptFromHookPayload() {
780
- const raw = await readStdin();
781
- if (!raw.trim()) return void 0;
782
- try {
783
- const payload = JSON.parse(raw);
784
- if (typeof payload !== "object" || payload === null) return void 0;
785
- const path = payload.transcript_path;
786
- return typeof path === "string" && path ? path : void 0;
787
- } catch {
788
- return;
789
- }
790
- }
791
- function toCount(raw) {
792
- if (raw === void 0) return void 0;
793
- const value = Number(raw);
794
- return Number.isFinite(value) && value >= 0 ? value : void 0;
795
- }
796
- /**
797
- * What the transcript has accumulated since this session's baseline.
798
- *
799
- * A session with no baseline yet establishes one here rather than counting
800
- * from the top: everything before the ticket started belongs to whatever the
801
- * agent was doing beforehand, not to this ticket.
802
- */
803
- function pendingUsage(session, transcript) {
804
- if (!transcript) return {
805
- tokensIn: 0,
806
- tokensOut: 0
807
- };
808
- let baseline = session.transcriptOffset;
809
- if (baseline === void 0) {
810
- baseline = transcriptSize(transcript) ?? void 0;
811
- if (baseline !== void 0) writeSession({
812
- ...session,
813
- transcriptOffset: baseline
814
- });
815
- }
816
- if (baseline === void 0) return {
817
- tokensIn: 0,
818
- tokensOut: 0
819
- };
820
- const usage = readUsageSince(transcript, baseline);
821
- if (!usage) return {
822
- tokensIn: 0,
823
- tokensOut: 0
824
- };
825
- return {
826
- tokensIn: usage.tokensIn,
827
- tokensOut: usage.tokensOut,
828
- nextOffset: usage.offset
829
- };
830
- }
831
- /**
832
- * Hands a delta to the server, advancing the local offset only once it is
833
- * accepted — a dropped request leaves the window to be retried by the next
834
- * call instead of silently losing those tokens.
835
- */
836
- async function postUsage(credentials, session, delta) {
837
- try {
838
- await post(credentials.url, "/api/agent/events", {
839
- token: credentials.token,
840
- event: "report",
841
- sessionKey: session.sessionKey,
842
- tokensIn: delta.tokensIn,
843
- tokensOut: delta.tokensOut
844
- });
845
- } catch {
846
- return false;
847
- }
848
- if (delta.nextOffset !== void 0) writeSession({
849
- ...session,
850
- transcriptOffset: delta.nextOffset
851
- });
852
- return true;
853
- }
854
- /**
855
- * Called from an agent hook on every turn, so it is deliberately silent and
856
- * never exits non-zero: a token count is not worth interrupting someone's work
857
- * over, and a hook that fails loudly is a hook people uninstall.
858
- */
859
- async function report(flags) {
860
- const credentials = readCredentials();
861
- const session = readSession();
862
- if (!credentials || !session) {
863
- if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
864
- return;
865
- }
866
- const fromTranscript = pendingUsage(session, flags.get("transcript") || await transcriptFromHookPayload() || claudeTranscriptPath());
867
- const delta = {
868
- tokensIn: (toCount(flags.get("tokens-in")) ?? 0) + fromTranscript.tokensIn,
869
- tokensOut: (toCount(flags.get("tokens-out")) ?? 0) + fromTranscript.tokensOut,
870
- nextOffset: fromTranscript.nextOffset
871
- };
872
- if (!delta.tokensIn && !delta.tokensOut && !flags.has("heartbeat")) {
873
- if (flags.has("verbose")) info(dim("No new usage to report."));
874
- return;
875
- }
876
- await postUsage(credentials, session, delta);
877
- if (flags.has("verbose")) info(dim(`Reported ${delta.tokensIn} in / ${delta.tokensOut} out`));
878
- }
879
- //#endregion
880
904
  //#region src/commands/start.ts
881
905
  const TOOLS = /* @__PURE__ */ new Set([
882
906
  "claude-code",
@@ -913,6 +937,7 @@ async function start(flags, positional) {
913
937
  info(dim(`Already tracking ${ref}.`));
914
938
  return;
915
939
  }
940
+ const transcript = flags.get("transcript") || claudeTranscriptPath();
916
941
  let result;
917
942
  try {
918
943
  result = await post(credentials.url, "/api/agent/events", {
@@ -929,12 +954,7 @@ async function start(flags, positional) {
929
954
  }
930
955
  throw error;
931
956
  }
932
- if (existing) await post(credentials.url, "/api/agent/events", {
933
- token: credentials.token,
934
- event: "stop",
935
- sessionKey: existing.sessionKey
936
- }).catch(() => void 0);
937
- const transcript = flags.get("transcript") || claudeTranscriptPath();
957
+ if (existing) await stopSession(credentials, existing, transcript);
938
958
  writeSession({
939
959
  sessionKey: result.sessionKey,
940
960
  ticket: ref,
@@ -970,16 +990,7 @@ async function stop(flags) {
970
990
  return;
971
991
  }
972
992
  const credentials = readCredentials();
973
- if (credentials) {
974
- const usage = pendingUsage(session, flags.get("transcript") || claudeTranscriptPath());
975
- await post(credentials.url, "/api/agent/events", {
976
- token: credentials.token,
977
- event: "stop",
978
- sessionKey: session.sessionKey,
979
- tokensIn: usage.tokensIn,
980
- tokensOut: usage.tokensOut
981
- }).catch(() => void 0);
982
- }
993
+ if (credentials) await stopSession(credentials, session, flags.get("transcript") || claudeTranscriptPath());
983
994
  clearSession();
984
995
  success(`Stopped tracking ${session.ticket}`);
985
996
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",
@@ -40,5 +40,8 @@
40
40
  "@types/node": "^26.2.0",
41
41
  "tsdown": "^0.22.14",
42
42
  "typescript": "^7.0.2"
43
+ },
44
+ "dependencies": {
45
+ "neuvikon": "link:"
43
46
  }
44
47
  }