linkshell-cli 0.5.14 → 0.5.15

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.
@@ -283,6 +283,55 @@ function contextWindowForModel(model: string | undefined): number | undefined {
283
283
  return undefined;
284
284
  }
285
285
 
286
+ // Normalize a raw provider usage payload (Claude message.usage / result.usage,
287
+ // Codex thread/tokenUsage/updated) into our wire field names. Provider field
288
+ // names vary, so probe snake_case + camelCase. Shared by the live-turn path and
289
+ // the on-disk-transcript path so the two never drift. Returns undefined when no
290
+ // token field resolves. Cost + updatedAt are layered on by callers.
291
+ function normalizeUsageFields(
292
+ raw: unknown,
293
+ model: string | undefined,
294
+ ): Pick<AgentConversationUsage, "inputTokens" | "outputTokens" | "cacheReadTokens" | "totalTokens" | "contextWindow"> | undefined {
295
+ const rec = asRecord(raw);
296
+ if (!rec) return undefined;
297
+ const num = (...keys: string[]): number | undefined => {
298
+ for (const k of keys) {
299
+ const v = rec[k];
300
+ if (typeof v === "number" && Number.isFinite(v)) return v;
301
+ }
302
+ return undefined;
303
+ };
304
+ const inputTokens = num("input_tokens", "inputTokens", "input");
305
+ const outputTokens = num("output_tokens", "outputTokens", "output");
306
+ const cacheReadTokens = num(
307
+ "cache_read_input_tokens",
308
+ "cacheReadInputTokens",
309
+ "cache_read_tokens",
310
+ "cached_input_tokens",
311
+ "cacheReadTokens",
312
+ );
313
+ let totalTokens = num("total_tokens", "totalTokens", "total");
314
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
315
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
316
+ }
317
+ if (inputTokens == null && outputTokens == null && cacheReadTokens == null && totalTokens == null) {
318
+ return undefined;
319
+ }
320
+ let contextWindow =
321
+ num("context_window", "contextWindow", "model_context_window", "window") ??
322
+ contextWindowForModel(model);
323
+ // Self-correcting window: tokens-in-context can't exceed the window, so if the
324
+ // measured occupancy (input + cache reads) already overshoots our assumed
325
+ // window, the real window must be larger — escalate to 1M. Fixes Claude 1M
326
+ // sessions, whose transcript records the model as plain "claude-opus-4.8"
327
+ // (no [1m] tag), which would otherwise show a misleading 100%.
328
+ const ctxUsed = (inputTokens ?? 0) + (cacheReadTokens ?? 0);
329
+ if (contextWindow && ctxUsed > contextWindow) {
330
+ contextWindow = ctxUsed > 1_000_000 ? ctxUsed : 1_000_000;
331
+ }
332
+ return { inputTokens, outputTokens, cacheReadTokens, totalTokens, contextWindow };
333
+ }
334
+
286
335
  function stringArray(value: unknown): string[] {
287
336
  if (!Array.isArray(value)) return [];
288
337
  return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
@@ -1302,6 +1351,7 @@ function parseRemoteSessions(value: unknown): Array<{
1302
1351
  model?: string;
1303
1352
  createdAt?: number;
1304
1353
  lastActivityAt?: number;
1354
+ usage?: unknown;
1305
1355
  }> {
1306
1356
  const raw = asRecord(value);
1307
1357
  const sessionsValue =
@@ -1318,6 +1368,7 @@ function parseRemoteSessions(value: unknown): Array<{
1318
1368
  model?: string;
1319
1369
  createdAt?: number;
1320
1370
  lastActivityAt?: number;
1371
+ usage?: unknown;
1321
1372
  }> = [];
1322
1373
  for (const entry of sessionsValue) {
1323
1374
  const session = asRecord(entry);
@@ -1336,6 +1387,7 @@ function parseRemoteSessions(value: unknown): Array<{
1336
1387
  model: firstString(source, ["model", "modelId"]),
1337
1388
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1338
1389
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
1390
+ usage: source.usage,
1339
1391
  });
1340
1392
  }
1341
1393
  return result;
@@ -1981,13 +2033,21 @@ export class AgentWorkspaceProxy {
1981
2033
  const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
1982
2034
  const existing = this.conversations.get(conversationId);
1983
2035
  const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
2036
+ const model = remote.model ?? existing?.model;
2037
+ // Usage read straight from the on-disk transcript (decoupled from any
2038
+ // live LinkShell turn) — so history + externally-started sessions all
2039
+ // carry a context meter. Falls back to any existing live-turn usage.
2040
+ const usageFields = normalizeUsageFields(remote.usage, model);
2041
+ const usage: AgentConversationUsage | undefined = usageFields
2042
+ ? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
2043
+ : existing?.usage;
1984
2044
  const conversation: AgentConversation = {
1985
2045
  id: conversationId,
1986
2046
  agentSessionId,
1987
2047
  provider,
1988
2048
  cwd,
1989
2049
  title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
1990
- model: remote.model ?? existing?.model,
2050
+ model,
1991
2051
  reasoningEffort: existing?.reasoningEffort,
1992
2052
  permissionMode: existing?.permissionMode,
1993
2053
  collaborationMode: existing?.collaborationMode,
@@ -1996,6 +2056,7 @@ export class AgentWorkspaceProxy {
1996
2056
  lastMessagePreview: existing?.lastMessagePreview,
1997
2057
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1998
2058
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
2059
+ usage,
1999
2060
  };
2000
2061
  this.conversations.set(conversation.id, conversation);
2001
2062
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
@@ -3847,44 +3908,22 @@ export class AgentWorkspaceProxy {
3847
3908
 
3848
3909
  // Normalize a provider usage payload (Claude result.usage, Codex
3849
3910
  // thread/tokenUsage/updated) into our wire shape, merge onto the conversation,
3850
- // and emit. Field names vary by provider so we probe snake_case + camelCase.
3851
- // Missing fields preserve the previous value (cumulative view). No-ops when
3852
- // nothing meaningful resolves, so a stray empty payload won't blank the meter.
3911
+ // and emit. Missing fields preserve the previous value (cumulative view).
3912
+ // No-ops when nothing meaningful resolves, so a stray empty payload won't
3913
+ // blank the meter.
3853
3914
  private applyConversationUsage(conversationId: string, raw: unknown, costUsd?: number): void {
3854
3915
  const conversation = this.conversations.get(conversationId);
3855
3916
  if (!conversation) return;
3856
- const rec = asRecord(raw);
3857
- const num = (...keys: string[]): number | undefined => {
3858
- for (const k of keys) {
3859
- const v = rec?.[k];
3860
- if (typeof v === "number" && Number.isFinite(v)) return v;
3861
- }
3862
- return undefined;
3863
- };
3864
- const inputTokens = num("input_tokens", "inputTokens", "input");
3865
- const outputTokens = num("output_tokens", "outputTokens", "output");
3866
- const cacheReadTokens = num(
3867
- "cache_read_input_tokens",
3868
- "cacheReadInputTokens",
3869
- "cache_read_tokens",
3870
- "cached_input_tokens",
3871
- "cacheReadTokens",
3872
- );
3873
- let totalTokens = num("total_tokens", "totalTokens", "total");
3874
- if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
3875
- totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
3876
- }
3877
- const contextWindow =
3878
- num("context_window", "contextWindow", "model_context_window", "window") ??
3879
- contextWindowForModel(conversation.model);
3917
+ const fields = normalizeUsageFields(raw, conversation.model);
3918
+ if (!fields && costUsd == null) return;
3880
3919
 
3881
3920
  const prev = conversation.usage ?? {};
3882
3921
  const next: AgentConversationUsage = {
3883
- inputTokens: inputTokens ?? prev.inputTokens,
3884
- outputTokens: outputTokens ?? prev.outputTokens,
3885
- cacheReadTokens: cacheReadTokens ?? prev.cacheReadTokens,
3886
- totalTokens: totalTokens ?? prev.totalTokens,
3887
- contextWindow: contextWindow ?? prev.contextWindow,
3922
+ inputTokens: fields?.inputTokens ?? prev.inputTokens,
3923
+ outputTokens: fields?.outputTokens ?? prev.outputTokens,
3924
+ cacheReadTokens: fields?.cacheReadTokens ?? prev.cacheReadTokens,
3925
+ totalTokens: fields?.totalTokens ?? prev.totalTokens,
3926
+ contextWindow: fields?.contextWindow ?? prev.contextWindow,
3888
3927
  totalCostUsd: costUsd ?? prev.totalCostUsd,
3889
3928
  updatedAt: Date.now(),
3890
3929
  };
@@ -235,6 +235,11 @@ export function parseClaudeJsonlSession(input: {
235
235
  let createdAt: number | undefined;
236
236
  let updatedAt = input.fallbackUpdatedAt;
237
237
  let cwd = input.cwd;
238
+ // Last assistant line's token usage. input_tokens at the latest turn is the
239
+ // current context-window occupancy (NOT a running sum), so we keep the last
240
+ // one we see rather than accumulating. Decoupled from any live LinkShell turn
241
+ // — this is read straight from the on-disk transcript.
242
+ let usage: Record<string, unknown> | undefined;
238
243
  let index = 0;
239
244
 
240
245
  for (const line of input.text.split(/\r?\n/)) {
@@ -313,6 +318,8 @@ export function parseClaudeJsonlSession(input: {
313
318
  }
314
319
  } else if (role === "assistant") {
315
320
  const assistantMessageId = stringField(message, ["id"]) ?? turnId;
321
+ const lineUsage = asRecord(message?.usage);
322
+ if (lineUsage) usage = lineUsage;
316
323
  if (typeof content === "string") {
317
324
  if (content.trim()) {
318
325
  items.push({
@@ -376,6 +383,7 @@ export function parseClaudeJsonlSession(input: {
376
383
  preview,
377
384
  createdAt,
378
385
  updatedAt,
386
+ usage,
379
387
  turns,
380
388
  },
381
389
  };
@@ -398,6 +406,7 @@ function claudeSessionMetadataFromFile(filePath: string, cwd: string, sessionId:
398
406
  createdAt: parseTimestamp(thread.createdAt),
399
407
  lastActivityAt: parseTimestamp(thread.updatedAt) ?? stat.mtimeMs,
400
408
  lastModified: stat.mtimeMs,
409
+ usage: asRecord(thread.usage),
401
410
  };
402
411
  }
403
412