@danypops/pi-jittor 0.3.1 → 0.5.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,8 @@ import {
33
34
  validateTaskFocusEvent,
34
35
  } from "@danypops/jittor";
35
36
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
+ import { jittorArgumentCompletions, jittorUsageError } from "./jittor-command.ts";
38
+ import { showJittorShell } from "./jittor-shell.ts";
36
39
  import {
37
40
  basePromptSegment,
38
41
  buildBasePromptItems,
@@ -49,14 +52,12 @@ import { type CompactionProgress, type IntegratedFooterState, installIntegratedF
49
52
  import { LocalRunTelemetry } from "./observability/model-run.ts";
50
53
  import { captureProviderContextSnapshot } from "./observability/provider-context-snapshot.ts";
51
54
  import { ProviderResponseTelemetry } from "./observability/provider-response.ts";
52
- import { buildFooterBudget, providerBudgetMetricQuery, showJittorPanel } from "./observability/status.ts";
55
+ import { buildFooterBudget, providerBudgetMetricQuery } from "./observability/status.ts";
53
56
  import { showUsagePanel } from "./observability/usage.ts";
54
- import { showBenchmarkPanel } from "./optimization/model-selection-panel.ts";
55
57
  import { CodexRecoveryCapability, type CodexRecoveryRuntime, SYSTEM_RECOVERY_RUNTIME } from "./optimization/recovery/codex.ts";
56
58
  import { callJittor } from "./service-client.ts";
57
59
  import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
58
60
  import { type CodexRecoveryControl, type EnforcementControl, persistentEnforcementControl, type UsageBudgetControl } from "./settings.ts";
59
- import { showSettingsPanel } from "./settings-tui.ts";
60
61
 
61
62
  export { formatFooterStatus } from "./observability/status.ts";
62
63
  export type { CodexRecoveryRuntime } from "./optimization/recovery/codex.ts";
@@ -161,6 +162,23 @@ interface PiRouteModel {
161
162
  cost?: { input?: number; output?: number };
162
163
  }
163
164
 
165
+ /**
166
+ * Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the `/scoped-models`
167
+ * command shows) is the authority for which models a session may actually use -- e.g. a "work"
168
+ * profile scoped to one provider/model set versus a "personal" profile scoped to a different one.
169
+ * `ctx.modelRegistry.getAvailable()` enumerates every authenticated model on the host regardless of
170
+ * that restriction (Pi's own docs warn against using it for a model picker for exactly this reason:
171
+ * "instead of enumerating the whole catalogue via ctx.modelRegistry.getAvailable()"). Every Jittor
172
+ * call site that builds a candidate/route set for routing or ranking must prefer the scoped set
173
+ * when one is configured, or a scoped session keeps seeing -- and can be automatically routed onto
174
+ * -- another profile's models. An empty scopedModels list means "no scoping configured" (matching
175
+ * Pi's own semantics), not "scoped to nothing", so it still falls back to the full catalog.
176
+ */
177
+ function scopedOrAvailableModels(ctx: ExtensionContext): PiRouteModel[] {
178
+ if (ctx.scopedModels.length > 0) return ctx.scopedModels.map((entry) => entry.model as PiRouteModel);
179
+ return ctx.modelRegistry.getAvailable() as PiRouteModel[];
180
+ }
181
+
164
182
  const THINKING_DESCENDING = ["max", "xhigh", "high", "medium", "low", "minimal", "off"] as const;
165
183
 
166
184
  function supportsThinking(model: PiRouteModel, level: string): boolean {
@@ -237,7 +255,7 @@ async function syncAvailableRoutes(pi: ExtensionAPI, client: JittorExtensionClie
237
255
  await client.call("router.available_routes", { routes: [], session_id, ...secret });
238
256
  return;
239
257
  }
240
- const models = ctx.modelRegistry.getAvailable() as PiRouteModel[];
258
+ const models = scopedOrAvailableModels(ctx);
241
259
  const routes = routesFromPi(models, ctx.model as PiRouteModel, pi.getThinkingLevel());
242
260
  await client.call("router.available_routes", { routes, session_id, ...secret });
243
261
  }
@@ -292,16 +310,24 @@ async function applyDecision(
292
310
  );
293
311
  }
294
312
 
313
+ let assistantUsageRunSequence = 0;
314
+
295
315
  /**
296
316
  * taskId, when a Papyrus task is focused, tags the metric for cost-per-task correlation. thinking
297
317
  * 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.
318
+ * has no thinking field of its own, and the level can't have changed mid-message. sessionId tags every
319
+ * row so cache economics (see @danypops/jittor's cache-economics.ts) can correlate a cache write back
320
+ * to this same session's own context-prefix reset evidence, without ever widening scope by provider/model.
321
+ * runId ties every metric emitted for this one turn together (a counter, not just observedAt, since
322
+ * two turns can share a millisecond) so cache economics can resolve per-turn (e.g. tiered) catalog
323
+ * pricing against this turn's own real size instead of a blended sum across a whole query window.
299
324
  */
300
325
  function assistantUsageMetrics(
301
326
  message: unknown,
302
327
  observedAt: number,
303
328
  taskId: string | null = null,
304
329
  thinking: string | null = null,
330
+ sessionId: string | null = null,
305
331
  ): MetricObservation[] {
306
332
  if (typeof message !== "object" || message === null || Array.isArray(message)) return [];
307
333
  const value = message as Record<string, unknown>;
@@ -313,11 +339,14 @@ function assistantUsageMetrics(
313
339
  const provider = typeof value.provider === "string" ? value.provider : "unknown";
314
340
  const model = typeof value.model === "string" ? value.model : "unknown";
315
341
  const scope = `${provider}:${model}`;
342
+ const runId = `pi-usage-${metricObservedAt}-${++assistantUsageRunSequence}`;
316
343
  const attributes = {
317
344
  provider,
318
345
  model,
346
+ runId,
319
347
  ...(taskId === null ? {} : { taskId }),
320
348
  ...(thinking === null || thinking.length === 0 ? {} : { thinking }),
349
+ ...(sessionId === null || sessionId.length === 0 ? {} : { sessionId }),
321
350
  };
322
351
  const metrics: MetricObservation[] = [];
323
352
  for (const [field, metric, tokenScope] of [
@@ -348,9 +377,23 @@ function assistantUsageMetrics(
348
377
  },
349
378
  });
350
379
  }
351
- const cost = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>).total : undefined;
380
+ const costBreakdown = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>) : undefined;
381
+ const cost = costBreakdown?.total;
352
382
  if (typeof cost === "number" && Number.isFinite(cost))
