@danypops/pi-jittor 0.3.1 → 0.4.0

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.
@@ -12,6 +12,7 @@ import {
12
12
  loadOpenAiTextTokenCounter,
13
13
  MAX_DYNAMIC_ROUTES,
14
14
  type MetricObservation,
15
+ MILLISECONDS_PER_DAY,
15
16
  type ModelCandidate,
16
17
  type ModelTaskDomain,
17
18
  type ModelTaskType,
@@ -33,6 +34,7 @@ import {
33
34
  validateTaskFocusEvent,
34
35
  } from "@danypops/jittor";
35
36
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
+ import { showCacheEconomicsPanel } from "./observability/cache-economics-view.ts";
36
38
  import {
37
39
  basePromptSegment,
38
40
  buildBasePromptItems,
@@ -161,6 +163,23 @@ interface PiRouteModel {
161
163
  cost?: { input?: number; output?: number };
162
164
  }
163
165
 
166
+ /**
167
+ * Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the `/scoped-models`
168
+ * command shows) is the authority for which models a session may actually use -- e.g. a "work"
169
+ * profile scoped to one provider/model set versus a "personal" profile scoped to a different one.
170
+ * `ctx.modelRegistry.getAvailable()` enumerates every authenticated model on the host regardless of
171
+ * that restriction (Pi's own docs warn against using it for a model picker for exactly this reason:
172
+ * "instead of enumerating the whole catalogue via ctx.modelRegistry.getAvailable()"). Every Jittor
173
+ * call site that builds a candidate/route set for routing or ranking must prefer the scoped set
174
+ * when one is configured, or a scoped session keeps seeing -- and can be automatically routed onto
175
+ * -- another profile's models. An empty scopedModels list means "no scoping configured" (matching
176
+ * Pi's own semantics), not "scoped to nothing", so it still falls back to the full catalog.
177
+ */
178
+ function scopedOrAvailableModels(ctx: ExtensionContext): PiRouteModel[] {
179
+ if (ctx.scopedModels.length > 0) return ctx.scopedModels.map((entry) => entry.model as PiRouteModel);
180
+ return ctx.modelRegistry.getAvailable() as PiRouteModel[];
181
+ }
182
+
164
183
  const THINKING_DESCENDING = ["max", "xhigh", "high", "medium", "low", "minimal", "off"] as const;
165
184
 
166
185
  function supportsThinking(model: PiRouteModel, level: string): boolean {
@@ -237,7 +256,7 @@ async function syncAvailableRoutes(pi: ExtensionAPI, client: JittorExtensionClie
237
256
  await client.call("router.available_routes", { routes: [], session_id, ...secret });
238
257
  return;
239
258
  }
240
- const models = ctx.modelRegistry.getAvailable() as PiRouteModel[];
259
+ const models = scopedOrAvailableModels(ctx);
241
260
  const routes = routesFromPi(models, ctx.model as PiRouteModel, pi.getThinkingLevel());
242
261
  await client.call("router.available_routes", { routes, session_id, ...secret });
243
262
  }
@@ -292,16 +311,24 @@ async function applyDecision(
292
311
  );
293
312
  }
294
313
 
314
+ let assistantUsageRunSequence = 0;
315
+
295
316
  /**
296
317
  * taskId, when a Papyrus task is focused, tags the metric for cost-per-task correlation. thinking
297
318
  * comes from pi.getThinkingLevel() at message_end time, not from the message itself -- AssistantMessage
298
- * has no thinking field of its own, and the level can't have changed mid-message.
319
+ * has no thinking field of its own, and the level can't have changed mid-message. sessionId tags every
320
+ * row so cache economics (see @danypops/jittor's cache-economics.ts) can correlate a cache write back
321
+ * to this same session's own context-prefix reset evidence, without ever widening scope by provider/model.
322
+ * runId ties every metric emitted for this one turn together (a counter, not just observedAt, since
323
+ * two turns can share a millisecond) so cache economics can resolve per-turn (e.g. tiered) catalog
324
+ * pricing against this turn's own real size instead of a blended sum across a whole query window.
299
325
  */
300
326
  function assistantUsageMetrics(
301
327
  message: unknown,
302
328
  observedAt: number,
303
329
  taskId: string | null = null,
304
330
  thinking: string | null = null,
331
+ sessionId: string | null = null,
305
332
  ): MetricObservation[] {
306
333
  if (typeof message !== "object" || message === null || Array.isArray(message)) return [];
307
334
  const value = message as Record<string, unknown>;
@@ -313,11 +340,14 @@ function assistantUsageMetrics(
313
340
  const provider = typeof value.provider === "string" ? value.provider : "unknown";
314
341
  const model = typeof value.model === "string" ? value.model : "unknown";
315
342
  const scope = `${provider}:${model}`;
343
+ const runId = `pi-usage-${metricObservedAt}-${++assistantUsageRunSequence}`;
316
344
  const attributes = {
317
345
  provider,
318
346
  model,
347
+ runId,
319
348
  ...(taskId === null ? {} : { taskId }),
320
349
  ...(thinking === null || thinking.length === 0 ? {} : { thinking }),
350
+ ...(sessionId === null || sessionId.length === 0 ? {} : { sessionId }),
321
351
  };
322
352
  const metrics: MetricObservation[] = [];
323
353
  for (const [field, metric, tokenScope] of [
@@ -348,9 +378,23 @@ function assistantUsageMetrics(
348
378
  },
349
379
  });
350
380
  }
351
- const cost = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>).total : undefined;
381
+ const costBreakdown = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>) : undefined;
382
+ const cost = costBreakdown?.total;
352
383
  if (typeof cost === "number" && Number.isFinite(cost))
