@groeponline/pi-wishcraft 0.17.3

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.
Files changed (106) hide show
  1. package/AGENTS.md +68 -0
  2. package/CHANGELOG.md +724 -0
  3. package/CONTRIBUTING.md +37 -0
  4. package/README.md +648 -0
  5. package/RELEASE.md +117 -0
  6. package/ROADMAP.md +52 -0
  7. package/bash-mode/completion-providers.ts +269 -0
  8. package/bash-mode/completion.ts +416 -0
  9. package/bash-mode/editor-ghost.ts +40 -0
  10. package/bash-mode/editor-input.ts +80 -0
  11. package/bash-mode/editor.ts +437 -0
  12. package/bash-mode/history.ts +263 -0
  13. package/bash-mode/shell-session.ts +286 -0
  14. package/bash-mode/transcript.ts +108 -0
  15. package/bash-mode/types.ts +80 -0
  16. package/index.ts +6 -0
  17. package/package.json +55 -0
  18. package/queue/store.ts +443 -0
  19. package/queue/types.ts +54 -0
  20. package/src/config/custom-items.ts +182 -0
  21. package/src/config/extension-statuses.ts +51 -0
  22. package/src/config/layout.ts +60 -0
  23. package/src/config/parse.ts +127 -0
  24. package/src/config/powerline-config.ts +18 -0
  25. package/src/config/presets.ts +245 -0
  26. package/src/config/primitives.ts +117 -0
  27. package/src/config/segment-ids.ts +114 -0
  28. package/src/config/segment-options.ts +128 -0
  29. package/src/config/settings-patch.ts +26 -0
  30. package/src/config/types.ts +277 -0
  31. package/src/core/frontmatter.ts +40 -0
  32. package/src/editor/autocomplete-chain.ts +41 -0
  33. package/src/extension/activate.ts +28 -0
  34. package/src/extension/bash-mode-actions.ts +104 -0
  35. package/src/extension/commands.ts +268 -0
  36. package/src/extension/constants.ts +46 -0
  37. package/src/extension/custom-editor.ts +406 -0
  38. package/src/extension/git-invalidation.ts +40 -0
  39. package/src/extension/layout.ts +160 -0
  40. package/src/extension/menu-views.ts +393 -0
  41. package/src/extension/powerline-widgets.ts +95 -0
  42. package/src/extension/prompt-history.ts +219 -0
  43. package/src/extension/queue-commands.ts +245 -0
  44. package/src/extension/queue-context.ts +12 -0
  45. package/src/extension/queue-integration.ts +434 -0
  46. package/src/extension/segment-context.ts +212 -0
  47. package/src/extension/session-lifecycle.ts +373 -0
  48. package/src/extension/settings-io.ts +202 -0
  49. package/src/extension/shortcuts-config.ts +357 -0
  50. package/src/extension/shortcuts-router.ts +383 -0
  51. package/src/extension/skills/inline-invocation.ts +174 -0
  52. package/src/extension/skills/ook.md +6 -0
  53. package/src/extension/skills/test.md +6 -0
  54. package/src/extension/stale-context.ts +10 -0
  55. package/src/extension/stash-history.ts +103 -0
  56. package/src/extension/state.ts +159 -0
  57. package/src/extension/status-line-renderers.ts +222 -0
  58. package/src/extension/types.ts +97 -0
  59. package/src/extension/vibe-command.ts +160 -0
  60. package/src/extension/welcome-control.ts +27 -0
  61. package/src/extension/welcome-integration.ts +153 -0
  62. package/src/git/status.ts +332 -0
  63. package/src/paths/agent-dirs.ts +67 -0
  64. package/src/render/timer.ts +46 -0
  65. package/src/segments/core.ts +256 -0
  66. package/src/segments/custom.ts +114 -0
  67. package/src/segments/index.ts +3 -0
  68. package/src/segments/registry.ts +87 -0
  69. package/src/segments/shared.ts +36 -0
  70. package/src/segments/system.ts +235 -0
  71. package/src/segments/usage.ts +178 -0
  72. package/src/shell/cd-command.ts +190 -0
  73. package/src/shortcuts/matching.ts +61 -0
  74. package/src/theme/colors.ts +60 -0
  75. package/src/theme/icons.ts +175 -0
  76. package/src/theme/separators.ts +41 -0
  77. package/src/theme/theme.ts +211 -0
  78. package/src/tools/graph.ts +75 -0
  79. package/src/tools/patch.ts +179 -0
  80. package/src/tools/ripgrep.ts +104 -0
  81. package/src/usage/context.ts +97 -0
  82. package/src/usage/ledger.ts +293 -0
  83. package/src/usage/rates.ts +155 -0
  84. package/src/welcome/auto-dismiss.ts +43 -0
  85. package/src/welcome/banner.ts +68 -0
  86. package/src/welcome/discover.ts +234 -0
  87. package/src/welcome/format.ts +18 -0
  88. package/src/welcome/index.ts +5 -0
  89. package/src/welcome/layout.ts +36 -0
  90. package/src/welcome/overlay.ts +80 -0
  91. package/src/welcome/renderer.ts +157 -0
  92. package/src/welcome/sessions.ts +107 -0
  93. package/src/welcome/types.ts +41 -0
  94. package/src/welcome/widgets/graph-widget.ts +25 -0
  95. package/src/welcome/widgets/index.ts +20 -0
  96. package/src/welcome/widgets/queue-widget.ts +26 -0
  97. package/src/welcome/widgets/sessions-widget.ts +23 -0
  98. package/src/welcome/widgets/shortcuts-widget.ts +17 -0
  99. package/src/welcome/widgets/system-widget.ts +29 -0
  100. package/src/working-vibes/generate.ts +144 -0
  101. package/src/working-vibes/index.ts +24 -0
  102. package/src/working-vibes/manager.ts +198 -0
  103. package/src/working-vibes/provider.ts +163 -0
  104. package/src/working-vibes/storage.ts +357 -0
  105. package/theme.example.json +24 -0
  106. package/tsconfig.json +13 -0
