@zihanw/pi-forge 0.1.0 → 0.3.0

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/src/compiler.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import type {
5
+ CompileMessageSource,
3
6
  CompileMessagesResult,
4
7
  CompileSystemPromptResult,
5
8
  PromptRuntime,
@@ -8,8 +11,12 @@ import type {
8
11
  PromptStackItem,
9
12
  PromptStackRole,
10
13
  PromptStackSlotItem,
14
+ PromptStackSlotFormat,
15
+ PromptVariableValue,
11
16
  PromptVariableStore,
12
17
  } from "./types.ts";
18
+ import { applyResourcePolicy } from "./policy.ts";
19
+ import { applyRegexRulesToMessages, applyRegexRulesToString } from "./regex.ts";
13
20
  import { SUPPORTED_SLOTS } from "./types.ts";
14
21
 
15
22
  const ZERO_USAGE = {
@@ -21,7 +28,11 @@ const ZERO_USAGE = {
21
28
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
22
29
  };
23
30
 
24
- export function createPromptVariableStore(sessionVariables: Record<string, string> = {}): PromptVariableStore {
31
+ type PromptVariableScope = "static" | "session" | "turn";
32
+
33
+ const SUMMARY_ROLES = new Set(["branchSummary", "compactionSummary"]);
34
+
35
+ export function createPromptVariableStore(sessionVariables: Record<string, PromptVariableValue> = {}): PromptVariableStore {
25
36
  return { turn: {}, session: { ...sessionVariables }, sessionDirty: false };
26
37
  }
27
38
 
@@ -63,20 +74,20 @@ export function compileSystemPrompt(
63
74
  const compiled = parts.join("\n\n");
64
75
  const mode = stack.mode ?? "replace";
65
76
 
77
+ let systemPrompt: string;
66
78
  if (!compiled) {
67
79
  diagnostics.push({ level: "warning", message: "Compiled system prompt is empty; preserving base system prompt." });
68
- return { systemPrompt: baseSystemPrompt, diagnostics };
69
- }
70
-
71
- if (mode === "append") {
72
- return { systemPrompt: compiled ? `${baseSystemPrompt}\n\n${compiled}` : baseSystemPrompt, diagnostics };
73
- }
74
-
75
- if (mode === "prepend") {
76
- return { systemPrompt: compiled ? `${compiled}\n\n${baseSystemPrompt}` : baseSystemPrompt, diagnostics };
80
+ systemPrompt = baseSystemPrompt;
81
+ } else if (mode === "append") {
82
+ systemPrompt = compiled ? `${baseSystemPrompt}\n\n${compiled}` : baseSystemPrompt;
83
+ } else if (mode === "prepend") {
84
+ systemPrompt = compiled ? `${compiled}\n\n${baseSystemPrompt}` : baseSystemPrompt;
85
+ } else {
86
+ systemPrompt = compiled;
77
87
  }
78
88
 
79
- return { systemPrompt: compiled, diagnostics };
89
+ systemPrompt = applyRegexRulesToString(stack, systemPrompt, "compiled", "system", diagnostics);
90
+ return { systemPrompt, diagnostics };
80
91
  }
81
92
 
82
93
  export function compileMessages(
@@ -85,7 +96,8 @@ export function compileMessages(
85
96
  originalMessages: AgentMessage[],
86
97
  ): CompileMessagesResult {
87
98
  const diagnostics: PromptStackDiagnostic[] = [];
88
- const messages: AgentMessage[] = [];
99
+ let messages: AgentMessage[] = [];
100
+ let messageSources: CompileMessageSource[] = [];
89
101
  let insertedHistory = false;
90
102
 
91
103
  for (const item of enabledItems(stack)) {
@@ -98,7 +110,10 @@ export function compileMessages(
98
110
  });
99
111
  continue;
100
112
  }
101
- messages.push(...getChatHistoryMessages(originalMessages, item));
113
+ const historyMessages = getChatHistoryMessages(originalMessages, item, diagnostics);
114
+ const transformedHistory = applyRegexRulesToMessages(stack, historyMessages, "history", diagnostics);
115
+ messages.push(...transformedHistory);
116
+ messageSources.push(...chatHistoryMessageSources(transformedHistory, item));
102
117
  insertedHistory = true;
103
118
  continue;
104
119
  }
@@ -110,14 +125,18 @@ export function compileMessages(
110
125
  : renderSlotText(item, stack, runtime, diagnostics);
111
126
 
112
127
  if (!content.trim()) continue;
113
- messages.push(createSyntheticMessage(item.role, content, stack, runtime));
128
+ const message = createSyntheticMessage(item.role, content, stack, runtime);
129
+ messages.push(message);
130
+ messageSources.push(stackItemMessageSource(message, item));
114
131
  }
115
132
 
116
133
  if (!insertedHistory) {
117
134
  messages.push(...originalMessages);
135
+ messageSources.push(...implicitHistoryMessageSources(originalMessages));
118
136
  }
119
137
 
120
- return { messages, diagnostics };
138
+ messages = applyRegexRulesToMessages(stack, messages, "compiled", diagnostics);
139
+ return { messages, messageSources, diagnostics };
121
140
  }
122
141
 
123
142
  export function getLatestUserMessage(messages: AgentMessage[]): string | undefined {
@@ -131,32 +150,68 @@ export function getLatestUserMessage(messages: AgentMessage[]): string | undefin
131
150
  return undefined;
132
151
  }
133
152
 
134
- export function renderPreviewMessages(messages: AgentMessage[], maxChars = 8000): string {
135
- let text = "";
136
- for (const message of messages) {
137
- const role = message.role;
138
- text += `\n--- ${role} ---\n`;
139
- text += contentToText((message as { content?: unknown; summary?: string; command?: string; output?: string }).content);
140
- if (role === "bashExecution") {
141
- const bash = message as { command?: string; output?: string };
142
- text += `Ran ${bash.command ?? ""}\n${bash.output ?? ""}`;
143
- }
144
- if (role === "branchSummary" || role === "compactionSummary") {
145
- text += (message as { summary?: string }).summary ?? "";
146
- }
147
- text += "\n";
148
- if (text.length > maxChars) return `${text.slice(0, maxChars)}\n\n[preview truncated]`;
153
+ export function agentMessageToPreviewText(message: AgentMessage): string {
154
+ let text = contentToText((message as { content?: unknown; summary?: string; command?: string; output?: string }).content);
155
+ const role = message.role;
156
+ if (role === "bashExecution") {
157
+ const bash = message as { command?: string; output?: string };
158
+ text += `Ran ${bash.command ?? ""}\n${bash.output ?? ""}`;
159
+ }
160
+ if (role === "branchSummary" || role === "compactionSummary") {
161
+ text += (message as { summary?: string }).summary ?? "";
149
162
  }
150
- return text.trimStart();
163
+ return text;
151
164
  }
152
165
 
153
- function getChatHistoryMessages(messages: AgentMessage[], item: PromptStackSlotItem): AgentMessage[] {
154
- if (item.options?.includeLastUserMessage !== false) return messages;
166
+ function getChatHistoryMessages(
167
+ messages: AgentMessage[],
168
+ item: PromptStackSlotItem,
169
+ diagnostics: PromptStackDiagnostic[],
170
+ ): AgentMessage[] {
171
+ let result = messages;
172
+ const options = item.options ?? {};
173
+ let shouldRepairToolPairs = false;
174
+
175
+ if (options.includeLastUserMessage === false) {
176
+ const lastUserIndex = findLastUserMessageIndex(result);
177
+ if (lastUserIndex !== -1) result = result.filter((_message, index) => index !== lastUserIndex);
178
+ }
179
+
180
+ if (options.includeSummaries === false) {
181
+ const next = result.filter((message) => !isSummaryMessage(message));
182
+ addHistoryFilterDiagnostic(diagnostics, item.id, "summary", result.length, next.length);
183
+ result = next;
184
+ }
185
+
186
+ if (isStringArray(options.roles) && options.roles.length > 0) {
187
+ const allowedRoles = new Set(options.roles);
188
+ const next = result.filter((message) => allowedRoles.has(messageRole(message)));
189
+ addHistoryFilterDiagnostic(diagnostics, item.id, "role", result.length, next.length);
190
+ result = next;
191
+ shouldRepairToolPairs = true;
192
+ }
193
+
194
+ if (options.toolMode === "drop") {
195
+ result = dropToolHistory(result, diagnostics, item.id);
196
+ } else if (options.toolMode !== undefined && options.toolMode !== "keep") {
197
+ diagnostics.push({ level: "warning", message: `Unsupported chat-history toolMode: ${String(options.toolMode)}.`, itemId: item.id });
198
+ }
199
+
200
+ if (options.stripAssistantThinking === true) {
201
+ result = stripAssistantThinkingFromHistory(result, diagnostics, item.id);
202
+ }
203
+
204
+ const limited = limitChatHistory(result, options, diagnostics, item.id);
205
+ if (limited !== result) {
206
+ result = limited;
207
+ shouldRepairToolPairs = true;
208
+ }
155
209
 
156
- const lastUserIndex = findLastUserMessageIndex(messages);
157
- if (lastUserIndex === -1) return messages;
210
+ if (shouldRepairToolPairs && options.toolMode !== "drop") {
211
+ result = repairToolHistory(result, diagnostics, item.id);
212
+ }
158
213
 
159
- return messages.filter((_message, index) => index !== lastUserIndex);
214
+ return result;
160
215
  }
161
216
 
162
217
  function findLastUserMessageIndex(messages: AgentMessage[]): number {
@@ -166,6 +221,309 @@ function findLastUserMessageIndex(messages: AgentMessage[]): number {
166
221
  return -1;
167
222
  }
168
223
 
224
+ function limitChatHistory(
225
+ messages: AgentMessage[],
226
+ options: NonNullable<PromptStackSlotItem["options"]>,
227
+ diagnostics: PromptStackDiagnostic[],
228
+ itemId: string,
229
+ ): AgentMessage[] {
230
+ let result = messages;
231
+ const maxMessages = positiveIntegerOption(options.maxMessages);
232
+ const maxChars = positiveIntegerOption(options.maxChars);
233
+
234
+ if (maxMessages !== undefined && result.length > maxMessages) {
235
+ const next = result.slice(-maxMessages);
236
+ diagnostics.push({
237
+ level: "info",
238
+ message: `Trimmed chat history from ${result.length} to ${next.length} message(s) by maxMessages.`,
239
+ itemId,
240
+ });
241
+ result = next;
242
+ }
243
+
244
+ if (maxChars !== undefined) {
245
+ const next = takeRecentMessagesWithinChars(result, maxChars);
246
+ if (next.length < result.length) {
247
+ diagnostics.push({
248
+ level: "info",
249
+ message: `Trimmed chat history from ${result.length} to ${next.length} message(s) by maxChars.`,
250
+ itemId,
251
+ });
252
+ result = next;
253
+ }
254
+ }
255
+
256
+ return result;
257
+ }
258
+
259
+ function takeRecentMessagesWithinChars(messages: AgentMessage[], maxChars: number): AgentMessage[] {
260
+ const selected: AgentMessage[] = [];
261
+ let chars = 0;
262
+ for (let index = messages.length - 1; index >= 0; index--) {
263
+ const message = messages[index];
264
+ const messageChars = agentMessageToPreviewText(message).length;
265
+ if (selected.length > 0 && chars + messageChars > maxChars) break;
266
+ selected.push(message);
267
+ chars += messageChars;
268
+ }
269
+ return selected.reverse();
270
+ }
271
+
272
+ function addHistoryFilterDiagnostic(
273
+ diagnostics: PromptStackDiagnostic[],
274
+ itemId: string,
275
+ filter: string,
276
+ before: number,
277
+ after: number,
278
+ ): void {
279
+ if (before === after) return;
280
+ diagnostics.push({
281
+ level: "info",
282
+ message: `Filtered ${before - after} chat-history message(s) by ${filter}.`,
283
+ itemId,
284
+ });
285
+ }
286
+
287
+ function dropToolHistory(
288
+ messages: AgentMessage[],
289
+ diagnostics: PromptStackDiagnostic[],
290
+ itemId: string,
291
+ ): AgentMessage[] {
292
+ let removedToolCalls = 0;
293
+ let droppedToolResults = 0;
294
+ let droppedEmptyMessages = 0;
295
+ let changed = false;
296
+ const result: AgentMessage[] = [];
297
+
298
+ for (const message of messages) {
299
+ if (isToolResultMessage(message)) {
300
+ droppedToolResults++;
301
+ changed = true;
302
+ continue;
303
+ }
304
+ const stripped = stripToolCallParts(message, () => false);
305
+ removedToolCalls += stripped.removedCalls;
306
+ if (stripped.message !== message) changed = true;
307
+ if (!stripped.message) {
308
+ droppedEmptyMessages++;
309
+ continue;
310
+ }
311
+ result.push(stripped.message);
312
+ }
313
+
314
+ if (!changed) return messages;
315
+ diagnostics.push({
316
+ level: "info",
317
+ message: `Dropped tool history from chat-history: removed ${removedToolCalls} tool call(s), dropped ${droppedToolResults} tool result message(s)` +
318
+ (droppedEmptyMessages > 0 ? `, and dropped ${droppedEmptyMessages} empty message(s).` : "."),
319
+ itemId,
320
+ });
321
+ return result;
322
+ }
323
+
324
+ function repairToolHistory(
325
+ messages: AgentMessage[],
326
+ diagnostics: PromptStackDiagnostic[],
327
+ itemId: string,
328
+ ): AgentMessage[] {
329
+ const includedCallIds = new Set<string>();
330
+ const includedResultIds = new Set<string>();
331
+ for (const message of messages) {
332
+ for (const id of toolCallIdsForMessage(message)) includedCallIds.add(id);
333
+ const resultId = toolResultMessageId(message);
334
+ if (resultId) includedResultIds.add(resultId);
335
+ }
336
+
337
+ let removedToolCalls = 0;
338
+ let droppedToolResults = 0;
339
+ let droppedEmptyMessages = 0;
340
+ let changed = false;
341
+ const result: AgentMessage[] = [];
342
+
343
+ for (const message of messages) {
344
+ if (isToolResultMessage(message)) {
345
+ const resultId = toolResultMessageId(message);
346
+ if (!resultId || !includedCallIds.has(resultId)) {
347
+ droppedToolResults++;
348
+ changed = true;
349
+ continue;
350
+ }
351
+ result.push(message);
352
+ continue;
353
+ }
354
+
355
+ const stripped = stripToolCallParts(message, (id) => !!id && includedResultIds.has(id));
356
+ removedToolCalls += stripped.removedCalls;
357
+ if (stripped.message !== message) changed = true;
358
+ if (!stripped.message) {
359
+ droppedEmptyMessages++;
360
+ continue;
361
+ }
362
+ result.push(stripped.message);
363
+ }
364
+
365
+ if (!changed) return messages;
366
+ diagnostics.push({
367
+ level: "info",
368
+ message: `Repaired tool history after chat-history filtering: removed ${removedToolCalls} dangling tool call(s), dropped ${droppedToolResults} dangling tool result message(s)` +
369
+ (droppedEmptyMessages > 0 ? `, and dropped ${droppedEmptyMessages} empty message(s).` : "."),
370
+ itemId,
371
+ });
372
+ return result;
373
+ }
374
+
375
+ function stripToolCallParts(
376
+ message: AgentMessage,
377
+ keep: (id: string | undefined) => boolean,
378
+ ): { message?: AgentMessage; removedCalls: number } {
379
+ const content = (message as { content?: unknown }).content;
380
+ if (!Array.isArray(content)) return { message, removedCalls: 0 };
381
+
382
+ const nextContent = content.filter((part) => !isToolCallContent(part) || keep(toolCallPartId(part)));
383
+ const removedCalls = content.length - nextContent.length;
384
+ if (removedCalls === 0) return { message, removedCalls: 0 };
385
+ if (nextContent.length === 0) return { removedCalls };
386
+ return { message: { ...message, content: nextContent } as AgentMessage, removedCalls };
387
+ }
388
+
389
+ function toolCallIdsForMessage(message: AgentMessage): string[] {
390
+ const content = (message as { content?: unknown }).content;
391
+ if (!Array.isArray(content)) return [];
392
+ return content
393
+ .map((part) => toolCallPartId(part))
394
+ .filter((id): id is string => !!id);
395
+ }
396
+
397
+ function isToolCallContent(value: unknown): boolean {
398
+ return isPlainObject(value) && value.type === "toolCall";
399
+ }
400
+
401
+ function toolCallPartId(value: unknown): string | undefined {
402
+ if (!isPlainObject(value)) return undefined;
403
+ return firstString(value.id, value.toolCallId, value.callId);
404
+ }
405
+
406
+ function isToolResultMessage(message: AgentMessage): boolean {
407
+ return messageRole(message) === "toolResult";
408
+ }
409
+
410
+ function toolResultMessageId(message: AgentMessage): string | undefined {
411
+ if (!isToolResultMessage(message)) return undefined;
412
+ const raw = message as unknown as Record<string, unknown>;
413
+ return firstString(raw.toolCallId, raw.id, raw.callId);
414
+ }
415
+
416
+ function isSummaryMessage(message: AgentMessage): boolean {
417
+ return SUMMARY_ROLES.has(messageRole(message));
418
+ }
419
+
420
+ function messageRole(message: AgentMessage): string {
421
+ return String((message as { role?: unknown }).role ?? "");
422
+ }
423
+
424
+ function positiveIntegerOption(value: unknown): number | undefined {
425
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
426
+ }
427
+
428
+ function firstString(...values: unknown[]): string | undefined {
429
+ for (const value of values) {
430
+ if (typeof value === "string" && value) return value;
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ function isStringArray(value: unknown): value is string[] {
436
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
437
+ }
438
+
439
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
440
+ return !!value && typeof value === "object" && !Array.isArray(value);
441
+ }
442
+
443
+ function chatHistoryMessageSources(messages: AgentMessage[], item: PromptStackSlotItem): CompileMessageSource[] {
444
+ return messages.map((message, index) => ({
445
+ kind: "chat-history",
446
+ itemId: item.id,
447
+ itemName: item.name,
448
+ slot: item.slot,
449
+ historyIndex: index + 1,
450
+ historyCount: messages.length,
451
+ role: String((message as { role?: unknown }).role ?? ""),
452
+ }));
453
+ }
454
+
455
+ function implicitHistoryMessageSources(messages: AgentMessage[]): CompileMessageSource[] {
456
+ return messages.map((message, index) => ({
457
+ kind: "implicit-history",
458
+ itemName: "Conversation history",
459
+ slot: "chat-history",
460
+ historyIndex: index + 1,
461
+ historyCount: messages.length,
462
+ role: String((message as { role?: unknown }).role ?? ""),
463
+ }));
464
+ }
465
+
466
+ function stackItemMessageSource(message: AgentMessage, item: PromptStackItem): CompileMessageSource {
467
+ return {
468
+ kind: "stack-item",
469
+ itemId: item.id,
470
+ itemName: item.name,
471
+ role: String((message as { role?: unknown }).role ?? ""),
472
+ };
473
+ }
474
+
475
+ function stripAssistantThinkingFromHistory(
476
+ messages: AgentMessage[],
477
+ diagnostics: PromptStackDiagnostic[],
478
+ itemId: string,
479
+ ): AgentMessage[] {
480
+ let strippedBlocks = 0;
481
+ let changedMessages = 0;
482
+ let droppedMessages = 0;
483
+ let changed = false;
484
+ const result: AgentMessage[] = [];
485
+
486
+ for (const message of messages) {
487
+ const stripped = stripAssistantThinkingFromMessage(message);
488
+ if (stripped.message !== message) {
489
+ changed = true;
490
+ strippedBlocks += stripped.removedBlocks;
491
+ changedMessages++;
492
+ }
493
+ if (!stripped.message) {
494
+ droppedMessages++;
495
+ continue;
496
+ }
497
+ result.push(stripped.message);
498
+ }
499
+
500
+ if (!changed) return messages;
501
+
502
+ diagnostics.push({
503
+ level: "info",
504
+ message: `Stripped ${strippedBlocks} assistant thinking block(s) from ${changedMessages} chat-history message(s)` +
505
+ (droppedMessages > 0 ? ` and dropped ${droppedMessages} empty assistant message(s).` : "."),
506
+ itemId,
507
+ });
508
+ return result;
509
+ }
510
+
511
+ function stripAssistantThinkingFromMessage(message: AgentMessage): { message?: AgentMessage; removedBlocks: number } {
512
+ if (message.role !== "assistant") return { message, removedBlocks: 0 };
513
+ const content = (message as { content?: unknown }).content;
514
+ if (!Array.isArray(content)) return { message, removedBlocks: 0 };
515
+
516
+ const nextContent = content.filter((part) => !isThinkingContent(part));
517
+ const removedBlocks = content.length - nextContent.length;
518
+ if (removedBlocks === 0) return { message, removedBlocks: 0 };
519
+ if (nextContent.length === 0) return { removedBlocks };
520
+ return { message: { ...message, content: nextContent } as AgentMessage, removedBlocks };
521
+ }
522
+
523
+ function isThinkingContent(value: unknown): boolean {
524
+ return !!value && typeof value === "object" && !Array.isArray(value) && (value as { type?: unknown }).type === "thinking";
525
+ }
526
+
169
527
  function enabledItems(stack: PromptStack): PromptStackItem[] {
170
528
  return stack.items.filter((item) => item.enabled !== false);
171
529
  }
@@ -185,13 +543,13 @@ function renderSlotText(
185
543
 
186
544
  switch (slot) {
187
545
  case "tools":
188
- return renderTools(runtime);
546
+ return renderTools(item, stack, runtime);
189
547
  case "tool-guidelines":
190
- return renderToolGuidelines(runtime);
548
+ return renderToolGuidelines(item, stack, runtime);
191
549
  case "skills":
192
- return renderSkills(runtime);
550
+ return renderSkills(item, stack, runtime);
193
551
  case "project-context":
194
- return renderProjectContext(runtime);
552
+ return renderProjectContext(item, runtime);
195
553
  case "append-system-prompt":
196
554
  return runtime.options.appendSystemPrompt ?? "";
197
555
  case "date":
@@ -215,9 +573,24 @@ function renderSlotText(
215
573
  }
216
574
  }
217
575
 
218
- function renderTools(runtime: PromptRuntime): string {
219
- const tools = runtime.options.selectedTools ?? [];
576
+ function renderTools(item: PromptStackSlotItem, stack: PromptStack, runtime: PromptRuntime): string {
220
577
  const snippets = runtime.options.toolSnippets ?? {};
578
+ const tools = item.options?.onlyWithSnippets === true
579
+ ? scopedToolNames(stack, runtime).filter((name) => !!snippets[name])
580
+ : scopedToolNames(stack, runtime);
581
+
582
+ if (slotTextFormat(item) === "plain") {
583
+ const lines = ["Available tools:"];
584
+ if (tools.length === 0) {
585
+ lines.push(item.options?.onlyWithSnippets === true ? "(none)" : "- (none)");
586
+ } else {
587
+ for (const name of tools) {
588
+ lines.push(plainBullet(name, snippets[name] ?? "No prompt snippet provided."));
589
+ }
590
+ }
591
+ return lines.join("\n");
592
+ }
593
+
221
594
  const lines = ["<available_tools>"];
222
595
 
223
596
  if (tools.length === 0) {
@@ -233,8 +606,8 @@ function renderTools(runtime: PromptRuntime): string {
233
606
  return lines.join("\n");
234
607
  }
235
608
 
236
- function renderToolGuidelines(runtime: PromptRuntime): string {
237
- const tools = runtime.options.selectedTools ?? [];
609
+ function renderToolGuidelines(item: PromptStackSlotItem, stack: PromptStack, runtime: PromptRuntime): string {
610
+ const tools = scopedToolNames(stack, runtime);
238
611
  const guidelines: string[] = [];
239
612
  const seen = new Set<string>();
240
613
  const add = (line: string) => {
@@ -245,17 +618,31 @@ function renderToolGuidelines(runtime: PromptRuntime): string {
245
618
  };
246
619
 
247
620
  if (tools.includes("bash") && !tools.includes("grep") && !tools.includes("find") && !tools.includes("ls")) {
248
- add("Use bash for file operations like ls, rg, find.");
621
+ add(item.options?.piStyle === true ? "Use bash for file operations like ls, rg, find" : "Use bash for file operations like ls, rg, find.");
249
622
  }
250
623
 
251
624
  for (const guideline of runtime.options.promptGuidelines ?? []) add(guideline);
625
+ if (item.options?.includePiDefaultGuidelines === true) {
626
+ add("Be concise in your responses");
627
+ add("Show file paths clearly when working with files");
628
+ }
252
629
 
253
630
  if (guidelines.length === 0) return "";
631
+ if (slotTextFormat(item) === "plain") {
632
+ const heading = typeof item.options?.heading === "string" ? item.options.heading.trim() : "Tool guidelines:";
633
+ return [
634
+ ...(heading ? [heading] : []),
635
+ ...guidelines.map((line) => `- ${plainContinuation(line, " ")}`),
636
+ ].join("\n");
637
+ }
254
638
  return ["<tool_guidelines>", ...guidelines.map((line) => `- ${line}`), "</tool_guidelines>"].join("\n");
255
639
  }
256
640
 
257
- function renderSkills(runtime: PromptRuntime): string {
258
- const skills = (runtime.options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
641
+ function renderSkills(item: PromptStackSlotItem, stack: PromptStack, runtime: PromptRuntime): string {
642
+ if (item.options?.requireReadTool === true && !scopedToolNames(stack, runtime).includes("read")) return "";
643
+ const skills = (runtime.options.skills ?? [])
644
+ .filter((skill) => !skill.disableModelInvocation)
645
+ .filter((skill) => applyResourcePolicy([skill.name], stack.skills).length > 0);
259
646
  if (skills.length === 0) return "";
260
647
 
261
648
  const lines = [
@@ -263,9 +650,18 @@ function renderSkills(runtime: PromptRuntime): string {
263
650
  "Use the read tool to load a skill's file when the task matches its description.",
264
651
  "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
265
652
  "",
266
- "<available_skills>",
267
653
  ];
268
654
 
655
+ if (slotTextFormat(item) === "plain") {
656
+ lines.push("Available skills:");
657
+ for (const skill of skills) {
658
+ lines.push(plainBullet(skill.name, skill.description));
659
+ lines.push(` Location: ${plainContinuation(skill.filePath, " ")}`);
660
+ }
661
+ return lines.join("\n");
662
+ }
663
+
664
+ lines.push("<available_skills>");
269
665
  for (const skill of skills) {
270
666
  lines.push(" <skill>");
271
667
  lines.push(` <name>${escapeXml(skill.name)}</name>`);
@@ -278,10 +674,23 @@ function renderSkills(runtime: PromptRuntime): string {
278
674
  return lines.join("\n");
279
675
  }
280
676
 
281
- function renderProjectContext(runtime: PromptRuntime): string {
677
+ function scopedToolNames(stack: PromptStack, runtime: PromptRuntime): string[] {
678
+ return applyResourcePolicy(runtime.options.selectedTools ?? [], stack.tools);
679
+ }
680
+
681
+ function renderProjectContext(item: PromptStackSlotItem, runtime: PromptRuntime): string {
282
682
  const contextFiles = runtime.options.contextFiles ?? [];
283
683
  if (contextFiles.length === 0) return "";
284
684
 
685
+ if (slotTextFormat(item) === "plain") {
686
+ const lines = ["Project context:", "", "Project-specific instructions and guidelines:", ""];
687
+ for (const file of contextFiles) {
688
+ lines.push(`Path: ${file.path}`);
689
+ lines.push(indentPlainBlock(file.content, " "), "");
690
+ }
691
+ return lines.join("\n").trimEnd();
692
+ }
693
+
285
694
  const lines = ["<project_context>", "", "Project-specific instructions and guidelines:", ""];
286
695
  for (const file of contextFiles) {
287
696
  lines.push(`<project_instructions path=\"${escapeXml(file.path)}\">`);
@@ -292,58 +701,141 @@ function renderProjectContext(runtime: PromptRuntime): string {
292
701
  return lines.join("\n");
293
702
  }
294
703
 
704
+ function slotTextFormat(item: PromptStackSlotItem, options: { allowJson?: boolean } = {}): PromptStackSlotFormat {
705
+ const format = item.options?.format;
706
+ if (format === "plain") return "plain";
707
+ if (format === "json" && options.allowJson) return "json";
708
+ return "xml";
709
+ }
710
+
295
711
  function renderPiDocsGuidance(): string {
712
+ const paths = piDocsPaths();
296
713
  return [
297
- "Pi documentation guidance:",
298
- "- Read Pi documentation only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI.",
299
- "- Resolve docs/... against Pi's installed documentation directory and examples/... against Pi's installed examples directory.",
714
+ "Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):",
715
+ `- Main documentation: ${paths.readme}`,
716
+ `- Additional docs: ${paths.docs}`,
717
+ `- Examples: ${paths.examples} (extensions, custom tools, SDK)`,
718
+ "- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory",
719
+ "- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)",
720
+ "- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing",
721
+ "- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)",
300
722
  ].join("\n");
301
723
  }
302
724
 
725
+ function piDocsPaths(): { readme: string; docs: string; examples: string } {
726
+ const fallbackRoot = "@earendil-works/pi-coding-agent";
727
+ try {
728
+ const resolve = (import.meta as unknown as { resolve?: (specifier: string) => string }).resolve;
729
+ if (!resolve) throw new Error("import.meta.resolve unavailable");
730
+ const resolved = resolve("@earendil-works/pi-coding-agent");
731
+ if (!resolved.startsWith("file:")) throw new Error("non-file package resolution");
732
+ const packageRoot = dirname(dirname(fileURLToPath(resolved)));
733
+ return {
734
+ readme: join(packageRoot, "README.md"),
735
+ docs: join(packageRoot, "docs"),
736
+ examples: join(packageRoot, "examples"),
737
+ };
738
+ } catch {
739
+ return {
740
+ readme: `${fallbackRoot}/README.md`,
741
+ docs: `${fallbackRoot}/docs`,
742
+ examples: `${fallbackRoot}/examples`,
743
+ };
744
+ }
745
+ }
746
+
303
747
  function renderVariables(
304
748
  item: PromptStackSlotItem,
305
749
  stack: PromptStack,
306
750
  runtime: PromptRuntime,
307
751
  ): string {
308
752
  const options = item.options ?? {};
309
- const includeStatic = options.includeStatic !== false;
310
- const includeSession = options.includeSession !== false;
311
- const includeTurn = options.includeTurn !== false;
753
+ const scopes = selectedVariableScopes(options);
754
+ const format = slotTextFormat(item);
312
755
  const store = runtime.variables;
313
- const staticVars = stack.variables ?? {};
756
+ const grouped: Record<PromptVariableScope, Record<string, PromptVariableValue>> = {
757
+ static: {},
758
+ session: {},
759
+ turn: {},
760
+ };
314
761
 
315
- const parts: string[] = ["<prompt_variables>"];
762
+ if (scopes.includes("static")) grouped.static = collectStaticVariables(stack);
763
+ if (scopes.includes("session")) grouped.session = { ...(store?.session ?? {}) };
764
+ if (scopes.includes("turn")) grouped.turn = { ...(store?.turn ?? {}) };
316
765
 
317
- if (includeStatic && Object.keys(staticVars).length > 0) {
318
- parts.push(" <static>");
319
- for (const [key, value] of Object.entries(staticVars).sort(([a], [b]) => a.localeCompare(b))) {
320
- parts.push(` <${escapeXml(key)}>${escapeXml(value)}</${escapeXml(key)}>`);
321
- }
322
- parts.push(" </static>");
766
+ const hasVariables = Object.values(grouped).some((values) => Object.keys(values).length > 0);
767
+ if (!hasVariables) return "";
768
+
769
+ if (format === "plain") {
770
+ return renderPlainVariables(grouped);
323
771
  }
324
772
 
325
- if (includeSession && store && Object.keys(store.session).length > 0) {
326
- parts.push(" <session>");
327
- for (const [key, value] of Object.entries(store.session).sort(([a], [b]) => a.localeCompare(b))) {
328
- parts.push(` <${escapeXml(key)}>${escapeXml(value)}</${escapeXml(key)}>`);
773
+ const parts: string[] = ["<variables>"];
774
+
775
+ for (const scope of scopes) {
776
+ const entries = Object.entries(grouped[scope]).sort(([a], [b]) => a.localeCompare(b));
777
+ if (entries.length === 0) continue;
778
+ parts.push(` <${scope}>`);
779
+ for (const [name, value] of entries) {
780
+ parts.push(` <var name=\"${escapeXml(name)}\">${escapeXml(variableValueToPromptText(value))}</var>`);
329
781
  }
330
- parts.push(" </session>");
782
+ parts.push(` </${scope}>`);
331
783
  }
332
784
 
333
- if (includeTurn && store && Object.keys(store.turn).length > 0) {
334
- parts.push(" <turn>");
335
- for (const [key, value] of Object.entries(store.turn).sort(([a], [b]) => a.localeCompare(b))) {
336
- parts.push(` <${escapeXml(key)}>${escapeXml(value)}</${escapeXml(key)}>`);
785
+ parts.push("</variables>");
786
+ return parts.join("\n");
787
+ }
788
+
789
+ function renderPlainVariables(
790
+ grouped: Record<PromptVariableScope, Record<string, PromptVariableValue>>,
791
+ ): string {
792
+ const parts: string[] = ["Variables:"];
793
+
794
+ for (const scope of ["static", "session", "turn"] as const) {
795
+ const entries = Object.entries(grouped[scope]).sort(([a], [b]) => a.localeCompare(b));
796
+ if (entries.length === 0) continue;
797
+ parts.push(`${scope}:`);
798
+ for (const [name, value] of entries) {
799
+ parts.push(plainBullet(name, variableValueToPromptText(value)));
337
800
  }
338
- parts.push(" </turn>");
339
801
  }
340
802
 
341
- parts.push("</prompt_variables>");
803
+ return parts.join("\n");
804
+ }
342
805
 
343
- // Don't emit empty variables block
344
- if (parts.length <= 2) return "";
806
+ function plainBullet(label: string, value: string): string {
807
+ return `- ${label}: ${plainContinuation(value, " ")}`;
808
+ }
345
809
 
346
- return parts.join("\n");
810
+ function plainContinuation(value: string, indent: string): string {
811
+ return value.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
812
+ }
813
+
814
+ function indentPlainBlock(value: string, indent: string): string {
815
+ return value.split("\n").map((line) => `${indent}${line}`).join("\n");
816
+ }
817
+
818
+ function selectedVariableScopes(options: Record<string, unknown>): PromptVariableScope[] {
819
+ const scopes: PromptVariableScope[] = [];
820
+ if (options.includeStatic !== false) scopes.push("static");
821
+ if (options.includeSession !== false) scopes.push("session");
822
+ if (options.includeTurn !== false) scopes.push("turn");
823
+ return scopes;
824
+ }
825
+
826
+ function collectStaticVariables(stack: PromptStack): Record<string, PromptVariableValue> {
827
+ return { ...(stack.variables ?? {}) };
828
+ }
829
+
830
+ function variableValueToPromptText(value: PromptVariableValue): string {
831
+ if (typeof value === "string") return value;
832
+ return JSON.stringify(value, null, 2);
833
+ }
834
+
835
+ function variableValueToMacroText(value: PromptVariableValue | undefined): string {
836
+ if (value === undefined) return "";
837
+ if (typeof value === "string") return value;
838
+ return JSON.stringify(value);
347
839
  }
348
840
 
349
841
  function expandMacros(
@@ -354,7 +846,6 @@ function expandMacros(
354
846
  itemId: string,
355
847
  ): string {
356
848
  const policy = stack.defaults?.unresolvedMacroPolicy ?? "warn";
357
- const staticVariables = stack.variables ?? {};
358
849
  const unknown = new Set<string>();
359
850
 
360
851
  const result = text.replace(/\{\{([^{}]+)\}\}/g, (full, rawName: string) => {
@@ -386,9 +877,9 @@ function expandMacros(
386
877
  unknown.add(expression);
387
878
  return full;
388
879
  }
389
- if (command === "getturnvar") return runtime.variables?.turn[name] ?? "";
390
- if (command === "getsessionvar") return runtime.variables?.session[name] ?? "";
391
- return getRuntimeVariable(runtime, staticVariables, name) ?? "";
880
+ if (command === "getturnvar") return variableValueToMacroText(runtime.variables?.turn[name]);
881
+ if (command === "getsessionvar") return variableValueToMacroText(runtime.variables?.session[name]);
882
+ return variableValueToMacroText(getRuntimeVariable(runtime, stack, name));
392
883
  }
393
884
 
394
885
  if (command === "clearvar" || command === "clearturnvar" || command === "clearsessionvar") {
@@ -403,11 +894,11 @@ function expandMacros(
403
894
  return "";
404
895
  }
405
896
 
406
- const dynamicValue = getBuiltinMacro(command, runtime);
897
+ const dynamicValue = getBuiltinMacro(command, stack, runtime);
407
898
  if (dynamicValue !== undefined) return dynamicValue;
408
899
 
409
- const variableValue = getRuntimeVariable(runtime, staticVariables, command);
410
- if (variableValue !== undefined) return variableValue;
900
+ const variableValue = getRuntimeVariable(runtime, stack, command);
901
+ if (variableValue !== undefined) return variableValueToMacroText(variableValue);
411
902
 
412
903
  unknown.add(expression);
413
904
  return full;
@@ -425,17 +916,19 @@ function expandMacros(
425
916
  return result;
426
917
  }
427
918
 
428
- function getBuiltinMacro(name: string, runtime: PromptRuntime): string | undefined {
919
+ function getBuiltinMacro(name: string, stack: PromptStack, runtime: PromptRuntime): string | undefined {
429
920
  switch (name) {
430
921
  case "cwd":
431
922
  return runtime.options.cwd;
432
923
  case "date":
433
924
  return formatDate(runtime.now);
925
+ case "time":
926
+ return formatTime(runtime.now);
434
927
  case "lastUserMessage":
435
928
  return runtime.latestUserMessage ?? "";
436
929
  case "selectedTools":
437
930
  case "tools":
438
- return (runtime.options.selectedTools ?? []).join(", ");
931
+ return scopedToolNames(stack, runtime).join(", ");
439
932
  case "activeModel": {
440
933
  const model = runtime.ctx?.model;
441
934
  return model ? `${model.provider}/${model.id}` : "";
@@ -445,9 +938,10 @@ function getBuiltinMacro(name: string, runtime: PromptRuntime): string | undefin
445
938
  }
446
939
  }
447
940
 
448
- function getRuntimeVariable(runtime: PromptRuntime, staticVariables: Record<string, string>, name: string): string | undefined {
941
+ function getRuntimeVariable(runtime: PromptRuntime, stack: PromptStack, name: string): PromptVariableValue | undefined {
449
942
  if (runtime.variables && Object.prototype.hasOwnProperty.call(runtime.variables.turn, name)) return runtime.variables.turn[name];
450
943
  if (runtime.variables && Object.prototype.hasOwnProperty.call(runtime.variables.session, name)) return runtime.variables.session[name];
944
+ const staticVariables = collectStaticVariables(stack);
451
945
  if (Object.prototype.hasOwnProperty.call(staticVariables, name)) return staticVariables[name];
452
946
  return undefined;
453
947
  }
@@ -537,6 +1031,13 @@ function formatDate(now: Date): string {
537
1031
  return `${year}-${month}-${day}`;
538
1032
  }
539
1033
 
1034
+ function formatTime(now: Date): string {
1035
+ const hours = String(now.getHours()).padStart(2, "0");
1036
+ const minutes = String(now.getMinutes()).padStart(2, "0");
1037
+ const seconds = String(now.getSeconds()).padStart(2, "0");
1038
+ return `${hours}:${minutes}:${seconds}`;
1039
+ }
1040
+
540
1041
  function escapeXml(value: string): string {
541
1042
  return value
542
1043
  .replace(/&/g, "&amp;")