@danypops/pi-jittor 0.5.3 → 0.6.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.
- package/README.md +20 -51
- package/extension/src/index.ts +125 -15
- package/extension/src/jittor-shell.ts +19 -4
- package/extension/src/optimization/auto-mode-dialog.ts +108 -0
- package/extension/src/optimization/auto-mode.ts +75 -0
- package/extension/src/optimization/model-selection-panel.ts +24 -4
- package/extension/src/settings-tui.ts +63 -12
- package/extension/src/settings.ts +34 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,75 +1,44 @@
|
|
|
1
1
|
# @danypops/pi-jittor
|
|
2
2
|
|
|
3
|
-
The Pi extension for Jittor: token, cost, context, provider-budget, and model-run observation plus routing, recovery, and model-selection controls,
|
|
3
|
+
The Pi extension for Jittor: token, cost, context, provider-budget, and model-run observation plus routing, recovery, and model-selection controls, over an authenticated loopback client to the [`@danypops/jittor`](../jittor) daemon. See the [repo root README](../../README.md) for the two-package overview.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it does
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- `extension/src/optimization/` contains routing-adjacent recovery and model-selection controls.
|
|
9
|
-
- `extension/src/index.ts` composes Pi lifecycle handlers; daemon transport, settings, and session identity remain thin boundary modules.
|
|
7
|
+
Records context composition, response headers, finalized usage, cost, and model-run behavior through the daemon; preflights provider turns and applies model/thinking decisions; blocks requests when required telemetry is unsafe. It follows Pi's current authenticated model/provider and re-syncs Pi's available models before every decision, so an unavailable catalog route is never selected. Route state is scoped per Pi session, so concurrent sessions can't clobber each other's active provider or footer budget.
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
The extension registers no model-callable tools — daemon JSON, CLI `--json`, human CLI output, notifications, panels, and the footer are separate bounded channels (see `@danypops/jittor`'s `docs/OUTPUT_CHANNELS.md`).
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Blocking always has an escape hatch: `/jittor off` enters persisted monitor-only mode and never blocks; `/jittor footer on|off` controls the informational footer independently of enforcement; `/jittor on` only re-enables enforcement after telemetry/route-sync succeed.
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
## Footer
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
An integrated footer shows repository/model identity, cumulative usage, a color-coded context-window bar, and current-provider budget telemetry. Codex/OpenRouter/Anthropic each render a draining remaining-budget bar when the provider's own telemetry exposes a real limit; without one, spend stays honest text-only rather than a fabricated denominator. During Pi compaction the bar drains against a learned duration estimate (median of the last ≤20 completed compactions, needs ≥3 before trusting it) — never a fake timer, and it blinks in place until there's enough evidence. `jittor compaction estimate [--json]` inspects the estimate directly.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
## `/jittor` — settings and status
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Transient Codex recovery is securely off by default and controlled through the existing Jittor command surface:
|
|
24
|
-
|
|
25
|
-
```text
|
|
26
|
-
/jittor recovery status
|
|
27
|
-
/jittor recovery on
|
|
28
|
-
/jittor recovery off
|
|
29
|
-
/jittor recovery cancel
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
The on/off choice persists privately in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`). Status reports only enabled state, cooldown, bounded attempt/window counters, and the normalized failure class. `cancel` clears the current cooldown and attempt window without changing the persisted on/off choice.
|
|
33
|
-
|
|
34
|
-
The extension observes finalized Codex assistant errors through Pi's public message lifecycle, classifies only bounded error metadata, and waits for `agent_settled` before acting. That boundary guarantees Pi's built-in retry, compaction retry, and queued follow-up work has finished. A transient concurrency, rate-limit, overload, or transport failure then schedules one hidden follow-up with Retry-After-aware capped jitter. Recovery is limited to three attempts per ten-minute window, never overlaps pending Pi messages, resets after success, and is canceled by human input or session shutdown. Quota, authentication, invalid-request, unknown, and aborted failures remain terminal. Raw provider payloads are never retained or injected.
|
|
35
|
-
|
|
36
|
-
### Settings
|
|
37
|
-
|
|
38
|
-
`/jittor` is the settings and control command. Bare `/jittor` (or `/jittor settings`) opens one keyboard-navigable TUI covering routing enforcement, the informational footer, Codex recovery, and all four token-budget thresholds, with explicit ON/OFF and configured/not-configured labels, bounded rendering on narrow terminals, and confirmation for weaker enforcement/recovery changes. `/jittor status` shows the routing/pressure panel that used to be the bare command's default. Existing non-TUI subcommands (`benchmarks`, `outcome`, `recovery`, `on`/`off`, `footer on`/`off`, `context`) remain available for automation and are unchanged.
|
|
19
|
+
`/jittor` (or `/jittor settings`) opens a keyboard-navigable TUI covering routing enforcement, the footer, Codex recovery, and the four token-budget thresholds. `/jittor status` shows the routing/pressure panel. `benchmarks`, `outcome`, `recovery`, `on`/`off`, `footer on`/`off`, and `context` remain available as direct subcommands for automation.
|
|
39
20
|
|
|
40
|
-
###
|
|
41
|
-
|
|
42
|
-
`/usage` is its own top-level command, separate from `/jittor`. Bare `/usage` opens a colored Unicode cumulative graph with X/Y axes, per-provider/model series, and explicit **Hourly**, **Daily**, **Weekly**, **Monthly**, and **Quarterly** periods; `/usage cost` opens the same graph showing aggregated USD spend instead of tokens, reusing the `cost` metric already recorded content-free on every finalized Pi assistant message (no new instrumentation). Left/Right or Tab/Shift+Tab changes the time frame, `v` toggles between the token and cost views, and `r` refreshes.
|
|
43
|
-
|
|
44
|
-
The graph fetches metrics per distinct provider/model scope (`jittor metrics distinct-scopes`, bounded to 40 scopes, 250 rows each) rather than one flat "most recent rows" query. A flat query lets one heavy, long-running session monopolize the entire row budget with its own most recent activity, silently hiding every other provider from the chart no matter which time frame is selected, since the query would never reach back far enough in time to see anything else. Fetching per scope guarantees every active provider/model gets its own fair share of the query budget instead.
|
|
45
|
-
|
|
46
|
-
Series are colored with a categorical palette chosen to avoid this UI's own status colors ("success"/"warning"/"error" already mean something specific elsewhere in this panel, so reusing them for arbitrary model identity would make a model's bar segment look like a warning or a failure) and instead reuses the theme's syntax-highlighting roles. A stable provider/model identity hash keeps each model's color fixed across token and cost graphics, period switches, refreshes, ranking changes, and extension restarts. The hash also selects an optional bold variant as a second visual channel, reducing collisions when more model identities exist than terminal hues. Multiple models active within the same cumulative time frame are rendered as one bar stacked by color, not separate bars.
|
|
47
|
-
|
|
48
|
-
Token-budget thresholds are optional and must be configured by the user; Jittor never infers a token allowance from Codex or another provider's subscription percentage. Configure or clear one period with `/usage budget <hourly|daily|weekly|monthly|quarterly> <positive-tokens|off>`, and inspect all of them with `/usage budget`. A configured budget appears as a horizontal threshold on the cumulative graph with explicit remaining or **OVER BUDGET** state; the cost view does not yet support a budget threshold. These private settings persist in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`).
|
|
49
|
-
|
|
50
|
-
See [`docs/USAGE_PRIOR_ART.md`](docs/USAGE_PRIOR_ART.md) for the chart design research.
|
|
51
|
-
|
|
52
|
-
### Cost per Papyrus task
|
|
21
|
+
### Opt-in Codex settled-turn recovery
|
|
53
22
|
|
|
54
|
-
|
|
23
|
+
Off by default. `/jittor recovery on|off|status|cancel` — a transient Codex failure (rate-limit, overload, transport) schedules one hidden retry after Pi's own `agent_settled` boundary, Retry-After-aware, capped at 3 attempts per 10-minute window, canceled by human input or session shutdown. Quota/auth/invalid-request/unknown/aborted failures stay terminal; raw provider payloads are never retained. The on/off choice persists in `$XDG_CONFIG_HOME/jittor/extension.json`.
|
|
55
24
|
|
|
56
|
-
|
|
25
|
+
## `/usage` — token and cost graphs
|
|
57
26
|
|
|
58
|
-
|
|
27
|
+
A colored Unicode cumulative graph (Hourly/Daily/Weekly/Monthly/Quarterly) per provider/model; `/usage cost` shows aggregated USD spend on the same axes. `Tab`/arrows switch period, `v` toggles token/cost, `r` refreshes. Each active provider/model gets a stable color (a second bold-variant channel once identities exceed available hues) so a model's color stays fixed across periods, refreshes, and restarts. `/usage budget <period> <tokens|off>` sets an optional threshold, rendered as a horizontal line on the graph with remaining/**OVER BUDGET** state (token view only; Jittor never infers a budget from a provider's own subscription tier). See [`docs/USAGE_PRIOR_ART.md`](docs/USAGE_PRIOR_ART.md) for the chart design research.
|
|
59
28
|
|
|
60
|
-
|
|
29
|
+
## Cost per Papyrus task
|
|
61
30
|
|
|
62
|
-
|
|
31
|
+
Every token/cost metric already recorded on a finalized assistant message is tagged with the currently-focused Papyrus task (via the `papyrus.task-focus.v1` event bus, no new instrumentation) plus the provider/model/thinking level active at that moment. `jittor metrics cost-by-task --since <ms> --until <ms> [--json]` gives a bounded per-task cost/token breakdown. Spend with nothing focused reports as unattributed rather than folding into an invented task.
|
|
63
32
|
|
|
64
|
-
|
|
33
|
+
## Benchmark evidence panel
|
|
65
34
|
|
|
66
|
-
|
|
35
|
+
`/jittor benchmarks [coding|general] [research|planning|general]` shows a responsive model-recommendation panel over the daemon's benchmark ranking (see `@danypops/jittor`'s own README for ingestion sources). Labeled **ADVISORY** — Pi's extension API doesn't expose the real `/scoped-models` set, so this offers no direct selection action. `/jittor outcome accepted|rejected` attaches real outcome evidence to the latest completed run.
|
|
67
36
|
|
|
68
|
-
|
|
37
|
+
## `/context` — context window inspection
|
|
69
38
|
|
|
70
|
-
|
|
39
|
+
A tree-aware breakdown of the current context window: `/` search, `f` scope (all/active/historical), `m` size threshold, `g`/`G`/arrows navigate. Conservatively-mapped OpenAI-family models get exact `gpt-tokenizer` counts (marked `tokenizer-exact-text`); everything else stays `≈ char/4`. Assistant turns also show the provider's own authoritative aggregate request context (input + cache read + cache write) alongside individual item costs, with an explicit unattributed residual rather than proportional allocation. See [`../jittor/docs/TOKEN_MEASUREMENT.md`](../jittor/docs/TOKEN_MEASUREMENT.md) and [`../jittor/docs/CONTEXT_SNAPSHOTS.md`](../jittor/docs/CONTEXT_SNAPSHOTS.md).
|
|
71
40
|
|
|
72
|
-
|
|
41
|
+
`/jittor context` shows the in-session Papyrus prompt-injection / Pi compaction telemetry summary — see `@danypops/jittor`'s README for what's recorded.
|
|
73
42
|
|
|
74
43
|
## Development
|
|
75
44
|
|
package/extension/src/index.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
CONTEXT_HUB_CONTRIBUTION_CHANNEL,
|
|
7
7
|
CompactionTelemetry,
|
|
8
8
|
type ContextAssessment,
|
|
9
|
+
classifyEffort,
|
|
9
10
|
classifyTaskFromTools,
|
|
11
|
+
declaredEffortFromTaskFocusEvent,
|
|
12
|
+
EFFORT_CLASSIFICATION_MAX_TOOL_NAMES,
|
|
10
13
|
FOOTER_COMPACTION_RENDER_INTERVAL_MS,
|
|
11
14
|
HmacContextFingerprinter,
|
|
12
15
|
loadOpenAiTextTokenCounter,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
MILLISECONDS_PER_DAY,
|
|
16
19
|
type ModelCandidate,
|
|
17
20
|
type ModelTaskDomain,
|
|
21
|
+
type ModelTaskEffort,
|
|
18
22
|
type ModelTaskType,
|
|
19
23
|
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
20
24
|
PAPYRUS_TASK_FOCUS_CHANNEL,
|
|
@@ -54,10 +58,19 @@ import { captureProviderContextSnapshot } from "./observability/provider-context
|
|
|
54
58
|
import { ProviderResponseTelemetry } from "./observability/provider-response.ts";
|
|
55
59
|
import { buildFooterBudget, providerBudgetMetricQuery } from "./observability/status.ts";
|
|
56
60
|
import { showUsagePanel } from "./observability/usage.ts";
|
|
61
|
+
import { candidateIdentityOf, decideAutoMode, newAutoModeSessionState, recordAutoModeDismissal } from "./optimization/auto-mode.ts";
|
|
62
|
+
import { showAutoModeSuggestion } from "./optimization/auto-mode-dialog.ts";
|
|
63
|
+
import { fetchBenchmarkRanking } from "./optimization/model-selection-panel.ts";
|
|
57
64
|
import { CodexRecoveryCapability, type CodexRecoveryRuntime, SYSTEM_RECOVERY_RUNTIME } from "./optimization/recovery/codex.ts";
|
|
58
65
|
import { callJittor } from "./service-client.ts";
|
|
59
66
|
import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
|
|
60
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
type AutoModeControl,
|
|
69
|
+
type CodexRecoveryControl,
|
|
70
|
+
type EnforcementControl,
|
|
71
|
+
persistentEnforcementControl,
|
|
72
|
+
type UsageBudgetControl,
|
|
73
|
+
} from "./settings.ts";
|
|
61
74
|
|
|
62
75
|
export { formatFooterStatus } from "./observability/status.ts";
|
|
63
76
|
export type { CodexRecoveryRuntime } from "./optimization/recovery/codex.ts";
|
|
@@ -108,6 +121,28 @@ function recoveryControl(enforcement: EnforcementControl): CodexRecoveryControl
|
|
|
108
121
|
: { isCodexRecoveryEnabled: () => false, setCodexRecoveryEnabled() {} };
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Falls back to "off" (never "suggest", the real persisted default in persistentEnforcementControl
|
|
126
|
+
* itself) when the passed-in control doesn't implement AutoModeControl -- e.g. a test harness's
|
|
127
|
+
* minimal enforcement-only fake. Matches usageBudgetControl/recoveryControl's own conservative
|
|
128
|
+
* fallback stubs: assume the feature is off/unconfigured rather than defaulting it on somewhere
|
|
129
|
+
* a caller never actually wired real persisted state for it.
|
|
130
|
+
*/
|
|
131
|
+
function autoModeControl(enforcement: EnforcementControl): AutoModeControl {
|
|
132
|
+
const candidate = enforcement as EnforcementControl & Partial<AutoModeControl>;
|
|
133
|
+
return typeof candidate.getAutoMode === "function" &&
|
|
134
|
+
typeof candidate.setAutoMode === "function" &&
|
|
135
|
+
typeof candidate.isAutoModeVerbose === "function" &&
|
|
136
|
+
typeof candidate.setAutoModeVerbose === "function"
|
|
137
|
+
? {
|
|
138
|
+
getAutoMode: () => candidate.getAutoMode!(),
|
|
139
|
+
setAutoMode: (mode) => candidate.setAutoMode!(mode),
|
|
140
|
+
isAutoModeVerbose: () => candidate.isAutoModeVerbose!(),
|
|
141
|
+
setAutoModeVerbose: (verbose) => candidate.setAutoModeVerbose!(verbose),
|
|
142
|
+
}
|
|
143
|
+
: { getAutoMode: () => "off", setAutoMode() {}, isAutoModeVerbose: () => false, setAutoModeVerbose() {} };
|
|
144
|
+
}
|
|
145
|
+
|
|
111
146
|
async function recordMetrics(client: JittorExtensionClient, metrics: MetricObservation[]): Promise<void> {
|
|
112
147
|
if (metrics.length === 0) return;
|
|
113
148
|
// One atomic transaction rather than a per-metric RPC loop: a later observation in the same
|
|
@@ -404,12 +439,57 @@ export function registerJittorExtension(
|
|
|
404
439
|
codexRecovery: CodexRecoveryControl = recoveryControl(enforcement),
|
|
405
440
|
recoveryRuntime: CodexRecoveryRuntime = SYSTEM_RECOVERY_RUNTIME,
|
|
406
441
|
contextGrowth: ContextGrowthCapability = new ContextGrowthCapability(),
|
|
442
|
+
autoMode: AutoModeControl = autoModeControl(enforcement),
|
|
407
443
|
): void {
|
|
408
444
|
const footerState: IntegratedFooterState = { providerBudget: null };
|
|
409
445
|
const usageBudgets = usageBudgetControl(enforcement);
|
|
410
446
|
let compactionTelemetry = new CompactionTelemetry();
|
|
411
447
|
let contextGrowthTurn = 0;
|
|
412
448
|
const localRunTelemetry = new LocalRunTelemetry();
|
|
449
|
+
// Auto mode's own turn-boundary state: the just-received user text and the completed prior
|
|
450
|
+
// turn's tool-call mix feed classifyEffort; autoModeState remembers the last-seen effort and
|
|
451
|
+
// any dismissed suggestion (see optimization/auto-mode.ts's own anti-nag doc comment).
|
|
452
|
+
let pendingUserText: string | null = null;
|
|
453
|
+
let currentTurnToolNames: string[] = [];
|
|
454
|
+
let priorTurnToolNames: string[] = [];
|
|
455
|
+
const autoModeState = newAutoModeSessionState();
|
|
456
|
+
let autoModeSwitchNotifiedThisSession = false;
|
|
457
|
+
// Advisory/best-effort by construction: any failure here must never block or halt the turn the
|
|
458
|
+
// way budget enforcement's own fail-closed halt does -- callers wrap this in try/catch and swallow.
|
|
459
|
+
// Bind-beforehand: a Papyrus task focused with a declared `extra.effort` pins routing to that
|
|
460
|
+
// effort for as long as it stays focused -- no live per-turn detection needed for that task.
|
|
461
|
+
// Live classification remains the fallback whenever no task is focused, or the focused task
|
|
462
|
+
// declares no effort. Reset semantics mirror focusedTaskId's own handling below.
|
|
463
|
+
const runAutoModeTurn = async (ctx: ExtensionContext): Promise<void> => {
|
|
464
|
+
const mode = autoMode.getAutoMode();
|
|
465
|
+
if (mode === "off" || !ctx.model) return;
|
|
466
|
+
const candidates = benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel());
|
|
467
|
+
if (candidates.length === 0) return;
|
|
468
|
+
const currentCandidate: ModelCandidate = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
469
|
+
const effort = declaredEffort ?? classifyEffort({ userText: pendingUserText ?? "", priorTurnToolNames }).effort;
|
|
470
|
+
const ranking = await fetchBenchmarkRanking(ctx, client, candidates, "general", "general", effort, currentCandidate);
|
|
471
|
+
const decision = decideAutoMode({ mode, effort, ranking, state: autoModeState });
|
|
472
|
+
if (decision.kind === "switch") {
|
|
473
|
+
const applied = await applyRoute(pi, ctx, decision.candidate);
|
|
474
|
+
if (applied && !autoModeSwitchNotifiedThisSession) {
|
|
475
|
+
ctx.ui.notify(`Jittor auto-switched to ${candidateIdentityOf(decision.candidate)} (effort: ${effort}).`, "info");
|
|
476
|
+
autoModeSwitchNotifiedThisSession = true;
|
|
477
|
+
}
|
|
478
|
+
} else if (decision.kind === "suggest") {
|
|
479
|
+
const choice = await showAutoModeSuggestion(
|
|
480
|
+
ctx,
|
|
481
|
+
decision.candidate,
|
|
482
|
+
effort,
|
|
483
|
+
decision.utilityDelta,
|
|
484
|
+
decision.confidence,
|
|
485
|
+
ranking,
|
|
486
|
+
`${currentCandidate.provider}/${currentCandidate.model}`,
|
|
487
|
+
autoMode.isAutoModeVerbose(),
|
|
488
|
+
);
|
|
489
|
+
if (choice === "switch") await applyRoute(pi, ctx, decision.candidate);
|
|
490
|
+
else recordAutoModeDismissal(autoModeState, decision.candidate);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
413
493
|
const providerResponseTelemetry = new ProviderResponseTelemetry();
|
|
414
494
|
const codexRecoveryCapability = new CodexRecoveryCapability(pi, codexRecovery, recoveryRuntime);
|
|
415
495
|
const contextHub = new ContextHubCapability();
|
|
@@ -494,11 +574,14 @@ export function registerJittorExtension(
|
|
|
494
574
|
// affect this one's attribution.
|
|
495
575
|
let currentSessionId: string | undefined;
|
|
496
576
|
let focusedTaskId: string | null = null;
|
|
577
|
+
// Read by runAutoModeTurn (declared above, only ever invoked later) as the bind-beforehand pin.
|
|
578
|
+
let declaredEffort: ModelTaskEffort | null = null;
|
|
497
579
|
const stopPapyrusTaskFocus = pi.events?.on?.(PAPYRUS_TASK_FOCUS_CHANNEL, (payload) => {
|
|
498
580
|
try {
|
|
499
581
|
const event = validateTaskFocusEvent(payload);
|
|
500
582
|
if (event.sessionId !== undefined && event.sessionId !== currentSessionId) return;
|
|
501
583
|
focusedTaskId = applyTaskFocusEvent(event);
|
|
584
|
+
declaredEffort = declaredEffortFromTaskFocusEvent(event);
|
|
502
585
|
} catch {
|
|
503
586
|
// Reject malformed or stale cross-extension events without retaining payloads or crashing the extension.
|
|
504
587
|
}
|
|
@@ -595,6 +678,7 @@ export function registerJittorExtension(
|
|
|
595
678
|
enforcement,
|
|
596
679
|
recovery: codexRecovery,
|
|
597
680
|
budgets: usageBudgets,
|
|
681
|
+
autoMode,
|
|
598
682
|
effects: {
|
|
599
683
|
setEnforcement: async (enabled) => (enabled ? enable(ctx) : disable(ctx)),
|
|
600
684
|
setFooter: async (enabled) => {
|
|
@@ -606,6 +690,8 @@ export function registerJittorExtension(
|
|
|
606
690
|
if (!enabled) cancelRecovery(true);
|
|
607
691
|
await codexRecovery.setCodexRecoveryEnabled(enabled);
|
|
608
692
|
},
|
|
693
|
+
setAutoMode: (mode) => autoMode.setAutoMode(mode),
|
|
694
|
+
setAutoModeVerbose: (verbose) => autoMode.setAutoModeVerbose(verbose),
|
|
609
695
|
},
|
|
610
696
|
},
|
|
611
697
|
status: { client },
|
|
@@ -615,6 +701,11 @@ export function registerJittorExtension(
|
|
|
615
701
|
currentIdentity: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
|
|
616
702
|
domain: benchmarksTask?.domain ?? "general",
|
|
617
703
|
type: benchmarksTask?.type ?? "general",
|
|
704
|
+
// This on-demand panel is not tied to a live turn, so it always uses the neutral
|
|
705
|
+
// "medium" effort (matching the pre-effort-axis cost weighting exactly) -- live
|
|
706
|
+
// per-turn effort detection feeds the turn_start Auto-mode decision path instead.
|
|
707
|
+
effort: "medium",
|
|
708
|
+
currentCandidate: ctx.model ? { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() } : null,
|
|
618
709
|
},
|
|
619
710
|
cache: { client, windowMs: 7 * MILLISECONDS_PER_DAY },
|
|
620
711
|
});
|
|
@@ -821,6 +912,7 @@ export function registerJittorExtension(
|
|
|
821
912
|
pi.on("session_start", async (_event, ctx) => {
|
|
822
913
|
currentSessionId = ctx.sessionManager.getSessionId();
|
|
823
914
|
focusedTaskId = null;
|
|
915
|
+
declaredEffort = null;
|
|
824
916
|
finishCompactionUi();
|
|
825
917
|
compactionTelemetry = new CompactionTelemetry();
|
|
826
918
|
contextGrowthTurn = 0;
|
|
@@ -918,7 +1010,13 @@ export function registerJittorExtension(
|
|
|
918
1010
|
});
|
|
919
1011
|
|
|
920
1012
|
pi.on("input", async (event, ctx) => {
|
|
921
|
-
if (event.source !== "extension")
|
|
1013
|
+
if (event.source !== "extension") {
|
|
1014
|
+
cancelRecovery(true);
|
|
1015
|
+
// Auto mode's own effort classifier reads this at the next turn_start -- captured
|
|
1016
|
+
// unconditionally (independent of enforcement.isEnabled()) since Auto mode is a
|
|
1017
|
+
// separate, independently-toggled feature from budget enforcement.
|
|
1018
|
+
pendingUserText = event.text;
|
|
1019
|
+
}
|
|
922
1020
|
if (event.source === "extension" || !enforcement.isEnabled()) return { action: "continue" as const };
|
|
923
1021
|
try {
|
|
924
1022
|
const next = (await client.call("router.decide", { session_id: ctx.sessionManager.getSessionId() })) as PolicyDecision;
|
|
@@ -950,20 +1048,26 @@ export function registerJittorExtension(
|
|
|
950
1048
|
codexRecoveryCapability.resetTurn();
|
|
951
1049
|
providerResponseTelemetry.resetTurn();
|
|
952
1050
|
localRunTelemetry.beginTurn(event.timestamp);
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
client,
|
|
960
|
-
ctx
|
|
961
|
-
(await client.call("router.decide", {
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1051
|
+
// Budget/enforcement routing is safety-critical and always wins on conflict: Auto mode is
|
|
1052
|
+
// only evaluated when budget pressure required no action this turn (action === "continue"),
|
|
1053
|
+
// so the two decision sources never fight over the model mid-turn.
|
|
1054
|
+
let budgetActedThisTurn = false;
|
|
1055
|
+
if (enforcement.isEnabled()) {
|
|
1056
|
+
try {
|
|
1057
|
+
await syncCurrentRoute(pi, client, ctx);
|
|
1058
|
+
await syncAvailableRoutes(pi, client, ctx);
|
|
1059
|
+
const budgetDecision = (await client.call("router.decide", {
|
|
1060
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
1061
|
+
})) as PolicyDecision;
|
|
1062
|
+
budgetActedThisTurn = budgetDecision.action !== "continue";
|
|
1063
|
+
await applyDecision(pi, client, ctx, budgetDecision);
|
|
1064
|
+
await refreshFooter(client, footerState, ctx.sessionManager.getSessionId());
|
|
1065
|
+
} catch {
|
|
1066
|
+
halt(ctx, "Jittor could not verify or apply a safe route");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
966
1069
|
}
|
|
1070
|
+
if (!budgetActedThisTurn) await runAutoModeTurn(ctx).catch(() => undefined);
|
|
967
1071
|
});
|
|
968
1072
|
|
|
969
1073
|
pi.on("message_update", async (event) => {
|
|
@@ -974,6 +1078,10 @@ export function registerJittorExtension(
|
|
|
974
1078
|
localRunTelemetry.onToolExecutionEnd(event.toolName, event.isError);
|
|
975
1079
|
const classification = classifyTaskFromTools([event.toolName]);
|
|
976
1080
|
compactionTelemetry.observeToolClass(`${classification.domain}-${classification.type}`, event.isError);
|
|
1081
|
+
// Bounded accumulation for the effort classifier's tool-call-mix signal -- classifyEffort
|
|
1082
|
+
// itself would slice defensively too, but there's no reason to grow this array unboundedly
|
|
1083
|
+
// across a very long turn in the meantime.
|
|
1084
|
+
if (currentTurnToolNames.length < EFFORT_CLASSIFICATION_MAX_TOOL_NAMES) currentTurnToolNames.push(event.toolName);
|
|
977
1085
|
});
|
|
978
1086
|
|
|
979
1087
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
@@ -991,6 +1099,8 @@ export function registerJittorExtension(
|
|
|
991
1099
|
if (typeof tokens === "number" && Number.isFinite(tokens)) contextGrowth.observe(++contextGrowthTurn, tokens);
|
|
992
1100
|
const metrics = localRunTelemetry.completeTurn(event.message, pi.getThinkingLevel());
|
|
993
1101
|
await recordMetrics(client, metrics).catch(() => undefined);
|
|
1102
|
+
priorTurnToolNames = currentTurnToolNames;
|
|
1103
|
+
currentTurnToolNames = [];
|
|
994
1104
|
});
|
|
995
1105
|
|
|
996
1106
|
pi.on("message_end", async (event, ctx) => {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* shell first opens. Switching to a tab that has never been visited fetches it once, on first
|
|
20
20
|
* visit, not before.
|
|
21
21
|
*/
|
|
22
|
-
import type { ModelCandidate, ModelRankingResult, ModelTaskDomain, ModelTaskType, RouterStatus } from "@danypops/jittor";
|
|
22
|
+
import type { ModelCandidate, ModelRankingResult, ModelTaskDomain, ModelTaskEffort, ModelTaskType, RouterStatus } from "@danypops/jittor";
|
|
23
23
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
25
25
|
import { BorderedSelectPanel, type MnemonicContext, type TabBarTheme, TabbedContainer, type TextMeasure } from "malevich-tui-components";
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
fetchBenchmarkRanking,
|
|
47
47
|
showBenchmarkPanel,
|
|
48
48
|
} from "./optimization/model-selection-panel.ts";
|
|
49
|
-
import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
|
|
49
|
+
import type { AutoModeControl, CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
|
|
50
50
|
import {
|
|
51
51
|
createSettingsPanel,
|
|
52
52
|
runSettingsAction,
|
|
@@ -64,6 +64,7 @@ export interface JittorShellDeps {
|
|
|
64
64
|
enforcement: EnforcementControl;
|
|
65
65
|
recovery: CodexRecoveryControl;
|
|
66
66
|
budgets: UsageBudgetControl;
|
|
67
|
+
autoMode: AutoModeControl;
|
|
67
68
|
effects: SettingsEffects;
|
|
68
69
|
};
|
|
69
70
|
status: { client: JittorPanelClient };
|
|
@@ -73,6 +74,8 @@ export interface JittorShellDeps {
|
|
|
73
74
|
currentIdentity: string;
|
|
74
75
|
domain: ModelTaskDomain;
|
|
75
76
|
type: ModelTaskType;
|
|
77
|
+
effort: ModelTaskEffort;
|
|
78
|
+
currentCandidate: ModelCandidate | null;
|
|
76
79
|
};
|
|
77
80
|
cache: { client: CacheEconomicsPanelClient; windowMs: number; now?: () => number };
|
|
78
81
|
}
|
|
@@ -158,7 +161,7 @@ async function ensureLoaded(
|
|
|
158
161
|
): Promise<void> {
|
|
159
162
|
// Settings reads already-in-memory persisted state -- no daemon round trip, so eagerly keeping
|
|
160
163
|
// it fresh costs nothing and never violates "no network call for a tab never visited".
|
|
161
|
-
state.settings = settingsSnapshot(deps.settings.enforcement, deps.settings.recovery, deps.settings.budgets);
|
|
164
|
+
state.settings = settingsSnapshot(deps.settings.enforcement, deps.settings.recovery, deps.settings.budgets, deps.settings.autoMode);
|
|
162
165
|
if (activeKey === "status" && state.status === undefined) {
|
|
163
166
|
state.status = await fetchStatusSnapshot(deps.status.client, ctx.sessionManager.getSessionId());
|
|
164
167
|
}
|
|
@@ -169,6 +172,8 @@ async function ensureLoaded(
|
|
|
169
172
|
deps.benchmarks.candidates,
|
|
170
173
|
deps.benchmarks.domain,
|
|
171
174
|
deps.benchmarks.type,
|
|
175
|
+
deps.benchmarks.effort,
|
|
176
|
+
deps.benchmarks.currentCandidate,
|
|
172
177
|
);
|
|
173
178
|
}
|
|
174
179
|
if (activeKey === "cache" && state.cache === undefined) {
|
|
@@ -205,7 +210,14 @@ function tabBarTheme(theme: ShellTheme): TabBarTheme {
|
|
|
205
210
|
*/
|
|
206
211
|
async function showNonTuiFallback(ctx: ExtensionCommandContext, deps: JittorShellDeps, initialTab: JittorTabKey): Promise<void> {
|
|
207
212
|
if (initialTab === "settings")
|
|
208
|
-
return showSettingsPanel(
|
|
213
|
+
return showSettingsPanel(
|
|
214
|
+
ctx,
|
|
215
|
+
deps.settings.enforcement,
|
|
216
|
+
deps.settings.recovery,
|
|
217
|
+
deps.settings.budgets,
|
|
218
|
+
deps.settings.autoMode,
|
|
219
|
+
deps.settings.effects,
|
|
220
|
+
);
|
|
209
221
|
if (initialTab === "status") return showJittorPanel(ctx, deps.status.client);
|
|
210
222
|
if (initialTab === "benchmarks")
|
|
211
223
|
return showBenchmarkPanel(
|
|
@@ -215,6 +227,8 @@ async function showNonTuiFallback(ctx: ExtensionCommandContext, deps: JittorShel
|
|
|
215
227
|
deps.benchmarks.currentIdentity,
|
|
216
228
|
deps.benchmarks.domain,
|
|
217
229
|
deps.benchmarks.type,
|
|
230
|
+
deps.benchmarks.effort,
|
|
231
|
+
deps.benchmarks.currentCandidate,
|
|
218
232
|
);
|
|
219
233
|
return showCacheEconomicsPanel(ctx, deps.cache.client, deps.cache.windowMs, deps.cache.now);
|
|
220
234
|
}
|
|
@@ -314,6 +328,7 @@ export async function showJittorShell(
|
|
|
314
328
|
deps.settings.recovery,
|
|
315
329
|
deps.settings.budgets,
|
|
316
330
|
deps.settings.effects,
|
|
331
|
+
deps.settings.autoMode,
|
|
317
332
|
);
|
|
318
333
|
continue;
|
|
319
334
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggest-mode's real interactive surface: a compact one-line Dialog (Switch / Details / Not
|
|
3
|
+
* now) with Details expanding into the existing benchmark panel's full component/confidence/
|
|
4
|
+
* provenance breakdown before the user decides -- both verbosity levels are supported, never
|
|
5
|
+
* just one. Reuses `createBenchmarkPanel` rather than building a second detail renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModelCandidate, ModelRankingResult, ModelTaskEffort } from "@danypops/jittor";
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Dialog, type TextMeasure } from "malevich-tui-components";
|
|
12
|
+
import { candidateIdentityOf } from "./auto-mode.ts";
|
|
13
|
+
import { createBenchmarkPanel } from "./model-selection-panel.ts";
|
|
14
|
+
|
|
15
|
+
export type AutoModeSuggestionChoice = "switch" | "dismiss";
|
|
16
|
+
|
|
17
|
+
interface SuggestionTheme {
|
|
18
|
+
fg(color: string, text: string): string;
|
|
19
|
+
bold(text: string): string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
|
|
23
|
+
|
|
24
|
+
function summaryLine(candidate: ModelCandidate, effort: ModelTaskEffort, utilityDelta: number, confidence: number): string {
|
|
25
|
+
return `Jittor suggests ${candidateIdentityOf(candidate)} for this turn -- effort: ${effort}, +${utilityDelta.toFixed(2)} utility, ${(confidence * 100).toFixed(0)}% confidence.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shows the existing benchmark breakdown as a read-only detail view; any dismissal (Esc, "r", Ctrl+C) returns control to the caller, which re-shows the compact decision dialog. */
|
|
29
|
+
async function showSuggestionDetails(ctx: ExtensionContext, ranking: ModelRankingResult, currentIdentity: string): Promise<void> {
|
|
30
|
+
await ctx.ui.custom<"close">((_tui, theme: SuggestionTheme, _keybindings, done) => {
|
|
31
|
+
const panel = createBenchmarkPanel(ranking, currentIdentity, theme, () => done("close"), true);
|
|
32
|
+
return {
|
|
33
|
+
invalidate: () => panel.invalidate(),
|
|
34
|
+
render: (width: number) => panel.render(width),
|
|
35
|
+
handleInput(data: string): void {
|
|
36
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
37
|
+
done("close");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
panel.handleInput(data);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Non-TUI (`ctx.mode !== "tui"`) mode has no interactive dialog to show, matching every other
|
|
48
|
+
* jittor confirmation's own non-TUI fallback -- it stays a plain notify and the suggestion is
|
|
49
|
+
* treated as dismissed (advisory-only; the user reads it, no automatic action follows).
|
|
50
|
+
*/
|
|
51
|
+
export async function showAutoModeSuggestion(
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
candidate: ModelCandidate,
|
|
54
|
+
effort: ModelTaskEffort,
|
|
55
|
+
utilityDelta: number,
|
|
56
|
+
confidence: number,
|
|
57
|
+
ranking: ModelRankingResult,
|
|
58
|
+
currentIdentity: string,
|
|
59
|
+
verboseByDefault: boolean,
|
|
60
|
+
): Promise<AutoModeSuggestionChoice> {
|
|
61
|
+
const summary = summaryLine(candidate, effort, utilityDelta, confidence);
|
|
62
|
+
if (ctx.mode !== "tui") {
|
|
63
|
+
ctx.ui.notify(`${summary} Run /jittor benchmarks for details.`, "info");
|
|
64
|
+
return "dismiss";
|
|
65
|
+
}
|
|
66
|
+
let showDetails = verboseByDefault;
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (showDetails) {
|
|
69
|
+
await showSuggestionDetails(ctx, ranking, currentIdentity);
|
|
70
|
+
showDetails = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const choice = await ctx.ui.custom<AutoModeSuggestionChoice | "details">((_tui, theme: SuggestionTheme, _keybindings, done) => {
|
|
74
|
+
const dialog = new Dialog({
|
|
75
|
+
title: "Jittor suggestion",
|
|
76
|
+
body: summary,
|
|
77
|
+
actions: [
|
|
78
|
+
{ label: "Switch", key: "s", action: () => done("switch") },
|
|
79
|
+
{ label: "Details", key: "d", action: () => done("details") },
|
|
80
|
+
{ label: "Not now", key: "n", action: () => done("dismiss") },
|
|
81
|
+
],
|
|
82
|
+
theme: {
|
|
83
|
+
border: (text) => theme.fg("borderMuted", text),
|
|
84
|
+
title: theme.bold,
|
|
85
|
+
body: (text) => text,
|
|
86
|
+
dim: (text) => theme.fg("dim", text),
|
|
87
|
+
},
|
|
88
|
+
measure: hostTextMeasure,
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
invalidate: () => dialog.invalidate(),
|
|
92
|
+
render: (width: number) => dialog.render(width),
|
|
93
|
+
handleInput(data: string): void {
|
|
94
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
95
|
+
done("dismiss");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
dialog.handleInput(data);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
if (choice === "details") {
|
|
103
|
+
showDetails = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
return choice ?? "dismiss";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure effort-based Auto mode decision logic -- deliberately separate from the existing
|
|
3
|
+
* budget-pressure `JittorRouter`/`applyDecision` path (optimization/routing), which reacts to
|
|
4
|
+
* provider quota pressure, not task complexity. The two compose at the call site (index.ts's
|
|
5
|
+
* `turn_start` handler): budget/enforcement always takes precedence on conflict, matching the
|
|
6
|
+
* design recorded in Doc "Auto mode: effort-aware model routing -- prior art and design".
|
|
7
|
+
*
|
|
8
|
+
* This module owns only the decision (what should happen this turn, given a fresh ranking
|
|
9
|
+
* result and the anti-nag session state) -- never the side effect itself (showing a dialog,
|
|
10
|
+
* calling `pi.setModel`, persisting a setting). That stays in index.ts, so this logic is fully
|
|
11
|
+
* unit-testable without mocking any Pi/TUI surface.
|
|
12
|
+
*/
|
|
13
|
+
import type { ModelCandidate, ModelRankingResult, ModelTaskEffort } from "@danypops/jittor";
|
|
14
|
+
|
|
15
|
+
export const AUTO_MODE_SETTINGS = ["off", "suggest", "auto-switch"] as const;
|
|
16
|
+
export type AutoModeSetting = (typeof AUTO_MODE_SETTINGS)[number];
|
|
17
|
+
|
|
18
|
+
export interface AutoModeSessionState {
|
|
19
|
+
lastEffort: ModelTaskEffort | null;
|
|
20
|
+
dismissedIdentity: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function newAutoModeSessionState(): AutoModeSessionState {
|
|
24
|
+
return { lastEffort: null, dismissedIdentity: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function candidateIdentityOf(candidate: ModelCandidate): string {
|
|
28
|
+
return `${candidate.provider}/${candidate.model}:${candidate.thinking}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AutoModeDecisionInput {
|
|
32
|
+
mode: AutoModeSetting;
|
|
33
|
+
/** The effort level this turn was classified/declared as -- only used to decide whether to re-evaluate a previously dismissed suggestion, never re-derived here. */
|
|
34
|
+
effort: ModelTaskEffort;
|
|
35
|
+
/** A ranking result already computed with this turn's effort and current model as its own uplift-gate baseline (see rankModelCandidates). */
|
|
36
|
+
ranking: ModelRankingResult;
|
|
37
|
+
state: AutoModeSessionState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AutoModeDecision =
|
|
41
|
+
| { kind: "none" }
|
|
42
|
+
| { kind: "suggest"; candidate: ModelCandidate; utilityDelta: number; confidence: number }
|
|
43
|
+
| { kind: "switch"; candidate: ModelCandidate };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Anti-nag bound: a Suggest-mode dismissal for one specific candidate identity is remembered and
|
|
47
|
+
* not re-offered again until either the detected/declared effort changes, or the recommended
|
|
48
|
+
* candidate itself changes (a stronger, different upgrade is always worth surfacing). The uplift
|
|
49
|
+
* gate inside `rankModelCandidates` itself is the primary anti-spam mechanism -- this only
|
|
50
|
+
* prevents re-nagging about the exact same already-declined suggestion every single turn.
|
|
51
|
+
*/
|
|
52
|
+
export function decideAutoMode(input: AutoModeDecisionInput): AutoModeDecision {
|
|
53
|
+
const { mode, effort, ranking, state } = input;
|
|
54
|
+
const effortChanged = state.lastEffort !== effort;
|
|
55
|
+
state.lastEffort = effort;
|
|
56
|
+
if (mode === "off") return { kind: "none" };
|
|
57
|
+
const recommendation = ranking.recommendation;
|
|
58
|
+
if (!recommendation) {
|
|
59
|
+
state.dismissedIdentity = null;
|
|
60
|
+
return { kind: "none" };
|
|
61
|
+
}
|
|
62
|
+
const identity = candidateIdentityOf(recommendation.candidate);
|
|
63
|
+
if (!effortChanged && state.dismissedIdentity === identity) return { kind: "none" };
|
|
64
|
+
if (mode === "auto-switch") return { kind: "switch", candidate: recommendation.candidate };
|
|
65
|
+
return {
|
|
66
|
+
kind: "suggest",
|
|
67
|
+
candidate: recommendation.candidate,
|
|
68
|
+
utilityDelta: recommendation.utilityDelta,
|
|
69
|
+
confidence: recommendation.confidence,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function recordAutoModeDismissal(state: AutoModeSessionState, candidate: ModelCandidate): void {
|
|
74
|
+
state.dismissedIdentity = candidateIdentityOf(candidate);
|
|
75
|
+
}
|
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
type ModelCandidate,
|
|
10
10
|
type ModelRankingResult,
|
|
11
11
|
type ModelTaskDomain,
|
|
12
|
+
type ModelTaskEffort,
|
|
12
13
|
type ModelTaskType,
|
|
13
14
|
type RankedModel,
|
|
14
15
|
type UtilityComponentName,
|
|
15
16
|
} from "@danypops/jittor";
|
|
16
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
19
|
import { BorderedSelectPanel, Table, type TextMeasure } from "malevich-tui-components";
|
|
19
20
|
import { sessionSecretField } from "../session-identity.ts";
|
|
@@ -130,22 +131,39 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
|
|
|
130
131
|
return createBenchmarkPanel(result, currentIdentity, theme, () => undefined).render(Math.max(1, width));
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the
|
|
136
|
+
* `/scoped-models` command shows) is the exact-session authority once it is configured -- an
|
|
137
|
+
* empty list means "no scoping configured" (matching Pi's own semantics), not "scoped to
|
|
138
|
+
* nothing", and stays advisory. Mirrors the identical predicate `scopedOrAvailableModels` in
|
|
139
|
+
* `index.ts` already uses to build the candidate set itself; this call site independently
|
|
140
|
+
* hardcoded `"available-models"` after that fix landed, which kept automatic selection
|
|
141
|
+
* disabled for a reason (Pi lacking `ctx.scopedModels`) that no longer exists.
|
|
142
|
+
*/
|
|
143
|
+
function scopeAuthorityFor(ctx: ExtensionContext): ModelRankingResult["scopeAuthority"] {
|
|
144
|
+
return ctx.scopedModels.length > 0 ? "exact-session" : "available-models";
|
|
145
|
+
}
|
|
146
|
+
|
|
133
147
|
/** Shared by the standalone benchmark panel below and the unified /jittor shell, so both fetch identically. */
|
|
134
148
|
export async function fetchBenchmarkRanking(
|
|
135
|
-
ctx:
|
|
149
|
+
ctx: ExtensionContext,
|
|
136
150
|
client: BenchmarkPanelClient,
|
|
137
151
|
candidates: ModelCandidate[],
|
|
138
152
|
domain: ModelTaskDomain,
|
|
139
153
|
type: ModelTaskType,
|
|
154
|
+
effort: ModelTaskEffort,
|
|
155
|
+
currentCandidate: ModelCandidate | null,
|
|
140
156
|
): Promise<ModelRankingResult> {
|
|
141
157
|
const session_id = ctx.sessionManager.getSessionId();
|
|
142
158
|
return (await client.call("models.rank", {
|
|
143
159
|
candidates,
|
|
144
160
|
session_id,
|
|
145
161
|
...sessionSecretField(session_id),
|
|
146
|
-
scopeAuthority:
|
|
162
|
+
scopeAuthority: scopeAuthorityFor(ctx),
|
|
147
163
|
domain,
|
|
148
164
|
type,
|
|
165
|
+
effort,
|
|
166
|
+
currentCandidate,
|
|
149
167
|
budgetPressure: 0,
|
|
150
168
|
weights: {
|
|
151
169
|
quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
|
|
@@ -165,9 +183,11 @@ export async function showBenchmarkPanel(
|
|
|
165
183
|
currentIdentity: string,
|
|
166
184
|
domain: ModelTaskDomain,
|
|
167
185
|
type: ModelTaskType,
|
|
186
|
+
effort: ModelTaskEffort,
|
|
187
|
+
currentCandidate: ModelCandidate | null,
|
|
168
188
|
): Promise<void> {
|
|
169
189
|
for (;;) {
|
|
170
|
-
const result = await fetchBenchmarkRanking(ctx, client, candidates, domain, type);
|
|
190
|
+
const result = await fetchBenchmarkRanking(ctx, client, candidates, domain, type, effort, currentCandidate);
|
|
171
191
|
if (ctx.mode !== "tui") {
|
|
172
192
|
ctx.ui.notify(
|
|
173
193
|
renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"),
|
|
@@ -2,7 +2,8 @@ import { USAGE_PERIODS, type UsagePeriod } from "@danypops/jittor";
|
|
|
2
2
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { BorderedSelectPanel, Menu, type MenuTheme, type TextMeasure } from "malevich-tui-components";
|
|
5
|
-
import
|
|
5
|
+
import { AUTO_MODE_SETTINGS, type AutoModeSetting } from "./optimization/auto-mode.ts";
|
|
6
|
+
import type { AutoModeControl, CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
|
|
6
7
|
import { showConfirmDialog } from "./tui-prompts.ts";
|
|
7
8
|
|
|
8
9
|
export interface SettingsSnapshot {
|
|
@@ -10,6 +11,8 @@ export interface SettingsSnapshot {
|
|
|
10
11
|
footerEnabled: boolean;
|
|
11
12
|
codexRecoveryEnabled: boolean;
|
|
12
13
|
usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
|
|
14
|
+
autoMode: AutoModeSetting;
|
|
15
|
+
autoModeVerbose: boolean;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface SettingsTheme {
|
|
@@ -17,26 +20,37 @@ interface SettingsTheme {
|
|
|
17
20
|
bold(text: string): string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
|
|
23
|
+
export type SettingsKey = "enforcement" | "auto-mode" | "auto-mode-verbose" | "footer" | "recovery" | `budget-${UsagePeriod}`;
|
|
21
24
|
export type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
|
|
22
25
|
|
|
23
26
|
export interface SettingsEffects {
|
|
24
27
|
setEnforcement(enabled: boolean): void | Promise<void>;
|
|
25
28
|
setFooter(enabled: boolean): void | Promise<void>;
|
|
26
29
|
setRecovery(enabled: boolean): void | Promise<void>;
|
|
30
|
+
setAutoMode(mode: AutoModeSetting): void | Promise<void>;
|
|
31
|
+
setAutoModeVerbose(verbose: boolean): void | Promise<void>;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
// Enforcement -> Budget -> Providers -> UI: safety posture (the global kill-switch
|
|
30
|
-
// else is downstream of) leads, followed by
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
34
|
+
// Enforcement -> Routing -> Budget -> Providers -> UI: safety posture (the global kill-switch
|
|
35
|
+
// everything else is downstream of) leads, followed by effort-based Auto mode (a routing
|
|
36
|
+
// behavior, not a safety switch, but consequential enough to sit right after Enforcement), then
|
|
37
|
+
// spend limits, then provider-specific quirks (today, only Codex recovery -- grouped under its
|
|
38
|
+
// own header instead of sitting flat next to global switches, which read as "too Codex-oriented"
|
|
39
|
+
// with nothing to signal its narrower scope), then display preferences last.
|
|
40
|
+
const SETTINGS_KEYS: SettingsKey[] = [
|
|
41
|
+
"enforcement",
|
|
42
|
+
"auto-mode",
|
|
43
|
+
"auto-mode-verbose",
|
|
44
|
+
...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const),
|
|
45
|
+
"recovery",
|
|
46
|
+
"footer",
|
|
47
|
+
];
|
|
35
48
|
|
|
36
|
-
type SettingsCategory = "Enforcement" | "Budget" | "Providers" | "UI";
|
|
49
|
+
type SettingsCategory = "Enforcement" | "Routing" | "Budget" | "Providers" | "UI";
|
|
37
50
|
|
|
38
51
|
function categoryOf(key: SettingsKey): SettingsCategory {
|
|
39
52
|
if (key === "enforcement") return "Enforcement";
|
|
53
|
+
if (key === "auto-mode" || key === "auto-mode-verbose") return "Routing";
|
|
40
54
|
if (key === "recovery") return "Providers";
|
|
41
55
|
if (key === "footer") return "UI";
|
|
42
56
|
return "Budget";
|
|
@@ -51,8 +65,16 @@ function budgetLabel(period: UsagePeriod, snapshot: SettingsSnapshot): string {
|
|
|
51
65
|
return value === undefined ? "not configured" : `${value.toLocaleString()} tokens`;
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
function autoModeLabel(mode: AutoModeSetting, theme: SettingsTheme): string {
|
|
69
|
+
if (mode === "off") return theme.fg("muted", "OFF");
|
|
70
|
+
if (mode === "auto-switch") return theme.fg("success", "AUTO-SWITCH");
|
|
71
|
+
return theme.fg("accent", "SUGGEST");
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
function rowText(key: SettingsKey, snapshot: SettingsSnapshot, theme: SettingsTheme): string {
|
|
55
75
|
if (key === "enforcement") return `Routing enforcement ${state(snapshot.enforcementEnabled, theme)}`;
|
|
76
|
+
if (key === "auto-mode") return `Auto mode ${autoModeLabel(snapshot.autoMode, theme)}`;
|
|
77
|
+
if (key === "auto-mode-verbose") return `Suggestion details ${state(snapshot.autoModeVerbose, theme)}`;
|
|
56
78
|
if (key === "footer") return `Informational footer ${state(snapshot.footerEnabled, theme)}`;
|
|
57
79
|
if (key === "recovery") return `Codex recovery ${state(snapshot.codexRecoveryEnabled, theme)}`;
|
|
58
80
|
const period = key.slice("budget-".length) as UsagePeriod;
|
|
@@ -63,12 +85,15 @@ export function settingsSnapshot(
|
|
|
63
85
|
enforcement: EnforcementControl,
|
|
64
86
|
recovery: CodexRecoveryControl,
|
|
65
87
|
budgets: UsageBudgetControl,
|
|
88
|
+
autoMode: AutoModeControl,
|
|
66
89
|
): SettingsSnapshot {
|
|
67
90
|
return {
|
|
68
91
|
enforcementEnabled: enforcement.isEnabled(),
|
|
69
92
|
footerEnabled: enforcement.isFooterEnabled(),
|
|
70
93
|
codexRecoveryEnabled: recovery.isCodexRecoveryEnabled(),
|
|
71
94
|
usageTokenBudgets: Object.fromEntries(USAGE_PERIODS.map(({ id }) => [id, budgets.getUsageTokenBudget(id)])),
|
|
95
|
+
autoMode: autoMode.getAutoMode(),
|
|
96
|
+
autoModeVerbose: autoMode.isAutoModeVerbose(),
|
|
72
97
|
};
|
|
73
98
|
}
|
|
74
99
|
|
|
@@ -201,8 +226,31 @@ export async function runSettingsAction(
|
|
|
201
226
|
recovery: CodexRecoveryControl,
|
|
202
227
|
budgets: UsageBudgetControl,
|
|
203
228
|
effects: SettingsEffects,
|
|
229
|
+
autoMode: AutoModeControl,
|
|
204
230
|
): Promise<void> {
|
|
205
231
|
if (action.kind === "close") return;
|
|
232
|
+
if (action.key === "auto-mode") {
|
|
233
|
+
const current = autoMode.getAutoMode();
|
|
234
|
+
const next = AUTO_MODE_SETTINGS[(AUTO_MODE_SETTINGS.indexOf(current) + 1) % AUTO_MODE_SETTINGS.length]!;
|
|
235
|
+
// Entering the highest-autonomy state gets the same explicit confirmation Codex recovery's
|
|
236
|
+
// own opt-in already gets; leaving it (like disabling enforcement) needs none -- becoming
|
|
237
|
+
// more conservative is never something to gate behind a confirmation.
|
|
238
|
+
if (next === "auto-switch") {
|
|
239
|
+
if (
|
|
240
|
+
await showConfirmDialog(
|
|
241
|
+
ctx,
|
|
242
|
+
"Enable Auto-switch?",
|
|
243
|
+
"Jittor may switch your active model on its own when a turn's effort clearly calls for a different one, with no confirmation prompt.",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
await effects.setAutoMode(next);
|
|
247
|
+
} else await effects.setAutoMode(next);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (action.key === "auto-mode-verbose") {
|
|
251
|
+
await effects.setAutoModeVerbose(!autoMode.isAutoModeVerbose());
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
206
254
|
if (action.key === "enforcement") {
|
|
207
255
|
if (enforcement.isEnabled()) {
|
|
208
256
|
if (
|
|
@@ -241,19 +289,22 @@ export async function showSettingsPanel(
|
|
|
241
289
|
enforcement: EnforcementControl,
|
|
242
290
|
recovery: CodexRecoveryControl,
|
|
243
291
|
budgets: UsageBudgetControl,
|
|
292
|
+
autoMode: AutoModeControl,
|
|
244
293
|
effects: SettingsEffects = {
|
|
245
294
|
setEnforcement: (enabled) => enforcement.setEnabled(enabled),
|
|
246
295
|
setFooter: (enabled) => enforcement.setFooterEnabled(enabled),
|
|
247
296
|
setRecovery: (enabled) => recovery.setCodexRecoveryEnabled(enabled),
|
|
297
|
+
setAutoMode: (mode) => autoMode.setAutoMode(mode),
|
|
298
|
+
setAutoModeVerbose: (verbose) => autoMode.setAutoModeVerbose(verbose),
|
|
248
299
|
},
|
|
249
300
|
): Promise<void> {
|
|
250
301
|
if (ctx.mode !== "tui") {
|
|
251
|
-
const snapshot = settingsSnapshot(enforcement, recovery, budgets);
|
|
302
|
+
const snapshot = settingsSnapshot(enforcement, recovery, budgets, autoMode);
|
|
252
303
|
ctx.ui.notify(["Jittor Settings", ...SETTINGS_KEYS.map((key) => rowText(key, snapshot, plainTheme()))].join("\n"), "info");
|
|
253
304
|
return;
|
|
254
305
|
}
|
|
255
306
|
for (;;) {
|
|
256
|
-
const snapshot = settingsSnapshot(enforcement, recovery, budgets);
|
|
307
|
+
const snapshot = settingsSnapshot(enforcement, recovery, budgets, autoMode);
|
|
257
308
|
const action = await ctx.ui.custom<SettingsAction>((tui, theme, _keybindings, done) => {
|
|
258
309
|
const panel = createSettingsPanel(snapshot, theme, done);
|
|
259
310
|
return {
|
|
@@ -266,6 +317,6 @@ export async function showSettingsPanel(
|
|
|
266
317
|
};
|
|
267
318
|
});
|
|
268
319
|
if (!action || action.kind === "close") return;
|
|
269
|
-
await runSettingsAction(ctx, action, enforcement, recovery, budgets, effects);
|
|
320
|
+
await runSettingsAction(ctx, action, enforcement, recovery, budgets, effects, autoMode);
|
|
270
321
|
}
|
|
271
322
|
}
|
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { JITTOR_EXTENSION_SETTINGS_FILENAME, JITTOR_STATE_DIRECTORY, USAGE_PERIODS, type UsagePeriod } from "@danypops/jittor";
|
|
5
5
|
import { createAtomicJsonWriter } from "@danypops/vehicle-core";
|
|
6
6
|
import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
|
|
7
|
+
import { AUTO_MODE_SETTINGS, type AutoModeSetting } from "./optimization/auto-mode.ts";
|
|
7
8
|
|
|
8
9
|
const atomicJson = createAtomicJsonWriter({ fs: createNodeAtomicJsonFsAdapter() });
|
|
9
10
|
|
|
@@ -30,17 +31,35 @@ export interface UsageBudgetControl {
|
|
|
30
31
|
setUsageTokenBudget(period: UsagePeriod, tokens: number | undefined): void | Promise<void>;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export interface
|
|
34
|
+
export interface AutoModeControl {
|
|
35
|
+
getAutoMode(): AutoModeSetting;
|
|
36
|
+
setAutoMode(mode: AutoModeSetting): void | Promise<void>;
|
|
37
|
+
/** Whether a Suggest-mode dialog opens straight to the full benchmark breakdown instead of requiring the "Details" keypress. Purely a display preference -- never changes what action is taken. */
|
|
38
|
+
isAutoModeVerbose(): boolean;
|
|
39
|
+
setAutoModeVerbose(verbose: boolean): void | Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl, UsageBudgetControl, AutoModeControl {}
|
|
34
43
|
|
|
35
44
|
interface ExtensionSettings {
|
|
36
45
|
enforcementEnabled: boolean;
|
|
37
46
|
footerEnabled: boolean;
|
|
38
47
|
codexRecoveryEnabled: boolean;
|
|
39
48
|
usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
|
|
49
|
+
/** Suggest, not Auto-switch, is the recorded default -- Auto-switch (an active mutation) is fully available from day one but never silently pre-selected, per this project's shadow-mode-first governance for optimization interventions. */
|
|
50
|
+
autoMode: AutoModeSetting;
|
|
51
|
+
autoModeVerbose: boolean;
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
function defaultSettings(): ExtensionSettings {
|
|
43
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
enforcementEnabled: true,
|
|
57
|
+
footerEnabled: true,
|
|
58
|
+
codexRecoveryEnabled: false,
|
|
59
|
+
usageTokenBudgets: {},
|
|
60
|
+
autoMode: "suggest",
|
|
61
|
+
autoModeVerbose: false,
|
|
62
|
+
};
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
function parseUsageTokenBudgets(value: unknown): Partial<Record<UsagePeriod, number>> {
|
|
@@ -69,6 +88,8 @@ function loadSettings(path: string): ExtensionSettings {
|
|
|
69
88
|
footerEnabled: record.footerEnabled !== false,
|
|
70
89
|
codexRecoveryEnabled: record.codexRecoveryEnabled === true,
|
|
71
90
|
usageTokenBudgets: parseUsageTokenBudgets(record.usageTokenBudgets),
|
|
91
|
+
autoMode: AUTO_MODE_SETTINGS.includes(record.autoMode as AutoModeSetting) ? (record.autoMode as AutoModeSetting) : "suggest",
|
|
92
|
+
autoModeVerbose: record.autoModeVerbose === true,
|
|
72
93
|
};
|
|
73
94
|
} catch {
|
|
74
95
|
return defaultSettings();
|
|
@@ -109,5 +130,16 @@ export function persistentEnforcementControl(env: Record<string, string | undefi
|
|
|
109
130
|
else settings.usageTokenBudgets[period] = tokens;
|
|
110
131
|
await persistSettings(path, settings);
|
|
111
132
|
},
|
|
133
|
+
getAutoMode: () => settings.autoMode,
|
|
134
|
+
async setAutoMode(mode): Promise<void> {
|
|
135
|
+
if (!AUTO_MODE_SETTINGS.includes(mode)) throw new Error("auto mode must be one of off, suggest, auto-switch");
|
|
136
|
+
settings.autoMode = mode;
|
|
137
|
+
await persistSettings(path, settings);
|
|
138
|
+
},
|
|
139
|
+
isAutoModeVerbose: () => settings.autoModeVerbose,
|
|
140
|
+
async setAutoModeVerbose(verbose): Promise<void> {
|
|
141
|
+
settings.autoModeVerbose = verbose;
|
|
142
|
+
await persistSettings(path, settings);
|
|
143
|
+
},
|
|
112
144
|
};
|
|
113
145
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-jittor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi extension for Jittor token and context observability with optimization controls backed by the @danypops/jittor daemon",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"keywords": ["pi-package", "llm-router", "token-budget"],
|
|
7
8
|
"scripts": {
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
"@danypops/vehicle-client": "^0.10.1",
|
|
16
17
|
"@danypops/vehicle-core": "^0.17.1",
|
|
17
18
|
"@danypops/vehicle-server": "^0.25.1",
|
|
18
|
-
"@danypops/jittor": "^0.
|
|
19
|
+
"@danypops/jittor": "^0.20.0",
|
|
19
20
|
"malevich-tui-components": "^0.28.0"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|