@@ -0,0 +1,179 @@
1
+ import { readFileSync, writeFileSync, copyFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentPath } from "../paths/agent-dirs.ts";
4
+
5
+ export interface PatchHunk {
6
+ oldStart: number;
7
+ oldLines: number;
8
+ newStart: number;
9
+ newLines: number;
10
+ lines: string[];
11
+ }
12
+
13
+ export interface FilePatch {
14
+ targetFile: string;
15
+ hunks: PatchHunk[];
16
+ }
17
+
18
+ export interface PatchResult {
19
+ success: boolean;
20
+ targetFile: string;
21
+ appliedHunks: number;
22
+ totalHunks: number;
23
+ backupPath?: string;
24
+ error?: string;
25
+ }
26
+
27
+ const undoStack: { targetFile: string; backupPath: string; timestamp: number }[] = [];
28
+ const MAX_UNDO_STACK = 10;
29
+
30
+ /**
31
+ * Parse a unified diff string into structured FilePatch objects.
32
+ */
33
+ export function parseUnifiedDiff(diffText: string): FilePatch[] {
34
+ const patches: FilePatch[] = [];
35
+ const lines = diffText.split(/\r?\n/);
36
+ let currentPatch: FilePatch | null = null;
37
+ let currentHunk: PatchHunk | null = null;
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+
42
+ if (line.startsWith("--- ")) {
43
+ continue;
44
+ }
45
+
46
+ if (line.startsWith("+++ ")) {
47
+ const rawPath = line.slice(4).trim();
48
+ const targetFile = rawPath.replace(/^[ab]\//, "");
49
+ currentPatch = { targetFile, hunks: [] };
50
+ patches.push(currentPatch);
51
+ continue;
52
+ }
53
+
54
+ const hunkHeaderMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
55
+ if (hunkHeaderMatch && currentPatch) {
56
+ currentHunk = {
57
+ oldStart: parseInt(hunkHeaderMatch[1], 10),
58
+ oldLines: parseInt(hunkHeaderMatch[2] ?? "1", 10),
59
+ newStart: parseInt(hunkHeaderMatch[3], 10),
60
+ newLines: parseInt(hunkHeaderMatch[4] ?? "1", 10),
61
+ lines: [],
62
+ };
63
+ currentPatch.hunks.push(currentHunk);
64
+ continue;
65
+ }
66
+
67
+ if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") || line === "")) {
68
+ currentHunk.lines.push(line);
69
+ }
70
+ }
71
+
72
+ return patches;
73
+ }
74
+
75
+ /**
76
+ * Apply a FilePatch to a file safely with an atomic backup.
77
+ */
78
+ export function applyFilePatch(patch: FilePatch, baseDir: string = process.cwd()): PatchResult {
79
+ const fullPath = join(baseDir, patch.targetFile);
80
+
81
+ if (!existsSync(fullPath)) {
82
+ return {
83
+ success: false,
84
+ targetFile: patch.targetFile,
85
+ appliedHunks: 0,
86
+ totalHunks: patch.hunks.length,
87
+ error: `Target file not found: ${fullPath}`,
88
+ };
89
+ }
90
+
91
+ // 1. Create backup
92
+ const backupDir = getAgentPath("patch-backups");
93
+ if (!existsSync(backupDir)) {
94
+ mkdirSync(backupDir, { recursive: true });
95
+ }
96
+ const backupPath = join(backupDir, `${Date.now()}-${patch.targetFile.replace(/\//g, "_")}.bak`);
97
+ copyFileSync(fullPath, backupPath);
98
+
99
+ try {
100
+ const originalContent = readFileSync(fullPath, "utf-8");
101
+ let fileLines = originalContent.split(/\r?\n/);
102
+ let appliedHunks = 0;
103
+
104
+ for (const hunk of patch.hunks) {
105
+ const targetLineIdx = hunk.oldStart - 1;
106
+ const expectedOldLines = hunk.lines.filter((l) => !l.startsWith("+"));
107
+
108
+ // Match context check
109
+ let matches = true;
110
+ for (let j = 0; j < expectedOldLines.length; j++) {
111
+ const expected = expectedOldLines[j].slice(1);
112
+ const actual = fileLines[targetLineIdx + j];
113
+ if (actual !== undefined && actual !== expected) {
114
+ matches = false;
115
+ break;
116
+ }
117
+ }
118
+
119
+ if (matches) {
120
+ const newHunkLines: string[] = [];
121
+ for (const line of hunk.lines) {
122
+ if (!line.startsWith("-")) {
123
+ newHunkLines.push(line.startsWith("+") ? line.slice(1) : line.slice(1));
124
+ }
125
+ }
126
+
127
+ fileLines.splice(targetLineIdx, hunk.oldLines, ...newHunkLines);
128
+ appliedHunks++;
129
+ }
130
+ }
131
+
132
+ writeFileSync(fullPath, fileLines.join("\n"), "utf-8");
133
+
134
+ // Track undo stack
135
+ undoStack.push({ targetFile: fullPath, backupPath, timestamp: Date.now() });
136
+ if (undoStack.length > MAX_UNDO_STACK) {
137
+ const oldest = undoStack.shift();
138
+ if (oldest && existsSync(oldest.backupPath)) {
139
+ try { unlinkSync(oldest.backupPath); } catch {}
140
+ }
141
+ }
142
+
143
+ return {
144
+ success: appliedHunks > 0,
145
+ targetFile: patch.targetFile,
146
+ appliedHunks,
147
+ totalHunks: patch.hunks.length,
148
+ backupPath,
149
+ };
150
+ } catch (err) {
151
+ // Revert from backup
152
+ copyFileSync(backupPath, fullPath);
153
+ return {
154
+ success: false,
155
+ targetFile: patch.targetFile,
156
+ appliedHunks: 0,
157
+ totalHunks: patch.hunks.length,
158
+ error: err instanceof Error ? err.message : String(err),
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Revert the last applied patch.
165
+ */
166
+ export function undoLastPatch(): { success: boolean; targetFile?: string; error?: string } {
167
+ const last = undoStack.pop();
168
+ if (!last || !existsSync(last.backupPath)) {
169
+ return { success: false, error: "No undo backup available" };
170
+ }
171
+
172
+ try {
173
+ copyFileSync(last.backupPath, last.targetFile);
174
+ unlinkSync(last.backupPath);
175
+ return { success: true, targetFile: last.targetFile };
176
+ } catch (err) {
177
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
178
+ }
179
+ }
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export interface RipgrepMatch {
4
+ file: string;
5
+ lineNumber: number;
6
+ content: string;
7
+ }
8
+
9
+ export interface RipgrepOptions {
10
+ cwd?: string;
11
+ typeFilter?: string;
12
+ maxResults?: number;
13
+ }
14
+
15
+ export interface RipgrepResult {
16
+ matches: RipgrepMatch[];
17
+ totalCount: number;
18
+ engine: "ripgrep" | "grep-fallback";
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Execute a ripgrep (or fallback grep) search programmatically for subagent use.
24
+ */
25
+ export function searchRipgrep(pattern: string, options: RipgrepOptions = {}): RipgrepResult {
26
+ const cwd = options.cwd ?? process.cwd();
27
+ const maxResults = options.maxResults ?? 50;
28
+
29
+ // Try rg first
30
+ try {
31
+ const rgArgs = ["--json", "-m", String(maxResults), "-i"];
32
+ if (options.typeFilter) {
33
+ rgArgs.push("-t", options.typeFilter);
34
+ }
35
+ rgArgs.push(pattern, ".");
36
+
37
+ const proc = spawnSync("rg", rgArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
38
+
39
+ if (proc.status === 0 || proc.status === 1) {
40
+ const matches: RipgrepMatch[] = [];
41
+ const lines = (proc.stdout ?? "").split("\n");
42
+
43
+ for (const line of lines) {
44
+ if (!line.trim()) continue;
45
+ try {
46
+ const parsed = JSON.parse(line);
47
+ if (parsed.type === "match") {
48
+ const data = parsed.data;
49
+ matches.push({
50
+ file: data.path.text,
51
+ lineNumber: data.line_number,
52
+ content: data.lines.text.trimEnd(),
53
+ });
54
+ }
55
+ } catch {
56
+ // ignore non-json lines
57
+ }
58
+ }
59
+
60
+ return {
61
+ matches: matches.slice(0, maxResults),
62
+ totalCount: matches.length,
63
+ engine: "ripgrep",
64
+ };
65
+ }
66
+ } catch {
67
+ // Fallback to standard grep
68
+ }
69
+
70
+ // Fallback: standard grep
71
+ try {
72
+ const grepArgs = ["-rn", "-m", String(maxResults), "--exclude-dir=node_modules", "--exclude-dir=.git", pattern, "."];
73
+ const proc = spawnSync("grep", grepArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
74
+
75
+ const matches: RipgrepMatch[] = [];
76
+ const lines = (proc.stdout ?? "").split("\n");
77
+
78
+ for (const line of lines) {
79
+ if (!line.trim()) continue;
80
+ const parts = line.split(":");
81
+ if (parts.length >= 3) {
82
+ const file = parts[0];
83
+ const lineNumber = parseInt(parts[1], 10);
84
+ const content = parts.slice(2).join(":").trimEnd();
85
+ if (!isNaN(lineNumber)) {
86
+ matches.push({ file, lineNumber, content });
87
+ }
88
+ }
89
+ }
90
+
91
+ return {
92
+ matches: matches.slice(0, maxResults),
93
+ totalCount: matches.length,
94
+ engine: "grep-fallback",
95
+ };
96
+ } catch (err) {
97
+ return {
98
+ matches: [],
99
+ totalCount: 0,
100
+ engine: "grep-fallback",
101
+ error: err instanceof Error ? err.message : String(err),
102
+ };
103
+ }
104
+ }
@@ -0,0 +1,97 @@
1
+ export interface CoreContextUsage {
2
+ contextTokens: number;
3
+ contextWindow: number;
4
+ contextPercent: number;
5
+ }
6
+
7
+ interface ContextUsageSource {
8
+ sessionManager: {
9
+ getLeafId(): string | null;
10
+ };
11
+ getContextUsage(): unknown;
12
+ }
13
+
14
+ function isContextUsageSource(value: unknown): value is ContextUsageSource {
15
+ return (
16
+ typeof value === "object" &&
17
+ value !== null &&
18
+ "sessionManager" in value &&
19
+ "getContextUsage" in value &&
20
+ typeof (value as any).getContextUsage === "function" &&
21
+ typeof (value as any).sessionManager === "object" &&
22
+ (value as any).sessionManager !== null &&
23
+ "getLeafId" in (value as any).sessionManager &&
24
+ typeof (value as any).sessionManager.getLeafId === "function"
25
+ );
26
+ }
27
+
28
+ export class CoreContextUsageCache {
29
+ private _sessionManager: ContextUsageSource["sessionManager"] | null = null;
30
+ private _leafId: string | null = null;
31
+ private _usage: CoreContextUsage | null = null;
32
+
33
+ public get(ctx: unknown): CoreContextUsage | null {
34
+ if (!isContextUsageSource(ctx)) {
35
+ return readCoreContextUsage(ctx);
36
+ }
37
+ const mgr = ctx.sessionManager;
38
+ const leaf = mgr.getLeafId();
39
+ if (this._sessionManager !== mgr || this._leafId !== leaf) {
40
+ this._sessionManager = mgr;
41
+ this._leafId = leaf;
42
+ this._usage = readCoreContextUsage(ctx);
43
+ }
44
+ return this._usage;
45
+ }
46
+
47
+ public reset(): void {
48
+ this._sessionManager = null;
49
+ this._leafId = null;
50
+ this._usage = null;
51
+ }
52
+ }
53
+
54
+ export function estimateInitialContextTokens(ctx: unknown): number | null {
55
+ if (typeof ctx !== "object" || ctx === null || !("getSystemPrompt" in ctx)) {
56
+ return null;
57
+ }
58
+ const getter = (ctx as any).getSystemPrompt;
59
+ if (typeof getter !== "function") return null;
60
+
61
+ const prompt = getter.call(ctx);
62
+ if (typeof prompt !== "string" || prompt.trim() === "") {
63
+ return null;
64
+ }
65
+ return Math.ceil(prompt.length / 4);
66
+ }
67
+
68
+ export function readCoreContextUsage(ctx: unknown): CoreContextUsage | null {
69
+ if (typeof ctx !== "object" || ctx === null || !("getContextUsage" in ctx)) {
70
+ return null;
71
+ }
72
+ const getter = (ctx as any).getContextUsage;
73
+ if (typeof getter !== "function") return null;
74
+
75
+ const usage = getter.call(ctx);
76
+ if (typeof usage !== "object" || usage === null) {
77
+ return null;
78
+ }
79
+
80
+ const u = usage as any;
81
+ if (!("tokens" in u) || typeof u.tokens !== "number" || !Number.isFinite(u.tokens)) {
82
+ return null;
83
+ }
84
+ if (!("contextWindow" in u) || typeof u.contextWindow !== "number" || !Number.isFinite(u.contextWindow) || u.contextWindow <= 0) {
85
+ return null;
86
+ }
87
+
88
+ const pct = "percent" in u && typeof u.percent === "number" && Number.isFinite(u.percent)
89
+ ? u.percent
90
+ : (u.tokens / u.contextWindow) * 100;
91
+
92
+ return {
93
+ contextTokens: u.tokens,
94
+ contextWindow: u.contextWindow,
95
+ contextPercent: pct,
96
+ };
97
+ }
@@ -0,0 +1,293 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+
3
+ export type SessionAssistantUsage = AssistantMessage["usage"];
4
+
5
+ export interface SessionTokenStats {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ cost: number;
11
+ subagentCost: number;
12
+ lastAssistant: AssistantMessage | undefined;
13
+ thinkingLevelFromSession: string | null;
14
+ }
15
+
16
+ export type LedgerSnapshot = SessionTokenStats;
17
+
18
+ export function hasSessionAssistantUsage(value: unknown): value is SessionAssistantUsage {
19
+ return (
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ "input" in value &&
23
+ "output" in value &&
24
+ "cacheRead" in value &&
25
+ "cacheWrite" in value &&
26
+ "cost" in value &&
27
+ typeof (value as any).cost === "object" &&
28
+ "total" in (value as any).cost
29
+ );
30
+ }
31
+
32
+ export function isSessionAssistantMessage(value: unknown): value is AssistantMessage {
33
+ if (typeof value !== "object" || value === null) return false;
34
+ const msg = (value as any);
35
+ return msg.role === "assistant" && hasSessionAssistantUsage(msg.usage);
36
+ }
37
+
38
+ export function getUsageTokenTotal(usage: SessionAssistantUsage): number {
39
+ return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
40
+ }
41
+
42
+ const SUBAGENT_SLASH_RESULT_TYPE = "subagent-slash-result";
43
+ function getSubagentCost(entry: any): number {
44
+ if (!entry || typeof entry !== "object") return 0;
45
+
46
+ let results: unknown[] | undefined;
47
+ if (entry.type === "custom_message" && entry.customType === SUBAGENT_SLASH_RESULT_TYPE) {
48
+ const details = entry.details;
49
+ if (details && typeof details === "object") {
50
+ const result = (details as any).result;
51
+ if (result && typeof result === "object") {
52
+ const inner = (result as any).details;
53
+ if (inner && typeof inner === "object" && Array.isArray((inner as any).results)) {
54
+ results = (inner as any).results;
55
+ }
56
+ }
57
+ }
58
+ } else if (entry.type === "message" && entry.message && typeof entry.message === "object") {
59
+ const m = entry.message as any;
60
+ if (m.role === "toolResult" && m.toolName === "subagent" && m.details && typeof m.details === "object" && Array.isArray(m.details.results)) {
61
+ results = m.details.results;
62
+ }
63
+ }
64
+
65
+ if (!results) return 0;
66
+
67
+ let total = 0;
68
+ for (const r of results) {
69
+ if (r && typeof r === "object" && "usage" in r) {
70
+ const u = (r as any).usage;
71
+ if (u && typeof u === "object" && typeof u.cost === "number") {
72
+ total += u.cost;
73
+ }
74
+ }
75
+ }
76
+ return total;
77
+ }
78
+
79
+ export class TokenLedger {
80
+ private _input = 0;
81
+ private _output = 0;
82
+ private _cacheRead = 0;
83
+ private _cacheWrite = 0;
84
+ private _cost = 0;
85
+ private _subagentCost = 0;
86
+ private _lastAssistant: AssistantMessage | undefined = undefined;
87
+ private _thinkingLevel: string | null = null;
88
+ private _generation = 0;
89
+
90
+ public process(event: unknown): void {
91
+ if (typeof event !== "object" || event === null) return;
92
+ const e = event as any;
93
+
94
+ let changed = false;
95
+
96
+ if (e.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
97
+ this._thinkingLevel = e.thinkingLevel;
98
+ changed = true;
99
+ }
100
+
101
+ const subCost = getSubagentCost(e);
102
+ if (subCost > 0) {
103
+ this._subagentCost += subCost;
104
+ changed = true;
105
+ }
106
+
107
+ if (e.type === "message" && isSessionAssistantMessage(e.message)) {
108
+ const m = e.message;
109
+ if (m.stopReason !== "error" && m.stopReason !== "aborted") {
110
+ this._input += m.usage.input;
111
+ this._output += m.usage.output;
112
+ this._cacheRead += m.usage.cacheRead;
113
+ this._cacheWrite += m.usage.cacheWrite;
114
+ this._cost += m.usage.cost.total;
115
+ if (getUsageTokenTotal(m.usage) > 0) {
116
+ this._lastAssistant = m;
117
+ }
118
+ changed = true;
119
+ }
120
+ }
121
+
122
+ if (changed) {
123
+ this._generation++;
124
+ }
125
+ }
126
+
127
+ public get generation(): number {
128
+ return this._generation;
129
+ }
130
+
131
+ public clone(): TokenLedger {
132
+ const copy = new TokenLedger();
133
+ copy._input = this._input;
134
+ copy._output = this._output;
135
+ copy._cacheRead = this._cacheRead;
136
+ copy._cacheWrite = this._cacheWrite;
137
+ copy._cost = this._cost;
138
+ copy._subagentCost = this._subagentCost;
139
+ copy._lastAssistant = this._lastAssistant;
140
+ copy._thinkingLevel = this._thinkingLevel;
141
+ copy._generation = this._generation;
142
+ return copy;
143
+ }
144
+
145
+ public snapshot(): LedgerSnapshot {
146
+ return {
147
+ input: this._input,
148
+ output: this._output,
149
+ cacheRead: this._cacheRead,
150
+ cacheWrite: this._cacheWrite,
151
+ cost: this._cost,
152
+ subagentCost: this._subagentCost,
153
+ lastAssistant: this._lastAssistant,
154
+ thinkingLevelFromSession: this._thinkingLevel,
155
+ };
156
+ }
157
+ }
158
+
159
+ export interface SessionBranchProvider {
160
+ getLeafId(): string | null;
161
+ getBranch(): readonly unknown[];
162
+ }
163
+
164
+ function isSessionBranchProvider(value: unknown): value is SessionBranchProvider {
165
+ return (
166
+ typeof value === "object" &&
167
+ value !== null &&
168
+ "getLeafId" in value &&
169
+ "getBranch" in value &&
170
+ typeof (value as any).getLeafId === "function" &&
171
+ typeof (value as any).getBranch === "function"
172
+ );
173
+ }
174
+
175
+ export class SessionBranchCache {
176
+ private _provider: SessionBranchProvider | null = null;
177
+ private _leafId: string | null = null;
178
+ private _branch: readonly unknown[] = [];
179
+
180
+ get(source: unknown): readonly unknown[] {
181
+ if (!isSessionBranchProvider(source)) return [];
182
+ const leafId = source.getLeafId();
183
+ if (this._provider !== source || this._leafId !== leafId) {
184
+ this._provider = source;
185
+ this._leafId = leafId;
186
+ this._branch = source.getBranch();
187
+ }
188
+ return this._branch;
189
+ }
190
+
191
+ reset(): void {
192
+ this._provider = null;
193
+ this._leafId = null;
194
+ this._branch = [];
195
+ }
196
+ }
197
+
198
+ export function computeSessionTokenStats(sessionEvents: readonly unknown[]): SessionTokenStats {
199
+ const ledger = new TokenLedger();
200
+ for (const event of sessionEvents) {
201
+ ledger.process(event);
202
+ }
203
+ return ledger.snapshot();
204
+ }
205
+
206
+ function computeEventSignature(event: unknown): string {
207
+ if (typeof event !== "object" || event === null) return "nil";
208
+ const e = event as any;
209
+ if (e.type === "thinking_level_change") return `t:${e.thinkingLevel}`;
210
+ const subCost = getSubagentCost(e);
211
+ if (subCost > 0) return `s:${e.details?.results?.length}:${subCost}`;
212
+ if (e.type === "message" && e.message && e.message.role === "assistant") {
213
+ const m = e.message;
214
+ if (hasSessionAssistantUsage(m.usage)) {
215
+ return `a:${m.stopReason}:${m.usage.input}:${m.usage.output}:${m.usage.cacheRead}:${m.usage.cacheWrite}:${m.usage.cost.total}`;
216
+ }
217
+ return `m:${m.role}:${m.stopReason}`;
218
+ }
219
+ return `e:${e.type}`;
220
+ }
221
+
222
+ export class SessionTokenStatsCache {
223
+ private _processedCount = -1;
224
+ private _tailSignature = "";
225
+ private _tailEvent: unknown = undefined;
226
+
227
+ private _prefixLedger = new TokenLedger();
228
+ private _lastSnapshot: LedgerSnapshot | null = null;
229
+
230
+ get(events: readonly unknown[]): SessionTokenStats {
231
+ const totalCount = events.length;
232
+ const currentTail = totalCount > 0 ? events[totalCount - 1] : undefined;
233
+ const currentTailSignature = computeEventSignature(currentTail);
234
+
235
+ if (
236
+ this._lastSnapshot &&
237
+ this._processedCount === totalCount &&
238
+ this._tailEvent === currentTail &&
239
+ this._tailSignature === currentTailSignature
240
+ ) {
241
+ return this._lastSnapshot;
242
+ }
243
+
244
+ const canExtend = this._lastSnapshot !== null &&
245
+ totalCount > this._processedCount &&
246
+ (this._processedCount === 0 || (
247
+ events[this._processedCount - 1] === this._tailEvent &&
248
+ computeEventSignature(events[this._processedCount - 1]) === this._tailSignature
249
+ ));
250
+
251
+ let activeLedger: TokenLedger;
252
+
253
+ if (canExtend && this._lastSnapshot !== null) {
254
+ // Add the OLD tail to the prefix ledger, as well as any intermediate events
255
+ for (let i = Math.max(0, this._processedCount - 1); i < totalCount - 1; i++) {
256
+ this._prefixLedger.process(events[i]);
257
+ }
258
+ activeLedger = this._prefixLedger.clone();
259
+ } else if (
260
+ this._lastSnapshot !== null &&
261
+ this._processedCount === totalCount &&
262
+ this._tailEvent === currentTail
263
+ ) {
264
+ // In-place mutation of the tail event. The prefix is valid.
265
+ activeLedger = this._prefixLedger.clone();
266
+ } else {
267
+ this._prefixLedger = new TokenLedger();
268
+ for (let i = 0; i < totalCount - 1; i++) {
269
+ this._prefixLedger.process(events[i]);
270
+ }
271
+ activeLedger = this._prefixLedger.clone();
272
+ }
273
+
274
+ if (totalCount > 0) {
275
+ activeLedger.process(currentTail);
276
+ }
277
+
278
+ this._processedCount = totalCount;
279
+ this._tailEvent = currentTail;
280
+ this._tailSignature = currentTailSignature;
281
+ this._lastSnapshot = activeLedger.snapshot();
282
+
283
+ return this._lastSnapshot;
284
+ }
285
+
286
+ reset(): void {
287
+ this._processedCount = -1;
288
+ this._tailEvent = undefined;
289
+ this._tailSignature = "";
290
+ this._prefixLedger = new TokenLedger();
291
+ this._lastSnapshot = null;
292
+ }
293
+ }