353
383
  metrics.push({ source: "pi", scope, metric: "cost", value: cost, unit: "usd", observedAt: metricObservedAt, attributes });
384
+ // Itemized provider-reported cost, when the provider breaks it out -- the real dollar figures cache
385
+ // economics needs (see cache-economics.ts) instead of ever re-deriving them from catalog prices when
386
+ // the provider already told us. Never fabricated: omitted entirely when a field is absent.
387
+ for (const [field, metric] of [
388
+ ["input", "input-cost"],
389
+ ["output", "output-cost"],
390
+ ["cacheRead", "cache-read-cost"],
391
+ ["cacheWrite", "cache-write-cost"],
392
+ ] as const satisfies ReadonlyArray<readonly [string, string]>) {
393
+ const amount = costBreakdown?.[field];
394
+ if (typeof amount === "number" && Number.isFinite(amount))
395
+ metrics.push({ source: "pi", scope, metric, value: amount, unit: "usd", observedAt: metricObservedAt, attributes });
396
+ }
354
397
  return metrics;
355
398
  }
356
399
 
@@ -529,22 +572,54 @@ export function registerJittorExtension(
529
572
  };
530
573
 
531
574
  pi.registerCommand("jittor", {
532
- description: "Jittor settings, routing status, benchmarks, and Codex recovery controls",
575
+ description: "Jittor settings, routing status, benchmarks, cache economics, and Codex recovery controls",
576
+ getArgumentCompletions: jittorArgumentCompletions,
533
577
  handler: async (args, ctx) => {
534
578
  const action = args.trim().toLowerCase();
535
- if (action === "" || action === "settings") {
536
- await showSettingsPanel(ctx, enforcement, codexRecovery, usageBudgets, {
537
- setEnforcement: async (enabled) => (enabled ? enable(ctx) : disable(ctx)),
538
- setFooter: async (enabled) => {
539
- await enforcement.setFooterEnabled(enabled);
540
- showFooter(ctx);
541
- if (enabled) await refreshFooter(client, footerState, ctx.sessionManager.getSessionId()).catch(() => undefined);
542
- },
543
- setRecovery: async (enabled) => {
544
- if (!enabled) cancelRecovery(true);
545
- await codexRecovery.setCodexRecoveryEnabled(enabled);
579
+ const usageError = jittorUsageError(action);
580
+ if (usageError) {
581
+ ctx.ui.notify(usageError, "warning");
582
+ return;
583
+ }
584
+ // One shared JittorShellDeps builder for every /jittor entry point below -- all four tabs
585
+ // (Settings/Status/Benchmarks/Cache) are reachable from a single opened shell regardless of
586
+ // which subcommand opened it, so every entry point needs the full dependency set, not just
587
+ // its own tab's. `benchmarksTask` overrides the domain/type only for an explicit
588
+ // "/jittor benchmarks ..." invocation; every other entry point defaults to general/general
589
+ // (only meaningfully exercised if the user tab-cycles into Benchmarks from elsewhere).
590
+ const buildShellDeps = (benchmarksTask?: {
591
+ domain: ModelTaskDomain;
592
+ type: ModelTaskType;
593
+ }): Parameters<typeof showJittorShell>[1] => ({
594
+ settings: {
595
+ enforcement,
596
+ recovery: codexRecovery,
597
+ budgets: usageBudgets,
598
+ effects: {
599
+ setEnforcement: async (enabled) => (enabled ? enable(ctx) : disable(ctx)),
600
+ setFooter: async (enabled) => {
601
+ await enforcement.setFooterEnabled(enabled);
602
+ showFooter(ctx);
603
+ if (enabled) await refreshFooter(client, footerState, ctx.sessionManager.getSessionId()).catch(() => undefined);
604
+ },
605
+ setRecovery: async (enabled) => {
606
+ if (!enabled) cancelRecovery(true);
607
+ await codexRecovery.setCodexRecoveryEnabled(enabled);
608
+ },
546
609
  },
547
- });
610
+ },
611
+ status: { client },
612
+ benchmarks: {
613
+ client,
614
+ candidates: benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel()),
615
+ currentIdentity: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
616
+ domain: benchmarksTask?.domain ?? "general",
617
+ type: benchmarksTask?.type ?? "general",
618
+ },
619
+ cache: { client, windowMs: 7 * MILLISECONDS_PER_DAY },
620
+ });
621
+ if (action === "" || action === "settings") {
622
+ await showJittorShell(ctx, buildShellDeps(), "settings");
548
623
  return;
549
624
  }
550
625
  if (action === "benchmarks" || action.startsWith("benchmarks ")) {
@@ -569,17 +644,17 @@ export function registerJittorExtension(
569
644
  ctx.ui.notify("Usage: /jittor benchmarks [coding|general] [research|planning|general]", "warning");
570
645
  return;
571
646
  }
572
- const candidates = benchmarkCandidatesFromPi(ctx.modelRegistry.getAvailable() as PiRouteModel[], pi.getThinkingLevel());
573
- await showBenchmarkPanel(
647
+ await showJittorShell(
574
648
  ctx,
575
- client,
576
- candidates,
577
- `${ctx.model.provider}/${ctx.model.id}`,
578
- requestedDomain ?? "general",
579
- requestedType ?? "general",
649
+ buildShellDeps({ domain: requestedDomain ?? "general", type: requestedType ?? "general" }),
650
+ "benchmarks",
580
651
  );
581
652
  return;
582
653
  }
654
+ if (action === "cache") {
655
+ await showJittorShell(ctx, buildShellDeps(), "cache");
656
+ return;
657
+ }
583
658
  if (action === "outcome accepted" || action === "outcome rejected") {
584
659
  const explicitOutcome = action.endsWith("accepted") ? ("accepted" as const) : ("rejected" as const);
585
660
  const outcomeMetric = localRunTelemetry.explicitOutcomeMetric(explicitOutcome);
@@ -653,13 +728,13 @@ export function registerJittorExtension(
653
728
  );
654
729
  return;
655
730
  }
656
- // Reached only for the explicit "status" keyword or any other unrecognized text; bare "" is
657
- // handled above by the settings branch, so this always has a non-empty, non-settings action.
731
+ // Reached only for the explicit "status" keyword -- jittorUsageError above already rejected
732
+ // anything else this handler doesn't recognize, so this is never an unrecognized fallback.
658
733
  if (!enforcement.isEnabled()) {
659
734
  ctx.ui.notify("Jittor is monitor-only. Run /jittor on to re-enable blocking.", "info");
660
735
  return;
661
736
  }
662
- await showJittorPanel(ctx, client);
737
+ await showJittorShell(ctx, buildShellDeps(), "status");
663
738
  },
664
739
  });
665
740
 
@@ -929,7 +1004,13 @@ export function registerJittorExtension(
929
1004
  event.message.errorMessage,
930
1005
  );
931
1006
  }
932
- const metrics = assistantUsageMetrics(event.message, Date.now(), focusedTaskId, pi.getThinkingLevel());
1007
+ const metrics = assistantUsageMetrics(
1008
+ event.message,
1009
+ Date.now(),
1010
+ focusedTaskId,
1011
+ pi.getThinkingLevel(),
1012
+ ctx.sessionManager.getSessionId(),
1013
+ );
933
1014
  if (metrics.length > 0) {
934
1015
  const amount = (name: string): number =>
935
1016
  metrics
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The full argument grammar for /jittor, as one small data table -- the single source of truth
3
+ * both jittorUsageError (validation) and jittorArgumentCompletions (tab-completion) read from, so
4
+ * the two can never drift apart (a phrase added to one is automatically valid/completable in the
5
+ * other). /jittor's own registerCommand handler in index.ts owns dispatching a validated phrase to
6
+ * its real behavior; this module only ever answers "is this a real phrase" and "what could this
7
+ * become".
8
+ *
9
+ * `benchmarks` is deliberately excluded from LEAF_PHRASES: its own domain/type combinatorics
10
+ * (TASK_DOMAINS x TASK_TYPES, either order) already have a dedicated, tested malformed-usage
11
+ * branch in index.ts with its own specific error message -- this module defers to it entirely
12
+ * for validation, and only mirrors its real phrase space for completions.
13
+ */
14
+ import { TASK_DOMAINS, TASK_TYPES } from "@danypops/jittor";
15
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
16
+
17
+ export const JITTOR_TOP_LEVEL_COMMANDS = [
18
+ "settings",
19
+ "status",
20
+ "benchmarks",
21
+ "cache",
22
+ "outcome",
23
+ "recovery",
24
+ "on",
25
+ "off",
26
+ "footer",
27
+ "context",
28
+ ] as const;
29
+
30
+ /** Every real, currently-supported phrase EXCEPT benchmarks (see module doc comment). */
31
+ const LEAF_PHRASES = [
32
+ "",
33
+ "settings",
34
+ "status",
35
+ "cache",
36
+ "context",
37
+ "on",
38
+ "enable",
39
+ "off",
40
+ "disable",
41
+ "outcome accepted",
42
+ "outcome rejected",
43
+ "recovery",
44
+ "recovery status",
45
+ "recovery on",
46
+ "recovery enable",
47
+ "recovery off",
48
+ "recovery disable",
49
+ "recovery cancel",
50
+ "footer on",
51
+ "footer enable",
52
+ "footer off",
53
+ "footer disable",
54
+ ] as const;
55
+
56
+ /** Real benchmarks phrases: bare, one axis alone, or both axes in either accepted word order -- mirrors index.ts's own lenient two-word-any-order parsing, for completions only. */
57
+ function benchmarksPhrases(): string[] {
58
+ const phrases: string[] = ["benchmarks"];
59
+ for (const domain of TASK_DOMAINS) phrases.push(`benchmarks ${domain}`);
60
+ for (const type of TASK_TYPES) phrases.push(`benchmarks ${type}`);
61
+ for (const domain of TASK_DOMAINS) {
62
+ for (const type of TASK_TYPES) {
63
+ phrases.push(`benchmarks ${domain} ${type}`, `benchmarks ${type} ${domain}`);
64
+ }
65
+ }
66
+ return phrases;
67
+ }
68
+
69
+ /**
70
+ * null when `action` (already trimmed/lowercased by the caller, matching index.ts's own
71
+ * convention) is a real, currently-supported phrase; otherwise a human-readable message naming
72
+ * exactly what was wrong and what the real alternatives are -- never a silent fallback to
73
+ * unrelated behavior (the bug class this replaces: an unrecognized word, or a valid command with
74
+ * an unrecognized trailing argument, used to silently open the status panel instead).
75
+ */
76
+ export function jittorUsageError(action: string): string | null {
77
+ if ((LEAF_PHRASES as readonly string[]).includes(action)) return null;
78
+ if (action === "benchmarks" || action.startsWith("benchmarks ")) return null;
79
+
80
+ const words = action.split(/\s+/).filter(Boolean);
81
+ const firstWord = words[0]!;
82
+ if (!(JITTOR_TOP_LEVEL_COMMANDS as readonly string[]).includes(firstWord)) {
83
+ return `Unknown /jittor command "${firstWord}". Allowed: ${JITTOR_TOP_LEVEL_COMMANDS.join(", ")}.`;
84
+ }
85
+ const subPhrases = LEAF_PHRASES.filter((phrase) => phrase.startsWith(`${firstWord} `));
86
+ if (subPhrases.length === 0) return `/jittor ${firstWord} does not take any arguments.`;
87
+ const subArguments = subPhrases.map((phrase) => phrase.slice(firstWord.length + 1));
88
+ return `Unknown /jittor ${firstWord} argument "${words.slice(1).join(" ")}". Allowed: ${subArguments.join(", ")}.`;
89
+ }
90
+
91
+ let cachedCompletionPhrases: string[] | undefined;
92
+ function completionPhrases(): string[] {
93
+ if (!cachedCompletionPhrases) cachedCompletionPhrases = [...LEAF_PHRASES.filter((phrase) => phrase.length > 0), ...benchmarksPhrases()];
94
+ return cachedCompletionPhrases;
95
+ }
96
+
97
+ /**
98
+ * Every real phrase (see completionPhrases) that could still result from typing more after
99
+ * `argumentPrefix`, matching pi-tui's own SlashCommand.getArgumentCompletions contract: `value` is
100
+ * the FULL replacement for the entire argument text typed so far (pi-tui's own applyCompletion
101
+ * replaces the whole prefix span with it, not just the trailing word), so every candidate here is a
102
+ * complete phrase, not a per-word delta. Matching is case-insensitive on the typed prefix (a human
103
+ * may capitalize while typing); returned values are always canonical lowercase.
104
+ */
105
+ export function jittorArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
106
+ const needle = argumentPrefix.toLowerCase();
107
+ const matches = completionPhrases().filter((phrase) => phrase.startsWith(needle));
108
+ if (matches.length === 0) return null;
109
+ return matches.map((value) => ({ value, label: value }));
110
+ }