@yhong91/vibetime 0.1.71 → 0.1.73

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/bin/vibetime.mjs +280 -47
  2. package/package.json +1 -1
package/bin/vibetime.mjs CHANGED
@@ -2047,7 +2047,7 @@ function claudeStyleFileMetrics(tool, input) {
2047
2047
  }
2048
2048
 
2049
2049
  // src/lib/constants.ts
2050
- var PACKAGE_VERSION = true ? "0.1.71" : "0.1.1";
2050
+ var PACKAGE_VERSION = true ? "0.1.73" : "0.1.1";
2051
2051
  var GENERATED_MARKER = "Generated by vibetime.";
2052
2052
  var DEFAULT_API_URL = "http://121.196.224.82:3001";
2053
2053
  var DEFAULT_BACKFILL_BATCH_SIZE = 50;
@@ -9079,8 +9079,121 @@ function projectFromCwd(cwd, repoRoot) {
9079
9079
  const basis = repoRoot || cwd;
9080
9080
  return basis ? path15.basename(basis) || SOURCE : SOURCE;
9081
9081
  }
9082
+ function unixFrom(value) {
9083
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
9084
+ return void 0;
9085
+ }
9086
+ return value;
9087
+ }
9082
9088
  function isoFromUnix(value) {
9083
- return timestampFrom(typeof value === "number" ? value : Number(value));
9089
+ const unix = unixFrom(value);
9090
+ return unix === void 0 ? void 0 : timestampFrom(unix);
9091
+ }
9092
+ function maxUnix(...values) {
9093
+ let max;
9094
+ for (const value of values) {
9095
+ const unix = unixFrom(value);
9096
+ if (unix === void 0) {
9097
+ continue;
9098
+ }
9099
+ if (max === void 0 || unix > max) {
9100
+ max = unix;
9101
+ }
9102
+ }
9103
+ return max;
9104
+ }
9105
+ function isReapEndReason(reason) {
9106
+ return reason === "ws_orphan_reap" || reason === "startup_orphan_reap";
9107
+ }
9108
+ function isSyntheticUserContent(content) {
9109
+ if (!content) {
9110
+ return false;
9111
+ }
9112
+ const trimmed = content.trim();
9113
+ return trimmed.startsWith("[CONTEXT COMPACTION") || trimmed.startsWith("[IMPORTANT: Background process");
9114
+ }
9115
+ var INT_METRIC_KEYS = [
9116
+ "tokensInput",
9117
+ "tokensOutput",
9118
+ "tokensCacheReadInput",
9119
+ "tokensCacheCreationInput",
9120
+ "tokensCachedInput",
9121
+ "tokensReasoningOutput",
9122
+ "tokensTotal",
9123
+ "modelCalls"
9124
+ ];
9125
+ function splitMetricBag(bag, fractions) {
9126
+ if (fractions.length === 0) {
9127
+ return [];
9128
+ }
9129
+ if (fractions.length === 1) {
9130
+ return [{ ...bag }];
9131
+ }
9132
+ const out = fractions.map(() => ({}));
9133
+ for (const key of INT_METRIC_KEYS) {
9134
+ const value = bag[key];
9135
+ if (typeof value !== "number" || !Number.isFinite(value) || value === 0) {
9136
+ continue;
9137
+ }
9138
+ let allocated = 0;
9139
+ for (let i = 0; i < fractions.length; i += 1) {
9140
+ const part = i === fractions.length - 1 ? value - allocated : Math.floor(value * fractions[i]);
9141
+ allocated += part;
9142
+ if (part) {
9143
+ out[i][key] = part;
9144
+ }
9145
+ }
9146
+ }
9147
+ const cost = bag.costUsd;
9148
+ if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) {
9149
+ let allocated = 0;
9150
+ for (let i = 0; i < fractions.length; i += 1) {
9151
+ const part = i === fractions.length - 1 ? Math.round((cost - allocated) * 1e6) / 1e6 : Math.round(cost * fractions[i] * 1e6) / 1e6;
9152
+ allocated += part;
9153
+ if (part) {
9154
+ out[i].costUsd = part;
9155
+ }
9156
+ }
9157
+ }
9158
+ return out;
9159
+ }
9160
+ function metricBagHasWork(bag) {
9161
+ return INT_METRIC_KEYS.some((key) => (bag[key] || 0) > 0);
9162
+ }
9163
+ function allocateUsageAcrossTurns(usage, turns) {
9164
+ const first = unixFrom(usage.first_seen);
9165
+ const last = unixFrom(usage.last_seen);
9166
+ const start = first ?? last;
9167
+ const end = last ?? first;
9168
+ if (start === void 0 || end === void 0) {
9169
+ const turn = turns.at(-1);
9170
+ return [{ turn, fraction: 1, tsUnix: turn?.endUnix || turn?.startedUnix || 0 }];
9171
+ }
9172
+ if (turns.length === 0) {
9173
+ return [{ fraction: 1, tsUnix: end }];
9174
+ }
9175
+ if (end <= start) {
9176
+ const turn = turns.find((item) => start >= item.startedUnix && start < item.endUnix) || [...turns].reverse().find((item) => item.startedUnix <= start) || turns[0];
9177
+ return [{ turn, fraction: 1, tsUnix: start }];
9178
+ }
9179
+ const overlaps = [];
9180
+ for (const turn of turns) {
9181
+ const lo = Math.max(start, turn.startedUnix);
9182
+ const hi = Math.min(end, turn.endUnix);
9183
+ if (hi > lo) {
9184
+ overlaps.push({ turn, overlap: hi - lo });
9185
+ }
9186
+ }
9187
+ const total = overlaps.reduce((sum, item) => sum + item.overlap, 0);
9188
+ if (total <= 0) {
9189
+ const turn = [...turns].reverse().find((item) => item.startedUnix <= end) || turns[0];
9190
+ return [{ turn, fraction: 1, tsUnix: Math.min(end, turn?.endUnix || end) }];
9191
+ }
9192
+ return overlaps.map((item) => ({
9193
+ turn: item.turn,
9194
+ fraction: item.overlap / total,
9195
+ tsUnix: Math.min(end, item.turn.endUnix)
9196
+ }));
9084
9197
  }
