@zihanw/pi-forge 0.2.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,10 +11,12 @@ import type {
8
11
  PromptStackItem,
9
12
  PromptStackRole,
10
13
  PromptStackSlotItem,
11
- PromptStateScope,
12
- PromptStateValue,
14
+ PromptStackSlotFormat,
15
+ PromptVariableValue,
13
16
  PromptVariableStore,
14
17
  } from "./types.ts";
18
+ import { applyResourcePolicy } from "./policy.ts";
19
+ import { applyRegexRulesToMessages, applyRegexRulesToString } from "./regex.ts";
15
20
  import { SUPPORTED_SLOTS } from "./types.ts";
16
21
 
17
22
  const ZERO_USAGE = {
@@ -23,7 +28,11 @@ const ZERO_USAGE = {
23
28
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
24
29
  };
25
30
 
26
- export function createPromptVariableStore(sessionVariables: Record<string, PromptStateValue> = {}): 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 {
27
36
  return { turn: {}, session: { ...sessionVariables }, sessionDirty: false };
28
37
  }
29
38
 
@@ -65,20 +74,20 @@ export function compileSystemPrompt(
65
74
  const compiled = parts.join("\n\n");
66
75
  const mode = stack.mode ?? "replace";
67
76
 
77
+ let systemPrompt: string;
68
78
  if (!compiled) {
69
79
  diagnostics.push({ level: "warning", message: "Compiled system prompt is empty; preserving base system prompt." });
70
- return { systemPrompt: baseSystemPrompt, diagnostics };
71
- }
72
-
73
- if (mode === "append") {
74
- return { systemPrompt: compiled ? `${baseSystemPrompt}\n\n${compiled}` : baseSystemPrompt, diagnostics };
75
- }
76
-
77
- if (mode === "prepend") {
78
- 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;
79
87
  }
80
88
 
81
- return { systemPrompt: compiled, diagnostics };
89
+ systemPrompt = applyRegexRulesToString(stack, systemPrompt, "compiled", "system", diagnostics);
90
+ return { systemPrompt, diagnostics };
82
91
  }
83
92
 
84
93
  export function compileMessages(
@@ -87,7 +96,8 @@ export function compileMessages(
87
96
  originalMessages: AgentMessage[],
88
97
  ): CompileMessagesResult {
89
98
  const diagnostics: PromptStackDiagnostic[] = [];
90
- const messages: AgentMessage[] = [];
99
+ let messages: AgentMessage[] = [];
100
+ let messageSources: CompileMessageSource[] = [];
91
101
  let insertedHistory = false;
92
102
 
93
103
  for (const item of enabledItems(stack)) {
@@ -100,7 +110,10 @@ export function compileMessages(
100
110
  });
101
111
  continue;
102
112
  }
103
- 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));
104
117
  insertedHistory = true;
105
118
  continue;
106
119
  }
@@ -112,14 +125,18 @@ export function compileMessages(
112
125
  : renderSlotText(item, stack, runtime, diagnostics);
113
126
 
114
127
  if (!content.trim()) continue;
115
- 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));
116
131
  }
117
132
 
118
133
  if (!insertedHistory) {
119
134
  messages.push(...originalMessages);
135
+ messageSources.push(...implicitHistoryMessageSources(originalMessages));
120
136
  }
121
137
 
122
- return { messages, diagnostics };
138
+ messages = applyRegexRulesToMessages(stack, messages, "compiled", diagnostics);
139
+ return { messages, messageSources, diagnostics };
123
140
  }
124
141
 
125
142
  export function getLatestUserMessage(messages: AgentMessage[]): string | undefined {
@@ -133,32 +150,68 @@ export function getLatestUserMessage(messages: AgentMessage[]): string | undefin
133
150
  return undefined;
134
151
  }
135
152
 
136
- export function renderPreviewMessages(messages: AgentMessage[], maxChars = 8000): string {
137
- let text = "";
138
- for (const message of messages) {
139
- const role = message.role;
140
- text += `\n--- ${role} ---\n`;
141
- text += contentToText((message as { content?: unknown; summary?: string; command?: string; output?: string }).content);
142
- if (role === "bashExecution") {
143
- const bash = message as { command?: string; output?: string };
144
- text += `Ran ${bash.command ?? ""}\n${bash.output ?? ""}`;
145
- }
146
- if (role === "branchSummary" || role === "compactionSummary") {
147
- text += (message as { summary?: string }).summary ?? "";
148
- }
149
- text += "\n";
150
- 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 ?? "";
151
162
  }
152
- return text.trimStart();
163
+ return text;
153
164
  }
