@danypops/jittor 0.3.0 → 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.
- package/README.md +13 -1
- package/extension/src/index.ts +79 -13
- package/extension/src/settings-tui.ts +153 -0
- package/package.json +1 -1
- package/src/cli.ts +86 -14
- package/src/constants.ts +7 -0
- package/src/domain/context-telemetry.ts +280 -0
- package/src/service.ts +18 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The initial service scaffold is split into domain, ports, and adapters:
|
|
|
31
31
|
|
|
32
32
|
SQLite runs in WAL mode with versioned migrations, JSON validation, bounded queries, chronological indexes, pruning, and checkpoints. The database follows `XDG_DATA_HOME`; private authentication state follows `XDG_STATE_HOME`; the daemon handle follows `XDG_RUNTIME_DIR`.
|
|
33
33
|
|
|
34
|
-
Operations currently include `metrics.record`, `metrics.query`, `metrics.prune`, and `service.checkpoint`.
|
|
34
|
+
Operations currently include `metrics.record`, `metrics.query`, `metrics.prune`, `context.assess`, and `service.checkpoint`.
|
|
35
35
|
|
|
36
36
|
Provider adapters currently include official OpenRouter key/usage/model telemetry and an explicitly experimental Codex subscription adapter. The Codex adapter follows the pinned open-source CLI `/wham/usage` payload and `x-codex-*` response-header contracts, accepts additional metered limits, and fails closed on malformed windows or impossible percentages. File credentials must be explicitly configured and private (`0600`); Jittor reads only the access token and account ID, never refreshes credentials, and never logs or persists OAuth secrets.
|
|
37
37
|
|
|
@@ -54,10 +54,22 @@ The on/off choice persists privately in `$XDG_CONFIG_HOME/jittor/extension.json`
|
|
|
54
54
|
|
|
55
55
|
Jittor 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.
|
|
56
56
|
|
|
57
|
+
### Settings
|
|
58
|
+
|
|
59
|
+
Run `/jittor settings` for one keyboard-navigable TUI covering routing enforcement, the informational footer, Codex recovery, and all four token-budget thresholds. It uses explicit ON/OFF and configured/not-configured labels, remains bounded on narrow terminals, confirms weaker enforcement and recovery changes, and applies enforcement enablement through the same readiness checks as `/jittor on`. Existing non-TUI commands remain available for automation.
|
|
60
|
+
|
|
61
|
+
### Usage graphs
|
|
62
|
+
|
|
57
63
|
Run `/jittor usage` for a colored Unicode cumulative token graph with X/Y axes, provider/model series, input/output/cache totals, refresh, and explicit **Hourly**, **Daily**, **Weekly**, and **Monthly** periods. Left/Right changes period and `r` refreshes. Usage is persisted by the daemon from finalized Pi assistant messages.
|
|
58
64
|
|
|
59
65
|
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 `/jittor usage budget <hourly|daily|weekly|monthly> <positive-tokens|off>`, and inspect all four with `/jittor usage budget`. A configured budget appears as a horizontal threshold on the cumulative graph with explicit remaining or **OVER BUDGET** state. These private settings persist in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`).
|
|
60
66
|
|
|
67
|
+
### Context pressure
|
|
68
|
+
|
|
69
|
+
Papyrus emits content-free prompt-injection observations through Pi's shared extension event bus. Jittor validates and records their exact Rule/Task character sizes, prompt share, fingerprint repetition, and explicitly estimated token size. Jittor also records completed, aborted, and unmatched Pi compactions with duration, reason, retry state, pre-compaction context usage, and bounded turns/injection/provider/cache usage since the previous compaction.
|
|
70
|
+
|
|
71
|
+
Run `/jittor context` for the in-session summary, or `jittor context [--since <epoch-ms>] [--until <epoch-ms>] [--json]` through the authenticated daemon client. The assessment reports bounded average/p95/max injection, Rule/Task mix, unchanged rate, compaction frequency/duration/reasons, and between-compaction provider/cache facts. Repeated prompt content is not labeled billed waste: provider-reported input/cache usage and an injection-disabled control are required before making cost or compaction-causality claims.
|
|
72
|
+
|
|
61
73
|
See [`docs/CALIBRATION.md`](docs/CALIBRATION.md) for thresholds and rollback, and [`docs/USAGE_PRIOR_ART.md`](docs/USAGE_PRIOR_ART.md) for the chart design research.
|
|
62
74
|
|
|
63
75
|
```bash
|
package/extension/src/index.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
MAX_DYNAMIC_ROUTES,
|
|
10
10
|
MILLISECONDS_PER_MINUTE,
|
|
11
11
|
MILLISECONDS_PER_SECOND,
|
|
12
|
+
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
13
|
+
CONTEXT_EVENT_DEDUP_LIMIT,
|
|
12
14
|
} from "../../src/constants.ts";
|
|
13
15
|
import { CodexRecoveryPolicy, classifyCodexFailure, type CodexFailureKind, type CodexFailureMetadata } from "../../src/domain/codex-recovery.ts";
|
|
16
|
+
import { CompactionTelemetry, papyrusContextMetric, validatePapyrusContextInjection } from "../../src/domain/context-telemetry.ts";
|
|
14
17
|
import type { MetricObservation, StoredMetricObservation } from "../../src/domain/metric.ts";
|
|
15
18
|
import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
|
|
16
19
|
import type { PolicyDecision, Route } from "../../src/policy.ts";
|
|
@@ -19,6 +22,7 @@ import { parseCodexRateLimitHeaders } from "../../src/providers/codex.ts";
|
|
|
19
22
|
import { installIntegratedFooter, type IntegratedFooterState } from "./footer.ts";
|
|
20
23
|
import { callJittor } from "./service-client.ts";
|
|
21
24
|
import { persistentEnforcementControl, type CodexRecoveryControl, type EnforcementControl, type UsageBudgetControl } from "./settings.ts";
|
|
25
|
+
import { showSettingsPanel } from "./settings-tui.ts";
|
|
22
26
|
import { buildFooterBudget, formatFooterStatus, showJittorPanel } from "./tui.ts";
|
|
23
27
|
import { showUsagePanel } from "./usage.ts";
|
|
24
28
|
|
|
@@ -226,6 +230,21 @@ export function registerJittorExtension(
|
|
|
226
230
|
): void {
|
|
227
231
|
const footerState: IntegratedFooterState = { providerBudget: null };
|
|
228
232
|
const usageBudgets = usageBudgetControl(enforcement);
|
|
233
|
+
let compactionTelemetry = new CompactionTelemetry();
|
|
234
|
+
const contextObservations = new Set<string>();
|
|
235
|
+
const stopPapyrusContext = pi.events?.on?.(PAPYRUS_CONTEXT_INJECTION_CHANNEL, (payload) => {
|
|
236
|
+
try {
|
|
237
|
+
const observation = validatePapyrusContextInjection(payload);
|
|
238
|
+
const observationKey = `${observation.producerId}:${observation.sequence}`;
|
|
239
|
+
if (contextObservations.has(observationKey)) return;
|
|
240
|
+
contextObservations.add(observationKey);
|
|
241
|
+
if (contextObservations.size > CONTEXT_EVENT_DEDUP_LIMIT) contextObservations.delete(contextObservations.values().next().value!);
|
|
242
|
+
compactionTelemetry.observeInjection(observation.injected.characters, observation.estimatedTokens);
|
|
243
|
+
void recordMetrics(client, [papyrusContextMetric(observation)]).catch(() => undefined);
|
|
244
|
+
} catch {
|
|
245
|
+
// Reject malformed or stale cross-extension observations without retaining payloads.
|
|
246
|
+
}
|
|
247
|
+
});
|
|
229
248
|
const recoveryPolicy = new CodexRecoveryPolicy({
|
|
230
249
|
baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
|
|
231
250
|
maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
|
|
@@ -286,22 +305,22 @@ export function registerJittorExtension(
|
|
|
286
305
|
}, plan.delayMs);
|
|
287
306
|
};
|
|
288
307
|
let compactionTimer: ReturnType<typeof setInterval> | undefined;
|
|
289
|
-
const
|
|
308
|
+
const finishCompactionUi = (): void => {
|
|
290
309
|
if (compactionTimer) clearInterval(compactionTimer);
|
|
291
310
|
compactionTimer = undefined;
|
|
292
311
|
footerState.compaction = undefined;
|
|
293
312
|
footerState.requestRender?.();
|
|
294
313
|
};
|
|
295
|
-
const
|
|
296
|
-
|
|
314
|
+
const beginCompactionUi = (ctx: ExtensionContext, signal: AbortSignal): void => {
|
|
315
|
+
finishCompactionUi();
|
|
297
316
|
const usage = ctx.getContextUsage();
|
|
298
317
|
footerState.compaction = {
|
|
299
318
|
startedAt: Date.now(),
|
|
300
319
|
initialFraction: usage?.percent === null || usage?.percent === undefined ? 1 : usage.percent / 100,
|
|
301
320
|
};
|
|
302
321
|
compactionTimer = setInterval(() => footerState.requestRender?.(), FOOTER_COMPACTION_RENDER_INTERVAL_MS);
|
|
303
|
-
signal.addEventListener("abort",
|
|
304
|
-
if (signal.aborted)
|
|
322
|
+
signal.addEventListener("abort", finishCompactionUi, { once: true });
|
|
323
|
+
if (signal.aborted) finishCompactionUi();
|
|
305
324
|
else footerState.requestRender?.();
|
|
306
325
|
};
|
|
307
326
|
const showFooter = (ctx: ExtensionContext): void => {
|
|
@@ -372,6 +391,33 @@ export function registerJittorExtension(
|
|
|
372
391
|
ctx.ui.notify("Jittor informational footer enabled; routing enforcement is unchanged.", "info");
|
|
373
392
|
return;
|
|
374
393
|
}
|
|
394
|
+
if (action === "context") {
|
|
395
|
+
const summary = await client.call("context.assess", {}) as import("../../src/domain/context-telemetry.ts").ContextAssessment;
|
|
396
|
+
const average = summary.injection.averageCharacters === null ? "unknown" : Math.round(summary.injection.averageCharacters).toLocaleString();
|
|
397
|
+
const p95 = summary.injection.p95Characters === null ? "unknown" : Math.round(summary.injection.p95Characters).toLocaleString();
|
|
398
|
+
ctx.ui.notify([
|
|
399
|
+
`Papyrus injection: ${summary.injection.runs} runs · avg ${average} chars · p95 ${p95} chars · unchanged ${summary.injection.unchangedRate === null ? "unknown" : `${(summary.injection.unchangedRate * 100).toFixed(1)}%`}`,
|
|
400
|
+
`Mix: rules ${summary.injection.ruleCharacters.toLocaleString()} chars · tasks ${summary.injection.taskCharacters.toLocaleString()} chars · estimated ${summary.injection.estimatedTokens.toLocaleString()} tokens`,
|
|
401
|
+
`Compactions: ${summary.compaction.completed} completed · ${summary.compaction.aborted} aborted · ${summary.compaction.perRun === null ? "unknown" : summary.compaction.perRun.toFixed(3)} per agent run · ${summary.compaction.perTurn === null ? "unknown" : summary.compaction.perTurn.toFixed(3)} per turn`,
|
|
402
|
+
`Completeness: ${summary.completeness}`,
|
|
403
|
+
].join("\n"), "info");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (action === "settings") {
|
|
407
|
+
await showSettingsPanel(ctx, enforcement, codexRecovery, usageBudgets, {
|
|
408
|
+
setEnforcement: async (enabled) => enabled ? enable(ctx) : disable(ctx),
|
|
409
|
+
setFooter: async (enabled) => {
|
|
410
|
+
enforcement.setFooterEnabled(enabled);
|
|
411
|
+
showFooter(ctx);
|
|
412
|
+
if (enabled) await refreshFooter(client, footerState).catch(() => undefined);
|
|
413
|
+
},
|
|
414
|
+
setRecovery: (enabled) => {
|
|
415
|
+
if (!enabled) cancelRecovery(true);
|
|
416
|
+
codexRecovery.setCodexRecoveryEnabled(enabled);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
375
421
|
if (action === "usage budget" || action.startsWith("usage budget ")) {
|
|
376
422
|
const [, , periodText, valueText] = action.split(/\s+/);
|
|
377
423
|
const period = USAGE_PERIODS.some((candidate) => candidate.id === periodText) ? periodText as UsagePeriod : undefined;
|
|
@@ -411,7 +457,8 @@ export function registerJittorExtension(
|
|
|
411
457
|
});
|
|
412
458
|
|
|
413
459
|
pi.on("session_start", async (_event, ctx) => {
|
|
414
|
-
|
|
460
|
+
finishCompactionUi();
|
|
461
|
+
compactionTelemetry = new CompactionTelemetry();
|
|
415
462
|
cancelRecovery(true);
|
|
416
463
|
lastCodexResponse = {};
|
|
417
464
|
ctx.ui.setStatus("jittor", undefined);
|
|
@@ -427,16 +474,28 @@ export function registerJittorExtension(
|
|
|
427
474
|
}
|
|
428
475
|
});
|
|
429
476
|
|
|
430
|
-
pi.on("session_before_compact", (event, ctx) => {
|
|
431
|
-
|
|
477
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
478
|
+
beginCompactionUi(ctx, event.signal);
|
|
479
|
+
const usage = ctx.getContextUsage();
|
|
480
|
+
const metric = compactionTelemetry.begin({
|
|
481
|
+
reason: event.reason,
|
|
482
|
+
willRetry: event.willRetry,
|
|
483
|
+
...(usage?.percent === null || usage?.percent === undefined ? {} : { contextPercent: usage.percent }),
|
|
484
|
+
...(usage?.tokens === null || usage?.tokens === undefined ? {} : { contextTokens: usage.tokens }),
|
|
485
|
+
});
|
|
486
|
+
await recordMetrics(client, [metric]).catch(() => undefined);
|
|
432
487
|
});
|
|
433
488
|
|
|
434
|
-
pi.on("session_compact", () => {
|
|
435
|
-
|
|
489
|
+
pi.on("session_compact", async (event) => {
|
|
490
|
+
finishCompactionUi();
|
|
491
|
+
await recordMetrics(client, [compactionTelemetry.complete({ reason: event.reason, willRetry: event.willRetry })]).catch(() => undefined);
|
|
436
492
|
});
|
|
437
493
|
|
|
438
494
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
439
|
-
if (footerState.compaction)
|
|
495
|
+
if (footerState.compaction) {
|
|
496
|
+
finishCompactionUi();
|
|
497
|
+
if (compactionTelemetry.hasOpenCompaction()) await recordMetrics(client, [compactionTelemetry.abort(Date.now(), "agent-settled-without-completion")]).catch(() => undefined);
|
|
498
|
+
}
|
|
440
499
|
scheduleCodexRecovery(ctx);
|
|
441
500
|
if (!enforcement.isFooterEnabled()) return;
|
|
442
501
|
try {
|
|
@@ -475,6 +534,7 @@ export function registerJittorExtension(
|
|
|
475
534
|
});
|
|
476
535
|
|
|
477
536
|
pi.on("turn_start", async (_event, ctx) => {
|
|
537
|
+
compactionTelemetry.observeTurn();
|
|
478
538
|
lastCodexResponse = {};
|
|
479
539
|
if (!enforcement.isEnabled()) return;
|
|
480
540
|
try {
|
|
@@ -513,12 +573,18 @@ export function registerJittorExtension(
|
|
|
513
573
|
lastCodexResponse = {};
|
|
514
574
|
}
|
|
515
575
|
const metrics = assistantUsageMetrics(event.message, Date.now());
|
|
516
|
-
if (metrics.length > 0)
|
|
576
|
+
if (metrics.length > 0) {
|
|
577
|
+
const amount = (name: string): number => metrics.filter((metric) => metric.metric === name && typeof metric.value === "number").reduce((sum, metric) => sum + (metric.value ?? 0), 0);
|
|
578
|
+
compactionTelemetry.observeProviderUsage({ input: amount("input-tokens"), output: amount("output-tokens"), cacheRead: amount("cache-read-tokens"), cacheWrite: amount("cache-write-tokens") });
|
|
579
|
+
await recordMetrics(client, metrics).catch(() => undefined);
|
|
580
|
+
}
|
|
517
581
|
if (enforcement.isFooterEnabled()) await refreshFooter(client, footerState).catch(() => undefined);
|
|
518
582
|
});
|
|
519
583
|
|
|
520
584
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
521
|
-
|
|
585
|
+
finishCompactionUi();
|
|
586
|
+
if (compactionTelemetry.hasOpenCompaction()) await recordMetrics(client, [compactionTelemetry.abort(Date.now(), "session-shutdown")]).catch(() => undefined);
|
|
587
|
+
stopPapyrusContext?.();
|
|
522
588
|
cancelRecovery(true);
|
|
523
589
|
lastCodexResponse = {};
|
|
524
590
|
ctx.ui.setStatus("jittor", undefined);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
|
|
4
|
+
import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
|
|
5
|
+
|
|
6
|
+
export interface SettingsSnapshot {
|
|
7
|
+
enforcementEnabled: boolean;
|
|
8
|
+
footerEnabled: boolean;
|
|
9
|
+
codexRecoveryEnabled: boolean;
|
|
10
|
+
usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SettingsTheme {
|
|
14
|
+
fg(color: "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted", text: string): string;
|
|
15
|
+
bold(text: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
|
|
19
|
+
type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
|
|
20
|
+
|
|
21
|
+
export interface SettingsEffects {
|
|
22
|
+
setEnforcement(enabled: boolean): void | Promise<void>;
|
|
23
|
+
setFooter(enabled: boolean): void | Promise<void>;
|
|
24
|
+
setRecovery(enabled: boolean): void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SETTINGS_KEYS: SettingsKey[] = [
|
|
28
|
+
"enforcement",
|
|
29
|
+
"footer",
|
|
30
|
+
"recovery",
|
|
31
|
+
...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function state(enabled: boolean, theme: SettingsTheme): string {
|
|
35
|
+
return enabled ? theme.fg("success", "ON") : theme.fg("muted", "OFF");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function budgetLabel(period: UsagePeriod, snapshot: SettingsSnapshot): string {
|
|
39
|
+
const value = snapshot.usageTokenBudgets[period];
|
|
40
|
+
return value === undefined ? "not configured" : `${value.toLocaleString()} tokens`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rowText(key: SettingsKey, snapshot: SettingsSnapshot, theme: SettingsTheme): string {
|
|
44
|
+
if (key === "enforcement") return `Routing enforcement ${state(snapshot.enforcementEnabled, theme)}`;
|
|
45
|
+
if (key === "footer") return `Informational footer ${state(snapshot.footerEnabled, theme)}`;
|
|
46
|
+
if (key === "recovery") return `Codex recovery ${state(snapshot.codexRecoveryEnabled, theme)}`;
|
|
47
|
+
const period = key.slice("budget-".length) as UsagePeriod;
|
|
48
|
+
return `${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget ${budgetLabel(period, snapshot)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function settingsSnapshot(
|
|
52
|
+
enforcement: EnforcementControl,
|
|
53
|
+
recovery: CodexRecoveryControl,
|
|
54
|
+
budgets: UsageBudgetControl,
|
|
55
|
+
): SettingsSnapshot {
|
|
56
|
+
return {
|
|
57
|
+
enforcementEnabled: enforcement.isEnabled(),
|
|
58
|
+
footerEnabled: enforcement.isFooterEnabled(),
|
|
59
|
+
codexRecoveryEnabled: recovery.isCodexRecoveryEnabled(),
|
|
60
|
+
usageTokenBudgets: Object.fromEntries(USAGE_PERIODS.map(({ id }) => [id, budgets.getUsageTokenBudget(id)])),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderSettingsView(snapshot: SettingsSnapshot, selected: number, width: number, theme: SettingsTheme): string[] {
|
|
65
|
+
const safeWidth = Math.max(20, width);
|
|
66
|
+
const lines = [
|
|
67
|
+
theme.bold("Jittor Settings"),
|
|
68
|
+
theme.fg("dim", "Token budgets are user values; provider quotas remain separate."),
|
|
69
|
+
"",
|
|
70
|
+
];
|
|
71
|
+
for (let index = 0; index < SETTINGS_KEYS.length; index += 1) {
|
|
72
|
+
const selectedRow = index === selected;
|
|
73
|
+
const prefix = selectedRow ? theme.fg("accent", "› ") : " ";
|
|
74
|
+
lines.push(`${prefix}${rowText(SETTINGS_KEYS[index]!, snapshot, theme)}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("", theme.fg("dim", "↑/↓ select · Enter edit · Esc close"));
|
|
77
|
+
return lines.map((line) => visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function plainTheme(): SettingsTheme {
|
|
81
|
+
return { fg: (_color, text) => text, bold: (text) => text };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function editBudget(ctx: ExtensionCommandContext, budgets: UsageBudgetControl, period: UsagePeriod): Promise<void> {
|
|
85
|
+
const label = USAGE_PERIODS.find((candidate) => candidate.id === period)!.label;
|
|
86
|
+
const current = budgets.getUsageTokenBudget(period);
|
|
87
|
+
const input = await ctx.ui.input(`${label} token budget`, current?.toLocaleString() ?? "positive token count or off");
|
|
88
|
+
if (input === undefined) return;
|
|
89
|
+
const normalized = input.trim().toLowerCase();
|
|
90
|
+
if (normalized === "off" || normalized === "clear") {
|
|
91
|
+
budgets.setUsageTokenBudget(period, undefined);
|
|
92
|
+
ctx.ui.notify(`${label} token budget cleared.`, "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const tokens = Number(normalized.replaceAll(",", ""));
|
|
96
|
+
if (!Number.isFinite(tokens) || tokens <= 0) {
|
|
97
|
+
ctx.ui.notify("Enter a positive token count, or `off` to clear this threshold.", "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
budgets.setUsageTokenBudget(period, tokens);
|
|
101
|
+
ctx.ui.notify(`${label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function showSettingsPanel(
|
|
105
|
+
ctx: ExtensionCommandContext,
|
|
106
|
+
enforcement: EnforcementControl,
|
|
107
|
+
recovery: CodexRecoveryControl,
|
|
108
|
+
budgets: UsageBudgetControl,
|
|
109
|
+
effects: SettingsEffects = {
|
|
110
|
+
setEnforcement: (enabled) => enforcement.setEnabled(enabled),
|
|
111
|
+
setFooter: (enabled) => enforcement.setFooterEnabled(enabled),
|
|
112
|
+
setRecovery: (enabled) => recovery.setCodexRecoveryEnabled(enabled),
|
|
113
|
+
},
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (ctx.mode !== "tui") {
|
|
116
|
+
ctx.ui.notify(renderSettingsView(settingsSnapshot(enforcement, recovery, budgets), -1, 100, plainTheme()).slice(0, -2).join("\n"), "info");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (;;) {
|
|
120
|
+
const snapshot = settingsSnapshot(enforcement, recovery, budgets);
|
|
121
|
+
const action = await ctx.ui.custom<SettingsAction>((tui, theme, _keybindings, done) => {
|
|
122
|
+
let selected = 0;
|
|
123
|
+
return {
|
|
124
|
+
invalidate() {},
|
|
125
|
+
render(width: number): string[] { return renderSettingsView(snapshot, selected, width, theme); },
|
|
126
|
+
handleInput(data: string): void {
|
|
127
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done({ kind: "close" });
|
|
128
|
+
else if (matchesKey(data, "up")) { selected = (selected - 1 + SETTINGS_KEYS.length) % SETTINGS_KEYS.length; tui.requestRender(); }
|
|
129
|
+
else if (matchesKey(data, "down")) { selected = (selected + 1) % SETTINGS_KEYS.length; tui.requestRender(); }
|
|
130
|
+
else if (matchesKey(data, "return") || matchesKey(data, "enter") || matchesKey(data, "space")) done({ kind: "activate", key: SETTINGS_KEYS[selected]! });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
if (!action || action.kind === "close") return;
|
|
135
|
+
if (action.key === "enforcement") {
|
|
136
|
+
if (enforcement.isEnabled()) {
|
|
137
|
+
if (await ctx.ui.confirm("Disable routing enforcement?", "Jittor will remain monitor-only and will no longer block unsafe provider requests.")) await effects.setEnforcement(false);
|
|
138
|
+
} else await effects.setEnforcement(true);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (action.key === "footer") {
|
|
142
|
+
await effects.setFooter(!enforcement.isFooterEnabled());
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (action.key === "recovery") {
|
|
146
|
+
if (!recovery.isCodexRecoveryEnabled()) {
|
|
147
|
+
if (await ctx.ui.confirm("Enable Codex recovery?", "Jittor may start bounded hidden retries only after transient Codex failures fully settle.")) await effects.setRecovery(true);
|
|
148
|
+
} else await effects.setRecovery(false);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
await editBudget(ctx, budgets, action.key.slice("budget-".length) as UsagePeriod);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { SYSTEMD_UNIT_NAME } from "./constants.ts";
|
|
8
|
+
import { connectJittorClient } from "./client.ts";
|
|
8
9
|
import { serveMain } from "./daemon.ts";
|
|
10
|
+
import type { ContextAssessment } from "./domain/context-telemetry.ts";
|
|
9
11
|
import { resolveJittorPaths } from "./state.ts";
|
|
10
12
|
|
|
11
13
|
export interface SystemdUnitOptions {
|
|
@@ -33,6 +35,19 @@ WantedBy=default.target
|
|
|
33
35
|
`;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
interface ContextClient {
|
|
39
|
+
call(operation: "context.assess", input: { since?: number; until?: number }): Promise<ContextAssessment>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CliDependencies {
|
|
43
|
+
client: ContextClient;
|
|
44
|
+
stdout(line: string): void;
|
|
45
|
+
stderr(line: string): void;
|
|
46
|
+
systemctl(...args: string[]): void;
|
|
47
|
+
installService(): void;
|
|
48
|
+
serve(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
function systemctl(...args: string[]): void {
|
|
37
52
|
execFileSync("systemctl", ["--user", ...args], { stdio: "inherit" });
|
|
38
53
|
}
|
|
@@ -51,23 +66,80 @@ function installService(): void {
|
|
|
51
66
|
systemctl("restart", SYSTEMD_UNIT_NAME);
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
const DEFAULT_DEPENDENCIES: CliDependencies = {
|
|
70
|
+
get client() { return connectJittorClient(); },
|
|
71
|
+
stdout: console.log,
|
|
72
|
+
stderr: console.error,
|
|
73
|
+
systemctl,
|
|
74
|
+
installService,
|
|
75
|
+
serve: serveMain,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function usage(stderr: (line: string) => void): number {
|
|
79
|
+
stderr("Usage: jittor serve | service <install|start|stop|restart|status> | context [--since <ms>] [--until <ms>] [--json]");
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseContextArgs(args: string[]): { input: { since?: number; until?: number }; json: boolean } | null {
|
|
84
|
+
const input: { since?: number; until?: number } = {};
|
|
85
|
+
let json = false;
|
|
86
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
87
|
+
const argument = args[index];
|
|
88
|
+
if (argument === "--json") { json = true; continue; }
|
|
89
|
+
if (argument !== "--since" && argument !== "--until") return null;
|
|
90
|
+
const raw = args[++index];
|
|
91
|
+
const value = raw === undefined ? Number.NaN : Number(raw);
|
|
92
|
+
if (!Number.isSafeInteger(value) || value < 0) return null;
|
|
93
|
+
if (argument === "--since") input.since = value;
|
|
94
|
+
else input.until = value;
|
|
95
|
+
}
|
|
96
|
+
if (input.since !== undefined && input.until !== undefined && input.until < input.since) return null;
|
|
97
|
+
return { input, json };
|
|
57
98
|
}
|
|
58
99
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
function value(value: number | null, suffix = ""): string {
|
|
101
|
+
return value === null ? "unknown" : `${Math.round(value).toLocaleString()}${suffix}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatContextAssessment(summary: ContextAssessment): string {
|
|
105
|
+
return [
|
|
106
|
+
`Context assessment: ${summary.completeness}`,
|
|
107
|
+
`Papyrus injection: ${summary.injection.runs} runs · avg ${value(summary.injection.averageCharacters, " chars")} · p95 ${value(summary.injection.p95Characters, " chars")} · max ${value(summary.injection.maxCharacters, " chars")}`,
|
|
108
|
+
`Injection mix: rules ${summary.injection.ruleCharacters.toLocaleString()} chars · tasks ${summary.injection.taskCharacters.toLocaleString()} chars · estimated ${summary.injection.estimatedTokens.toLocaleString()} tokens · unchanged ${summary.injection.unchangedRate === null ? "unknown" : `${(summary.injection.unchangedRate * 100).toFixed(1)}%`}`,
|
|
109
|
+
`Compactions: ${summary.compaction.completed} completed · ${summary.compaction.aborted} aborted · avg ${value(summary.compaction.averageDurationMs, "ms")} · ${summary.compaction.perRun === null ? "unknown" : summary.compaction.perRun.toFixed(3)} per agent run · ${summary.compaction.perTurn === null ? "unknown" : summary.compaction.perTurn.toFixed(3)} per turn`,
|
|
110
|
+
`Between compactions: ${value(summary.compaction.averageTurnsBetween, " turns")} · ${value(summary.compaction.averageProviderTokensBetween, " provider tokens")} · ${value(summary.compaction.averageCacheReadTokensBetween, " cache-read tokens")}`,
|
|
111
|
+
`Reasons: threshold ${summary.compaction.reasons.threshold} · overflow ${summary.compaction.reasons.overflow} · manual ${summary.compaction.reasons.manual}`,
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runCli(args: string[], deps: CliDependencies = DEFAULT_DEPENDENCIES): Promise<number> {
|
|
116
|
+
const [command, action, ...rest] = args;
|
|
117
|
+
if (command === "serve") { deps.serve(); return 0; }
|
|
118
|
+
if (command === "context") {
|
|
119
|
+
const parsed = parseContextArgs([...(action === undefined ? [] : [action]), ...rest]);
|
|
120
|
+
if (!parsed) return usage(deps.stderr);
|
|
121
|
+
try {
|
|
122
|
+
const summary = await deps.client.call("context.assess", parsed.input);
|
|
123
|
+
deps.stdout(parsed.json ? JSON.stringify(summary) : formatContextAssessment(summary));
|
|
124
|
+
return 0;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
deps.stderr(error instanceof Error ? error.message : String(error));
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (command !== "service") return usage(deps.stderr);
|
|
63
131
|
switch (action) {
|
|
64
|
-
case "install": installService();
|
|
65
|
-
case "start": systemctl("start", SYSTEMD_UNIT_NAME);
|
|
66
|
-
case "stop": systemctl("stop", SYSTEMD_UNIT_NAME);
|
|
67
|
-
case "restart": systemctl("restart", SYSTEMD_UNIT_NAME);
|
|
68
|
-
case "status": systemctl("status", SYSTEMD_UNIT_NAME);
|
|
69
|
-
default: usage();
|
|
132
|
+
case "install": deps.installService(); return 0;
|
|
133
|
+
case "start": deps.systemctl("start", SYSTEMD_UNIT_NAME); return 0;
|
|
134
|
+
case "stop": deps.systemctl("stop", SYSTEMD_UNIT_NAME); return 0;
|
|
135
|
+
case "restart": deps.systemctl("restart", SYSTEMD_UNIT_NAME); return 0;
|
|
136
|
+
case "status": deps.systemctl("status", SYSTEMD_UNIT_NAME); return 0;
|
|
137
|
+
default: return usage(deps.stderr);
|
|
70
138
|
}
|
|
71
139
|
}
|
|
72
140
|
|
|
73
|
-
|
|
141
|
+
export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
|
|
142
|
+
process.exitCode = await runCli(args);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (import.meta.main) await main();
|
package/src/constants.ts
CHANGED
|
@@ -17,6 +17,13 @@ export const FOOTER_COMPACTION_DRAIN_STEP_MS = 3_000;
|
|
|
17
17
|
export const MILLISECONDS_PER_SECOND = 1_000;
|
|
18
18
|
export const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND;
|
|
19
19
|
export const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE;
|
|
20
|
+
export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
|
|
21
|
+
export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
|
|
22
|
+
export const CONTEXT_OBSERVATION_MAX_CHARACTERS = 10_000_000;
|
|
23
|
+
export const CONTEXT_OBSERVATION_MAX_AGE_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
24
|
+
export const CONTEXT_ASSESSMENT_QUERY_LIMIT = 10_000;
|
|
25
|
+
export const CONTEXT_EVENT_DEDUP_LIMIT = 1_000;
|
|
26
|
+
export const CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS = 24 * MILLISECONDS_PER_HOUR;
|
|
20
27
|
export const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR;
|
|
21
28
|
export const LOOPBACK_HOST = "127.0.0.1";
|
|
22
29
|
export const JITTOR_STATE_DIRECTORY = "jittor";
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTEXT_OBSERVATION_MAX_AGE_MS,
|
|
3
|
+
CONTEXT_OBSERVATION_MAX_CHARACTERS,
|
|
4
|
+
MILLISECONDS_PER_HOUR,
|
|
5
|
+
PAPYRUS_CONTEXT_INJECTION_SCHEMA,
|
|
6
|
+
} from "../constants.ts";
|
|
7
|
+
import type { MetricObservation, StoredMetricObservation } from "./metric.ts";
|
|
8
|
+
|
|
9
|
+
interface PayloadSize { characters: number; bytes: number }
|
|
10
|
+
|
|
11
|
+
export interface PapyrusContextInjection {
|
|
12
|
+
schema: typeof PAPYRUS_CONTEXT_INJECTION_SCHEMA;
|
|
13
|
+
observedAt: number;
|
|
14
|
+
sequence: number;
|
|
15
|
+
producerId: string;
|
|
16
|
+
before: PayloadSize;
|
|
17
|
+
rules: PayloadSize & { count: number };
|
|
18
|
+
tasks: PayloadSize;
|
|
19
|
+
injected: PayloadSize;
|
|
20
|
+
after: PayloadSize;
|
|
21
|
+
estimatedTokens: number;
|
|
22
|
+
share: number;
|
|
23
|
+
fingerprint: string;
|
|
24
|
+
unchanged: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TOP_LEVEL_FIELDS = new Set(["schema", "observedAt", "sequence", "producerId", "before", "rules", "tasks", "injected", "after", "estimatedTokens", "share", "fingerprint", "unchanged"]);
|
|
28
|
+
const SIZE_FIELDS = new Set(["characters", "bytes"]);
|
|
29
|
+
const RULE_SIZE_FIELDS = new Set(["characters", "bytes", "count"]);
|
|
30
|
+
|
|
31
|
+
function record(value: unknown, name: string, fields: Set<string>): Record<string, unknown> {
|
|
32
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
33
|
+
const result = value as Record<string, unknown>;
|
|
34
|
+
for (const key of Object.keys(result)) if (!fields.has(key)) throw new Error(`${name} contains unexpected field: ${key}`);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function integer(value: unknown, name: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
39
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) throw new Error(`${name} must be a bounded non-negative integer`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function size(value: unknown, name: string, fields = SIZE_FIELDS): PayloadSize {
|
|
44
|
+
const input = record(value, name, fields);
|
|
45
|
+
return {
|
|
46
|
+
characters: integer(input["characters"], `${name}.characters`, CONTEXT_OBSERVATION_MAX_CHARACTERS),
|
|
47
|
+
bytes: integer(input["bytes"], `${name}.bytes`, CONTEXT_OBSERVATION_MAX_CHARACTERS * 4),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validatePapyrusContextInjection(value: unknown, now = Date.now()): PapyrusContextInjection {
|
|
52
|
+
const input = record(value, "context injection", TOP_LEVEL_FIELDS);
|
|
53
|
+
if (input["schema"] !== PAPYRUS_CONTEXT_INJECTION_SCHEMA) throw new Error("context injection schema is not supported");
|
|
54
|
+
const observedAt = integer(input["observedAt"], "observedAt");
|
|
55
|
+
if (Math.abs(now - observedAt) > CONTEXT_OBSERVATION_MAX_AGE_MS) throw new Error("context injection observation is stale");
|
|
56
|
+
const producerId = input["producerId"];
|
|
57
|
+
if (typeof producerId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(producerId)) throw new Error("producerId must be a UUID");
|
|
58
|
+
const before = size(input["before"], "before");
|
|
59
|
+
const rulesInput = record(input["rules"], "rules", RULE_SIZE_FIELDS);
|
|
60
|
+
const rules = { ...size(rulesInput, "rules", RULE_SIZE_FIELDS), count: integer(rulesInput["count"], "rules.count") };
|
|
61
|
+
const tasks = size(input["tasks"], "tasks");
|
|
62
|
+
const injected = size(input["injected"], "injected");
|
|
63
|
+
const after = size(input["after"], "after");
|
|
64
|
+
if (injected.characters !== rules.characters + tasks.characters || after.characters !== before.characters + injected.characters) {
|
|
65
|
+
throw new Error("context injection sizes are inconsistent");
|
|
66
|
+
}
|
|
67
|
+
const estimatedTokens = integer(input["estimatedTokens"], "estimatedTokens", CONTEXT_OBSERVATION_MAX_CHARACTERS);
|
|
68
|
+
const share = input["share"];
|
|
69
|
+
if (typeof share !== "number" || !Number.isFinite(share) || share < 0 || share > 1) throw new Error("share must be a ratio");
|
|
70
|
+
const fingerprint = input["fingerprint"];
|
|
71
|
+
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) throw new Error("fingerprint must be a SHA-256 hex digest");
|
|
72
|
+
if (typeof input["unchanged"] !== "boolean") throw new Error("unchanged must be boolean");
|
|
73
|
+
return {
|
|
74
|
+
schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
|
|
75
|
+
observedAt,
|
|
76
|
+
sequence: integer(input["sequence"], "sequence"),
|
|
77
|
+
producerId,
|
|
78
|
+
before,
|
|
79
|
+
rules,
|
|
80
|
+
tasks,
|
|
81
|
+
injected,
|
|
82
|
+
after,
|
|
83
|
+
estimatedTokens,
|
|
84
|
+
share,
|
|
85
|
+
fingerprint,
|
|
86
|
+
unchanged: input["unchanged"],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function papyrusContextMetric(observation: PapyrusContextInjection): MetricObservation {
|
|
91
|
+
return {
|
|
92
|
+
source: "papyrus-context",
|
|
93
|
+
scope: "system-prompt",
|
|
94
|
+
metric: "injected-characters",
|
|
95
|
+
value: observation.injected.characters,
|
|
96
|
+
unit: "count",
|
|
97
|
+
observedAt: observation.observedAt,
|
|
98
|
+
attributes: {
|
|
99
|
+
sequence: observation.sequence,
|
|
100
|
+
producerId: observation.producerId,
|
|
101
|
+
beforeCharacters: observation.before.characters,
|
|
102
|
+
afterCharacters: observation.after.characters,
|
|
103
|
+
injectedBytes: observation.injected.bytes,
|
|
104
|
+
ruleCharacters: observation.rules.characters,
|
|
105
|
+
taskCharacters: observation.tasks.characters,
|
|
106
|
+
ruleCount: observation.rules.count,
|
|
107
|
+
estimatedTokens: observation.estimatedTokens,
|
|
108
|
+
share: observation.share,
|
|
109
|
+
fingerprint: observation.fingerprint,
|
|
110
|
+
unchanged: observation.unchanged,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CompactionStart {
|
|
116
|
+
reason: "manual" | "threshold" | "overflow";
|
|
117
|
+
willRetry: boolean;
|
|
118
|
+
contextPercent?: number;
|
|
119
|
+
contextTokens?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface OpenCompaction extends CompactionStart { startedAt: number }
|
|
123
|
+
interface UsageCounters {
|
|
124
|
+
turns: number;
|
|
125
|
+
injectedCharacters: number;
|
|
126
|
+
estimatedInjectedTokens: number;
|
|
127
|
+
providerTokens: number;
|
|
128
|
+
cacheReadTokens: number;
|
|
129
|
+
cacheWriteTokens: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function emptyCounters(): UsageCounters {
|
|
133
|
+
return { turns: 0, injectedCharacters: 0, estimatedInjectedTokens: 0, providerTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class CompactionTelemetry {
|
|
137
|
+
private open: OpenCompaction | undefined;
|
|
138
|
+
private counters = emptyCounters();
|
|
139
|
+
private previousCompletedAt: number | undefined;
|
|
140
|
+
|
|
141
|
+
hasOpenCompaction(): boolean { return this.open !== undefined; }
|
|
142
|
+
observeTurn(): void { this.counters.turns += 1; }
|
|
143
|
+
observeInjection(characters: number, estimatedTokens: number): void {
|
|
144
|
+
this.counters.injectedCharacters += Math.max(0, characters);
|
|
145
|
+
this.counters.estimatedInjectedTokens += Math.max(0, estimatedTokens);
|
|
146
|
+
}
|
|
147
|
+
observeProviderUsage(usage: { input: number; output: number; cacheRead: number; cacheWrite: number }): void {
|
|
148
|
+
this.counters.providerTokens += Math.max(0, usage.input) + Math.max(0, usage.output);
|
|
149
|
+
this.counters.cacheReadTokens += Math.max(0, usage.cacheRead);
|
|
150
|
+
this.counters.cacheWriteTokens += Math.max(0, usage.cacheWrite);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
begin(input: CompactionStart, now = Date.now()): MetricObservation {
|
|
154
|
+
this.open = { ...input, startedAt: now };
|
|
155
|
+
return { source: "pi-context", scope: "compaction", metric: "compaction-started", value: 1, unit: "count", observedAt: now, attributes: { ...input } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
complete(input: Pick<CompactionStart, "reason" | "willRetry">, now = Date.now()): MetricObservation {
|
|
159
|
+
if (!this.open) return { source: "pi-context", scope: "compaction", metric: "compaction-unmatched", value: 1, unit: "count", observedAt: now, attributes: { ...input } };
|
|
160
|
+
const open = this.open;
|
|
161
|
+
this.open = undefined;
|
|
162
|
+
const attributes = this.intervalAttributes(open, now);
|
|
163
|
+
this.previousCompletedAt = now;
|
|
164
|
+
this.counters = emptyCounters();
|
|
165
|
+
return { source: "pi-context", scope: "compaction", metric: "compaction-duration", value: Math.max(0, now - open.startedAt), unit: "milliseconds", observedAt: now, attributes: { ...attributes, reason: input.reason, willRetry: input.willRetry } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
abort(now = Date.now(), abortReason = "aborted"): MetricObservation {
|
|
169
|
+
const open = this.open;
|
|
170
|
+
this.open = undefined;
|
|
171
|
+
if (!open) return { source: "pi-context", scope: "compaction", metric: "compaction-unmatched", value: 1, unit: "count", observedAt: now, attributes: { abortReason } };
|
|
172
|
+
const attributes = this.intervalAttributes(open, now);
|
|
173
|
+
this.counters = emptyCounters();
|
|
174
|
+
return { source: "pi-context", scope: "compaction", metric: "compaction-aborted", value: 1, unit: "count", observedAt: now, attributes: { ...attributes, reason: open.reason, abortReason, durationMs: Math.max(0, now - open.startedAt) } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private intervalAttributes(open: OpenCompaction, now: number): Record<string, unknown> {
|
|
178
|
+
return {
|
|
179
|
+
reason: open.reason,
|
|
180
|
+
willRetry: open.willRetry,
|
|
181
|
+
...(open.contextPercent === undefined ? {} : { contextPercent: open.contextPercent }),
|
|
182
|
+
...(open.contextTokens === undefined ? {} : { contextTokens: open.contextTokens }),
|
|
183
|
+
turnsSincePrevious: this.counters.turns,
|
|
184
|
+
injectedCharactersSincePrevious: this.counters.injectedCharacters,
|
|
185
|
+
estimatedInjectedTokensSincePrevious: this.counters.estimatedInjectedTokens,
|
|
186
|
+
providerTokensSincePrevious: this.counters.providerTokens,
|
|
187
|
+
cacheReadTokensSincePrevious: this.counters.cacheReadTokens,
|
|
188
|
+
cacheWriteTokensSincePrevious: this.counters.cacheWriteTokens,
|
|
189
|
+
...(this.previousCompletedAt === undefined ? {} : { elapsedSincePreviousMs: Math.max(0, now - this.previousCompletedAt) }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ContextAssessment {
|
|
195
|
+
window: { since: number; until: number };
|
|
196
|
+
completeness: "complete" | "truncated";
|
|
197
|
+
injection: {
|
|
198
|
+
runs: number;
|
|
199
|
+
averageCharacters: number | null;
|
|
200
|
+
p95Characters: number | null;
|
|
201
|
+
maxCharacters: number | null;
|
|
202
|
+
estimatedTokens: number;
|
|
203
|
+
unchangedRate: number | null;
|
|
204
|
+
averageShare: number | null;
|
|
205
|
+
ruleCharacters: number;
|
|
206
|
+
taskCharacters: number;
|
|
207
|
+
};
|
|
208
|
+
compaction: {
|
|
209
|
+
completed: number;
|
|
210
|
+
aborted: number;
|
|
211
|
+
averageDurationMs: number | null;
|
|
212
|
+
perRun: number | null;
|
|
213
|
+
perTurn: number | null;
|
|
214
|
+
perHour: number | null;
|
|
215
|
+
averageTurnsBetween: number | null;
|
|
216
|
+
averageElapsedMsBetween: number | null;
|
|
217
|
+
averageProviderTokensBetween: number | null;
|
|
218
|
+
averageCacheReadTokensBetween: number | null;
|
|
219
|
+
reasons: Record<"manual" | "threshold" | "overflow", number>;
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function numericAttribute(row: StoredMetricObservation, key: string): number | null {
|
|
224
|
+
const value = row.attributes[key];
|
|
225
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
226
|
+
}
|
|
227
|
+
function average(values: number[]): number | null { return values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length; }
|
|
228
|
+
function sum(values: number[]): number { return values.reduce((total, value) => total + value, 0); }
|
|
229
|
+
function percentile(values: number[], percentage: number): number | null {
|
|
230
|
+
if (values.length === 0) return null;
|
|
231
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
232
|
+
return sorted[Math.max(0, Math.ceil(sorted.length * percentage) - 1)]!;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function assessContextTelemetry(
|
|
236
|
+
injections: StoredMetricObservation[],
|
|
237
|
+
compactions: StoredMetricObservation[],
|
|
238
|
+
options: { since: number; until: number; truncated: boolean },
|
|
239
|
+
): ContextAssessment {
|
|
240
|
+
const injectionValues = injections.flatMap((row) => typeof row.value === "number" ? [row.value] : []);
|
|
241
|
+
const shares = injections.flatMap((row) => numericAttribute(row, "share") ?? []);
|
|
242
|
+
const unchanged = injections.filter((row) => row.attributes["unchanged"] === true).length;
|
|
243
|
+
const completed = compactions.filter((row) => row.metric === "compaction-duration" && typeof row.value === "number");
|
|
244
|
+
const aborted = compactions.filter((row) => row.metric === "compaction-aborted");
|
|
245
|
+
const reasons = { manual: 0, threshold: 0, overflow: 0 };
|
|
246
|
+
for (const row of completed) {
|
|
247
|
+
const reason = row.attributes["reason"];
|
|
248
|
+
if (reason === "manual" || reason === "threshold" || reason === "overflow") reasons[reason] += 1;
|
|
249
|
+
}
|
|
250
|
+
const windowMs = Math.max(0, options.until - options.since);
|
|
251
|
+
const turnsBetween = completed.flatMap((row) => numericAttribute(row, "turnsSincePrevious") ?? []);
|
|
252
|
+
return {
|
|
253
|
+
window: { since: options.since, until: options.until },
|
|
254
|
+
completeness: options.truncated ? "truncated" : "complete",
|
|
255
|
+
injection: {
|
|
256
|
+
runs: injections.length,
|
|
257
|
+
averageCharacters: average(injectionValues),
|
|
258
|
+
p95Characters: percentile(injectionValues, 0.95),
|
|
259
|
+
maxCharacters: injectionValues.length === 0 ? null : Math.max(...injectionValues),
|
|
260
|
+
estimatedTokens: sum(injections.flatMap((row) => numericAttribute(row, "estimatedTokens") ?? [])),
|
|
261
|
+
unchangedRate: injections.length === 0 ? null : unchanged / injections.length,
|
|
262
|
+
averageShare: average(shares),
|
|
263
|
+
ruleCharacters: sum(injections.flatMap((row) => numericAttribute(row, "ruleCharacters") ?? [])),
|
|
264
|
+
taskCharacters: sum(injections.flatMap((row) => numericAttribute(row, "taskCharacters") ?? [])),
|
|
265
|
+
},
|
|
266
|
+
compaction: {
|
|
267
|
+
completed: completed.length,
|
|
268
|
+
aborted: aborted.length,
|
|
269
|
+
averageDurationMs: average(completed.map((row) => row.value as number)),
|
|
270
|
+
perRun: injections.length === 0 ? null : completed.length / injections.length,
|
|
271
|
+
perTurn: sum(turnsBetween) === 0 ? null : completed.length / sum(turnsBetween),
|
|
272
|
+
perHour: completed.length === 0 || windowMs === 0 ? null : completed.length / (windowMs / MILLISECONDS_PER_HOUR),
|
|
273
|
+
averageTurnsBetween: average(turnsBetween),
|
|
274
|
+
averageElapsedMsBetween: average(completed.flatMap((row) => numericAttribute(row, "elapsedSincePreviousMs") ?? [])),
|
|
275
|
+
averageProviderTokensBetween: average(completed.flatMap((row) => numericAttribute(row, "providerTokensSincePrevious") ?? [])),
|
|
276
|
+
averageCacheReadTokensBetween: average(completed.flatMap((row) => numericAttribute(row, "cacheReadTokensSincePrevious") ?? [])),
|
|
277
|
+
reasons,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { SERVICE_MAX_BODY_BYTES } from "./constants.ts";
|
|
1
|
+
import { CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS, CONTEXT_ASSESSMENT_QUERY_LIMIT, SERVICE_MAX_BODY_BYTES } from "./constants.ts";
|
|
2
2
|
import { VERSION } from "./version.ts";
|
|
3
3
|
import { validateMetricObservation, type MetricObservation, type MetricQuery, type StoredMetricObservation } from "./domain/metric.ts";
|
|
4
|
+
import { assessContextTelemetry, type ContextAssessment } from "./domain/context-telemetry.ts";
|
|
4
5
|
import type { MetricStore } from "./ports/metric-store.ts";
|
|
5
6
|
import type { RouteOverride, RouterController, RouterStatus, TelemetryPollResult } from "./ports/router-controller.ts";
|
|
6
7
|
import type { PolicyDecision, Route } from "./policy.ts";
|
|
@@ -9,6 +10,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
9
10
|
"metrics.record",
|
|
10
11
|
"metrics.query",
|
|
11
12
|
"metrics.prune",
|
|
13
|
+
"context.assess",
|
|
12
14
|
"service.checkpoint",
|
|
13
15
|
"telemetry.poll",
|
|
14
16
|
"router.status",
|
|
@@ -26,6 +28,7 @@ export interface OperationInputs {
|
|
|
26
28
|
"metrics.record": MetricObservation;
|
|
27
29
|
"metrics.query": MetricQuery;
|
|
28
30
|
"metrics.prune": { before: number };
|
|
31
|
+
"context.assess": { since?: number; until?: number };
|
|
29
32
|
"service.checkpoint": Record<string, never>;
|
|
30
33
|
"telemetry.poll": Record<string, never>;
|
|
31
34
|
"router.status": Record<string, never>;
|
|
@@ -41,6 +44,7 @@ export interface OperationOutputs {
|
|
|
41
44
|
"metrics.record": StoredMetricObservation;
|
|
42
45
|
"metrics.query": StoredMetricObservation[];
|
|
43
46
|
"metrics.prune": { deleted: number };
|
|
47
|
+
"context.assess": ContextAssessment;
|
|
44
48
|
"service.checkpoint": { ok: true };
|
|
45
49
|
"telemetry.poll": TelemetryPollResult;
|
|
46
50
|
"router.status": RouterStatus;
|
|
@@ -89,6 +93,19 @@ export class JittorService {
|
|
|
89
93
|
if (typeof before !== "number") throw new Error("before is required");
|
|
90
94
|
return { deleted: this.metrics.pruneBefore(before) };
|
|
91
95
|
}
|
|
96
|
+
case "context.assess": {
|
|
97
|
+
const until = input["until"] === undefined ? Date.now() : input["until"];
|
|
98
|
+
const since = input["since"] === undefined && typeof until === "number" ? Math.max(0, until - CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS) : input["since"];
|
|
99
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) throw new Error("context assessment requires non-negative ordered integer bounds");
|
|
100
|
+
const query = { since: since as number, until: until as number, order: "asc" as const, limit: CONTEXT_ASSESSMENT_QUERY_LIMIT };
|
|
101
|
+
const injections = this.metrics.query({ ...query, source: "papyrus-context", metric: "injected-characters" });
|
|
102
|
+
const compactions = this.metrics.query({ ...query, source: "pi-context" });
|
|
103
|
+
return assessContextTelemetry(injections, compactions, {
|
|
104
|
+
since: since as number,
|
|
105
|
+
until: until as number,
|
|
106
|
+
truncated: injections.length >= CONTEXT_ASSESSMENT_QUERY_LIMIT || compactions.length >= CONTEXT_ASSESSMENT_QUERY_LIMIT,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
92
109
|
case "service.checkpoint": this.metrics.checkpoint(); return { ok: true };
|
|
93
110
|
case "telemetry.poll": return this.router.poll();
|
|
94
111
|
case "router.status": return this.router.status();
|