@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 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
@@ -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 finishCompaction = (): void => {
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 beginCompaction = (ctx: ExtensionContext, signal: AbortSignal): void => {
296
- finishCompaction();
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", finishCompaction, { once: true });
304
- if (signal.aborted) finishCompaction();
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
- finishCompaction();
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
- beginCompaction(ctx, event.signal);
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
- finishCompaction();
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) finishCompaction();
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) await recordMetrics(client, metrics).catch(() => undefined);
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
- finishCompaction();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/jittor",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Just-in-Time Token Optimizing Router for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
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
- function usage(): never {
55
- console.error("Usage: jittor serve | service <install|start|stop|restart|status>");
56
- process.exit(2);
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
- export function main(args: string[] = process.argv.slice(2)): void {
60
- const [command, action] = args;
61
- if (command === "serve") { serveMain(); return; }
62
- if (command !== "service") usage();
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(); break;
65
- case "start": systemctl("start", SYSTEMD_UNIT_NAME); break;
66
- case "stop": systemctl("stop", SYSTEMD_UNIT_NAME); break;
67
- case "restart": systemctl("restart", SYSTEMD_UNIT_NAME); break;
68
- case "status": systemctl("status", SYSTEMD_UNIT_NAME); break;
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
- if (import.meta.main) main();
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();