@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- 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
|
+
}
|