9085
9198
  function tableColumns(db, table) {
9086
9199
  try {
@@ -9257,6 +9370,7 @@ async function parseHermesDb(filePath, options) {
9257
9370
  "parent_session_id",
9258
9371
  "started_at",
9259
9372
  "ended_at",
9373
+ "end_reason",
9260
9374
  "message_count",
9261
9375
  "tool_call_count",
9262
9376
  "input_tokens",
@@ -9346,8 +9460,9 @@ async function parseHermesDb(filePath, options) {
9346
9460
  if (!sessionHasWork(session, usageRows) && messages.length === 0) {
9347
9461
  continue;
9348
9462
  }
9349
- const startedTs = isoFromUnix(session.started_at);
9350
- if (!startedTs) {
9463
+ const startedUnix = unixFrom(session.started_at);
9464
+ const startedTs = isoFromUnix(startedUnix);
9465
+ if (!startedUnix || !startedTs) {
9351
9466
  continue;
9352
9467
  }
9353
9468
  lineNumber += 1;
@@ -9375,7 +9490,15 @@ async function parseHermesDb(filePath, options) {
9375
9490
  if (started) {
9376
9491
  events.push(started);
9377
9492
  }
9378
- const endedTs = isoFromUnix(session.ended_at) || isoFromUnix(session.last_activity_at);
9493
+ const lastMessageUnix = maxUnix(...messages.map((message) => message.timestamp));
9494
+ const lastUsageUnix = maxUnix(
9495
+ ...usageRows.flatMap((usage) => [usage.last_seen, usage.first_seen])
9496
+ );
9497
+ const lastActivityUnix = unixFrom(session.last_activity_at);
9498
+ const endReason = stringField(session, "end_reason");
9499
+ const closedUnix = isReapEndReason(endReason) ? void 0 : unixFrom(session.ended_at);
9500
+ const endedUnix = maxUnix(lastMessageUnix, lastUsageUnix, closedUnix) ?? lastActivityUnix;
9501
+ const endedTs = isoFromUnix(endedUnix);
9379
9502
  if (endedTs && endedTs !== startedTs) {
9380
9503
  lineNumber += 1;
9381
9504
  const ended = makeEvent({
@@ -9395,16 +9518,102 @@ async function parseHermesDb(filePath, options) {
9395
9518
  refs: refs({
9396
9519
  sourceId: `${ctx.rawId}:session:ended`,
9397
9520
  hermesPlatform: ctx.platform,
9398
- hermesProfile: ctx.profileName
9521
+ hermesProfile: ctx.profileName,
9522
+ hermesEndReason: endReason
9399
9523
  })
9400
9524
  }, lineNumber, filePath, options);
9401
9525
  if (ended) {
9402
9526
  events.push(ended);
9403
9527
  }
9404
9528
  }
9529
+ const userTurns = [];
9530
+ for (const message of messages) {
9531
+ const tsUnix = unixFrom(message.timestamp);
9532
+ if (!tsUnix) {
9533
+ continue;
9534
+ }
9535
+ const role = stringField(message, "role");
9536
+ if (role === "user") {
9537
+ if (isSyntheticUserContent(stringField(message, "content"))) {
9538
+ continue;
9539
+ }
9540
+ const index = userTurns.length + 1;
9541
+ userTurns.push({
9542
+ index,
9543
+ turnId: `${ctx.sessionId}:turn:${index}`,
9544
+ startedUnix: tsUnix,
9545
+ endUnix: tsUnix
9546
+ });
9547
+ continue;
9548
+ }
9549
+ const current = userTurns[userTurns.length - 1];
9550
+ if (current && (role === "assistant" || role === "tool")) {
9551
+ current.endUnix = Math.max(current.endUnix, tsUnix);
9552
+ }
9553
+ }
9405
9554
  if (usageRows.length > 0) {
9406
9555
  for (const usage of usageRows) {
9407
- const ts = isoFromUnix(usage.last_seen) || isoFromUnix(usage.first_seen) || startedTs;
9556
+ const allocations = allocateUsageAcrossTurns(usage, userTurns);
9557
+ const metricsParts = splitMetricBag(usageMetrics2(usage), allocations.map((item) => item.fraction));
9558
+ const model = stringField(usage, "model") || ctx.model;
9559
+ const provider = stringField(usage, "billing_provider") || ctx.provider;
9560
+ const task = stringField(usage, "task") || "main";
9561
+ const billing = stringField(usage, "billing_provider") || "";
9562
+ for (let i = 0; i < allocations.length; i += 1) {
9563
+ const allocation = allocations[i];
9564
+ const metrics = metricsParts[i];
9565
+ if (!metrics || !metricBagHasWork(metrics)) {
9566
+ continue;
9567
+ }
9568
+ const ts = isoFromUnix(allocation.tsUnix) || startedTs;
9569
+ const turnId = allocation.turn?.turnId;
9570
+ const slice = allocation.turn ? `:turn:${allocation.turn.index}` : "";
9571
+ lineNumber += 1;
9572
+ const event = makeEvent({
9573
+ schemaVersion: AGENT_TIME_SCHEMA_VERSION,
9574
+ ts,
9575
+ type: "model.usage",
9576
+ source: SOURCE,
9577
+ workspaceId: ctx.workspaceId,
9578
+ project: ctx.project,
9579
+ cwd: ctx.cwd,
9580
+ sessionId: ctx.sessionId,
9581
+ turnId,
9582
+ agent: SOURCE,
9583
+ model,
9584
+ provider,
9585
+ success: true,
9586
+ metrics,
9587
+ confidence: allocations.length > 1 ? "derived" : "exact",
9588
+ refs: refs({
9589
+ sourceId: `${ctx.rawId}:model:${model || "unknown"}:${task}:${billing}${slice}`,
9590
+ hermesPlatform: ctx.platform,
9591
+ hermesProfile: ctx.profileName,
9592
+ hermesTask: stringField(usage, "task"),
9593
+ hermesBillingMode: stringField(usage, "billing_mode")
9594
+ })
9595
+ }, lineNumber, filePath, options);
9596
+ if (event) {
9597
+ events.push(event);
9598
+ }
9599
+ }
9600
+ }
9601
+ } else if ((numberField(session, "input_tokens") || 0) + (numberField(session, "output_tokens") || 0) > 0) {
9602
+ const fallbackUsage = {
9603
+ first_seen: startedUnix,
9604
+ last_seen: endedUnix || startedUnix
9605
+ };
9606
+ const allocations = allocateUsageAcrossTurns(fallbackUsage, userTurns);
9607
+ const metricsParts = splitMetricBag(usageMetrics2(session), allocations.map((item) => item.fraction));
9608
+ for (let i = 0; i < allocations.length; i += 1) {
9609
+ const allocation = allocations[i];
9610
+ const metrics = metricsParts[i];
9611
+ if (!metrics || !metricBagHasWork(metrics)) {
9612
+ continue;
9613
+ }
9614
+ const ts = isoFromUnix(allocation.tsUnix) || startedTs;
9615
+ const turnId = allocation.turn?.turnId;
9616
+ const slice = allocation.turn ? `:turn:${allocation.turn.index}` : "";
9408
9617
  lineNumber += 1;
9409
9618
  const event = makeEvent({
9410
9619
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
@@ -9415,62 +9624,72 @@ async function parseHermesDb(filePath, options) {
9415
9624
  project: ctx.project,
9416
9625
  cwd: ctx.cwd,
9417
9626
  sessionId: ctx.sessionId,
9627
+ turnId,
9418
9628
  agent: SOURCE,
9419
- model: stringField(usage, "model") || ctx.model,
9420
- provider: stringField(usage, "billing_provider") || ctx.provider,
9629
+ model: ctx.model,
9630
+ provider: ctx.provider,
9421
9631
  success: true,
9422
- metrics: usageMetrics2(usage),
9423
- confidence: "exact",
9632
+ metrics,
9633
+ confidence: "partial",
9424
9634
  refs: refs({
9425
- sourceId: `${ctx.rawId}:model:${stringField(usage, "model") || "unknown"}:${stringField(usage, "task") || "main"}:${stringField(usage, "billing_provider") || ""}`,
9635
+ sourceId: `${ctx.rawId}:model:session${slice}`,
9426
9636
  hermesPlatform: ctx.platform,
9427
- hermesProfile: ctx.profileName,
9428
- hermesTask: stringField(usage, "task"),
9429
- hermesBillingMode: stringField(usage, "billing_mode")
9637
+ hermesProfile: ctx.profileName
9430
9638
  })
9431
9639
  }, lineNumber, filePath, options);
9432
9640
  if (event) {
9433
9641
  events.push(event);
9434
9642
  }
9435
9643
  }
9436
- } else if ((numberField(session, "input_tokens") || 0) + (numberField(session, "output_tokens") || 0) > 0) {
9644
+ }
9645
+ let turnIndex = 0;
9646
+ let activeTurnId;
9647
+ let activeTurnStartedTs;
9648
+ let lastWorkTs;
9649
+ const closeActiveTurn = (ts) => {
9650
+ if (!activeTurnId) {
9651
+ return;
9652
+ }
9437
9653
  lineNumber += 1;
9438
- const event = makeEvent({
9654
+ const completed = makeEvent({
9439
9655
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
9440
- ts: endedTs || startedTs,
9441
- type: "model.usage",
9656
+ ts,
9657
+ type: "turn.completed",
9442
9658
  source: SOURCE,
9443
9659
  workspaceId: ctx.workspaceId,
9444
9660
  project: ctx.project,
9445
9661
  cwd: ctx.cwd,
9446
9662
  sessionId: ctx.sessionId,
9663
+ turnId: activeTurnId,
9447
9664
  agent: SOURCE,
9448
- model: ctx.model,
9449
- provider: ctx.provider,
9450
- success: true,
9451
- metrics: usageMetrics2(session),
9452
- confidence: "partial",
9453
- refs: refs({
9454
- sourceId: `${ctx.rawId}:model:session`,
9455
- hermesPlatform: ctx.platform,
9456
- hermesProfile: ctx.profileName
9457
- })
9665
+ confidence: "derived",
9666
+ refs: refs({ sourceId: `${ctx.rawId}:turn:${turnIndex}:end` })
9458
9667
  }, lineNumber, filePath, options);
9459
- if (event) {
9460
- events.push(event);
9668
+ if (completed) {
9669
+ events.push(completed);
9461
9670
  }
9462
- }
9463
- let turnIndex = 0;
9464
- let activeTurnId;
9671
+ activeTurnId = void 0;
9672
+ activeTurnStartedTs = void 0;
9673
+ lastWorkTs = void 0;
9674
+ };
9465
9675
  for (const message of messages) {
9466
- const ts = isoFromUnix(message.timestamp);
9467
- if (!ts) {
9676
+ const tsUnix = unixFrom(message.timestamp);
9677
+ const ts = isoFromUnix(tsUnix);
9678
+ if (!tsUnix || !ts) {
9468
9679
  continue;
9469
9680
  }
9470
9681
  const role = stringField(message, "role");
9471
9682
  if (role === "user") {
9683
+ if (isSyntheticUserContent(stringField(message, "content"))) {
9684
+ continue;
9685
+ }
9686
+ if (activeTurnId && activeTurnStartedTs) {
9687
+ closeActiveTurn(lastWorkTs || activeTurnStartedTs);
9688
+ }
9472
9689
  turnIndex += 1;
9473
9690
  activeTurnId = `${ctx.sessionId}:turn:${turnIndex}`;
9691
+ activeTurnStartedTs = ts;
9692
+ lastWorkTs = void 0;
9474
9693
  lineNumber += 1;
9475
9694
  const turnStarted = makeEvent({
9476
9695
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
@@ -9511,6 +9730,10 @@ async function parseHermesDb(filePath, options) {
9511
9730
  }
9512
9731
  continue;
9513
9732
  }
9733
+ if (role !== "assistant" && role !== "tool") {
9734
+ continue;
9735
+ }
9736
+ lastWorkTs = ts;
9514
9737
  if (role !== "assistant") {
9515
9738
  continue;
9516
9739
  }
@@ -9565,6 +9788,9 @@ async function parseHermesDb(filePath, options) {
9565
9788
  }
9566
9789
  }
9567
9790
  }
9791
+ if (activeTurnId) {
9792
+ closeActiveTurn(lastWorkTs || endedTs || startedTs);
9793
+ }
9568
9794
  }
9569
9795
  return events.sort((a, b) => a.ts.localeCompare(b.ts) || (a.id || "").localeCompare(b.id || ""));
9570
9796
  } finally {
@@ -9625,7 +9851,6 @@ description: "VibeTime session sync \u2014 thin trigger that runs local backfill
9625
9851
  author: VibeTime
9626
9852
  hooks:
9627
9853
  - on_session_start
9628
- - on_session_end
9629
9854
  - on_session_finalize
9630
9855
  - post_tool_call
9631
9856
  `;
@@ -9636,6 +9861,10 @@ function pluginInit() {
9636
9861
 
9637
9862
  Thin trigger only: spawn \`vibetime hook --agent hermes\` on session/tool
9638
9863
  lifecycle events. Token usage is read from state.db by vibetime backfill.
9864
+
9865
+ \`on_session_end\` is a per-turn observer in Hermes (end of every
9866
+ run_conversation). Do not map it to SessionEnd \u2014 that would fire a
9867
+ backfill after every prompt. Real session teardown is on_session_finalize.
9639
9868
  """
9640
9869
  from __future__ import annotations
9641
9870
 
@@ -9651,15 +9880,20 @@ def _report(payload: dict[str, Any]) -> None:
9651
9880
  if not command:
9652
9881
  return
9653
9882
  try:
9654
- subprocess.run(
9883
+ # Popen + close stdin: do not block the Hermes tool loop waiting for
9884
+ # vibetime hook / backfill. stdout/stderr inherited would spam the
9885
+ # agent UI, so discard them.
9886
+ proc = subprocess.Popen(
9655
9887
  [command, "hook", "--agent", "hermes"],
9656
- input=json.dumps(payload),
9657
- text=True,
9658
- capture_output=True,
9659
- timeout=10,
9660
- check=False,
9888
+ stdin=subprocess.PIPE,
9889
+ stdout=subprocess.DEVNULL,
9890
+ stderr=subprocess.DEVNULL,
9661
9891
  env=os.environ.copy(),
9892
+ start_new_session=True,
9662
9893
  )
9894
+ if proc.stdin is not None:
9895
+ proc.stdin.write(json.dumps(payload).encode())
9896
+ proc.stdin.close()
9663
9897
  except Exception:
9664
9898
  return
9665
9899
 
@@ -9668,8 +9902,8 @@ def on_session_start(*, session_id: str = "", **_: Any) -> None:
9668
9902
  _report({"hook_event_name": "SessionStart", "session_id": session_id})
9669
9903
 
9670
9904
 
9671
- def on_session_end(*, session_id: str = "", **_: Any) -> None:
9672
- _report({"hook_event_name": "SessionEnd", "session_id": session_id})
9905
+ def on_session_finalize(*, session_id: str = "", **_: Any) -> None:
9906
+ _report({"hook_event_name": "SessionEnd", "session_id": session_id or ""})
9673
9907
 
9674
9908
 
9675
9909
  def on_post_tool_call(*, tool_name: str = "", session_id: str = "", **_: Any) -> None:
@@ -9682,8 +9916,7 @@ def on_post_tool_call(*, tool_name: str = "", session_id: str = "", **_: Any) ->
9682
9916
 
9683
9917
  def register(ctx) -> None:
9684
9918
  ctx.register_hook("on_session_start", on_session_start)
9685
- ctx.register_hook("on_session_end", on_session_end)
9686
- ctx.register_hook("on_session_finalize", on_session_end)
9919
+ ctx.register_hook("on_session_finalize", on_session_finalize)
9687
9920
  ctx.register_hook("post_tool_call", on_post_tool_call)
9688
9921
  `;
9689
9922
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yhong91/vibetime",
3
3
  "type": "module",
4
- "version": "0.1.71",
4
+ "version": "0.1.73",
5
5
  "description": "vibetime CLI — install AI-agent hooks (Claude Code, Codex, OpenCode, Pi, Cursor) and report activity to vibetime.",
6
6
  "license": "MIT",
7
7
  "publishConfig": {