@danypops/pi-jittor 0.2.0 → 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 +60 -29
- package/extension/src/context-report.ts +16 -5
- package/extension/src/context-view.ts +39 -7
- package/extension/src/footer.ts +56 -26
- package/extension/src/index.ts +228 -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 +3 -3
|
@@ -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);
|
|
@@ -1,5 +1,11 @@
|
|
|
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";
|
|
1
8
|
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { CONTEXT_DEFAULT_RESERVE_TOKENS, CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_NODES, type ContextSegment, type ContextSegmentItem } from "@danypops/jittor";
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Ported from pi-papyrus's context-budget.ts: the Pi-generic half (session message-history tree
|
|
@@ -29,21 +35,21 @@ export interface SessionTreeNodeLike {
|
|
|
29
35
|
function messageContentCharacters(message: unknown): number {
|
|
30
36
|
if (typeof message !== "object" || message === null) return 0;
|
|
31
37
|
const record = message as Record<string, unknown>;
|
|
32
|
-
if (record
|
|
38
|
+
if (record.role === "bashExecution") {
|
|
33
39
|
// Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
|
|
34
|
-
if (record
|
|
35
|
-
return String(record
|
|
40
|
+
if (record.excludeFromContext === true) return 0;
|
|
41
|
+
return String(record.command ?? "").length + String(record.output ?? "").length;
|
|
36
42
|
}
|
|
37
|
-
const content = record
|
|
43
|
+
const content = record.content;
|
|
38
44
|
if (typeof content === "string") return content.length;
|
|
39
45
|
if (!Array.isArray(content)) return 0;
|
|
40
46
|
let characters = 0;
|
|
41
47
|
for (const block of content) {
|
|
42
48
|
if (typeof block !== "object" || block === null) continue;
|
|
43
49
|
const b = block as Record<string, unknown>;
|
|
44
|
-
if (b
|
|
45
|
-
else if (b
|
|
46
|
-
else if (b
|
|
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;
|
|
47
53
|
// "image" blocks are deliberately not counted here -- image tokens follow a different,
|
|
48
54
|
// non-character-based cost model this char/4 estimate cannot represent; this is a real,
|
|
49
55
|
// documented undercount for image-heavy sessions, not a silent approximation.
|
|
@@ -54,13 +60,20 @@ function messageContentCharacters(message: unknown): number {
|
|
|
54
60
|
function messageSnippet(message: unknown, maxLength = 48): string {
|
|
55
61
|
if (typeof message !== "object" || message === null) return "";
|
|
56
62
|
const record = message as Record<string, unknown>;
|
|
57
|
-
if (record
|
|
58
|
-
const content = record
|
|
59
|
-
const text =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
: "";
|
|
64
77
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
65
78
|
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
|
|
66
79
|
}
|
|
@@ -68,7 +81,7 @@ function messageSnippet(message: unknown, maxLength = 48): string {
|
|
|
68
81
|
function entryLabel(entry: SessionEntryLike): string {
|
|
69
82
|
if (entry.type === "compaction") return "compaction summary";
|
|
70
83
|
if (entry.type === "branch_summary") return "branch summary";
|
|
71
|
-
const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>)
|
|
84
|
+
const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>).role : undefined;
|
|
72
85
|
const prefix = typeof role === "string" ? role : entry.type;
|
|
73
86
|
const snippet = messageSnippet(entry.message);
|
|
74
87
|
return snippet ? `${prefix}: ${snippet}` : prefix;
|
|
@@ -113,7 +126,11 @@ interface WalkFrame {
|
|
|
113
126
|
* by a reverse-order (children-before-parent) construction pass -- an ordinary long-running
|
|
114
127
|
* session is one long linear chain, so recursion depth would equal entry count.
|
|
115
128
|
*/
|
|
116
|
-
export function buildMessageHistoryTree(
|
|
129
|
+
export function buildMessageHistoryTree(
|
|
130
|
+
roots: ReadonlyArray<SessionTreeNodeLike>,
|
|
131
|
+
activeEntryIds: ReadonlySet<string>,
|
|
132
|
+
branchEntryIds?: ReadonlySet<string>,
|
|
133
|
+
): MessageHistoryTree {
|
|
117
134
|
const visited = new Set<string>();
|
|
118
135
|
let truncated = false;
|
|
119
136
|
let activeTokens = 0;
|
|
@@ -122,8 +139,14 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
|
|
|
122
139
|
const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
|
|
123
140
|
while (stack.length > 0) {
|
|
124
141
|
const frame = stack.pop()!;
|
|
125
|
-
if (order.length >= CONTEXT_TREE_MAX_NODES) {
|
|
126
|
-
|
|
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
|
|
127
150
|
visited.add(frame.node.entry.id);
|
|
128
151
|
const index = order.length;
|
|
129
152
|
order.push(frame);
|
|
@@ -137,11 +160,12 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
|
|
|
137
160
|
for (let index = order.length - 1; index >= 0; index--) {
|
|
138
161
|
const frame = order[index]!;
|
|
139
162
|
const entry = frame.node.entry;
|
|
140
|
-
const characters =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
145
169
|
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
146
170
|
const isActive = activeEntryIds.has(entry.id);
|
|
147
171
|
if (isActive) activeTokens += tokens;
|
|
@@ -210,7 +234,10 @@ export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCha
|
|
|
210
234
|
}
|
|
211
235
|
|
|
212
236
|
const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
|
|
213
|
-
const skillsCharacters = visibleSkills.reduce(
|
|
237
|
+
const skillsCharacters = visibleSkills.reduce(
|
|
238
|
+
(sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20,
|
|
239
|
+
0,
|
|
240
|
+
);
|
|
214
241
|
if (skillsCharacters > 0) {
|
|
215
242
|
items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
|
|
216
243
|
}
|
|
@@ -218,7 +245,10 @@ export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCha
|
|
|
218
245
|
const contextFiles = options.contextFiles ?? [];
|
|
219
246
|
const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
|
|
220
247
|
if (contextFilesCharacters > 0) {
|
|
221
|
-
items.push({
|
|
248
|
+
items.push({
|
|
249
|
+
label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`,
|
|
250
|
+
estimatedTokens: toCeilTokens(contextFilesCharacters),
|
|
251
|
+
});
|
|
222
252
|
}
|
|
223
253
|
|
|
224
254
|
const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
|
|
@@ -300,9 +330,10 @@ export function composeContextBreakdown(input: ComposeContextBreakdownInput): Co
|
|
|
300
330
|
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
301
331
|
const other: ContextSegment = {
|
|
302
332
|
key: "other",
|
|
303
|
-
label:
|
|
304
|
-
|
|
305
|
-
|
|
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)",
|
|
306
337
|
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
307
338
|
confidence: "correlated",
|
|
308
339
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import type { ContextSegment } from "@danypops/jittor";
|
|
1
2
|
import { buildContextRows, type ContextSegment as MalevichContextSegment } from "malevich-tui-components";
|
|
2
3
|
import type { ContextBreakdown } from "./context-breakdown.ts";
|
|
3
|
-
import type { ContextSegment } from "@danypops/jittor";
|
|
4
4
|
|
|
5
5
|
/** Bounds how many items render per segment in the plain-text fallback -- a notify-mode report is a scan-at-a-glance summary, not a full dump (the interactive TUI view has no such cap, since it scrolls). */
|
|
6
6
|
const MAX_ITEMS_PER_SEGMENT_LINE = 5;
|
|
@@ -15,8 +15,16 @@ function percentOf(part: number, whole: number): string {
|
|
|
15
15
|
|
|
16
16
|
/** Malevich's row builder is confidence-unaware (it's a generic segment/item shape); folding the tier into the label is how it survives into the rendered row text, e.g. "Active Rules [exact-cooperative]". */
|
|
17
17
|
function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
|
|
18
|
-
const items = [...(segment.items ?? [])]
|
|
19
|
-
|
|
18
|
+
const items = [...(segment.items ?? [])]
|
|
19
|
+
.sort((left, right) => right.estimatedTokens - left.estimatedTokens)
|
|
20
|
+
.slice(0, MAX_ITEMS_PER_SEGMENT_LINE);
|
|
21
|
+
return {
|
|
22
|
+
key: segment.key,
|
|
23
|
+
label: `${segment.label} [${segment.confidence}]`,
|
|
24
|
+
estimatedTokens: segment.estimatedTokens,
|
|
25
|
+
items,
|
|
26
|
+
unknown: segment.unknown,
|
|
27
|
+
};
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -29,13 +37,16 @@ function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
|
|
|
29
37
|
export function buildContextReport(breakdown: ContextBreakdown): string {
|
|
30
38
|
const lines: string[] = [];
|
|
31
39
|
if (breakdown.totalTokens !== null && breakdown.effectiveBudget !== null) {
|
|
32
|
-
lines.push(
|
|
40
|
+
lines.push(
|
|
41
|
+
`Real usage: ${formatTokens(breakdown.totalTokens)} / ${formatTokens(breakdown.effectiveBudget)} tokens (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)} of usable budget)`,
|
|
42
|
+
);
|
|
33
43
|
} else if (breakdown.totalTokens !== null) {
|
|
34
44
|
lines.push(`Real usage: ${formatTokens(breakdown.totalTokens)} tokens (model context window unknown)`);
|
|
35
45
|
} else {
|
|
36
46
|
lines.push("Real usage: not yet reported -- sizes below are estimates only");
|
|
37
47
|
}
|
|
38
|
-
if (breakdown.overshootTokens > 0)
|
|
48
|
+
if (breakdown.overshootTokens > 0)
|
|
49
|
+
lines.push(`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`);
|
|
39
50
|
|
|
40
51
|
const sorted = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
|
|
41
52
|
const rows = buildContextRows(sorted, breakdown.totalTokens ?? undefined);
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { buildContextRows, renderContextRowLines, renderContextUsageBar, type ContextBarTheme, type ContextRow, type ContextRowsTheme, type ContextSegment as MalevichContextSegment } from "malevich-tui-components";
|
|
4
1
|
import type { ContextSegment } from "@danypops/jittor";
|
|
2
|
+
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
buildContextRows,
|
|
6
|
+
type ContextBarTheme,
|
|
7
|
+
type ContextRow,
|
|
8
|
+
type ContextRowsTheme,
|
|
9
|
+
type ContextSegment as MalevichContextSegment,
|
|
10
|
+
renderContextRowLines,
|
|
11
|
+
renderContextUsageBar,
|
|
12
|
+
} from "malevich-tui-components";
|
|
5
13
|
import type { ContextBreakdown } from "./context-breakdown.ts";
|
|
6
14
|
import { buildContextReport } from "./context-report.ts";
|
|
7
15
|
|
|
@@ -32,7 +40,13 @@ function percentOf(part: number, whole: number): string {
|
|
|
32
40
|
|
|
33
41
|
/** Folds each segment's confidence tier into its label so it survives Malevich's confidence-unaware row builder. */
|
|
34
42
|
function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
|
|
35
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
key: segment.key,
|
|
45
|
+
label: `${segment.label} [${segment.confidence}]`,
|
|
46
|
+
estimatedTokens: segment.estimatedTokens,
|
|
47
|
+
items: segment.items,
|
|
48
|
+
unknown: segment.unknown,
|
|
49
|
+
};
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
class ContextViewport {
|
|
@@ -62,7 +76,13 @@ class ContextViewport {
|
|
|
62
76
|
|
|
63
77
|
const { totalTokens, effectiveBudget } = this.breakdown;
|
|
64
78
|
if (totalTokens !== null && effectiveBudget !== null) {
|
|
65
|
-
lines.push(
|
|
79
|
+
lines.push(
|
|
80
|
+
truncateToWidth(
|
|
81
|
+
`${formatTokenCount(totalTokens)} / ${formatTokenCount(effectiveBudget)} tokens (${percentOf(totalTokens, effectiveBudget)} of usable budget)`,
|
|
82
|
+
contentWidth,
|
|
83
|
+
"",
|
|
84
|
+
),
|
|
85
|
+
);
|
|
66
86
|
} else if (totalTokens !== null) {
|
|
67
87
|
lines.push(truncateToWidth(`${formatTokenCount(totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
68
88
|
} else {
|
|
@@ -73,7 +93,16 @@ class ContextViewport {
|
|
|
73
93
|
const barTheme: ContextBarTheme = { colorFor, empty: (s) => theme.fg("dim", s) };
|
|
74
94
|
lines.push(renderContextUsageBar(barTheme, this.segments, contentWidth, effectiveBudget ?? undefined, totalTokens ?? undefined));
|
|
75
95
|
if (this.breakdown.overshootTokens > 0) {
|
|
76
|
-
lines.push(
|
|
96
|
+
lines.push(
|
|
97
|
+
truncateToWidth(
|
|
98
|
+
theme.fg(
|
|
99
|
+
"warning",
|
|
100
|
+
`Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`,
|
|
101
|
+
),
|
|
102
|
+
contentWidth,
|
|
103
|
+
"",
|
|
104
|
+
),
|
|
105
|
+
);
|
|
77
106
|
}
|
|
78
107
|
lines.push("");
|
|
79
108
|
|
|
@@ -90,7 +119,10 @@ class ContextViewport {
|
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
handleInput(data: string): void {
|
|
93
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
122
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
123
|
+
this.close();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
94
126
|
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
95
127
|
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
96
128
|
else return;
|