@aaroncql/pim-agent 0.0.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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,188 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { STATUSES, type TodoItem, type TodoStatus } from "./schema";
3
+
4
+ export const TODO_STATE_CUSTOM_TYPE = "pim-todo-state";
5
+
6
+ export type TodoSummary = Record<TodoStatus, number>;
7
+
8
+ export type TodoDetails = {
9
+ readonly todos: readonly TodoItem[];
10
+ readonly summary: TodoSummary;
11
+ };
12
+
13
+ export type FormatChecklistOptions = {
14
+ readonly activeOnly?: boolean;
15
+ };
16
+
17
+ // Identity key for the per-session state slot. Extracted from ExtensionContext
18
+ // because ReadonlySessionManager isn't on the package's public entry point.
19
+ // Only identity is used (no methods called) — WeakMap reclaims the slot when
20
+ // the session is disposed.
21
+ export type TodoSessionKey = ExtensionContext["sessionManager"];
22
+
23
+ const itemsBySession = new WeakMap<TodoSessionKey, TodoItem[]>();
24
+
25
+ const markers: Record<TodoStatus, string> = {
26
+ pending: "[ ]",
27
+ in_progress: "[>]",
28
+ completed: "[x]",
29
+ cancelled: "[~]",
30
+ };
31
+
32
+ const statusSet: ReadonlySet<TodoStatus> = new Set(STATUSES);
33
+
34
+ export function getCurrentItems(
35
+ sessionManager: TodoSessionKey
36
+ ): readonly TodoItem[] {
37
+ return itemsBySession.get(sessionManager) ?? [];
38
+ }
39
+
40
+ export function replaceItems(
41
+ sessionManager: TodoSessionKey,
42
+ items: readonly TodoItem[]
43
+ ): readonly TodoItem[] {
44
+ const normalized = normalizeItems(items);
45
+ itemsBySession.set(sessionManager, normalized);
46
+ return normalized;
47
+ }
48
+
49
+ export function resetItems(sessionManager: TodoSessionKey): void {
50
+ itemsBySession.set(sessionManager, []);
51
+ }
52
+
53
+ export function reconstructFromBranch(
54
+ sessionManager: TodoSessionKey,
55
+ branch: readonly unknown[]
56
+ ): readonly TodoItem[] {
57
+ const items = findLatestTodoItems(branch);
58
+ itemsBySession.set(sessionManager, items);
59
+ return items;
60
+ }
61
+
62
+ export function normalizeItems(items: readonly TodoItem[]): TodoItem[] {
63
+ return items.flatMap((item) => {
64
+ const content = normalizeContent(item.content);
65
+ return content ? [{ content, status: item.status }] : [];
66
+ });
67
+ }
68
+
69
+ export function hasActiveItems(items: readonly TodoItem[]): boolean {
70
+ return items.some(isActive);
71
+ }
72
+
73
+ export function summarizeItems(items: readonly TodoItem[]): TodoSummary {
74
+ const summary: TodoSummary = {
75
+ pending: 0,
76
+ in_progress: 0,
77
+ completed: 0,
78
+ cancelled: 0,
79
+ };
80
+ for (const item of items) {
81
+ summary[item.status] += 1;
82
+ }
83
+ return summary;
84
+ }
85
+
86
+ export function makeDetails(items: readonly TodoItem[]): TodoDetails {
87
+ return {
88
+ todos: structuredClone(items),
89
+ summary: summarizeItems(items),
90
+ };
91
+ }
92
+
93
+ export function formatUpdateSummary(items: readonly TodoItem[]): string {
94
+ const summary = summarizeItems(items);
95
+ const segments = [
96
+ summary.completed > 0 ? `${summary.completed} completed` : undefined,
97
+ summary.in_progress > 0 ? `${summary.in_progress} in progress` : undefined,
98
+ summary.pending > 0 ? `${summary.pending} pending` : undefined,
99
+ summary.cancelled > 0 ? `${summary.cancelled} cancelled` : undefined,
100
+ ].filter((segment) => segment !== undefined);
101
+ return segments.length === 0
102
+ ? "Todos cleared."
103
+ : `Todos updated: ${segments.join(", ")}.`;
104
+ }
105
+
106
+ export function formatChecklist(
107
+ items: readonly TodoItem[],
108
+ options: FormatChecklistOptions = {}
109
+ ): string {
110
+ return items
111
+ .filter((item) => !options.activeOnly || isActive(item))
112
+ .map((item) => `${markers[item.status]} ${item.content}`)
113
+ .join("\n");
114
+ }
115
+
116
+ function isActive(item: TodoItem): boolean {
117
+ return item.status === "pending" || item.status === "in_progress";
118
+ }
119
+
120
+ function normalizeContent(content: string): string {
121
+ return content.trim().replaceAll(/\s+/g, " ");
122
+ }
123
+
124
+ function findLatestTodoItems(branch: readonly unknown[]): TodoItem[] {
125
+ for (let i = branch.length - 1; i >= 0; i--) {
126
+ const items = extractTodoItems(branch[i]);
127
+ if (items) {
128
+ return items;
129
+ }
130
+ }
131
+ return [];
132
+ }
133
+
134
+ function extractTodoItems(entry: unknown): TodoItem[] | undefined {
135
+ if (!isRecord(entry)) {
136
+ return undefined;
137
+ }
138
+ if (entry.type === "message") {
139
+ const message = entry.message;
140
+ if (
141
+ !isRecord(message) ||
142
+ message.role !== "toolResult" ||
143
+ message.toolName !== "todo"
144
+ ) {
145
+ return undefined;
146
+ }
147
+ const details = message.details;
148
+ if (!isRecord(details) || !Array.isArray(details.todos)) {
149
+ return undefined;
150
+ }
151
+ return normalizeUnknownItems(details.todos);
152
+ }
153
+ if (entry.type === "custom" && entry.customType === TODO_STATE_CUSTOM_TYPE) {
154
+ const data = entry.data;
155
+ if (!isRecord(data) || !Array.isArray(data.todos)) {
156
+ return undefined;
157
+ }
158
+ return normalizeUnknownItems(data.todos);
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ function normalizeUnknownItems(items: readonly unknown[]): TodoItem[] {
164
+ const out: TodoItem[] = [];
165
+ for (const item of items) {
166
+ if (!isRecord(item)) {
167
+ continue;
168
+ }
169
+ const { content, status } = item;
170
+ if (typeof content !== "string" || !isStatus(status)) {
171
+ continue;
172
+ }
173
+ const normalizedContent = normalizeContent(content);
174
+ if (!normalizedContent) {
175
+ continue;
176
+ }
177
+ out.push({ content: normalizedContent, status });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function isStatus(value: unknown): value is TodoStatus {
183
+ return typeof value === "string" && statusSet.has(value as TodoStatus);
184
+ }
185
+
186
+ function isRecord(value: unknown): value is Record<string, unknown> {
187
+ return typeof value === "object" && value !== null && !Array.isArray(value);
188
+ }
@@ -0,0 +1,254 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { PimSettings } from "../../shared/PimSettings";
4
+ import registerTps from "./index";
5
+
6
+ type Handler = (event: unknown, ctx: unknown) => unknown;
7
+
8
+ type MockPi = {
9
+ readonly api: ExtensionAPI;
10
+ readonly handlers: Map<string, Handler[]>;
11
+ };
12
+
13
+ const originalNow = Date.now;
14
+ const originalGet = PimSettings.get;
15
+
16
+ let now = 0;
17
+
18
+ function createPi(): MockPi {
19
+ const handlers = new Map<string, Handler[]>();
20
+ const api = {
21
+ on(event: string, handler: Handler): void {
22
+ const existing = handlers.get(event) ?? [];
23
+ existing.push(handler);
24
+ handlers.set(event, existing);
25
+ },
26
+ registerCommand(): void {},
27
+ } as unknown as ExtensionAPI;
28
+
29
+ return { api, handlers };
30
+ }
31
+
32
+ async function emit(
33
+ pi: MockPi,
34
+ event: string,
35
+ payload: unknown,
36
+ ctx: unknown
37
+ ): Promise<void> {
38
+ for (const handler of pi.handlers.get(event) ?? []) {
39
+ await handler(payload, ctx);
40
+ }
41
+ }
42
+
43
+ const assistantMessage = {
44
+ role: "assistant",
45
+ content: [{ type: "text", text: "hello" }],
46
+ api: "openai",
47
+ provider: "openai",
48
+ model: "test-model",
49
+ usage: {
50
+ input: 1000,
51
+ output: 50,
52
+ cacheRead: 5000,
53
+ cacheWrite: 100,
54
+ totalTokens: 6150,
55
+ cost: {
56
+ input: 0,
57
+ output: 0,
58
+ cacheRead: 0,
59
+ cacheWrite: 0,
60
+ total: 0,
61
+ },
62
+ },
63
+ stopReason: "stop",
64
+ timestamp: 1000,
65
+ } as const;
66
+
67
+ describe("tps extension", () => {
68
+ beforeEach(() => {
69
+ now = 0;
70
+ Date.now = () => now;
71
+ Object.defineProperty(PimSettings, "get", {
72
+ value: async () => ({ enabled: true }),
73
+ });
74
+ });
75
+
76
+ afterEach(() => {
77
+ Date.now = originalNow;
78
+ Object.defineProperty(PimSettings, "get", { value: originalGet });
79
+ });
80
+
81
+ test("reports metrics when stream updates and final message are different objects", async () => {
82
+ const pi = createPi();
83
+ const notifications: string[] = [];
84
+ const ctx = {
85
+ hasUI: true,
86
+ ui: {
87
+ notify(message: string): void {
88
+ notifications.push(message);
89
+ },
90
+ },
91
+ };
92
+
93
+ registerTps(pi.api);
94
+
95
+ await emit(pi, "agent_start", { type: "agent_start" }, ctx);
96
+
97
+ now = 1000;
98
+ await emit(
99
+ pi,
100
+ "before_provider_request",
101
+ { type: "before_provider_request", payload: {} },
102
+ ctx
103
+ );
104
+
105
+ now = 1150;
106
+ await emit(
107
+ pi,
108
+ "message_update",
109
+ {
110
+ type: "message_update",
111
+ message: { ...assistantMessage },
112
+ assistantMessageEvent: {
113
+ type: "text_start",
114
+ contentIndex: 0,
115
+ partial: assistantMessage,
116
+ },
117
+ },
118
+ ctx
119
+ );
120
+
121
+ now = 1200;
122
+ await emit(
123
+ pi,
124
+ "message_update",
125
+ {
126
+ type: "message_update",
127
+ message: { ...assistantMessage },
128
+ assistantMessageEvent: {
129
+ type: "text_delta",
130
+ contentIndex: 0,
131
+ delta: "h",
132
+ partial: assistantMessage,
133
+ },
134
+ },
135
+ ctx
136
+ );
137
+
138
+ now = 2200;
139
+ await emit(
140
+ pi,
141
+ "message_end",
142
+ { type: "message_end", message: assistantMessage },
143
+ ctx
144
+ );
145
+ await emit(
146
+ pi,
147
+ "agent_end",
148
+ { type: "agent_end", messages: [assistantMessage] },
149
+ ctx
150
+ );
151
+
152
+ expect(notifications).toEqual([
153
+ "Decode: 50.0 tps | Prefill: 5500.0 tps | Cache read: 5,000 | TTFT: 0.20s",
154
+ ]);
155
+ });
156
+
157
+ test("reports once at the end of a multi-turn agent cycle", async () => {
158
+ const pi = createPi();
159
+ const notifications: string[] = [];
160
+ const ctx = {
161
+ hasUI: true,
162
+ ui: {
163
+ notify(message: string): void {
164
+ notifications.push(message);
165
+ },
166
+ },
167
+ };
168
+
169
+ registerTps(pi.api);
170
+
171
+ await emit(pi, "agent_start", { type: "agent_start" }, ctx);
172
+
173
+ now = 1000;
174
+ await emit(
175
+ pi,
176
+ "before_provider_request",
177
+ { type: "before_provider_request", payload: {} },
178
+ ctx
179
+ );
180
+ now = 1200;
181
+ await emit(
182
+ pi,
183
+ "message_update",
184
+ {
185
+ type: "message_update",
186
+ message: { ...assistantMessage },
187
+ assistantMessageEvent: {
188
+ type: "thinking_delta",
189
+ contentIndex: 0,
190
+ delta: "thinking",
191
+ partial: assistantMessage,
192
+ },
193
+ },
194
+ ctx
195
+ );
196
+ now = 2200;
197
+ await emit(
198
+ pi,
199
+ "message_end",
200
+ { type: "message_end", message: assistantMessage },
201
+ ctx
202
+ );
203
+ await emit(
204
+ pi,
205
+ "turn_end",
206
+ { type: "turn_end", message: assistantMessage, toolResults: [] },
207
+ ctx
208
+ );
209
+
210
+ now = 3000;
211
+ await emit(
212
+ pi,
213
+ "before_provider_request",
214
+ { type: "before_provider_request", payload: {} },
215
+ ctx
216
+ );
217
+ now = 3200;
218
+ await emit(
219
+ pi,
220
+ "message_update",
221
+ {
222
+ type: "message_update",
223
+ message: { ...assistantMessage },
224
+ assistantMessageEvent: {
225
+ type: "text_delta",
226
+ contentIndex: 0,
227
+ delta: "h",
228
+ partial: assistantMessage,
229
+ },
230
+ },
231
+ ctx
232
+ );
233
+ now = 4200;
234
+ await emit(
235
+ pi,
236
+ "message_end",
237
+ { type: "message_end", message: assistantMessage },
238
+ ctx
239
+ );
240
+
241
+ expect(notifications).toEqual([]);
242
+
243
+ await emit(
244
+ pi,
245
+ "agent_end",
246
+ { type: "agent_end", messages: [assistantMessage, assistantMessage] },
247
+ ctx
248
+ );
249
+
250
+ expect(notifications).toEqual([
251
+ "Decode: 50.0 tps | Prefill: 5500.0 tps | Cache read: 10,000 | TTFT: 0.20s",
252
+ ]);
253
+ });
254
+ });
@@ -0,0 +1,136 @@
1
+ import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { PimSettings } from "../../shared/PimSettings";
4
+
5
+ type RequestTiming = {
6
+ readonly sentMs: number;
7
+ firstOutputMs: number | null;
8
+ };
9
+
10
+ function isOutputEvent(event: AssistantMessageEvent): boolean {
11
+ switch (event.type) {
12
+ case "text_delta":
13
+ case "thinking_delta":
14
+ case "toolcall_delta":
15
+ return event.delta.length > 0;
16
+ case "text_end":
17
+ case "thinking_end":
18
+ return event.content.length > 0;
19
+ case "toolcall_end":
20
+ return true;
21
+ default:
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI): void {
27
+ pi.registerCommand("tps", {
28
+ description: "Toggle per-cycle decode/prefill tps reporting",
29
+ handler: async (_args, ctx) => {
30
+ const current = await PimSettings.get("tps");
31
+ const next = { ...current, enabled: !current.enabled };
32
+ await PimSettings.set("tps", next);
33
+ ctx.ui.notify(
34
+ `TPS reporting ${next.enabled ? "enabled" : "disabled"}`,
35
+ "info"
36
+ );
37
+ },
38
+ });
39
+
40
+ let requestTiming: RequestTiming | null = null;
41
+
42
+ let promptTokens = 0;
43
+ let prefillMs = 0;
44
+ let outputTokens = 0;
45
+ let decodeMs = 0;
46
+ let cacheReadTokens = 0;
47
+ let firstTtftMs: number | null = null;
48
+
49
+ pi.on("agent_start", () => {
50
+ promptTokens = 0;
51
+ prefillMs = 0;
52
+ outputTokens = 0;
53
+ decodeMs = 0;
54
+ cacheReadTokens = 0;
55
+ firstTtftMs = null;
56
+ });
57
+
58
+ pi.on("before_provider_request", () => {
59
+ requestTiming = {
60
+ sentMs: Date.now(),
61
+ firstOutputMs: null,
62
+ };
63
+ });
64
+
65
+ pi.on("message_update", (event) => {
66
+ if (
67
+ event.message.role === "assistant" &&
68
+ requestTiming !== null &&
69
+ requestTiming.firstOutputMs === null &&
70
+ isOutputEvent(event.assistantMessageEvent)
71
+ ) {
72
+ requestTiming.firstOutputMs = Date.now();
73
+ }
74
+ });
75
+
76
+ pi.on("message_end", (event) => {
77
+ if (event.message.role !== "assistant") {
78
+ return;
79
+ }
80
+
81
+ const timing = requestTiming;
82
+ const endedMs = Date.now();
83
+ requestTiming = null;
84
+ if (timing === null) {
85
+ return;
86
+ }
87
+
88
+ const usage = event.message.usage;
89
+ const responseMs = timing.firstOutputMs ?? endedMs;
90
+ const ttft = responseMs - timing.sentMs;
91
+ const decode = endedMs - responseMs;
92
+
93
+ if (firstTtftMs === null && ttft > 0) {
94
+ firstTtftMs = ttft;
95
+ }
96
+
97
+ const prefillCounted = (usage.input ?? 0) + (usage.cacheWrite ?? 0);
98
+ if (prefillCounted > 0 && ttft > 0) {
99
+ promptTokens += prefillCounted;
100
+ prefillMs += ttft;
101
+ }
102
+ if ((usage.output ?? 0) > 0 && decode > 0) {
103
+ outputTokens += usage.output;
104
+ decodeMs += decode;
105
+ }
106
+ cacheReadTokens += usage.cacheRead ?? 0;
107
+ });
108
+
109
+ pi.on("agent_end", async (_event, ctx) => {
110
+ if (!ctx.hasUI) {
111
+ return;
112
+ }
113
+ if (decodeMs <= 0 && prefillMs <= 0) {
114
+ return;
115
+ }
116
+ const { enabled } = await PimSettings.get("tps");
117
+ if (!enabled) {
118
+ return;
119
+ }
120
+
121
+ const decodeTps = decodeMs > 0 ? outputTokens / (decodeMs / 1000) : 0;
122
+ const prefillTps = prefillMs > 0 ? promptTokens / (prefillMs / 1000) : 0;
123
+ const ttftSec = firstTtftMs !== null ? firstTtftMs / 1000 : 0;
124
+
125
+ const parts = [
126
+ `Decode: ${decodeTps.toFixed(1)} tps`,
127
+ `Prefill: ${prefillTps.toFixed(1)} tps`,
128
+ ];
129
+ if (cacheReadTokens > 0) {
130
+ parts.push(`Cache read: ${cacheReadTokens.toLocaleString()}`);
131
+ }
132
+ parts.push(`TTFT: ${ttftSec.toFixed(2)}s`);
133
+
134
+ ctx.ui.notify(parts.join(" | "), "info");
135
+ });
136
+ }