353
384
  metrics.push({ source: "pi", scope, metric: "cost", value: cost, unit: "usd", observedAt: metricObservedAt, attributes });
385
+ // Itemized provider-reported cost, when the provider breaks it out -- the real dollar figures cache
386
+ // economics needs (see cache-economics.ts) instead of ever re-deriving them from catalog prices when
387
+ // the provider already told us. Never fabricated: omitted entirely when a field is absent.
388
+ for (const [field, metric] of [
389
+ ["input", "input-cost"],
390
+ ["output", "output-cost"],
391
+ ["cacheRead", "cache-read-cost"],
392
+ ["cacheWrite", "cache-write-cost"],
393
+ ] as const satisfies ReadonlyArray<readonly [string, string]>) {
394
+ const amount = costBreakdown?.[field];
395
+ if (typeof amount === "number" && Number.isFinite(amount))
396
+ metrics.push({ source: "pi", scope, metric, value: amount, unit: "usd", observedAt: metricObservedAt, attributes });
397
+ }
354
398
  return metrics;
355
399
  }
356
400
 
@@ -529,7 +573,7 @@ export function registerJittorExtension(
529
573
  };
530
574
 
531
575
  pi.registerCommand("jittor", {
532
- description: "Jittor settings, routing status, benchmarks, and Codex recovery controls",
576
+ description: "Jittor settings, routing status, benchmarks, cache economics, and Codex recovery controls",
533
577
  handler: async (args, ctx) => {
534
578
  const action = args.trim().toLowerCase();
535
579
  if (action === "" || action === "settings") {
@@ -569,7 +613,7 @@ export function registerJittorExtension(
569
613
  ctx.ui.notify("Usage: /jittor benchmarks [coding|general] [research|planning|general]", "warning");
570
614
  return;
571
615
  }
572
- const candidates = benchmarkCandidatesFromPi(ctx.modelRegistry.getAvailable() as PiRouteModel[], pi.getThinkingLevel());
616
+ const candidates = benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel());
573
617
  await showBenchmarkPanel(
574
618
  ctx,
575
619
  client,
@@ -580,6 +624,10 @@ export function registerJittorExtension(
580
624
  );
581
625
  return;
582
626
  }
627
+ if (action === "cache") {
628
+ await showCacheEconomicsPanel(ctx, client, 7 * MILLISECONDS_PER_DAY);
629
+ return;
630
+ }
583
631
  if (action === "outcome accepted" || action === "outcome rejected") {
584
632
  const explicitOutcome = action.endsWith("accepted") ? ("accepted" as const) : ("rejected" as const);
585
633
  const outcomeMetric = localRunTelemetry.explicitOutcomeMetric(explicitOutcome);
@@ -929,7 +977,13 @@ export function registerJittorExtension(
929
977
  event.message.errorMessage,
930
978
  );
931
979
  }
932
- const metrics = assistantUsageMetrics(event.message, Date.now(), focusedTaskId, pi.getThinkingLevel());
980
+ const metrics = assistantUsageMetrics(
981
+ event.message,
982
+ Date.now(),
983
+ focusedTaskId,
984
+ pi.getThinkingLevel(),
985
+ ctx.sessionManager.getSessionId(),
986
+ );
933
987
  if (metrics.length > 0) {
934
988
  const amount = (name: string): number =>
935
989
  metrics
@@ -0,0 +1,155 @@
1
+ import type {
2
+ CacheEconomicsAggregateTotals,
3
+ CacheEconomicsModelSummary,
4
+ CacheEconomicsSummary,
5
+ CacheEconomicsTaskSummary,
6
+ } from "@danypops/jittor";
7
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { BorderedSelectPanel, type TextMeasure } from "malevich-tui-components";
10
+
11
+ export interface CacheEconomicsPanelTheme {
12
+ fg(color: string, text: string): string;
13
+ bold(text: string): string;
14
+ }
15
+
16
+ type CacheEconomicsPanelAction = "refresh" | "close";
17
+
18
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
19
+
20
+ export interface CacheEconomicsPanelClient {
21
+ call(operation: string, input: unknown): Promise<any>;
22
+ }
23
+
24
+ function formatUsd(amount: number): string {
25
+ return `$${amount.toFixed(Math.abs(amount) < 0.01 && amount !== 0 ? 4 : 2)}`;
26
+ }
27
+
28
+ function costField(amountUsd: number | null, basis: "provider-reported" | "catalog-estimate" | "unknown"): string {
29
+ if (amountUsd === null) return "unknown";
30
+ return basis === "catalog-estimate" ? `${formatUsd(amountUsd)} (est.)` : formatUsd(amountUsd);
31
+ }
32
+
33
+ function aggregateFields(totals: CacheEconomicsAggregateTotals): string {
34
+ const payback = totals.paybackAchieved === null ? "n/a" : totals.paybackAchieved ? "yes" : "not yet";
35
+ return [
36
+ `read ${totals.cacheReadTokens.toLocaleString()} tok (${costField(totals.cacheReadCostUsd, totals.cacheReadCostBasis)})`,
37
+ `write ${totals.cacheWriteTokens.toLocaleString()} tok (${costField(totals.cacheWriteCostUsd, totals.cacheWriteCostBasis)})`,
38
+ `savings ${totals.savingsUsd === null ? "unknown" : formatUsd(totals.savingsUsd)}`,
39
+ `payback ${payback}`,
40
+ ].join(" · ");
41
+ }
42
+
43
+ function freshnessSuffix(catalogFreshness: "fresh" | "stale" | null): string {
44
+ return catalogFreshness === "stale" ? " -- stale catalog snapshot used for the estimate(s) above" : "";
45
+ }
46
+
47
+ function modelLine(model: CacheEconomicsModelSummary): string {
48
+ return `${model.provider}/${model.model}: ${aggregateFields(model)}${freshnessSuffix(model.catalogFreshness)}`;
49
+ }
50
+
51
+ function taskLine(task: CacheEconomicsTaskSummary): string {
52
+ return `${task.taskId}: ${aggregateFields(task)}${freshnessSuffix(task.catalogFreshness)}`;
53
+ }
54
+
55
+ /** Plain multi-line text, shared by TUI notify and non-TUI notify -- a full interactive panel is deferred; this already satisfies "bounded query plus Pi presentation" without a new widget. */
56
+ export function renderCacheEconomicsView(summary: CacheEconomicsSummary): string[] {
57
+ const lines = [
58
+ `Cache economics (${new Date(summary.since).toISOString().slice(0, 10)} .. ${new Date(summary.until).toISOString().slice(0, 10)})${summary.truncated ? " -- query limit reached, totals are a lower bound" : ""}`,
59
+ ];
60
+ if (summary.models.length === 0) lines.push("No cache activity recorded in this window.");
61
+ else lines.push(...summary.models.map((model) => `- ${modelLine(model)}`));
62
+ if (summary.tasks.length > 0) {
63
+ lines.push(`By task: ${summary.tasks.length}`);
64
+ lines.push(...summary.tasks.map((task) => `- ${taskLine(task)}`));
65
+ }
66
+ const unattributed = summary.unattributedCacheActivity;
67
+ if (unattributed.cacheReadTokens > 0 || unattributed.cacheWriteTokens > 0) {
68
+ lines.push(
69
+ `Unattributed (no task focused): read ${unattributed.cacheReadTokens.toLocaleString()} tok (${costField(unattributed.cacheReadCostUsd, unattributed.cacheReadCostBasis)}) · write ${unattributed.cacheWriteTokens.toLocaleString()} tok (${costField(unattributed.cacheWriteCostUsd, unattributed.cacheWriteCostBasis)})`,
70
+ );
71
+ }
72
+ if (summary.stablePrefixChurn.length > 0) {
73
+ lines.push(`Stable-prefix churn (${summary.stablePrefixChurn.length} snapshot(s), oldest first):`);
74
+ for (const point of summary.stablePrefixChurn) {
75
+ lines.push(
76
+ `- ${new Date(point.observedAt).toISOString()} session ${point.sessionId}: ${point.stablePrefixTokens.toLocaleString()} tok${point.resetReason === null ? "" : ` (${point.resetReason} reset)`}`,
77
+ );
78
+ }
79
+ }
80
+ if (summary.missedOpportunities.length > 0) {
81
+ lines.push(`Candidate missed-cache opportunities: ${summary.missedOpportunities.length}`);
82
+ for (const candidate of summary.missedOpportunities.slice(0, 10)) {
83
+ lines.push(
84
+ `- session ${candidate.sessionId}: ${candidate.resetReason} reset, then ${candidate.cacheWriteTokens.toLocaleString()} cache-write tok${candidate.cacheWriteCostUsd === null ? "" : ` (${formatUsd(candidate.cacheWriteCostUsd)})`}`,
85
+ );
86
+ }
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ export async function showCacheEconomicsView(
92
+ ctx: ExtensionCommandContext,
93
+ client: CacheEconomicsPanelClient,
94
+ windowMs: number,
95
+ now: () => number = Date.now,
96
+ ): Promise<void> {
97
+ const until = now();
98
+ const since = Math.max(0, until - windowMs);
99
+ const summary = (await client.call("cache.economics", { since, until })) as CacheEconomicsSummary;
100
+ ctx.ui.notify(renderCacheEconomicsView(summary).join("\n"), "info");
101
+ }
102
+
103
+ /** The same content as renderCacheEconomicsView, wrapped in a titled, bordered, scrollable frame -- mirrors optimization/model-selection-panel.ts's renderBenchmarkView/BorderedSelectPanel pattern. */
104
+ export function renderCacheEconomicsPanel(summary: CacheEconomicsSummary, width: number, theme: CacheEconomicsPanelTheme): string[] {
105
+ const safeWidth = Math.max(1, width);
106
+ const lines = renderCacheEconomicsView(summary);
107
+ const content = {
108
+ invalidate: () => {},
109
+ render: (availableWidth: number): string[] => lines.map((line) => truncateToWidth(line, availableWidth, "…")),
110
+ };
111
+ return new BorderedSelectPanel({
112
+ title: "Jittor Cache Economics",
113
+ list: content,
114
+ helpText: "r refresh · Esc close",
115
+ theme: {
116
+ border: (text) => theme.fg("borderMuted", text),
117
+ title: theme.bold,
118
+ help: (text) => theme.fg("dim", text),
119
+ },
120
+ measure: hostTextMeasure,
121
+ }).render(safeWidth);
122
+ }
123
+
124
+ /**
125
+ * Interactive scrollable panel for /jittor cache, mirroring showBenchmarkPanel: a plain notify
126
+ * outside TUI mode (unchanged from showCacheEconomicsView's own behavior), an interactive
127
+ * BorderedSelectPanel with an 'r' refresh keybinding inside it.
128
+ */
129
+ export async function showCacheEconomicsPanel(
130
+ ctx: ExtensionCommandContext,
131
+ client: CacheEconomicsPanelClient,
132
+ windowMs: number,
133
+ now: () => number = Date.now,
134
+ ): Promise<void> {
135
+ for (;;) {
136
+ const until = now();
137
+ const since = Math.max(0, until - windowMs);
138
+ const summary = (await client.call("cache.economics", { since, until })) as CacheEconomicsSummary;
139
+ if (ctx.mode !== "tui") {
140
+ ctx.ui.notify(renderCacheEconomicsView(summary).join("\n"), "info");
141
+ return;
142
+ }
143
+ const action = await ctx.ui.custom<CacheEconomicsPanelAction>((_tui, theme, _keybindings, done) => ({
144
+ invalidate() {},
145
+ render(width: number): string[] {
146
+ return renderCacheEconomicsPanel(summary, width, theme);
147
+ },
148
+ handleInput(data: string): void {
149
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
150
+ else if (data === "r") done("refresh");
151
+ },
152
+ }));
153
+ if (!action || action === "close") return;
154
+ }
155
+ }
@@ -50,6 +50,7 @@ const OPERATION_RETRY_MODE = {
50
50
  "router.clear_override": "once",
51
51
  "router.current_route": "once",
52
52
  "router.available_routes": "once",
53
+ "cache.economics": "retry",
53
54
  } as const satisfies Record<OperationName, "retry" | "once">;
54
55
 
55
56
  export function operationRetryMode(operation: OperationName): "retry" | "once" {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-jittor",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for Jittor token and context observability with optimization controls backed by the @danypops/jittor daemon",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
@@ -15,7 +15,7 @@
15
15
  "@danypops/vehicle-client": "^0.5.1",
16
16
  "@danypops/vehicle-core": "^0.12.1",
17
17
  "@danypops/vehicle-server": "^0.17.0",
18
- "@danypops/jittor": "^0.17.0",
18
+ "@danypops/jittor": "^0.18.0",
19
19
  "malevich-tui-components": "^0.16.1"
20
20
  },
21
21
  "peerDependencies": {