@danypops/jittor 0.4.0 → 0.5.1

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
 
@@ -64,6 +64,12 @@ Run `/jittor usage` for a colored Unicode cumulative token graph with X/Y axes,
64
64
 
65
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`).
66
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
+
67
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.
68
74
 
69
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";
@@ -227,6 +230,21 @@ export function registerJittorExtension(
227
230
  ): void {
228
231
  const footerState: IntegratedFooterState = { providerBudget: null };
229
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
+ });
230
248
  const recoveryPolicy = new CodexRecoveryPolicy({
231
249
  baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
232
250
  maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
@@ -287,22 +305,22 @@ export function registerJittorExtension(
287
305
  }, plan.delayMs);
288
306
  };
289
307
  let compactionTimer: ReturnType<typeof setInterval> | undefined;
290
- const finishCompaction = (): void => {
308
+ const finishCompactionUi = (): void => {
291
309
  if (compactionTimer) clearInterval(compactionTimer);
292
310
  compactionTimer = undefined;
293
311
  footerState.compaction = undefined;
294
312
  footerState.requestRender?.();
295
313
  };
296
- const beginCompaction = (ctx: ExtensionContext, signal: AbortSignal): void => {
297
- finishCompaction();
314
+ const beginCompactionUi = (ctx: ExtensionContext, signal: AbortSignal): void => {
315
+ finishCompactionUi();
298
316
  const usage = ctx.getContextUsage();
299
317
  footerState.compaction = {
300
318
  startedAt: Date.now(),
301
319
  initialFraction: usage?.percent === null || usage?.percent === undefined ? 1 : usage.percent / 100,
302
320
  };
303
321
  compactionTimer = setInterval(() => footerState.requestRender?.(), FOOTER_COMPACTION_RENDER_INTERVAL_MS);
304
- signal.addEventListener("abort", finishCompaction, { once: true });
305
- if (signal.aborted) finishCompaction();
322
+ signal.addEventListener("abort", finishCompactionUi, { once: true });
323
+ if (signal.aborted) finishCompactionUi();
306
324
  else footerState.requestRender?.();
307
325
  };
308
326
  const showFooter = (ctx: ExtensionContext): void => {
@@ -373,6 +391,18 @@ export function registerJittorExtension(
373
391
  ctx.ui.notify("Jittor informational footer enabled; routing enforcement is unchanged.", "info");
374
392
  return;
375
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
+ }
376
406
  if (action === "settings") {
377
407
  await showSettingsPanel(ctx, enforcement, codexRecovery, usageBudgets, {
378
408
  setEnforcement: async (enabled) => enabled ? enable(ctx) : disable(ctx),
@@ -427,7 +457,8 @@ export function registerJittorExtension(
427
457
  });
428
458
 
429
459
  pi.on("session_start", async (_event, ctx) => {
430
- finishCompaction();
460
+ finishCompactionUi();
461
+ compactionTelemetry = new CompactionTelemetry();
431
462
  cancelRecovery(true);
432
463
  lastCodexResponse = {};
433
464
  ctx.ui.setStatus("jittor", undefined);
@@ -443,16 +474,28 @@ export function registerJittorExtension(
443
474
  }
444
475
  });
445
476
 
446
- pi.on("session_before_compact", (event, ctx) => {
447
- 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);
448
487
  });
449
488
 
450
- pi.on("session_compact", () => {
451
- finishCompaction();
489
+ pi.on("session_compact", async (event) => {
490
+ finishCompactionUi();
491
+ await recordMetrics(client, [compactionTelemetry.complete({ reason: event.reason, willRetry: event.willRetry })]).catch(() => undefined);
452
492
  });
453
493
 
454
494
  pi.on("agent_settled", async (_event, ctx) => {
455
- 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
+ }
456
499
  scheduleCodexRecovery(ctx);
457
500
  if (!enforcement.isFooterEnabled()) return;
458
501
  try {
@@ -491,6 +534,7 @@ export function registerJittorExtension(
491
534
  });
492
535
 
493
536
  pi.on("turn_start", async (_event, ctx) => {
537
+ compactionTelemetry.observeTurn();
494
538
  lastCodexResponse = {};
495
539
  if (!enforcement.isEnabled()) return;
496
540
  try {
@@ -529,12 +573,18 @@ export function registerJittorExtension(
529
573
  lastCodexResponse = {};
530
574
  }
531
575
  const metrics = assistantUsageMetrics(event.message, Date.now());
532
- 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
+ }
533
581
  if (enforcement.isFooterEnabled()) await refreshFooter(client, footerState).catch(() => undefined);
534
582
  });
535
583
 
536
584
  pi.on("session_shutdown", async (_event, ctx) => {
537
- finishCompaction();
585
+ finishCompactionUi();
586
+ if (compactionTelemetry.hasOpenCompaction()) await recordMetrics(client, [compactionTelemetry.abort(Date.now(), "session-shutdown")]).catch(() => undefined);
587
+ stopPapyrusContext?.();
538
588
  cancelRecovery(true);
539
589
  lastCodexResponse = {};
540
590
  ctx.ui.setStatus("jittor", undefined);
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@danypops/jittor",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Just-in-Time Token Optimizing Router for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
7
+ "bin": {
8
+ "jittor": "src/cli.ts"
9
+ },
7
10
  "scripts": {
8
11
  "test": "bun test",
9
12
  "typecheck": "tsc --noEmit",
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();