@yhong91/vibetime 0.1.72 → 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 +278 -48
  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.72" : "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,11 +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 isoFromUnix(value) {
9082
+ function unixFrom(value) {
9083
9083
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
9084
9084
  return void 0;
9085
9085
  }
9086
- return timestampFrom(value);
9086
+ return value;
9087
+ }
9088
+ function isoFromUnix(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
+ }));
9087
9197
  }
9088
9198
  function tableColumns(db, table) {
9089
9199
  try {
@@ -9260,6 +9370,7 @@ async function parseHermesDb(filePath, options) {
9260
9370
  "parent_session_id",
9261
9371
  "started_at",
9262
9372
  "ended_at",
9373
+ "end_reason",
9263
9374
  "message_count",
9264
9375
  "tool_call_count",
9265
9376
  "input_tokens",
@@ -9349,8 +9460,9 @@ async function parseHermesDb(filePath, options) {
9349
9460
  if (!sessionHasWork(session, usageRows) && messages.length === 0) {
9350
9461
  continue;
9351
9462
  }
9352
- const startedTs = isoFromUnix(session.started_at);
9353
- if (!startedTs) {
9463
+ const startedUnix = unixFrom(session.started_at);
9464
+ const startedTs = isoFromUnix(startedUnix);
9465
+ if (!startedUnix || !startedTs) {
9354
9466
  continue;
9355
9467
  }
9356
9468
  lineNumber += 1;
@@ -9378,7 +9490,15 @@ async function parseHermesDb(filePath, options) {
9378
9490
  if (started) {
9379
9491
  events.push(started);
9380
9492
  }
9381
- 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);
9382
9502
  if (endedTs && endedTs !== startedTs) {
9383
9503
  lineNumber += 1;
9384
9504
  const ended = makeEvent({
@@ -9398,16 +9518,102 @@ async function parseHermesDb(filePath, options) {
9398
9518
  refs: refs({
9399
9519
  sourceId: `${ctx.rawId}:session:ended`,
9400
9520
  hermesPlatform: ctx.platform,
9401
- hermesProfile: ctx.profileName
9521
+ hermesProfile: ctx.profileName,
9522
+ hermesEndReason: endReason
9402
9523
  })
9403
9524
  }, lineNumber, filePath, options);
9404
9525
  if (ended) {
9405
9526
  events.push(ended);
9406
9527
  }
9407
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
+ }
9408
9554
  if (usageRows.length > 0) {
9409
9555
  for (const usage of usageRows) {
9410
- 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}` : "";
9411
9617
  lineNumber += 1;
9412
9618
  const event = makeEvent({
9413
9619
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
@@ -9418,62 +9624,72 @@ async function parseHermesDb(filePath, options) {
9418
9624
  project: ctx.project,
9419
9625
  cwd: ctx.cwd,
9420
9626
  sessionId: ctx.sessionId,
9627
+ turnId,
9421
9628
  agent: SOURCE,
9422
- model: stringField(usage, "model") || ctx.model,
9423
- provider: stringField(usage, "billing_provider") || ctx.provider,
9629
+ model: ctx.model,
9630
+ provider: ctx.provider,
9424
9631
  success: true,
9425
- metrics: usageMetrics2(usage),
9426
- confidence: "exact",
9632
+ metrics,
9633
+ confidence: "partial",
9427
9634
  refs: refs({
9428
- sourceId: `${ctx.rawId}:model:${stringField(usage, "model") || "unknown"}:${stringField(usage, "task") || "main"}:${stringField(usage, "billing_provider") || ""}`,
9635
+ sourceId: `${ctx.rawId}:model:session${slice}`,
9429
9636
  hermesPlatform: ctx.platform,
9430
- hermesProfile: ctx.profileName,
9431
- hermesTask: stringField(usage, "task"),
9432
- hermesBillingMode: stringField(usage, "billing_mode")
9637
+ hermesProfile: ctx.profileName
9433
9638
  })
9434
9639
  }, lineNumber, filePath, options);
9435
9640
  if (event) {
9436
9641
  events.push(event);
9437
9642
  }
9438
9643
  }
9439
- } 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
+ }
9440
9653
  lineNumber += 1;
9441
- const event = makeEvent({
9654
+ const completed = makeEvent({
9442
9655
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
9443
- ts: endedTs || startedTs,
9444
- type: "model.usage",
9656
+ ts,
9657
+ type: "turn.completed",
9445
9658
  source: SOURCE,
9446
9659
  workspaceId: ctx.workspaceId,
9447
9660
  project: ctx.project,
9448
9661
  cwd: ctx.cwd,
9449
9662
  sessionId: ctx.sessionId,
9663
+ turnId: activeTurnId,
9450
9664
  agent: SOURCE,
9451
- model: ctx.model,
9452
- provider: ctx.provider,
9453
- success: true,
9454
- metrics: usageMetrics2(session),
9455
- confidence: "partial",
9456
- refs: refs({
9457
- sourceId: `${ctx.rawId}:model:session`,
9458
- hermesPlatform: ctx.platform,
9459
- hermesProfile: ctx.profileName
9460
- })
9665
+ confidence: "derived",
9666
+ refs: refs({ sourceId: `${ctx.rawId}:turn:${turnIndex}:end` })
9461
9667
  }, lineNumber, filePath, options);
9462
- if (event) {
9463
- events.push(event);
9668
+ if (completed) {
9669
+ events.push(completed);
9464
9670
  }
9465
- }
9466
- let turnIndex = 0;
9467
- let activeTurnId;
9671
+ activeTurnId = void 0;
9672
+ activeTurnStartedTs = void 0;
9673
+ lastWorkTs = void 0;
9674
+ };
9468
9675
  for (const message of messages) {
9469
- const ts = isoFromUnix(message.timestamp);
9470
- if (!ts) {
9676
+ const tsUnix = unixFrom(message.timestamp);
9677
+ const ts = isoFromUnix(tsUnix);
9678
+ if (!tsUnix || !ts) {
9471
9679
  continue;
9472
9680
  }
9473
9681
  const role = stringField(message, "role");
9474
9682
  if (role === "user") {
9683
+ if (isSyntheticUserContent(stringField(message, "content"))) {
9684
+ continue;
9685
+ }
9686
+ if (activeTurnId && activeTurnStartedTs) {
9687
+ closeActiveTurn(lastWorkTs || activeTurnStartedTs);
9688
+ }
9475
9689
  turnIndex += 1;
9476
9690
  activeTurnId = `${ctx.sessionId}:turn:${turnIndex}`;
9691
+ activeTurnStartedTs = ts;
9692
+ lastWorkTs = void 0;
9477
9693
  lineNumber += 1;
9478
9694
  const turnStarted = makeEvent({
9479
9695
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
@@ -9514,6 +9730,10 @@ async function parseHermesDb(filePath, options) {
9514
9730
  }
9515
9731
  continue;
9516
9732
  }
9733
+ if (role !== "assistant" && role !== "tool") {
9734
+ continue;
9735
+ }
9736
+ lastWorkTs = ts;
9517
9737
  if (role !== "assistant") {
9518
9738
  continue;
9519
9739
  }
@@ -9568,6 +9788,9 @@ async function parseHermesDb(filePath, options) {
9568
9788
  }
9569
9789
  }
9570
9790
  }
9791
+ if (activeTurnId) {
9792
+ closeActiveTurn(lastWorkTs || endedTs || startedTs);
9793
+ }
9571
9794
  }
9572
9795
  return events.sort((a, b) => a.ts.localeCompare(b.ts) || (a.id || "").localeCompare(b.id || ""));
9573
9796
  } finally {
@@ -9628,7 +9851,6 @@ description: "VibeTime session sync \u2014 thin trigger that runs local backfill
9628
9851
  author: VibeTime
9629
9852
  hooks:
9630
9853
  - on_session_start
9631
- - on_session_end
9632
9854
  - on_session_finalize
9633
9855
  - post_tool_call
9634
9856
  `;
@@ -9639,6 +9861,10 @@ function pluginInit() {
9639
9861
 
9640
9862
  Thin trigger only: spawn \`vibetime hook --agent hermes\` on session/tool
9641
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.
9642
9868
  """
9643
9869
  from __future__ import annotations
9644
9870
 
@@ -9654,15 +9880,20 @@ def _report(payload: dict[str, Any]) -> None:
9654
9880
  if not command:
9655
9881
  return
9656
9882
  try:
9657
- 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(
9658
9887
  [command, "hook", "--agent", "hermes"],
9659
- input=json.dumps(payload),
9660
- text=True,
9661
- capture_output=True,
9662
- timeout=10,
9663
- check=False,
9888
+ stdin=subprocess.PIPE,
9889
+ stdout=subprocess.DEVNULL,
9890
+ stderr=subprocess.DEVNULL,
9664
9891
  env=os.environ.copy(),
9892
+ start_new_session=True,
9665
9893
  )
9894
+ if proc.stdin is not None:
9895
+ proc.stdin.write(json.dumps(payload).encode())
9896
+ proc.stdin.close()
9666
9897
  except Exception:
9667
9898
  return
9668
9899
 
@@ -9671,8 +9902,8 @@ def on_session_start(*, session_id: str = "", **_: Any) -> None:
9671
9902
  _report({"hook_event_name": "SessionStart", "session_id": session_id})
9672
9903
 
9673
9904
 
9674
- def on_session_end(*, session_id: str = "", **_: Any) -> None:
9675
- _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 ""})
9676
9907
 
9677
9908
 
9678
9909
  def on_post_tool_call(*, tool_name: str = "", session_id: str = "", **_: Any) -> None:
@@ -9685,8 +9916,7 @@ def on_post_tool_call(*, tool_name: str = "", session_id: str = "", **_: Any) ->
9685
9916
 
9686
9917
  def register(ctx) -> None:
9687
9918
  ctx.register_hook("on_session_start", on_session_start)
9688
- ctx.register_hook("on_session_end", on_session_end)
9689
- ctx.register_hook("on_session_finalize", on_session_end)
9919
+ ctx.register_hook("on_session_finalize", on_session_finalize)
9690
9920
  ctx.register_hook("post_tool_call", on_post_tool_call)
9691
9921
  `;
9692
9922
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yhong91/vibetime",
3
3
  "type": "module",
4
- "version": "0.1.72",
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": {