@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.
@@ -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.map((component) => `${COMPONENT_LABELS[component.name]} ${component.score === null ? "?" : component.score.toFixed(3)}`).join(" · ");
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.slice(0, BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE).map((source) => `${source.sourceId}@${source.revision} ${source.freshness}`).join(" · ");
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 = recommended && currentIndex > 0
54
- ? `Recommendation differs from current: ${recommended.identity} ranks #1; current ranks #${currentIndex + 1}.`
55
- : recommended && currentIndex === 0 ? "Current model is the top recommendation." : "Current model is outside the ranked candidates.";
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" ? "Scope: exact session" : "Scope: available models · ADVISORY (exact session scope unavailable)",
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(renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"), "info");
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[] { return renderBenchmarkView(result, currentIdentity, width, theme); },
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) { return setTimeout(() => { void callback(); }, delayMs); },
29
- clearTimeout(handle) { clearTimeout(handle as ReturnType<typeof setTimeout>); },
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
- baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
52
- maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
53
- maxAttempts: CODEX_RECOVERY_MAX_ATTEMPTS,
54
- attemptWindowMs: CODEX_RECOVERY_ATTEMPT_WINDOW_MS,
55
- jitterRatio: CODEX_RECOVERY_JITTER_RATIO,
56
- }, runtime.random);
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 ? "pending"
94
- : state.attempts >= CODEX_RECOVERY_MAX_ATTEMPTS ? "exhausted"
95
- : state.attempts > 0 ? "waiting" : "idle";
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
- customType: "jittor-codex-recovery",
124
- content: `Retry the previous Codex request after a transient ${attempt.failureKind} failure. Automatic recovery attempt ${attempt.attempt} of ${CODEX_RECOVERY_MAX_ATTEMPTS}.`,
125
- display: false,
126
- details: { attempt: attempt.attempt, failureKind: attempt.failureKind },
127
- }, { triggerTurn: true, deliverAs: "followUp" });
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, modelRunMetrics, type MetricObservation, type ModelRunObservation } from "@danypops/jittor";
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["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 => typeof usage[name] === "number" && Number.isFinite(usage[name]) ? usage[name] as number : 0;
65
- const cost = typeof usage["cost"] === "object" && usage["cost"] !== null && typeof (usage["cost"] as Record<string, unknown>)["total"] === "number"
66
- ? (usage["cost"] as Record<string, number>)["total"] ?? 0 : 0;
67
- const stopReason = ["stop", "length", "toolUse", "error", "aborted"].includes(String(value["stopReason"]))
68
- ? value["stopReason"] as ModelRunObservation["stopReason"] : "unknown";
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["provider"],
73
- model: value["model"],
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 = { status, ...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}) };
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 = { status, ...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}) };
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(client, updates.flatMap((update) => update.metrics));
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(client: ProviderTelemetryClient, provider: string | undefined, stopReason: string | undefined, errorMessage: string | undefined): Promise<void> {
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
+ }