@danypops/pi-jittor 0.1.1 → 0.2.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/extension/src/benchmark-tui.ts +27 -12
- package/extension/src/capabilities/codex-recovery.ts +39 -23
- package/extension/src/capabilities/context-hub.ts +1 -5
- package/extension/src/capabilities/local-run-telemetry.ts +14 -10
- package/extension/src/capabilities/provider-response-telemetry.ts +20 -6
- package/extension/src/context-breakdown.ts +347 -0
- package/extension/src/context-report.ts +39 -25
- package/extension/src/context-view.ts +140 -0
- package/extension/src/footer.ts +56 -26
- package/extension/src/index.ts +261 -97
- package/extension/src/service-client.ts +1 -1
- package/extension/src/settings-tui.ts +35 -20
- package/extension/src/settings.ts +13 -10
- package/extension/src/tui.ts +148 -63
- package/extension/src/usage.ts +124 -42
- package/package.json +4 -4
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
1
|
import {
|
|
4
2
|
BENCHMARK_TUI_MAX_CANDIDATES,
|
|
5
3
|
BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE,
|
|
@@ -15,6 +13,8 @@ import {
|
|
|
15
13
|
type RankedModel,
|
|
16
14
|
type UtilityComponentName,
|
|
17
15
|
} from "@danypops/jittor";
|
|
16
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
18
18
|
import { sessionSecretField } from "./session-identity.ts";
|
|
19
19
|
|
|
20
20
|
export interface BenchmarkPanelClient {
|
|
@@ -31,13 +31,18 @@ type BenchmarkPanelAction = "refresh" | "close";
|
|
|
31
31
|
const COMPONENT_LABELS: Record<UtilityComponentName, string> = { quality: "Q", cost: "$", latency: "L", context: "C", reliability: "R" };
|
|
32
32
|
|
|
33
33
|
function componentText(item: RankedModel): string {
|
|
34
|
-
return item.components
|
|
34
|
+
return item.components
|
|
35
|
+
.map((component) => `${COMPONENT_LABELS[component.name]} ${component.score === null ? "?" : component.score.toFixed(3)}`)
|
|
36
|
+
.join(" · ");
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function candidateLines(item: RankedModel, index: number, currentIdentity: string): string[] {
|
|
38
40
|
const current = item.identity.startsWith(`${currentIdentity}:`);
|
|
39
41
|
const localSamples = item.components.find((component) => component.name === "reliability")?.evidenceCount ?? 0;
|
|
40
|
-
const provenance = item.provenance
|
|
42
|
+
const provenance = item.provenance
|
|
43
|
+
.slice(0, BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE)
|
|
44
|
+
.map((source) => `${source.sourceId}@${source.revision} ${source.freshness}`)
|
|
45
|
+
.join(" · ");
|
|
41
46
|
return [
|
|
42
47
|
` ${index + 1}. ${item.identity}${index === 0 ? " recommended" : ""}${current ? " current" : ""}`,
|
|
43
48
|
` utility ${item.utility === null ? "?" : item.utility.toFixed(3)} · confidence ${(item.confidence * 100).toFixed(0)}% · ${componentText(item)}`,
|
|
@@ -50,13 +55,18 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
|
|
|
50
55
|
const shown = result.ranked.slice(0, BENCHMARK_TUI_MAX_CANDIDATES);
|
|
51
56
|
const currentIndex = result.ranked.findIndex((item) => item.identity.startsWith(`${currentIdentity}:`));
|
|
52
57
|
const recommended = result.ranked[0];
|
|
53
|
-
const reason =
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const reason =
|
|
59
|
+
recommended && currentIndex > 0
|
|
60
|
+
? `Recommendation differs from current: ${recommended.identity} ranks #1; current ranks #${currentIndex + 1}.`
|
|
61
|
+
: recommended && currentIndex === 0
|
|
62
|
+
? "Current model is the top recommendation."
|
|
63
|
+
: "Current model is outside the ranked candidates.";
|
|
56
64
|
const lines = [
|
|
57
65
|
theme.fg("borderMuted", "─".repeat(safeWidth)),
|
|
58
66
|
theme.bold("Jittor Benchmark Recommendations"),
|
|
59
|
-
result.scopeAuthority === "exact-session"
|
|
67
|
+
result.scopeAuthority === "exact-session"
|
|
68
|
+
? "Scope: exact session"
|
|
69
|
+
: "Scope: available models · ADVISORY (exact session scope unavailable)",
|
|
60
70
|
`Domain: ${result.domain} · Type: ${result.type} · evidence ${result.completeness}`,
|
|
61
71
|
reason,
|
|
62
72
|
...shown.flatMap((item, index) => candidateLines(item, index, currentIdentity)),
|
|
@@ -78,7 +88,7 @@ export async function showBenchmarkPanel(
|
|
|
78
88
|
): Promise<void> {
|
|
79
89
|
for (;;) {
|
|
80
90
|
const session_id = ctx.sessionManager.getSessionId();
|
|
81
|
-
const result = await client.call("models.rank", {
|
|
91
|
+
const result = (await client.call("models.rank", {
|
|
82
92
|
candidates,
|
|
83
93
|
session_id,
|
|
84
94
|
...sessionSecretField(session_id),
|
|
@@ -94,14 +104,19 @@ export async function showBenchmarkPanel(
|
|
|
94
104
|
reliability: MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
|
|
95
105
|
},
|
|
96
106
|
sourceIds: ["openrouter-models", "lmarena-hf", "artificial-analysis-direct", "openrouter-design-arena"],
|
|
97
|
-
}) as ModelRankingResult;
|
|
107
|
+
})) as ModelRankingResult;
|
|
98
108
|
if (ctx.mode !== "tui") {
|
|
99
|
-
ctx.ui.notify(
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"),
|
|
111
|
+
"info",
|
|
112
|
+
);
|
|
100
113
|
return;
|
|
101
114
|
}
|
|
102
115
|
const action = await ctx.ui.custom<BenchmarkPanelAction>((_tui, theme, _keybindings, done) => ({
|
|
103
116
|
invalidate() {},
|
|
104
|
-
render(width: number): string[] {
|
|
117
|
+
render(width: number): string[] {
|
|
118
|
+
return renderBenchmarkView(result, currentIdentity, width, theme);
|
|
119
|
+
},
|
|
105
120
|
handleInput(data: string): void {
|
|
106
121
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
|
|
107
122
|
else if (data === "r") done("refresh");
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import {
|
|
3
2
|
CODEX_RECOVERY_ATTEMPT_WINDOW_MS,
|
|
4
3
|
CODEX_RECOVERY_BASE_DELAY_MS,
|
|
5
4
|
CODEX_RECOVERY_JITTER_RATIO,
|
|
6
5
|
CODEX_RECOVERY_MAX_ATTEMPTS,
|
|
7
6
|
CODEX_RECOVERY_MAX_DELAY_MS,
|
|
8
|
-
MILLISECONDS_PER_MINUTE,
|
|
9
|
-
MILLISECONDS_PER_SECOND,
|
|
10
|
-
CodexRecoveryPolicy,
|
|
11
|
-
classifyCodexFailure,
|
|
12
7
|
type CodexFailureKind,
|
|
13
8
|
type CodexFailureMetadata,
|
|
9
|
+
CodexRecoveryPolicy,
|
|
10
|
+
classifyCodexFailure,
|
|
11
|
+
MILLISECONDS_PER_MINUTE,
|
|
12
|
+
MILLISECONDS_PER_SECOND,
|
|
14
13
|
} from "@danypops/jittor";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import type { CodexRecoveryControl } from "../settings.ts";
|
|
16
16
|
import { headerValue } from "./http-headers.ts";
|
|
17
17
|
|
|
@@ -25,8 +25,14 @@ export interface CodexRecoveryRuntime {
|
|
|
25
25
|
export const SYSTEM_RECOVERY_RUNTIME: CodexRecoveryRuntime = {
|
|
26
26
|
now: Date.now,
|
|
27
27
|
random: Math.random,
|
|
28
|
-
setTimeout(callback, delayMs) {
|
|
29
|
-
|
|
28
|
+
setTimeout(callback, delayMs) {
|
|
29
|
+
return setTimeout(() => {
|
|
30
|
+
void callback();
|
|
31
|
+
}, delayMs);
|
|
32
|
+
},
|
|
33
|
+
clearTimeout(handle) {
|
|
34
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
35
|
+
},
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -47,13 +53,16 @@ export class CodexRecoveryCapability {
|
|
|
47
53
|
private readonly control: CodexRecoveryControl,
|
|
48
54
|
private readonly runtime: CodexRecoveryRuntime,
|
|
49
55
|
) {
|
|
50
|
-
this.policy = new CodexRecoveryPolicy(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
this.policy = new CodexRecoveryPolicy(
|
|
57
|
+
{
|
|
58
|
+
baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
|
|
59
|
+
maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
|
|
60
|
+
maxAttempts: CODEX_RECOVERY_MAX_ATTEMPTS,
|
|
61
|
+
attemptWindowMs: CODEX_RECOVERY_ATTEMPT_WINDOW_MS,
|
|
62
|
+
jitterRatio: CODEX_RECOVERY_JITTER_RATIO,
|
|
63
|
+
},
|
|
64
|
+
runtime.random,
|
|
65
|
+
);
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/** Clears the tracked response at the start of every new turn, before any Codex response for it has arrived. */
|
|
@@ -90,9 +99,13 @@ export class CodexRecoveryCapability {
|
|
|
90
99
|
const attempt = this.cooldown?.attempt ?? (state.pending ? state.attempts + 1 : state.attempts);
|
|
91
100
|
const phase = this.cooldown
|
|
92
101
|
? `cooldown ${Math.ceil(Math.max(0, this.cooldown.until - now) / MILLISECONDS_PER_SECOND)}s`
|
|
93
|
-
: state.pending
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
: state.pending
|
|
103
|
+
? "pending"
|
|
104
|
+
: state.attempts >= CODEX_RECOVERY_MAX_ATTEMPTS
|
|
105
|
+
? "exhausted"
|
|
106
|
+
: state.attempts > 0
|
|
107
|
+
? "waiting"
|
|
108
|
+
: "idle";
|
|
96
109
|
const failureKind = this.cooldown?.failureKind ?? state.lastFailureKind;
|
|
97
110
|
return [
|
|
98
111
|
`Codex recovery: ${enabled ? "on" : "off"}`,
|
|
@@ -119,12 +132,15 @@ export class CodexRecoveryCapability {
|
|
|
119
132
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
120
133
|
const attempt = this.policy.recordAttempt(this.runtime.now());
|
|
121
134
|
if (!attempt) return;
|
|
122
|
-
this.pi.sendMessage(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
this.pi.sendMessage(
|
|
136
|
+
{
|
|
137
|
+
customType: "jittor-codex-recovery",
|
|
138
|
+
content: `Retry the previous Codex request after a transient ${attempt.failureKind} failure. Automatic recovery attempt ${attempt.attempt} of ${CODEX_RECOVERY_MAX_ATTEMPTS}.`,
|
|
139
|
+
display: false,
|
|
140
|
+
details: { attempt: attempt.attempt, failureKind: attempt.failureKind },
|
|
141
|
+
},
|
|
142
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
143
|
+
);
|
|
128
144
|
}, plan.delayMs);
|
|
129
145
|
}
|
|
130
146
|
}
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT,
|
|
3
|
-
validateContextContribution,
|
|
4
|
-
type ContextSegment,
|
|
5
|
-
} from "@danypops/jittor";
|
|
1
|
+
import { CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT, type ContextSegment, validateContextContribution } from "@danypops/jittor";
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
4
|
* Merges Jittor's own directly-computed segments (tool ledger, real usage) with whatever
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifyTaskFromTools,
|
|
1
|
+
import { classifyTaskFromTools, type MetricObservation, type ModelRunObservation, modelRunMetrics } from "@danypops/jittor";
|
|
2
2
|
|
|
3
3
|
export interface ActiveLocalModelRun {
|
|
4
4
|
runId: string;
|
|
@@ -59,18 +59,22 @@ export class LocalRunTelemetry {
|
|
|
59
59
|
this.active = undefined;
|
|
60
60
|
if (!active || typeof message !== "object" || message === null || Array.isArray(message)) return [];
|
|
61
61
|
const value = message as Record<string, unknown>;
|
|
62
|
-
if (value
|
|
63
|
-
const usage = typeof value
|
|
64
|
-
const amount = (name: string): number =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
if (value.role !== "assistant" || typeof value.provider !== "string" || typeof value.model !== "string") return [];
|
|
63
|
+
const usage = typeof value.usage === "object" && value.usage !== null ? (value.usage as Record<string, unknown>) : {};
|
|
64
|
+
const amount = (name: string): number =>
|
|
65
|
+
typeof usage[name] === "number" && Number.isFinite(usage[name]) ? (usage[name] as number) : 0;
|
|
66
|
+
const cost =
|
|
67
|
+
typeof usage.cost === "object" && usage.cost !== null && typeof (usage.cost as Record<string, unknown>).total === "number"
|
|
68
|
+
? ((usage.cost as Record<string, number>).total ?? 0)
|
|
69
|
+
: 0;
|
|
70
|
+
const stopReason = ["stop", "length", "toolUse", "error", "aborted"].includes(String(value.stopReason))
|
|
71
|
+
? (value.stopReason as ModelRunObservation["stopReason"])
|
|
72
|
+
: "unknown";
|
|
69
73
|
const completedAt = Math.max(Date.now(), active.firstTokenAt ?? active.startedAt, active.startedAt);
|
|
70
74
|
this.lastCompleted = {
|
|
71
75
|
runId: active.runId,
|
|
72
|
-
provider: value
|
|
73
|
-
model: value
|
|
76
|
+
provider: value.provider,
|
|
77
|
+
model: value.model,
|
|
74
78
|
thinking: thinkingLevel,
|
|
75
79
|
...classifyTaskFromTools(active.toolNames),
|
|
76
80
|
startedAt: active.startedAt,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
classifyGoogleVertexFailure,
|
|
3
|
+
type GoogleVertexFailureMetadata,
|
|
3
4
|
googleVertexFailureMetrics,
|
|
4
5
|
hasAnthropicRateLimitHeaders,
|
|
6
|
+
type MetricObservation,
|
|
5
7
|
parseAnthropicRateLimitHeaders,
|
|
6
8
|
parseCodexRateLimitHeaders,
|
|
7
|
-
type GoogleVertexFailureMetadata,
|
|
8
|
-
type MetricObservation,
|
|
9
9
|
} from "@danypops/jittor";
|
|
10
10
|
import { headerValue } from "./http-headers.ts";
|
|
11
11
|
|
|
@@ -67,22 +67,36 @@ export class ProviderResponseTelemetry {
|
|
|
67
67
|
}
|
|
68
68
|
// Well-evidenced regardless of headers: GCP's own quota system fronts this transport, so the
|
|
69
69
|
// same failure classification as google-vertex applies below.
|
|
70
|
-
this.lastAnthropicVertexResponse = {
|
|
70
|
+
this.lastAnthropicVertexResponse = {
|
|
71
|
+
status,
|
|
72
|
+
...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}),
|
|
73
|
+
};
|
|
71
74
|
}
|
|
72
75
|
if (provider === "google-vertex") {
|
|
73
|
-
this.lastGoogleVertexResponse = {
|
|
76
|
+
this.lastGoogleVertexResponse = {
|
|
77
|
+
status,
|
|
78
|
+
...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}),
|
|
79
|
+
};
|
|
74
80
|
}
|
|
75
81
|
if (Object.keys(headers).some((name) => name.toLowerCase().startsWith("x-codex-"))) {
|
|
76
82
|
try {
|
|
77
83
|
const updates = parseCodexRateLimitHeaders(new Headers(headers), Date.now());
|
|
78
|
-
await recordMetrics(
|
|
84
|
+
await recordMetrics(
|
|
85
|
+
client,
|
|
86
|
+
updates.flatMap((update) => update.metrics),
|
|
87
|
+
);
|
|
79
88
|
} catch {
|
|
80
89
|
notifySchemaDrift("Codex telemetry schema drift");
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
async handleMessageEnd(
|
|
94
|
+
async handleMessageEnd(
|
|
95
|
+
client: ProviderTelemetryClient,
|
|
96
|
+
provider: string | undefined,
|
|
97
|
+
stopReason: string | undefined,
|
|
98
|
+
errorMessage: string | undefined,
|
|
99
|
+
): Promise<void> {
|
|
86
100
|
if (provider === "google-vertex") {
|
|
87
101
|
if (stopReason === "error") {
|
|
88
102
|
const failure = classifyGoogleVertexFailure(errorMessage, this.lastGoogleVertexResponse);
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTEXT_DEFAULT_RESERVE_TOKENS,
|
|
3
|
+
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
4
|
+
CONTEXT_TREE_MAX_NODES,
|
|
5
|
+
type ContextSegment,
|
|
6
|
+
type ContextSegmentItem,
|
|
7
|
+
} from "@danypops/jittor";
|
|
8
|
+
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ported from pi-papyrus's context-budget.ts: the Pi-generic half (session message-history tree
|
|
12
|
+
* walk, base-prompt structural breakdown, and the known-segments-vs-real-total composer) that
|
|
13
|
+
* has nothing to do with Papyrus's own artifacts. Papyrus's rules/tasks segments stay in
|
|
14
|
+
* pi-papyrus, contributed to this same breakdown over CONTEXT_HUB_CONTRIBUTION_CHANNEL instead
|
|
15
|
+
* of being computed here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Session entries and tree nodes as SessionManager exposes them (docs/session-format.md,
|
|
20
|
+
* SessionTreeNode from @earendil-works/pi-coding-agent): a subset covering only the fields
|
|
21
|
+
* this estimate reads, so this stays testable with plain object literals instead of
|
|
22
|
+
* importing pi's own session types.
|
|
23
|
+
*/
|
|
24
|
+
export interface SessionEntryLike {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
message?: unknown;
|
|
28
|
+
summary?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SessionTreeNodeLike {
|
|
31
|
+
entry: SessionEntryLike;
|
|
32
|
+
children: SessionTreeNodeLike[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function messageContentCharacters(message: unknown): number {
|
|
36
|
+
if (typeof message !== "object" || message === null) return 0;
|
|
37
|
+
const record = message as Record<string, unknown>;
|
|
38
|
+
if (record.role === "bashExecution") {
|
|
39
|
+
// Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
|
|
40
|
+
if (record.excludeFromContext === true) return 0;
|
|
41
|
+
return String(record.command ?? "").length + String(record.output ?? "").length;
|
|
42
|
+
}
|
|
43
|
+
const content = record.content;
|
|
44
|
+
if (typeof content === "string") return content.length;
|
|
45
|
+
if (!Array.isArray(content)) return 0;
|
|
46
|
+
let characters = 0;
|
|
47
|
+
for (const block of content) {
|
|
48
|
+
if (typeof block !== "object" || block === null) continue;
|
|
49
|
+
const b = block as Record<string, unknown>;
|
|
50
|
+
if (b.type === "text") characters += String(b.text ?? "").length;
|
|
51
|
+
else if (b.type === "thinking") characters += String(b.thinking ?? "").length;
|
|
52
|
+
else if (b.type === "toolCall") characters += JSON.stringify(b.arguments ?? {}).length;
|
|
53
|
+
// "image" blocks are deliberately not counted here -- image tokens follow a different,
|
|
54
|
+
// non-character-based cost model this char/4 estimate cannot represent; this is a real,
|
|
55
|
+
// documented undercount for image-heavy sessions, not a silent approximation.
|
|
56
|
+
}
|
|
57
|
+
return characters;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function messageSnippet(message: unknown, maxLength = 48): string {
|
|
61
|
+
if (typeof message !== "object" || message === null) return "";
|
|
62
|
+
const record = message as Record<string, unknown>;
|
|
63
|
+
if (record.role === "bashExecution") return String(record.command ?? "");
|
|
64
|
+
const content = record.content;
|
|
65
|
+
const text =
|
|
66
|
+
typeof content === "string"
|
|
67
|
+
? content
|
|
68
|
+
: Array.isArray(content)
|
|
69
|
+
? content
|
|
70
|
+
.map((block) =>
|
|
71
|
+
typeof block === "object" && block !== null && (block as Record<string, unknown>).type === "text"
|
|
72
|
+
? String((block as Record<string, unknown>).text ?? "")
|
|
73
|
+
: "",
|
|
74
|
+
)
|
|
75
|
+
.join(" ")
|
|
76
|
+
: "";
|
|
77
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
78
|
+
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function entryLabel(entry: SessionEntryLike): string {
|
|
82
|
+
if (entry.type === "compaction") return "compaction summary";
|
|
83
|
+
if (entry.type === "branch_summary") return "branch summary";
|
|
84
|
+
const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>).role : undefined;
|
|
85
|
+
const prefix = typeof role === "string" ? role : entry.type;
|
|
86
|
+
const snippet = messageSnippet(entry.message);
|
|
87
|
+
return snippet ? `${prefix}: ${snippet}` : prefix;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface MessageHistoryTree {
|
|
91
|
+
/** One item per real tree root (ordinarily one, the session's first entry). */
|
|
92
|
+
items: ContextSegmentItem[];
|
|
93
|
+
/** Sum of tokens for entries on the CURRENT active path only -- what actually feeds the LLM's context right now, unlike content sitting in an abandoned /tree branch. */
|
|
94
|
+
activeTokens: number;
|
|
95
|
+
/** True if the walk hit CONTEXT_TREE_MAX_NODES or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
|
|
96
|
+
truncated: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface WalkFrame {
|
|
100
|
+
node: SessionTreeNodeLike;
|
|
101
|
+
parentIndex: number | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Walks Pi's own real session tree (ctx.sessionManager.getTree(), docs/session-format.md --
|
|
106
|
+
* entries form a genuine tree via id/parentId, not just the linear current-branch path) to
|
|
107
|
+
* estimate the conversation's context contribution AND surface branches explored via /tree
|
|
108
|
+
* that are no longer on the active path -- content that cost real tokens to generate but is
|
|
109
|
+
* NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES).
|
|
110
|
+
*
|
|
111
|
+
* `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
|
|
112
|
+
* getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
|
|
113
|
+
* to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
|
|
114
|
+
* already summarized away; using it here would overcount activeTokens for any session that has
|
|
115
|
+
* been compacted at all. buildContextEntries() is Pi's own compaction-aware entry list: the
|
|
116
|
+
* latest compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
|
|
117
|
+
*
|
|
118
|
+
* `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
|
|
119
|
+
* When given, an entry on the branch path but excluded from activeEntryIds is labeled
|
|
120
|
+
* "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
|
|
121
|
+
* entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
|
|
122
|
+
* preserves the simpler binary active/inactive-branch labeling for callers that only have one
|
|
123
|
+
* set to give (e.g. tests).
|
|
124
|
+
*
|
|
125
|
+
* Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass followed
|
|
126
|
+
* by a reverse-order (children-before-parent) construction pass -- an ordinary long-running
|
|
127
|
+
* session is one long linear chain, so recursion depth would equal entry count.
|
|
128
|
+
*/
|
|
129
|
+
export function buildMessageHistoryTree(
|
|
130
|
+
roots: ReadonlyArray<SessionTreeNodeLike>,
|
|
131
|
+
activeEntryIds: ReadonlySet<string>,
|
|
132
|
+
branchEntryIds?: ReadonlySet<string>,
|
|
133
|
+
): MessageHistoryTree {
|
|
134
|
+
const visited = new Set<string>();
|
|
135
|
+
let truncated = false;
|
|
136
|
+
let activeTokens = 0;
|
|
137
|
+
|
|
138
|
+
const order: WalkFrame[] = [];
|
|
139
|
+
const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
|
|
140
|
+
while (stack.length > 0) {
|
|
141
|
+
const frame = stack.pop()!;
|
|
142
|
+
if (order.length >= CONTEXT_TREE_MAX_NODES) {
|
|
143
|
+
truncated = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (visited.has(frame.node.entry.id)) {
|
|
147
|
+
truncated = true;
|
|
148
|
+
continue;
|
|
149
|
+
} // cycle guard
|
|
150
|
+
visited.add(frame.node.entry.id);
|
|
151
|
+
const index = order.length;
|
|
152
|
+
order.push(frame);
|
|
153
|
+
const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
|
|
154
|
+
stack.push(...children);
|
|
155
|
+
}
|
|
156
|
+
if (stack.length > 0) truncated = true; // node bound hit with more work still queued
|
|
157
|
+
|
|
158
|
+
const childItemsByParent = new Map<number, ContextSegmentItem[]>();
|
|
159
|
+
const itemByIndex = new Map<number, ContextSegmentItem>();
|
|
160
|
+
for (let index = order.length - 1; index >= 0; index--) {
|
|
161
|
+
const frame = order[index]!;
|
|
162
|
+
const entry = frame.node.entry;
|
|
163
|
+
const characters =
|
|
164
|
+
entry.type === "message"
|
|
165
|
+
? messageContentCharacters(entry.message)
|
|
166
|
+
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
167
|
+
? (entry.summary ?? "").length
|
|
168
|
+
: 0;
|
|
169
|
+
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
170
|
+
const isActive = activeEntryIds.has(entry.id);
|
|
171
|
+
if (isActive) activeTokens += tokens;
|
|
172
|
+
const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
|
|
173
|
+
|
|
174
|
+
const children = childItemsByParent.get(index) ?? [];
|
|
175
|
+
if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
|
|
176
|
+
|
|
177
|
+
const item: ContextSegmentItem = {
|
|
178
|
+
label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
|
|
179
|
+
estimatedTokens: tokens,
|
|
180
|
+
...(children.length > 0 ? { children } : {}),
|
|
181
|
+
};
|
|
182
|
+
itemByIndex.set(index, item);
|
|
183
|
+
if (frame.parentIndex !== null) {
|
|
184
|
+
const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
|
|
185
|
+
siblings.unshift(item); // reverse-order processing -- unshift restores original document order
|
|
186
|
+
childItemsByParent.set(frame.parentIndex, siblings);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const items: ContextSegmentItem[] = [];
|
|
191
|
+
for (let index = 0; index < order.length; index++) {
|
|
192
|
+
if (order[index]!.parentIndex === null) {
|
|
193
|
+
const item = itemByIndex.get(index);
|
|
194
|
+
if (item) items.push(item);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { items, activeTokens, truncated };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function toCeilTokens(characters: number): number {
|
|
201
|
+
return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Splits Pi's base system prompt into real structural sub-segments instead of one opaque
|
|
206
|
+
* number, using BeforeAgentStartEvent's own systemPromptOptions field -- Pi's own doc comment
|
|
207
|
+
* on it: "Extensions can inspect this to understand what Pi loaded without re-discovering
|
|
208
|
+
* resources." No new hook, no new risk: before_agent_start is already wired.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately measures each INPUT's raw content size (tool snippet text, skill metadata,
|
|
211
|
+
* context file content) rather than attempting to byte-for-byte reproduce Pi's internal
|
|
212
|
+
* wrapping/tag format -- buildSystemPrompt() and formatSkillsForPrompt() are Pi-internal
|
|
213
|
+
* functions, not part of the public extension API. The remainder item absorbs whatever
|
|
214
|
+
* wrapping/template text this doesn't attribute, so the segment's total always still matches
|
|
215
|
+
* the real observed prompt length exactly.
|
|
216
|
+
*
|
|
217
|
+
* Measured as of THIS extension's own before_agent_start handler, which runs at whatever point
|
|
218
|
+
* Pi's own extension-load order places it in the before_agent_start chain -- an earlier
|
|
219
|
+
* extension's own systemPrompt mutation (e.g. an injected Rules/Tasks block) is already baked
|
|
220
|
+
* into event.systemPrompt by the time a later handler sees it. There is no per-handler identity
|
|
221
|
+
* in Pi's event payload to detect this, so this measurement is only as "pure Pi base prompt" as
|
|
222
|
+
* this extension's actual position in the load order happens to make it -- a real, documented
|
|
223
|
+
* limitation, not a promise.
|
|
224
|
+
*/
|
|
225
|
+
export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCharacters: number): ContextSegmentItem[] {
|
|
226
|
+
const items: ContextSegmentItem[] = [];
|
|
227
|
+
|
|
228
|
+
const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
|
|
229
|
+
// Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
|
|
230
|
+
// fair estimate without importing Pi-internal formatting code.
|
|
231
|
+
const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
|
|
232
|
+
if (toolSnippetsCharacters > 0) {
|
|
233
|
+
items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
|
|
237
|
+
const skillsCharacters = visibleSkills.reduce(
|
|
238
|
+
(sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20,
|
|
239
|
+
0,
|
|
240
|
+
);
|
|
241
|
+
if (skillsCharacters > 0) {
|
|
242
|
+
items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const contextFiles = options.contextFiles ?? [];
|
|
246
|
+
const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
|
|
247
|
+
if (contextFilesCharacters > 0) {
|
|
248
|
+
items.push({
|
|
249
|
+
label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`,
|
|
250
|
+
estimatedTokens: toCeilTokens(contextFilesCharacters),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
|
|
255
|
+
const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
|
|
256
|
+
if (remainderCharacters > 0 || items.length === 0) {
|
|
257
|
+
items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return items;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Wraps a cached before_agent_start observation into the basePrompt ContextSegment -- `unknown` before any turn has run yet, so a display layer never mistakes "not observed yet" for "measured and empty". */
|
|
264
|
+
export function basePromptSegment(estimatedTokens: number | null, items: ContextSegmentItem[]): ContextSegment {
|
|
265
|
+
return {
|
|
266
|
+
key: "basePrompt",
|
|
267
|
+
label: estimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
268
|
+
estimatedTokens: estimatedTokens ?? 0,
|
|
269
|
+
confidence: "exact-structural",
|
|
270
|
+
...(estimatedTokens === null ? { unknown: true } : {}),
|
|
271
|
+
...(items.length > 0 ? { items } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Wraps a buildMessageHistoryTree() result into the messageHistory ContextSegment -- only the active-path token sum counts toward the segment total; an abandoned /tree branch still appears in items but contributes zero. */
|
|
276
|
+
export function messageHistorySegment(tree: MessageHistoryTree): ContextSegment {
|
|
277
|
+
return {
|
|
278
|
+
key: "messageHistory",
|
|
279
|
+
label: "Conversation message history",
|
|
280
|
+
estimatedTokens: tree.activeTokens,
|
|
281
|
+
confidence: "exact-structural",
|
|
282
|
+
...(tree.items.length > 0 ? { items: tree.items } : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ContextBreakdown {
|
|
287
|
+
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
288
|
+
totalTokens: number | null;
|
|
289
|
+
/** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
|
|
290
|
+
contextWindow: number | null;
|
|
291
|
+
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
292
|
+
effectiveBudget: number | null;
|
|
293
|
+
/**
|
|
294
|
+
* How much the known segments (Jittor's own plus whatever else was contributed) exceed the
|
|
295
|
+
* real total, when they do. Zero means no overshoot. This must stay visible rather than only
|
|
296
|
+
* being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero unaccounted segment
|
|
297
|
+
* does NOT mean wire-protocol overhead is actually free; it means the other segments already
|
|
298
|
+
* consumed the entire real budget on paper. Hiding that distinction would make a genuinely
|
|
299
|
+
* nonzero cost look like zero.
|
|
300
|
+
*/
|
|
301
|
+
overshootTokens: number;
|
|
302
|
+
/** Every input segment, in the order given, plus "other" absorbing whatever real usage the rest don't account for. */
|
|
303
|
+
segments: ContextSegment[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface ComposeContextBreakdownInput {
|
|
307
|
+
totalTokens: number | null;
|
|
308
|
+
contextWindow: number | null;
|
|
309
|
+
reserveTokens?: number;
|
|
310
|
+
/** Every segment currently known: Jittor's own directly-computed ones (basePrompt, messageHistory, toolDefinitions) plus whatever else was contributed on CONTEXT_HUB_CONTRIBUTION_CHANNEL (e.g. Papyrus's rules/tasks). Order is preserved for rendering. */
|
|
311
|
+
segments: ContextSegment[];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Composes every segment currently known (Jittor's own, plus whatever any extension
|
|
316
|
+
* contributed) against the real total Pi reports, deriving "unaccounted" (genuine
|
|
317
|
+
* wire-protocol overhead -- message envelope/role wrapping, cache-control markers -- which
|
|
318
|
+
* really is invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
319
|
+
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
320
|
+
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
321
|
+
* amount itself is preserved as overshootTokens rather than silently discarded, so a consumer
|
|
322
|
+
* can tell "genuinely zero" apart from "our other estimates already exceeded the real total".
|
|
323
|
+
* When the real total is unavailable, unaccounted is reported as zero and totalTokens surfaces
|
|
324
|
+
* as null so callers can label the whole breakdown as estimate-only rather than silently
|
|
325
|
+
* treating a partial sum as ground truth.
|
|
326
|
+
*/
|
|
327
|
+
export function composeContextBreakdown(input: ComposeContextBreakdownInput): ContextBreakdown {
|
|
328
|
+
const reserveTokens = input.reserveTokens ?? CONTEXT_DEFAULT_RESERVE_TOKENS;
|
|
329
|
+
const knownTokens = input.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
330
|
+
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
331
|
+
const other: ContextSegment = {
|
|
332
|
+
key: "other",
|
|
333
|
+
label:
|
|
334
|
+
overshootTokens > 0
|
|
335
|
+
? `Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
|
|
336
|
+
: "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
|
|
337
|
+
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
338
|
+
confidence: "correlated",
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
totalTokens: input.totalTokens,
|
|
342
|
+
contextWindow: input.contextWindow,
|
|
343
|
+
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
344
|
+
overshootTokens,
|
|
345
|
+
segments: [...input.segments, other],
|
|
346
|
+
};
|
|
347
|
+
}
|