@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,418 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type {
3
+ AgentSessionEvent,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ AuthStorage,
10
+ createAgentSession,
11
+ DefaultResourceLoader,
12
+ getAgentDir,
13
+ SessionManager,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type {
16
+ AssistantMessage,
17
+ TextContent,
18
+ Usage,
19
+ } from "@earendil-works/pi-ai";
20
+ import { formatTopLine } from "./render";
21
+
22
+ export const PER_TASK_OUTPUT_CAP = 32 * 1024;
23
+ export const SUBAGENT_TOOL_NAME = "subagent";
24
+
25
+ const inSubagent = new AsyncLocalStorage<true>();
26
+
27
+ export type SubagentUsage = {
28
+ readonly input: number;
29
+ readonly output: number;
30
+ readonly cacheRead: number;
31
+ readonly cacheWrite: number;
32
+ readonly cost: number;
33
+ readonly turns: number;
34
+ readonly contextTokens: number | undefined;
35
+ };
36
+
37
+ export type SubagentToolCall = {
38
+ readonly name: string;
39
+ readonly isError: boolean;
40
+ };
41
+
42
+ export type SubagentDetails = {
43
+ readonly returnedOutput: string;
44
+ readonly fullOutput: string;
45
+ readonly outputTruncated: boolean;
46
+ readonly omittedBytes: number;
47
+ readonly usage: SubagentUsage;
48
+ readonly toolCalls: readonly SubagentToolCall[];
49
+ readonly activeToolNames: readonly string[];
50
+ readonly lastToolName: string | undefined;
51
+ readonly stopReason: string | undefined;
52
+ readonly errorMessage: string | undefined;
53
+ readonly model: string | undefined;
54
+ readonly contextWindow: number | undefined;
55
+ readonly topLine: string;
56
+ };
57
+
58
+ export type SubagentSnapshot = {
59
+ readonly finalOutput: string;
60
+ readonly usage: SubagentUsage;
61
+ readonly toolCalls: readonly SubagentToolCall[];
62
+ readonly activeToolNames: readonly string[];
63
+ readonly lastToolName: string | undefined;
64
+ readonly stopReason: string | undefined;
65
+ readonly errorMessage: string | undefined;
66
+ readonly model: string | undefined;
67
+ readonly contextWindow: number | undefined;
68
+ };
69
+
70
+ export type SubagentSession = {
71
+ readonly subscribe: (
72
+ listener: (event: AgentSessionEvent) => void
73
+ ) => () => void;
74
+ readonly prompt: (prompt: string) => Promise<void>;
75
+ readonly abort: () => Promise<void>;
76
+ readonly dispose: () => void;
77
+ };
78
+
79
+ export type CreateSubagentSession = (
80
+ parentCtx: ExtensionContext,
81
+ activeToolNames: readonly string[] | undefined
82
+ ) => Promise<SubagentSession>;
83
+
84
+ export function childToolNames(
85
+ activeToolNames: readonly string[]
86
+ ): readonly string[] {
87
+ return activeToolNames.filter((name) => name !== SUBAGENT_TOOL_NAME);
88
+ }
89
+
90
+ export async function createSdkSubagentSession(
91
+ parentCtx: ExtensionContext,
92
+ activeToolNames: readonly string[] | undefined
93
+ ): Promise<SubagentSession> {
94
+ const loader = new DefaultResourceLoader({
95
+ cwd: parentCtx.cwd,
96
+ agentDir: getAgentDir(),
97
+ });
98
+ await loader.reload();
99
+
100
+ const { session } = await createAgentSession({
101
+ cwd: parentCtx.cwd,
102
+ model: parentCtx.model,
103
+ modelRegistry: parentCtx.modelRegistry,
104
+ authStorage: AuthStorage.create(),
105
+ sessionManager: SessionManager.inMemory(parentCtx.cwd),
106
+ resourceLoader: loader,
107
+ tools: activeToolNames ? [...childToolNames(activeToolNames)] : undefined,
108
+ });
109
+
110
+ return session;
111
+ }
112
+
113
+ export async function runSubagent(
114
+ prompt: string,
115
+ parentCtx: ExtensionContext,
116
+ signal?: AbortSignal,
117
+ onUpdate?: AgentToolUpdateCallback<SubagentDetails>,
118
+ createSession: CreateSubagentSession = createSdkSubagentSession,
119
+ activeToolNames?: readonly string[]
120
+ ): Promise<AgentToolResult<SubagentDetails>> {
121
+ // Hard block against subagent recursion
122
+ if (inSubagent.getStore()) {
123
+ throw new Error("subagents cannot call subagent tool");
124
+ }
125
+
126
+ return inSubagent.run(true, async () => {
127
+ const capture = new SubagentEventCapture(onUpdate, {
128
+ contextWindow: parentCtx.model?.contextWindow,
129
+ model: parentCtx.model?.id,
130
+ });
131
+ let session: SubagentSession | undefined;
132
+ let thrown: unknown;
133
+ let abortRequested = false;
134
+ let abortPromise: Promise<void> | undefined;
135
+
136
+ const ensureAbort = (): Promise<void> => {
137
+ if (!session) {
138
+ return Promise.resolve();
139
+ }
140
+ abortPromise ??= session.abort().catch(() => {});
141
+ return abortPromise;
142
+ };
143
+
144
+ const onAbort = () => {
145
+ abortRequested = true;
146
+ void ensureAbort();
147
+ };
148
+
149
+ try {
150
+ session = await createSession(parentCtx, activeToolNames);
151
+ session.subscribe((event) => capture.handle(event));
152
+ signal?.addEventListener("abort", onAbort, { once: true });
153
+ if (signal?.aborted) {
154
+ abortRequested = true;
155
+ throw new Error("subagent aborted before start");
156
+ }
157
+ await session.prompt(prompt);
158
+ if (abortRequested && capture.snapshot().stopReason !== "aborted") {
159
+ capture.markAborted();
160
+ }
161
+ } catch (err) {
162
+ thrown = err;
163
+ } finally {
164
+ signal?.removeEventListener("abort", onAbort);
165
+ await ensureAbort();
166
+ session?.dispose();
167
+ }
168
+
169
+ const snapshot = capture.snapshot();
170
+ if (thrown !== undefined) {
171
+ throw makeFailureError(
172
+ thrownMessage(thrown),
173
+ undefined,
174
+ snapshot.finalOutput
175
+ );
176
+ }
177
+ if (snapshot.stopReason === "error" || snapshot.stopReason === "aborted") {
178
+ throw makeFailureError(
179
+ snapshot.stopReason,
180
+ snapshot.errorMessage,
181
+ snapshot.finalOutput
182
+ );
183
+ }
184
+
185
+ const details = capture.details();
186
+ const text =
187
+ details.returnedOutput ||
188
+ "[subagent tool: completed with no text output.]";
189
+ return {
190
+ content: [{ type: "text", text }],
191
+ details,
192
+ };
193
+ });
194
+ }
195
+
196
+ export class SubagentEventCapture {
197
+ private finalOutput = "";
198
+ private pendingMessage: AssistantMessage | undefined;
199
+ private readonly usage: MutableUsage = emptyUsage();
200
+ private readonly toolCalls: SubagentToolCall[] = [];
201
+ private readonly activeToolsById = new Map<string, string>();
202
+ private lastToolName: string | undefined;
203
+ private stopReason: string | undefined;
204
+ private errorMessage: string | undefined;
205
+ private model: string | undefined;
206
+
207
+ public constructor(
208
+ private readonly onUpdate?: AgentToolUpdateCallback<SubagentDetails>,
209
+ private readonly options: {
210
+ readonly contextWindow?: number;
211
+ readonly model?: string;
212
+ } = {}
213
+ ) {
214
+ this.model = options.model;
215
+ }
216
+
217
+ public handle(event: AgentSessionEvent): void {
218
+ if (event.type === "message_start" && isAssistantMessage(event.message)) {
219
+ this.finalOutput = "";
220
+ this.pendingMessage = undefined;
221
+ this.emitUpdate();
222
+ return;
223
+ }
224
+
225
+ if (event.type === "message_update" && isAssistantMessage(event.message)) {
226
+ this.pendingMessage = event.message;
227
+ return;
228
+ }
229
+
230
+ if (event.type === "message_end" && isAssistantMessage(event.message)) {
231
+ this.pendingMessage = undefined;
232
+ this.finalOutput = collectText(event.message);
233
+ addUsage(this.usage, event.message.usage);
234
+ this.usage.turns += 1;
235
+ this.stopReason = event.message.stopReason;
236
+ this.errorMessage = event.message.errorMessage;
237
+ this.model = event.message.model;
238
+ this.emitUpdate();
239
+ return;
240
+ }
241
+
242
+ if (event.type === "tool_execution_start") {
243
+ this.activeToolsById.set(event.toolCallId, event.toolName);
244
+ this.lastToolName = event.toolName;
245
+ this.emitUpdate();
246
+ return;
247
+ }
248
+
249
+ if (event.type === "tool_execution_end") {
250
+ this.activeToolsById.delete(event.toolCallId);
251
+ this.toolCalls.push({ name: event.toolName, isError: event.isError });
252
+ this.lastToolName = event.toolName;
253
+ this.emitUpdate();
254
+ }
255
+ }
256
+
257
+ public markAborted(): void {
258
+ this.stopReason = "aborted";
259
+ this.emitUpdate();
260
+ }
261
+
262
+ public snapshot(): SubagentSnapshot {
263
+ this.materializePending();
264
+ return {
265
+ finalOutput: this.finalOutput,
266
+ usage: freezeUsage(this.usage),
267
+ toolCalls: [...this.toolCalls],
268
+ activeToolNames: Array.from(new Set(this.activeToolsById.values())),
269
+ lastToolName: this.lastToolName,
270
+ stopReason: this.stopReason,
271
+ errorMessage: this.errorMessage,
272
+ model: this.model,
273
+ contextWindow: this.options.contextWindow,
274
+ };
275
+ }
276
+
277
+ public details(): SubagentDetails {
278
+ const snapshot = this.snapshot();
279
+ const cap = applyOutputCap(snapshot.finalOutput);
280
+ return detailsFromSnapshot(snapshot, cap.text, cap);
281
+ }
282
+
283
+ private materializePending(): void {
284
+ if (this.pendingMessage) {
285
+ this.finalOutput = collectText(this.pendingMessage);
286
+ this.pendingMessage = undefined;
287
+ }
288
+ }
289
+
290
+ private emitUpdate(): void {
291
+ if (!this.onUpdate) {
292
+ return;
293
+ }
294
+ const details = this.details();
295
+ this.onUpdate({
296
+ content: [{ type: "text", text: details.topLine }],
297
+ details,
298
+ });
299
+ }
300
+ }
301
+
302
+ type MutableUsage = {
303
+ input: number;
304
+ output: number;
305
+ cacheRead: number;
306
+ cacheWrite: number;
307
+ cost: number;
308
+ turns: number;
309
+ contextTokens: number | undefined;
310
+ };
311
+
312
+ export type OutputCapResult = {
313
+ readonly text: string;
314
+ readonly truncated: boolean;
315
+ readonly omittedBytes: number;
316
+ };
317
+
318
+ export function applyOutputCap(
319
+ text: string,
320
+ capBytes = PER_TASK_OUTPUT_CAP
321
+ ): OutputCapResult {
322
+ const encoder = new TextEncoder();
323
+ const totalBytes = encoder.encode(text).byteLength;
324
+ if (totalBytes <= capBytes) {
325
+ return { text, truncated: false, omittedBytes: 0 };
326
+ }
327
+
328
+ const buffer = new Uint8Array(capBytes);
329
+ const { read, written } = encoder.encodeInto(text, buffer);
330
+ const out = text.slice(0, read);
331
+ const omittedBytes = totalBytes - written;
332
+ return {
333
+ text: `${out}\n[subagent: output truncated, ${omittedBytes} bytes omitted; full output preserved in tool details.]`,
334
+ truncated: true,
335
+ omittedBytes,
336
+ };
337
+ }
338
+
339
+ function makeFailureError(
340
+ reason: string,
341
+ errorMessage: string | undefined,
342
+ partialOutput: string
343
+ ): Error {
344
+ const capped = applyOutputCap(partialOutput);
345
+ return new Error(
346
+ `Subagent failed: ${reason}. Error: ${errorMessage ?? "none"}.\n` +
347
+ `Partial output before failure:\n${capped.text}`
348
+ );
349
+ }
350
+
351
+ function detailsFromSnapshot(
352
+ snapshot: SubagentSnapshot,
353
+ returnedOutput: string,
354
+ capResult: OutputCapResult
355
+ ): SubagentDetails {
356
+ return {
357
+ returnedOutput,
358
+ fullOutput: snapshot.finalOutput,
359
+ outputTruncated: capResult.truncated,
360
+ omittedBytes: capResult.omittedBytes,
361
+ usage: snapshot.usage,
362
+ toolCalls: snapshot.toolCalls,
363
+ activeToolNames: snapshot.activeToolNames,
364
+ lastToolName: snapshot.lastToolName,
365
+ stopReason: snapshot.stopReason,
366
+ errorMessage: snapshot.errorMessage,
367
+ model: snapshot.model,
368
+ contextWindow: snapshot.contextWindow,
369
+ topLine: formatTopLine(snapshot),
370
+ };
371
+ }
372
+
373
+ function collectText(message: AssistantMessage): string {
374
+ return message.content
375
+ .filter((part): part is TextContent => part.type === "text")
376
+ .map((part) => part.text)
377
+ .join("");
378
+ }
379
+
380
+ function isAssistantMessage(message: unknown): message is AssistantMessage {
381
+ return (
382
+ typeof message === "object" &&
383
+ message !== null &&
384
+ "role" in message &&
385
+ message.role === "assistant" &&
386
+ "content" in message &&
387
+ Array.isArray(message.content)
388
+ );
389
+ }
390
+
391
+ function emptyUsage(): MutableUsage {
392
+ return {
393
+ input: 0,
394
+ output: 0,
395
+ cacheRead: 0,
396
+ cacheWrite: 0,
397
+ cost: 0,
398
+ turns: 0,
399
+ contextTokens: undefined,
400
+ };
401
+ }
402
+
403
+ function freezeUsage(usage: MutableUsage): SubagentUsage {
404
+ return { ...usage };
405
+ }
406
+
407
+ function addUsage(target: MutableUsage, usage: Usage): void {
408
+ target.input += usage.input;
409
+ target.output += usage.output;
410
+ target.cacheRead += usage.cacheRead;
411
+ target.cacheWrite += usage.cacheWrite;
412
+ target.cost += usage.cost.total;
413
+ target.contextTokens = usage.totalTokens || target.contextTokens;
414
+ }
415
+
416
+ function thrownMessage(err: unknown): string {
417
+ return err instanceof Error ? err.message : String(err);
418
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
3
+ import { buildSystemPrompt } from "./prompt";
4
+
5
+ export default function (pi: ExtensionAPI): void {
6
+ pi.on("before_agent_start", (event, ctx) => {
7
+ const {
8
+ cwd,
9
+ contextFiles,
10
+ skills,
11
+ promptGuidelines,
12
+ appendSystemPrompt,
13
+ customPrompt,
14
+ } = event.systemPromptOptions;
15
+ return {
16
+ systemPrompt: buildSystemPrompt({
17
+ model: ctx.model,
18
+ cwd,
19
+ contextFiles: contextFiles ?? [],
20
+ skillsBlock:
21
+ skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
22
+ toolGuidelines: promptGuidelines ?? [],
23
+ appendSystemPrompt,
24
+ customPrompt,
25
+ }),
26
+ };
27
+ });
28
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildSystemPrompt, describeOs } from "./prompt";
3
+
4
+ describe("buildSystemPrompt", () => {
5
+ test("emits a best-effort os field instead of process.platform", () => {
6
+ const prompt = buildSystemPrompt({
7
+ cwd: "/repo",
8
+ contextFiles: [],
9
+ skillsBlock: "",
10
+ toolGuidelines: [],
11
+ os: "Ubuntu 24.04.2 LTS",
12
+ });
13
+
14
+ expect(prompt).toContain("- os: Ubuntu 24.04.2 LTS");
15
+ expect(prompt).not.toContain("- platform:");
16
+ });
17
+ });
18
+
19
+ describe("describeOs", () => {
20
+ test("uses PRETTY_NAME from /etc/os-release on Linux", () => {
21
+ const os = describeOs({
22
+ platform: "linux",
23
+ runCommand: (cmd) =>
24
+ cmd.join(" ") === "cat /etc/os-release"
25
+ ? 'NAME="Ubuntu"\nVERSION="24.04.2 LTS"\nPRETTY_NAME="Ubuntu 24.04.2 LTS"\n'
26
+ : undefined,
27
+ });
28
+
29
+ expect(os).toBe("Ubuntu 24.04.2 LTS");
30
+ });
31
+
32
+ test("formats macOS from sw_vers", () => {
33
+ const os = describeOs({
34
+ platform: "darwin",
35
+ runCommand: (cmd) =>
36
+ cmd.join(" ") === "sw_vers"
37
+ ? "ProductName:\t\tmacOS\nProductVersion:\t15.5\nBuildVersion:\t\t24F74\n"
38
+ : undefined,
39
+ });
40
+
41
+ expect(os).toBe("macOS 15.5");
42
+ });
43
+
44
+ test("falls back to process platform when no probe succeeds", () => {
45
+ const os = describeOs({
46
+ platform: "linux",
47
+ runCommand: () => undefined,
48
+ });
49
+
50
+ expect(os).toBe("linux");
51
+ });
52
+
53
+ test("falls back to NAME and VERSION when PRETTY_NAME is absent", () => {
54
+ const os = describeOs({
55
+ platform: "linux",
56
+ runCommand: (cmd) =>
57
+ cmd.join(" ") === "cat /etc/os-release"
58
+ ? 'NAME=Fedora\nVERSION="40 (Workstation Edition)"'
59
+ : undefined,
60
+ });
61
+
62
+ expect(os).toBe("Fedora 40 (Workstation Edition)");
63
+ });
64
+ });