@danypops/jittor 0.4.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 +7 -1
- package/extension/src/index.ts +63 -13
- 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
|
|
|
@@ -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
|
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";
|
|
@@ -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
|
|
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
|
|
297
|
-
|
|
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",
|
|
305
|
-
if (signal.aborted)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
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();
|