154
165
 
155
- function getChatHistoryMessages(messages: AgentMessage[], item: PromptStackSlotItem): AgentMessage[] {
156
- 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
+ }
157
199
 
158
- const lastUserIndex = findLastUserMessageIndex(messages);
159
- if (lastUserIndex === -1) return messages;
200
+ if (options.stripAssistantThinking === true) {
201
+ result = stripAssistantThinkingFromHistory(result, diagnostics, item.id);
202
+ }
160
203
 
161
- return messages.filter((_message, index) => index !== lastUserIndex);
204
+ const limited = limitChatHistory(result, options, diagnostics, item.id);
205
+ if (limited !== result) {
206
+ result = limited;
207
+ shouldRepairToolPairs = true;
208
+ }
209
+
210
+ if (shouldRepairToolPairs && options.toolMode !== "drop") {
211
+ result = repairToolHistory(result, diagnostics, item.id);
212
+ }
213
+
214
+ return result;
162
215
  }
163
216
 
164
217
  function findLastUserMessageIndex(messages: AgentMessage[]): number {
@@ -168,6 +221,309 @@ function findLastUserMessageIndex(messages: AgentMessage[]): number {
168
221
  return -1;
169
222
  }
170
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
+
171
527
  function enabledItems(stack: PromptStack): PromptStackItem[] {
172
528
  return stack.items.filter((item) => item.enabled !== false);
173
529
  }
@@ -187,13 +543,13 @@ function renderSlotText(
187
543
 
188
544
  switch (slot) {
189
545
  case "tools":
190
- return renderTools(runtime);
546
+ return renderTools(item, stack, runtime);
191
547
  case "tool-guidelines":
192
- return renderToolGuidelines(runtime);
548
+ return renderToolGuidelines(item, stack, runtime);
193
549
  case "skills":
194
- return renderSkills(runtime);
550
+ return renderSkills(item, stack, runtime);
195
551
  case "project-context":
196
- return renderProjectContext(runtime);
552
+ return renderProjectContext(item, runtime);
197
553
  case "append-system-prompt":
198
554
  return runtime.options.appendSystemPrompt ?? "";
199
555
  case "date":
@@ -217,9 +573,24 @@ function renderSlotText(
217
573
  }
218
574
  }
219
575
 
220
- function renderTools(runtime: PromptRuntime): string {
221
- const tools = runtime.options.selectedTools ?? [];
576
+ function renderTools(item: PromptStackSlotItem, stack: PromptStack, runtime: PromptRuntime): string {
222
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
+
223
594
  const lines = ["<available_tools>"];
224
595
 
225
596
  if (tools.length === 0) {
@@ -235,8 +606,8 @@ function renderTools(runtime: PromptRuntime): string {
235
606
  return lines.join("\n");
236
607
  }
237
608
 
238
- function renderToolGuidelines(runtime: PromptRuntime): string {
239
- const tools = runtime.options.selectedTools ?? [];
609
+ function renderToolGuidelines(item: PromptStackSlotItem, stack: PromptStack, runtime: PromptRuntime): string {
610
+ const tools = scopedToolNames(stack, runtime);
240
611
  const guidelines: string[] = [];
241
612
  const seen = new Set<string>();
242
613
  const add = (line: string) => {
@@ -247,17 +618,31 @@ function renderToolGuidelines(runtime: PromptRuntime): string {
247
618
  };
248
619
 
249
620
  if (tools.includes("bash") && !tools.includes("grep") && !tools.includes("find") && !tools.includes("ls")) {
250
- 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.");
251
622
  }
252
623
 
253
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
+ }
254
629
 
255
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
+ }
256
638
  return ["<tool_guidelines>", ...guidelines.map((line) => `- ${line}`), "</tool_guidelines>"].join("\n");
257
639
  }
258
640
 
259
- function renderSkills(runtime: PromptRuntime): string {
260
- 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);
261
646
  if (skills.length === 0) return "";
262
647
 
263
648
  const lines = [
@@ -265,9 +650,18 @@ function renderSkills(runtime: PromptRuntime): string {
265
650
  "Use the read tool to load a skill's file when the task matches its description.",
266
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.",
267
652
  "",
268
- "<available_skills>",
269
653
  ];
270
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>");
271
665
  for (const skill of skills) {
272
666
  lines.push(" <skill>");
273
667
  lines.push(` <name>${escapeXml(skill.name)}</name>`);
@@ -280,10 +674,23 @@ function renderSkills(runtime: PromptRuntime): string {
280
674
  return lines.join("\n");
281
675
  }
282
676
 
283
- 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 {
284
682
  const contextFiles = runtime.options.contextFiles ?? [];
285
683
  if (contextFiles.length === 0) return "";
286
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
+
287
694
  const lines = ["<project_context>", "", "Project-specific instructions and guidelines:", ""];
288
695
  for (const file of contextFiles) {
289
696
  lines.push(`<project_instructions path=\"${escapeXml(file.path)}\">`);
@@ -294,14 +701,49 @@ function renderProjectContext(runtime: PromptRuntime): string {
294
701
  return lines.join("\n");
295
702
  }
296
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
+
297
711
  function renderPiDocsGuidance(): string {
712
+ const paths = piDocsPaths();
298
713
  return [
299
- "Pi documentation guidance:",
300
- "- Read Pi documentation only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI.",
301
- "- 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)",
302
722
  ].join("\n");
303
723
  }
304
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
+
305
747
  function renderVariables(
306
748
  item: PromptStackSlotItem,
307
749
  stack: PromptStack,
@@ -309,159 +751,88 @@ function renderVariables(
309
751
  ): string {
310
752
  const options = item.options ?? {};
311
753
  const scopes = selectedVariableScopes(options);
312
- const includeMetadata = options.includeMetadata === true;
313
- const format = options.format === "json" ? "json" : "xml";
314
- const includeNamespaces = normalizeStringArray(options.includeNamespaces);
315
- const excludeNamespaces = normalizeStringArray(options.excludeNamespaces);
316
- const maxValueChars = typeof options.maxValueChars === "number" && Number.isFinite(options.maxValueChars) && options.maxValueChars > 0
317
- ? Math.floor(options.maxValueChars)
318
- : undefined;
754
+ const format = slotTextFormat(item);
319
755
  const store = runtime.variables;
320
- const grouped: Record<PromptStateScope, Record<string, PromptStateValue>> = {
756
+ const grouped: Record<PromptVariableScope, Record<string, PromptVariableValue>> = {
321
757
  static: {},
322
758
  session: {},
323
759
  turn: {},
324
760
  };
325
761
 
326
- if (scopes.has("static")) {
327
- grouped.static = collectStaticVariables(stack);
328
- }
329
- if (scopes.has("session")) {
330
- grouped.session = { ...collectDefaultVariables(stack, "session"), ...(store?.session ?? {}) };
331
- }
332
- if (scopes.has("turn")) {
333
- grouped.turn = { ...collectDefaultVariables(stack, "turn"), ...(store?.turn ?? {}) };
334
- }
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 ?? {}) };
335
765
 
336
- for (const scope of ["static", "session", "turn"] as const) {
337
- grouped[scope] = Object.fromEntries(
338
- Object.entries(grouped[scope]).filter(([name]) => shouldRenderVariable(name, includeNamespaces, excludeNamespaces)),
339
- );
340
- }
341
-
342
- if (!Object.values(grouped).some((values) => Object.keys(values).length > 0)) return "";
343
-
344
- if (format === "json") {
345
- const payload: Record<string, unknown> = {};
346
- for (const scope of ["static", "session", "turn"] as const) {
347
- if (Object.keys(grouped[scope]).length === 0) continue;
348
- payload[scope] = includeMetadata
349
- ? Object.fromEntries(Object.entries(grouped[scope]).sort(([a], [b]) => a.localeCompare(b)).map(([name, value]) => [
350
- name,
351
- {
352
- value,
353
- ...metadataForVariable(stack, name),
354
- },
355
- ]))
356
- : Object.fromEntries(Object.entries(grouped[scope]).sort(([a], [b]) => a.localeCompare(b)));
357
- }
358
- return `<prompt_state format=\"json\">\n${escapeXml(JSON.stringify(payload, null, 2))}\n</prompt_state>`;
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);
359
771
  }
360
772
 
361
- const parts: string[] = ["<prompt_state>"];
773
+ const parts: string[] = ["<variables>"];
362
774
 
363
- for (const scope of ["static", "session", "turn"] as const) {
775
+ for (const scope of scopes) {
364
776
  const entries = Object.entries(grouped[scope]).sort(([a], [b]) => a.localeCompare(b));
365
777
  if (entries.length === 0) continue;
366
778
  parts.push(` <${scope}>`);
367
779
  for (const [name, value] of entries) {
368
- const metadata = includeMetadata ? metadataForVariable(stack, name) : {};
369
- const attrs = [
370
- `name=\"${escapeXml(name)}\"`,
371
- `type=\"${escapeXml(metadata.type ?? inferStateValueType(value))}\"`,
372
- ];
373
- if (metadata.description) attrs.push(`description=\"${escapeXml(metadata.description)}\"`);
374
- if (metadata.agentWritable !== undefined) attrs.push(`agentWritable=\"${metadata.agentWritable ? "true" : "false"}\"`);
375
- if (metadata.userWritable !== undefined) attrs.push(`userWritable=\"${metadata.userWritable ? "true" : "false"}\"`);
376
- parts.push(` <var ${attrs.join(" ")}>${escapeXml(truncateValue(stateValueToPromptText(value), maxValueChars))}</var>`);
780
+ parts.push(` <var name=\"${escapeXml(name)}\">${escapeXml(variableValueToPromptText(value))}</var>`);
377
781
  }
378
782
  parts.push(` </${scope}>`);
379
783
  }
380
784
 
381
- parts.push("</prompt_state>");
785
+ parts.push("</variables>");
382
786
  return parts.join("\n");
383
787
  }
384
788
 
385
- function selectedVariableScopes(options: Record<string, unknown>): Set<PromptStateScope> {
386
- const explicit = normalizeStringArray(options.includeScopes).filter(isStateScope);
387
- if (explicit.length > 0) return new Set(explicit);
388
-
389
- const scopes = new Set<PromptStateScope>();
390
- if (options.includeStatic !== false) scopes.add("static");
391
- if (options.includeSession !== false) scopes.add("session");
392
- if (options.includeTurn !== false) scopes.add("turn");
393
- return scopes;
394
- }
395
-
396
- function collectStaticVariables(stack: PromptStack): Record<string, PromptStateValue> {
397
- return {
398
- ...collectDefaultVariables(stack, "static"),
399
- ...(stack.variables ?? {}),
400
- };
401
- }
789
+ function renderPlainVariables(
790
+ grouped: Record<PromptVariableScope, Record<string, PromptVariableValue>>,
791
+ ): string {
792
+ const parts: string[] = ["Variables:"];
402
793
 
403
- function collectDefaultVariables(stack: PromptStack, scope: PromptStateScope): Record<string, PromptStateValue> {
404
- const values: Record<string, PromptStateValue> = {};
405
- for (const [name, definition] of Object.entries(stack.state?.definitions ?? {})) {
406
- if ((definition.scope ?? "session") !== scope) continue;
407
- if (definition.default !== undefined) values[name] = definition.default;
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)));
800
+ }
408
801
  }
409
- return values;
410
- }
411
802
 
412
- function shouldRenderVariable(name: string, includePatterns: string[], excludePatterns: string[]): boolean {
413
- if (includePatterns.length > 0 && !includePatterns.some((pattern) => matchesNamespace(name, pattern))) return false;
414
- if (excludePatterns.some((pattern) => matchesNamespace(name, pattern))) return false;
415
- return true;
416
- }
417
-
418
- function matchesNamespace(name: string, pattern: string): boolean {
419
- if (!pattern) return false;
420
- if (pattern.endsWith("*")) return name.startsWith(pattern.slice(0, -1));
421
- return name === pattern;
803
+ return parts.join("\n");
422
804
  }
423
805
 
424
- function metadataForVariable(stack: PromptStack, name: string): {
425
- type?: string;
426
- description?: string;
427
- agentWritable?: boolean;
428
- userWritable?: boolean;
429
- } {
430
- const definition = stack.state?.definitions?.[name];
431
- if (!definition) return {};
432
- return {
433
- type: definition.type,
434
- description: definition.description,
435
- agentWritable: definition.agentWritable,
436
- userWritable: definition.userWritable,
437
- };
806
+ function plainBullet(label: string, value: string): string {
807
+ return `- ${label}: ${plainContinuation(value, " ")}`;
438
808
  }
439
809
 
440
- function normalizeStringArray(value: unknown): string[] {
441
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
810
+ function plainContinuation(value: string, indent: string): string {
811
+ return value.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
442
812
  }
443
813
 
444
- function isStateScope(value: string): value is PromptStateScope {
445
- return value === "static" || value === "session" || value === "turn";
814
+ function indentPlainBlock(value: string, indent: string): string {
815
+ return value.split("\n").map((line) => `${indent}${line}`).join("\n");
446
816
  }
447
817
 
448
- function inferStateValueType(value: PromptStateValue): string {
449
- if (value === null) return "null";
450
- if (Array.isArray(value)) return "array";
451
- return typeof value;
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;
452
824
  }
453
825
 
454
- function truncateValue(value: string, maxChars: number | undefined): string {
455
- if (!maxChars || value.length <= maxChars) return value;
456
- return `${value.slice(0, maxChars)}\n[truncated]`;
826
+ function collectStaticVariables(stack: PromptStack): Record<string, PromptVariableValue> {
827
+ return { ...(stack.variables ?? {}) };
457
828
  }
458
829
 
459
- function stateValueToPromptText(value: PromptStateValue): string {
830
+ function variableValueToPromptText(value: PromptVariableValue): string {
460
831
  if (typeof value === "string") return value;
461
832
  return JSON.stringify(value, null, 2);
462
833
  }
463
834
 
464
- function stateValueToMacroText(value: PromptStateValue | undefined): string {
835
+ function variableValueToMacroText(value: PromptVariableValue | undefined): string {
465
836
  if (value === undefined) return "";
466
837
  if (typeof value === "string") return value;
467
838
  return JSON.stringify(value);
@@ -506,9 +877,9 @@ function expandMacros(
506
877
  unknown.add(expression);
507
878
  return full;
508
879
  }
509
- if (command === "getturnvar") return stateValueToMacroText(runtime.variables?.turn[name]);
510
- if (command === "getsessionvar") return stateValueToMacroText(runtime.variables?.session[name]);
511
- return stateValueToMacroText(getRuntimeVariable(runtime, stack, 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));
512
883
  }
513
884
 
514
885
  if (command === "clearvar" || command === "clearturnvar" || command === "clearsessionvar") {
@@ -523,11 +894,11 @@ function expandMacros(
523
894
  return "";
524
895
  }
525
896
 
526
- const dynamicValue = getBuiltinMacro(command, runtime);
897
+ const dynamicValue = getBuiltinMacro(command, stack, runtime);
527
898
  if (dynamicValue !== undefined) return dynamicValue;
528
899
 
529
900
  const variableValue = getRuntimeVariable(runtime, stack, command);
530
- if (variableValue !== undefined) return stateValueToMacroText(variableValue);
901
+ if (variableValue !== undefined) return variableValueToMacroText(variableValue);
531
902
 
532
903
  unknown.add(expression);
533
904
  return full;
@@ -545,7 +916,7 @@ function expandMacros(
545
916
  return result;
546
917
  }
547
918
 
548
- function getBuiltinMacro(name: string, runtime: PromptRuntime): string | undefined {
919
+ function getBuiltinMacro(name: string, stack: PromptStack, runtime: PromptRuntime): string | undefined {
549
920
  switch (name) {
550
921
  case "cwd":
551
922
  return runtime.options.cwd;
@@ -557,7 +928,7 @@ function getBuiltinMacro(name: string, runtime: PromptRuntime): string | undefin
557
928
  return runtime.latestUserMessage ?? "";
558
929
  case "selectedTools":
559
930
  case "tools":
560
- return (runtime.options.selectedTools ?? []).join(", ");
931
+ return scopedToolNames(stack, runtime).join(", ");
561
932
  case "activeModel": {
562
933
  const model = runtime.ctx?.model;
563
934
  return model ? `${model.provider}/${model.id}` : "";
@@ -567,7 +938,7 @@ function getBuiltinMacro(name: string, runtime: PromptRuntime): string | undefin
567
938
  }
568
939
  }
569
940
 
570
- function getRuntimeVariable(runtime: PromptRuntime, stack: PromptStack, name: string): PromptStateValue | undefined {
941
+ function getRuntimeVariable(runtime: PromptRuntime, stack: PromptStack, name: string): PromptVariableValue | undefined {
571
942
  if (runtime.variables && Object.prototype.hasOwnProperty.call(runtime.variables.turn, name)) return runtime.variables.turn[name];
572
943
  if (runtime.variables && Object.prototype.hasOwnProperty.call(runtime.variables.session, name)) return runtime.variables.session[name];
573
944
  const staticVariables = collectStaticVariables(stack);