@selesai/code 0.13.26 → 0.13.27

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 (119) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli/args.js +1 -0
  3. package/dist/cli/session-picker.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/core/agent-session-auto-handoff.test.js +2 -1
  6. package/dist/core/agent-session-length-continuation.test.js +2 -1
  7. package/dist/core/agent-session.d.ts +58 -7
  8. package/dist/core/agent-session.js +334 -167
  9. package/dist/core/bug-report-upload.d.ts +11 -0
  10. package/dist/core/bug-report-upload.js +21 -0
  11. package/dist/core/bug-report.d.ts +175 -0
  12. package/dist/core/bug-report.js +291 -0
  13. package/dist/core/cache-stats.js +12 -1
  14. package/dist/core/cache-warmer.d.ts +101 -0
  15. package/dist/core/cache-warmer.js +341 -0
  16. package/dist/core/compaction/branch-summarization.js +2 -2
  17. package/dist/core/compaction/compaction.d.ts +6 -5
  18. package/dist/core/compaction/compaction.js +49 -42
  19. package/dist/core/crash-log.d.ts +21 -0
  20. package/dist/core/crash-log.js +70 -0
  21. package/dist/core/experimental.d.ts +0 -4
  22. package/dist/core/experimental.js +0 -4
  23. package/dist/core/extensions/index.d.ts +1 -1
  24. package/dist/core/extensions/jiti-loader.d.ts +1 -0
  25. package/dist/core/extensions/jiti-loader.js +3 -0
  26. package/dist/core/extensions/jiti-static-loader.d.ts +1 -0
  27. package/dist/core/extensions/jiti-static-loader.js +3 -0
  28. package/dist/core/extensions/loader.js +39 -49
  29. package/dist/core/extensions/runner.d.ts +10 -7
  30. package/dist/core/extensions/runner.js +85 -67
  31. package/dist/core/extensions/types.d.ts +66 -52
  32. package/dist/core/extensions/virtual-modules.d.ts +2 -0
  33. package/dist/core/extensions/virtual-modules.js +39 -0
  34. package/dist/core/extensions/wrapper.js +1 -20
  35. package/dist/core/index.d.ts +2 -1
  36. package/dist/core/keybindings.d.ts +1 -1
  37. package/dist/core/keybindings.js +1 -1
  38. package/dist/core/messages.js +1 -0
  39. package/dist/core/model-config.d.ts +101 -20
  40. package/dist/core/model-config.js +25 -18
  41. package/dist/core/model-resolver.js +2 -1
  42. package/dist/core/model-runtime.js +5 -3
  43. package/dist/core/provider-composer.d.ts +7 -2
  44. package/dist/core/provider-composer.js +10 -5
  45. package/dist/core/radius.d.ts +3 -0
  46. package/dist/core/radius.js +6 -0
  47. package/dist/core/sdk.js +60 -38
  48. package/dist/core/session-export.d.ts +6 -2
  49. package/dist/core/session-export.js +14 -13
  50. package/dist/core/session-format.test.d.ts +1 -0
  51. package/dist/core/session-format.test.js +96 -0
  52. package/dist/core/session-manager.d.ts +29 -6
  53. package/dist/core/session-manager.js +159 -96
  54. package/dist/core/settings-manager.d.ts +22 -5
  55. package/dist/core/settings-manager.js +56 -23
  56. package/dist/core/slash-commands.js +1 -0
  57. package/dist/core/system-prompt.d.ts +49 -4
  58. package/dist/core/system-prompt.js +155 -102
  59. package/dist/core/system-prompt.test.js +61 -23
  60. package/dist/core/tools/bash.d.ts +2 -1
  61. package/dist/core/tools/bash.js +10 -4
  62. package/dist/core/tools/edit.js +1 -2
  63. package/dist/core/tools/read.js +1 -2
  64. package/dist/core/tools/renderers/bash.js +9 -1
  65. package/dist/core/tools/write.js +1 -2
  66. package/dist/core/usage-totals.d.ts +1 -1
  67. package/dist/core/usage-totals.js +5 -1
  68. package/dist/extensions/pi-web-agent/src/backends/factory.ts +14 -6
  69. package/dist/extensions/pi-web-agent/src/extension.ts +3 -1
  70. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +7 -3
  71. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +3 -0
  72. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +2 -0
  73. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +4 -2
  74. package/dist/extensions/pi-web-agent/src/search/tokenin.ts +4 -1
  75. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +2 -1
  76. package/dist/extensions/pi-web-agent/src/types.ts +4 -0
  77. package/dist/index.d.ts +3 -2
  78. package/dist/main.js +13 -9
  79. package/dist/migrations.js +2 -2
  80. package/dist/modes/interactive/bug-report.d.ts +15 -0
  81. package/dist/modes/interactive/bug-report.js +205 -0
  82. package/dist/modes/interactive/chat-viewport.js +1 -1
  83. package/dist/modes/interactive/components/branch-summary-message.js +12 -5
  84. package/dist/modes/interactive/components/compaction-summary-message.js +12 -5
  85. package/dist/modes/interactive/components/custom-editor.d.ts +3 -3
  86. package/dist/modes/interactive/components/extension-editor.d.ts +4 -1
  87. package/dist/modes/interactive/components/extension-editor.js +7 -2
  88. package/dist/modes/interactive/components/extension-input.d.ts +2 -0
  89. package/dist/modes/interactive/components/extension-input.js +6 -0
  90. package/dist/modes/interactive/components/extension-selector.d.ts +1 -0
  91. package/dist/modes/interactive/components/extension-selector.js +4 -0
  92. package/dist/modes/interactive/components/footer.js +4 -1
  93. package/dist/modes/interactive/components/session-selector.d.ts +5 -5
  94. package/dist/modes/interactive/components/session-selector.js +74 -48
  95. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  96. package/dist/modes/interactive/components/settings-selector.js +11 -0
  97. package/dist/modes/interactive/components/skill-invocation-message.js +11 -4
  98. package/dist/modes/interactive/components/status-indicator.d.ts +2 -2
  99. package/dist/modes/interactive/components/status-indicator.js +7 -7
  100. package/dist/modes/interactive/components/tool-execution.js +18 -9
  101. package/dist/modes/interactive/components/tree-selector.js +2 -0
  102. package/dist/modes/interactive/interactive-mode.d.ts +15 -2
  103. package/dist/modes/interactive/interactive-mode.js +257 -81
  104. package/dist/modes/interactive/session-share.d.ts +2 -0
  105. package/dist/modes/interactive/session-share.js +8 -4
  106. package/dist/modes/interactive/tui-renderer.js +2 -2
  107. package/dist/modes/rpc/rpc-mode.js +2 -2
  108. package/dist/utils/clipboard-command.d.ts +6 -0
  109. package/dist/utils/clipboard-command.js +44 -0
  110. package/dist/utils/clipboard-image.js +55 -89
  111. package/dist/utils/clipboard.d.ts +1 -1
  112. package/dist/utils/clipboard.js +118 -108
  113. package/dist/utils/wsl.d.ts +2 -0
  114. package/dist/utils/wsl.js +14 -0
  115. package/dist/utils/zip.d.ts +6 -0
  116. package/dist/utils/zip.js +59 -0
  117. package/package.json +9 -9
  118. package/dist/utils/clipboard-native.d.ts +0 -10
  119. package/dist/utils/clipboard-native.js +0 -19
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * System prompt construction and project context loading
3
3
  */
4
+ import { getSystemMessageText } from "@earendil-works/pi-ai";
4
5
  import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
5
6
  import { formatAgentsForPrompt } from "./agents.js";
6
7
  import { formatSkillsForPrompt } from "./skills.js";
@@ -10,120 +11,172 @@ import { formatSkillsForPrompt } from "./skills.js";
10
11
  * sessions without such a tool are unaffected.
11
12
  */
12
13
  const DELEGATION_ROUTING_GUIDELINE = "When a delegation/subagent tool is available: keep tiny targeted reads and simple answers local; send broad local investigation, external research, and mutation/implementation work to a capable delegated agent, inspecting the delegation catalog/list before selecting; the parent remains the decision-maker and normally the sole writer.";
13
- /** Build the system prompt with tools, guidelines, and context */
14
- export function buildSystemPrompt(options) {
15
- const { customPrompt, selectedTools, toolSnippets, promptGuidelines, appendSystemPrompt, cwd, contextFiles: providedContextFiles, skills: providedSkills, } = options;
16
- const promptCwd = cwd.replace(/\\/g, "/");
17
- const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
18
- const contextFiles = providedContextFiles ?? [];
19
- const skills = providedSkills ?? [];
20
- const agents = options.agents ?? [];
21
- const tools = selectedTools || ["read", "bash", "edit", "write"];
22
- const hasRead = tools.includes("read");
23
- const skillFileReadTool = ["read", "bash"].find((tool) => tools.includes(tool));
24
- if (customPrompt) {
25
- let prompt = customPrompt;
26
- if (appendSection) {
27
- prompt += appendSection;
28
- }
29
- // Append project context files
30
- if (contextFiles.length > 0) {
31
- prompt += "\n\n<project_context>\n\n";
32
- prompt += "Project-specific instructions and guidelines:\n\n";
33
- for (const { path: filePath, content } of contextFiles) {
34
- prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
35
- }
36
- prompt += "</project_context>\n";
37
- }
38
- // Append skills when a tool capable of reading their files is available.
39
- if (skillFileReadTool && skills.length > 0) {
40
- prompt += formatSkillsForPrompt(skills, skillFileReadTool);
14
+ const SYSTEM_PROMPT_SECTION_NAME = /^[a-z][a-z0-9_-]*$/;
15
+ /** Normalize prompt input into the mutable, collection-complete shape exposed to extensions. */
16
+ export function normalizeBuildSystemPromptOptions(input) {
17
+ return {
18
+ customPrompt: input.customPrompt,
19
+ forceSystemPrompt: input.forceSystemPrompt,
20
+ selectedTools: [...(input.selectedTools ?? ["read", "bash", "edit", "write"])],
21
+ toolSnippets: { ...(input.toolSnippets ?? {}) },
22
+ toolGuidelines: Object.fromEntries(Object.entries(input.toolGuidelines ?? {}).map(([name, guidelines]) => [name, [...guidelines]])),
23
+ promptGuidelines: [...(input.promptGuidelines ?? [])],
24
+ appendSystemPrompt: input.appendSystemPrompt ?? "",
25
+ sections: { ...(input.sections ?? {}) },
26
+ cwd: input.cwd,
27
+ contextFiles: (input.contextFiles ?? []).map((file) => ({ ...file })),
28
+ skills: (input.skills ?? []).map((skill) => ({ ...skill })),
29
+ agents: (input.agents ?? []).map((agent) => ({ ...agent })),
30
+ };
31
+ }
32
+ function renderProjectContext(contextFiles) {
33
+ return [
34
+ "Project-specific instructions and guidelines:",
35
+ ...contextFiles.map(({ path, content }) => `<project_instructions path="${path}">\n${content}\n</project_instructions>`),
36
+ ].join("\n\n");
37
+ }
38
+ export function buildRules(selectedTools, toolGuidelines, promptGuidelines) {
39
+ const rules = [];
40
+ const seen = new Set();
41
+ const addRule = (rule) => {
42
+ const normalized = rule.trim();
43
+ if (!normalized || seen.has(normalized))
44
+ return;
45
+ seen.add(normalized);
46
+ rules.push(normalized);
47
+ };
48
+ const hasBash = selectedTools.includes("bash");
49
+ const hasPowerShell = selectedTools.includes("powershell");
50
+ const hasGrep = selectedTools.includes("grep");
51
+ const hasFind = selectedTools.includes("find");
52
+ const hasLs = selectedTools.includes("ls");
53
+ if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) {
54
+ if (hasBash && hasPowerShell) {
55
+ addRule("Use bash or PowerShell for file operations like listing, searching, and finding files");
41
56
  }
42
- if (hasRead && agents.length > 0) {
43
- prompt += formatAgentsForPrompt(agents);
57
+ else if (hasPowerShell) {
58
+ addRule("Use PowerShell for file operations like listing, searching, and finding files");
44
59
  }
45
- // Render active-tool guidelines (e.g., mandatory delegation contracts) in
46
- // custom-prompt sessions too, without requiring the read tool. The caller's
47
- // custom prompt text stays unchanged; a clearly delimited section is appended
48
- // only when non-empty guidelines exist.
49
- const activeGuidelines = (promptGuidelines ?? [])
50
- .map((guideline) => guideline.trim())
51
- .filter((guideline) => guideline.length > 0)
52
- .filter((guideline, index, all) => all.indexOf(guideline) === index);
53
- if (activeGuidelines.length > 0) {
54
- prompt += `\n\nActive tool guidelines:\n${activeGuidelines.map((guideline) => `- ${guideline}`).join("\n")}`;
60
+ else {
61
+ addRule("Use bash for file operations like ls, rg, find");
55
62
  }
56
- prompt += `\nCurrent working directory: ${promptCwd}\n`;
57
- return prompt;
58
63
  }
59
- // Get absolute paths to documentation and examples
64
+ for (const name of selectedTools) {
65
+ for (const rule of toolGuidelines[name] ?? [])
66
+ addRule(rule);
67
+ }
68
+ for (const rule of promptGuidelines)
69
+ addRule(rule);
70
+ // Selesai fork: delegation routing is part of the default rule set.
71
+ addRule(DELEGATION_ROUTING_GUIDELINE);
72
+ addRule("Be concise in your responses");
73
+ addRule("Show file paths clearly when working with files");
74
+ return rules.map((rule) => `- ${rule}`).join("\n");
75
+ }
76
+ /** Selesai fork: the fork names itself rather than upstream, and keeps its own docs pointers. */
77
+ function buildForkPreamble() {
78
+ return "You are an expert coding assistant operating inside SelesaiCode fork of Pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
79
+ }
80
+ function buildForkDocsSection() {
60
81
  const readmePath = getReadmePath();
61
82
  const docsPath = getDocsPath();
62
83
  const examplesPath = getExamplesPath();
63
- // Build tools list based on selected tools.
64
- // A tool appears in Available tools only when the caller provides a one-line snippet.
65
- const visibleTools = tools.filter((name) => !!toolSnippets?.[name]);
66
- const toolsList = visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";
67
- // Build guidelines based on which tools are actually available
68
- const guidelinesList = [];
69
- const guidelinesSet = new Set();
70
- const addGuideline = (guideline) => {
71
- if (guidelinesSet.has(guideline)) {
72
- return;
73
- }
74
- guidelinesSet.add(guideline);
75
- guidelinesList.push(guideline);
76
- };
77
- const hasBash = tools.includes("bash");
78
- const hasGrep = tools.includes("grep");
79
- const hasFind = tools.includes("find");
80
- const hasLs = tools.includes("ls");
81
- // File exploration guidelines
82
- if (hasBash && !hasGrep && !hasFind && !hasLs) {
83
- addGuideline("Use bash for file operations like ls, rg, find");
84
- }
85
- for (const guideline of promptGuidelines ?? []) {
86
- const normalized = guideline.trim();
87
- if (normalized.length > 0) {
88
- addGuideline(normalized);
84
+ return `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI): main docs ${readmePath}, additional docs ${docsPath}, examples ${examplesPath}.
85
+ When working on pi topics, resolve docs/... and examples/... under those paths (not cwd), read the relevant .md files completely, and follow cross-references before implementing.`;
86
+ }
87
+ /**
88
+ * Active-tool guidelines for a custom-prompt session: the caller's custom prompt text stays
89
+ * unchanged and a clearly delimited section is appended only when non-empty guidelines exist.
90
+ */
91
+ function buildActiveToolGuidelines(promptGuidelines) {
92
+ const activeGuidelines = promptGuidelines
93
+ .map((guideline) => guideline.trim())
94
+ .filter((guideline) => guideline.length > 0)
95
+ .filter((guideline, index, all) => all.indexOf(guideline) === index);
96
+ if (activeGuidelines.length === 0)
97
+ return undefined;
98
+ return `Active tool guidelines:\n${activeGuidelines.map((guideline) => `- ${guideline}`).join("\n")}`;
99
+ }
100
+ /** Build the ordered, independently replaceable sections of the structured system prompt. */
101
+ export function buildSystemPromptSections(input) {
102
+ const options = normalizeBuildSystemPromptOptions(input);
103
+ const { customPrompt, selectedTools, toolSnippets, toolGuidelines, promptGuidelines, appendSystemPrompt, sections: customSections, cwd, contextFiles, skills, agents, } = options;
104
+ for (const name of Object.keys(customSections)) {
105
+ if (!SYSTEM_PROMPT_SECTION_NAME.test(name) || name === "preamble") {
106
+ throw new Error(`Invalid system prompt section name: ${name}`);
89
107
  }
90
108
  }
91
- // Always include these
92
- addGuideline(DELEGATION_ROUTING_GUIDELINE);
93
- addGuideline("Be concise in your responses");
94
- addGuideline("Show file paths clearly when working with files");
95
- const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");
96
- let prompt = `You are an expert coding assistant operating inside SelesaiCode fork of Pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
97
-
98
- Available tools:
99
- ${toolsList}
100
-
101
- In addition to the tools above, you may have access to other custom tools depending on the project.
102
-
103
- Guidelines:
104
- ${guidelines}
105
-
106
- Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI): main docs ${readmePath}, additional docs ${docsPath}, examples ${examplesPath}.
107
- When working on pi topics, resolve docs/... and examples/... under those paths (not cwd), read the relevant .md files completely, and follow cross-references before implementing.`;
108
- if (appendSection) {
109
- prompt += appendSection;
109
+ const promptSections = {};
110
+ if (customPrompt) {
111
+ promptSections.preamble = customPrompt;
112
+ // Selesai fork: custom-prompt sessions still render active-tool guidelines.
113
+ const activeGuidelines = buildActiveToolGuidelines(promptGuidelines);
114
+ if (activeGuidelines)
115
+ promptSections.active_tool_guidelines = activeGuidelines;
110
116
  }
111
- // Append project context files
112
- if (contextFiles.length > 0) {
113
- prompt += "\n\n<project_context>\n\n";
114
- prompt += "Project-specific instructions and guidelines:\n\n";
115
- for (const { path: filePath, content } of contextFiles) {
116
- prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
117
- }
118
- prompt += "</project_context>\n";
117
+ else {
118
+ promptSections.preamble = buildForkPreamble();
119
+ const visibleTools = selectedTools.filter((name) => !!toolSnippets[name]);
120
+ const tools = visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";
121
+ promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`;
122
+ promptSections.rules = buildRules(selectedTools, toolGuidelines, promptGuidelines);
123
+ promptSections.docs = buildForkDocsSection();
119
124
  }
120
- // Append skills when a tool capable of reading their files is available.
125
+ if (appendSystemPrompt)
126
+ promptSections.addendum = appendSystemPrompt;
127
+ if (contextFiles.length > 0)
128
+ promptSections.project_context = renderProjectContext(contextFiles);
129
+ const skillFileReadTool = ["read", "bash"].find((tool) => selectedTools.includes(tool));
121
130
  if (skillFileReadTool && skills.length > 0) {
122
- prompt += formatSkillsForPrompt(skills, skillFileReadTool);
131
+ const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim();
132
+ if (skillsPrompt)
133
+ promptSections.skills = skillsPrompt;
134
+ }
135
+ // Selesai fork: the available-agents section belongs to the fork's delegation story.
136
+ if (selectedTools.includes("read") && agents.length > 0) {
137
+ const agentsPrompt = formatAgentsForPrompt(agents).trim();
138
+ if (agentsPrompt)
139
+ promptSections.agents = agentsPrompt;
140
+ }
141
+ promptSections.cwd = cwd.replace(/\\/g, "/");
142
+ for (const [name, content] of Object.entries(customSections)) {
143
+ if (content)
144
+ promptSections[name] = content;
145
+ }
146
+ const sections = { preamble: promptSections.preamble };
147
+ for (const [name, content] of Object.entries(promptSections)) {
148
+ if (name !== "preamble")
149
+ sections[name] = `<${name}>\n${content}\n</${name}>`;
150
+ }
151
+ return sections;
152
+ }
153
+ /**
154
+ * The complete prompt state for `input`. A forced prompt is opaque and lives in `content`
155
+ * with no sections; otherwise `content` is empty and the structured sections carry the prompt.
156
+ */
157
+ export function buildSystemPromptState(input) {
158
+ if (input.forceSystemPrompt !== undefined)
159
+ return { content: input.forceSystemPrompt };
160
+ return { content: "", sections: buildSystemPromptSections(input) };
161
+ }
162
+ /** Build the system prompt text, rendered exactly as the transcript's system message replays it. */
163
+ export function buildSystemPrompt(input) {
164
+ return getSystemMessageText({ role: "system", ...buildSystemPromptState(input), timestamp: 0 });
165
+ }
166
+ /**
167
+ * Diff the sections the model currently has (replayed from the transcript, so never null)
168
+ * against the desired ones. Returns a `SystemMessage.sections` patch, or undefined when
169
+ * nothing changed.
170
+ */
171
+ export function diffSystemPromptSections(previous, current) {
172
+ const patch = {};
173
+ for (const [name, text] of Object.entries(current)) {
174
+ if (previous[name] !== text)
175
+ patch[name] = text;
123
176
  }
124
- if (hasRead && agents.length > 0) {
125
- prompt += formatAgentsForPrompt(agents);
177
+ for (const name of Object.keys(previous)) {
178
+ if (current[name] === undefined)
179
+ patch[name] = null;
126
180
  }
127
- prompt += `\nCurrent working directory: ${promptCwd}`;
128
- return prompt;
181
+ return Object.keys(patch).length > 0 ? patch : undefined;
129
182
  }
@@ -1,5 +1,21 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildSystemPrompt } from "./system-prompt.js";
2
+ import { buildSystemPrompt, buildSystemPromptSections, buildSystemPromptState } from "./system-prompt.js";
3
+ const skill = {
4
+ name: "test-skill",
5
+ description: "Test skill description",
6
+ filePath: "/tmp/skills/test-skill/SKILL.md",
7
+ baseDir: "/tmp/skills/test-skill",
8
+ sourceInfo: { type: "file", path: "/tmp/skills/test-skill/SKILL.md" },
9
+ disableModelInvocation: false,
10
+ };
11
+ const agent = {
12
+ name: "test-agent",
13
+ description: "Test agent description",
14
+ filePath: "/tmp/agents/test-agent.md",
15
+ baseDir: "/tmp/agents",
16
+ sourceInfo: { type: "file", path: "/tmp/agents/test-agent.md" },
17
+ frontmatter: {},
18
+ };
3
19
  describe("buildSystemPrompt", () => {
4
20
  it("includes the generic delegation-routing threshold in the default prompt", () => {
5
21
  const prompt = buildSystemPrompt({ cwd: "/tmp/project" });
@@ -9,8 +25,9 @@ describe("buildSystemPrompt", () => {
9
25
  expect(prompt).toMatch(/capable delegated agent/i);
10
26
  expect(prompt).toMatch(/inspecting the delegation catalog\/list before selecting/i);
11
27
  expect(prompt).toMatch(/parent remains the decision-maker and normally the sole writer/i);
12
- expect(prompt).toMatch(/Guidelines:/);
13
- expect(prompt).toContain("Current working directory: /tmp/project");
28
+ expect(prompt).toContain("<rules>");
29
+ expect(prompt).toContain("- Be concise in your responses");
30
+ expect(prompt).toContain("<cwd>\n/tmp/project\n</cwd>");
14
31
  });
15
32
  it("keeps the collapsed Pi-docs block without the per-topic filename map", () => {
16
33
  const prompt = buildSystemPrompt({ cwd: "/tmp/project" });
@@ -19,31 +36,23 @@ describe("buildSystemPrompt", () => {
19
36
  expect(prompt).not.toContain("(docs/extensions.md");
20
37
  });
21
38
  it("keeps the default tool snippets, skills, and agents XML read-gated", () => {
22
- const skill = {
23
- name: "test-skill",
24
- description: "Test skill description",
25
- filePath: "/tmp/skills/test-skill/SKILL.md",
26
- baseDir: "/tmp/skills/test-skill",
27
- sourceInfo: { type: "file", path: "/tmp/skills/test-skill/SKILL.md" },
28
- disableModelInvocation: false,
29
- };
30
- const agent = {
31
- name: "test-agent",
32
- description: "Test agent description",
33
- filePath: "/tmp/agents/test-agent.md",
34
- baseDir: "/tmp/agents",
35
- sourceInfo: { type: "file", path: "/tmp/agents/test-agent.md" },
36
- frontmatter: {},
37
- };
38
39
  const withoutRead = buildSystemPrompt({
39
40
  cwd: "/tmp/project",
40
- selectedTools: ["bash"],
41
+ selectedTools: ["edit"],
41
42
  skills: [skill],
42
43
  agents: [agent],
43
44
  });
44
45
  expect(withoutRead).not.toContain("<available_skills>");
45
46
  expect(withoutRead).not.toContain("<available_agents>");
46
- expect(withoutRead).toContain("Current working directory: /tmp/project");
47
+ expect(withoutRead).toContain("<cwd>\n/tmp/project\n</cwd>");
48
+ const withBash = buildSystemPrompt({
49
+ cwd: "/tmp/project",
50
+ selectedTools: ["bash"],
51
+ skills: [skill],
52
+ agents: [agent],
53
+ });
54
+ expect(withBash).toContain("<available_skills>");
55
+ expect(withBash).not.toContain("<available_agents>");
47
56
  const withRead = buildSystemPrompt({
48
57
  cwd: "/tmp/project",
49
58
  selectedTools: ["read", "bash"],
@@ -64,7 +73,7 @@ describe("buildSystemPrompt", () => {
64
73
  expect(prompt).toContain("You are my custom assistant.");
65
74
  expect(prompt).toContain("Active tool guidelines:");
66
75
  expect(prompt).toContain('- Before executing, call { action: "list" }.');
67
- expect(prompt).toContain("Current working directory: /tmp/project");
76
+ expect(prompt).toContain("<cwd>\n/tmp/project\n</cwd>");
68
77
  });
69
78
  it("deduplicates and trims active promptGuidelines in custom prompts", () => {
70
79
  const prompt = buildSystemPrompt({
@@ -79,7 +88,7 @@ describe("buildSystemPrompt", () => {
79
88
  const prompt = buildSystemPrompt({ cwd: "/tmp/project", customPrompt: "Custom text." });
80
89
  expect(prompt).toContain("Custom text.");
81
90
  expect(prompt).not.toContain("Active tool guidelines:");
82
- expect(prompt).toContain("Current working directory: /tmp/project");
91
+ expect(prompt).toContain("<cwd>\n/tmp/project\n</cwd>");
83
92
  });
84
93
  it("renders active promptGuidelines in custom prompts without requiring read", () => {
85
94
  const prompt = buildSystemPrompt({
@@ -92,3 +101,32 @@ describe("buildSystemPrompt", () => {
92
101
  expect(prompt).not.toContain("<available_agents>");
93
102
  });
94
103
  });
104
+ describe("system prompt sections", () => {
105
+ it("tags every non-preamble section with its own name so a later update can address it", () => {
106
+ const sections = buildSystemPromptSections({
107
+ cwd: "/tmp/project",
108
+ selectedTools: ["read", "bash"],
109
+ skills: [skill],
110
+ });
111
+ expect(sections.preamble).toContain("SelesaiCode fork of Pi");
112
+ expect(sections.preamble.startsWith("<")).toBe(false);
113
+ for (const [name, content] of Object.entries(sections)) {
114
+ if (name === "preamble")
115
+ continue;
116
+ expect(content).toBe(`<${name}>\n${content.slice(name.length + 3, -1 * (name.length + 4))}\n</${name}>`);
117
+ }
118
+ expect(Object.keys(sections)).toContain("tools");
119
+ expect(Object.keys(sections)).toContain("rules");
120
+ expect(Object.keys(sections)).toContain("docs");
121
+ expect(Object.keys(sections)).toContain("skills");
122
+ expect(Object.keys(sections)).toContain("cwd");
123
+ });
124
+ it("carries a forced prompt as opaque content with no sections", () => {
125
+ expect(buildSystemPromptState({ forceSystemPrompt: "exact", cwd: "/tmp" })).toEqual({ content: "exact" });
126
+ expect(buildSystemPrompt({ forceSystemPrompt: "exact", cwd: "/tmp" })).toBe("exact");
127
+ });
128
+ it("rejects an invalid section name rather than producing a malformed prompt", () => {
129
+ expect(() => buildSystemPromptSections({ cwd: "/tmp", sections: { "Bad Name": "x" } })).toThrow(/Invalid system prompt section name/);
130
+ expect(() => buildSystemPromptSections({ cwd: "/tmp", sections: { preamble: "x" } })).toThrow(/Invalid system prompt section name/);
131
+ });
132
+ });
@@ -26,7 +26,8 @@ export interface BashOperations {
26
26
  * @param command The command to execute
27
27
  * @param cwd Working directory
28
28
  * @param options Execution options
29
- * @returns Promise resolving to exit code (null if killed)
29
+ * @returns Promise resolving to the exit code. Report signal terminations as 128 + signal number;
30
+ * a null exit code is treated as a failed command.
30
31
  */
31
32
  exec: (command: string, cwd: string, options: {
32
33
  onData: (data: Buffer) => void;
@@ -1,10 +1,10 @@
1
1
  import { constants } from "node:fs";
2
2
  import { access as fsAccess } from "node:fs/promises";
3
+ import { constants as osConstants } from "node:os";
3
4
  import { spawn } from "child_process";
4
5
  import { Type } from "typebox";
5
6
  import { waitForChildProcess } from "../../utils/child-process.js";
6
7
  import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.js";
7
- import { getExperimentalToolSampling } from "../experimental.js";
8
8
  import { OutputAccumulator } from "./output-accumulator.js";
9
9
  import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.js";
10
10
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
@@ -94,7 +94,10 @@ export function createLocalShellOperations(shellName, resolveShellConfig) {
94
94
  if (timedOut) {
95
95
  throw new Error(`timeout:${timeout}`);
96
96
  }
97
- return { exitCode };
97
+ // A signal-killed shell has no exit code. Use the standard shell convention so
98
+ // callers do not mistake the termination for a successful command.
99
+ const signalCode = child.signalCode;
100
+ return { exitCode: exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1) };
98
101
  }
99
102
  finally {
100
103
  if (child.pid)
@@ -151,7 +154,7 @@ export function createShellToolDefinition(cwd, config, options) {
151
154
  promptSnippet: config.promptSnippet,
152
155
  promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined,
153
156
  parameters: bashSchema,
154
- constrainedSampling: getExperimentalToolSampling(),
157
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
155
158
  async execute(_toolCallId, { command, timeout }, signal, onUpdate, ctx) {
156
159
  const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
157
160
  const spawnContext = resolveSpawnContext(resolvedCommand, ctx?.cwd || cwd, spawnHook, exposeSessionEnvironment, ctx);
@@ -260,7 +263,10 @@ export function createShellToolDefinition(cwd, config, options) {
260
263
  }
261
264
  const snapshot = await finishOutput();
262
265
  const { text: outputText, details } = formatOutput(snapshot);
263
- if (exitCode !== 0 && exitCode !== null) {
266
+ if (exitCode === null) {
267
+ throw new Error(appendStatus(outputText, "Command terminated without an exit code"));
268
+ }
269
+ if (exitCode !== 0) {
264
270
  throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
265
271
  }
266
272
  return { content: [{ type: "text", text: outputText }], details };
@@ -1,7 +1,6 @@
1
1
  import { constants } from "fs";
2
2
  import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
3
3
  import { Type } from "typebox";
4
- import { getExperimentalToolSampling } from "../experimental.js";
5
4
  import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
6
5
  import { withFileMutationQueue } from "./file-mutation-queue.js";
7
6
  import { resolveToCwd } from "./path-utils.js";
@@ -67,7 +66,7 @@ export function createEditToolDefinition(cwd, options) {
67
66
  "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
68
67
  ],
69
68
  parameters: editSchema,
70
- constrainedSampling: getExperimentalToolSampling(),
69
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
71
70
  renderShell: "self",
72
71
  prepareArguments: prepareEditArguments,
73
72
  async execute(_toolCallId, input, signal, _onUpdate, ctx) {
@@ -3,7 +3,6 @@ import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
3
3
  import { Type } from "typebox";
4
4
  import { processImage } from "../../utils/image-process.js";
5
5
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
6
- import { getExperimentalToolSampling } from "../experimental.js";
7
6
  import { captionImageWithModel } from "../vision-caption.js";
8
7
  import { resolveReadPathAsync } from "./path-utils.js";
9
8
  import { readRenderers } from "./renderers/read.js";
@@ -70,7 +69,7 @@ export function createReadToolDefinition(cwd, options) {
70
69
  promptSnippet: readToolSystemPromptContribution.snippet,
71
70
  promptGuidelines: [...readToolSystemPromptContribution.guidelines],
72
71
  parameters: readSchema,
73
- constrainedSampling: getExperimentalToolSampling(),
72
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
74
73
  async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, ctx) {
75
74
  return new Promise((resolve, reject) => {
76
75
  if (signal?.aborted) {
@@ -21,7 +21,15 @@ class BashResultRenderComponent extends Container {
21
21
  };
22
22
  }
23
23
  function formatDuration(ms) {
24
- return `${(ms / 1000).toFixed(1)}s`;
24
+ const seconds = ms / 1000;
25
+ if (seconds < 60)
26
+ return `${seconds.toFixed(1)}s`;
27
+ const totalSeconds = Math.floor(seconds);
28
+ const minutes = Math.floor(totalSeconds / 60);
29
+ const remainder = totalSeconds % 60;
30
+ if (minutes < 60)
31
+ return `${minutes}m ${remainder}s`;
32
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`;
25
33
  }
26
34
  function formatShellCall(args, prompt) {
27
35
  const command = str(args?.command);
@@ -1,7 +1,6 @@
1
1
  import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
2
2
  import { dirname } from "path";
3
3
  import { Type } from "typebox";
4
- import { getExperimentalToolSampling } from "../experimental.js";
5
4
  import { withFileMutationQueue } from "./file-mutation-queue.js";
6
5
  import { resolveToCwd } from "./path-utils.js";
7
6
  import { writeRenderers } from "./renderers/write.js";
@@ -23,7 +22,7 @@ export function createWriteToolDefinition(cwd, options) {
23
22
  promptSnippet: "Create or overwrite files",
24
23
  promptGuidelines: ["Use write only for new files or complete rewrites."],
25
24
  parameters: writeSchema,
26
- constrainedSampling: getExperimentalToolSampling(),
25
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
27
26
  async execute(_toolCallId, { path, content }, signal, _onUpdate, ctx) {
28
27
  const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
29
28
  const dir = dirname(absolutePath);
@@ -16,5 +16,5 @@ export interface UsageCostBreakdownEntry {
16
16
  cost: number;
17
17
  tokens: number;
18
18
  }
19
- /** Group attributable assistant usage by model and all other usage into a separate bucket. */
19
+ /** Group model-attributed usage by model and all other usage into a separate bucket. */
20
20
  export declare function getUsageCostBreakdown(entries: SessionEntry[]): UsageCostBreakdownEntry[];
@@ -24,7 +24,7 @@ export function addUsageToTotals(totals, usage) {
24
24
  totals.cacheWrite += usage.cacheWrite;
25
25
  totals.cost += usage.cost.total;
26
26
  }
27
- /** Group attributable assistant usage by model and all other usage into a separate bucket. */
27
+ /** Group model-attributed usage by model and all other usage into a separate bucket. */
28
28
  export function getUsageCostBreakdown(entries) {
29
29
  const totalsByKey = new Map();
30
30
  for (const entry of entries) {
@@ -34,6 +34,10 @@ export function getUsageCostBreakdown(entries) {
34
34
  key = `${entry.message.provider}/${entry.message.responseModel ?? entry.message.model}`;
35
35
  usage = entry.message.usage;
36
36
  }
37
+ else if (entry.type === "usage") {
38
+ key = `${entry.provider}/${entry.model}`;
39
+ usage = entry.usage;
40
+ }
37
41
  else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
38
42
  key = "Tools/summaries";
39
43
  usage = entry.message.usage;
@@ -213,13 +213,21 @@ export function createBackendSet(
213
213
  });
214
214
  }
215
215
 
216
- // Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
217
- // it wraps whatever search ended up being (plain DDG or the fanout set) so a total failure
218
- // still has somewhere to go. Opt out with PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1.
216
+ // The keyless Tavily safety net, kept even under fanout — it wraps whatever search ended up
217
+ // being so a total failure still has somewhere to go. It used to arm only for the bare
218
+ // no-account DuckDuckGo default, which skipped every hosted provider configured with
219
+ // `fallback: duckduckgo`: when that fallback then hit a DNS-blocked or bot-walled DuckDuckGo
220
+ // the whole search died even though keyless Tavily was sitting right there. Arm it whenever
221
+ // DuckDuckGo is reachable in the chain. Opt out with PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1.
219
222
  const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
220
- const usingDuckDuckGoDefault =
221
- config.search.provider === 'duckduckgo' || !config.search.provider;
222
- if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
223
+ // Fanout with no explicit provider list means "all usable", which always includes DuckDuckGo.
224
+ const fanoutActive = config.search.fanout?.mode !== undefined && config.search.fanout.mode !== 'off';
225
+ const duckDuckGoInChain =
226
+ !config.search.provider ||
227
+ config.search.provider === 'duckduckgo' ||
228
+ config.search.fallback === 'duckduckgo' ||
229
+ (fanoutActive && (config.search.fanout?.providers?.includes('duckduckgo') ?? true));
230
+ if (duckDuckGoInChain && !keylessFallbackDisabled) {
223
231
  search = withSearchFallback(search, createTavilySearch({ keyless: true }), 'duckduckgo');
224
232
  }
225
233
 
@@ -23,7 +23,9 @@ function serializeForModel(result: WebExploreResponse): string {
23
23
  return `Research failed: ${result.error?.message ?? 'Unknown research failure.'}`;
24
24
  }
25
25
  if (result.findings.length === 0 && result.sources.length === 0) {
26
- return 'No usable evidence found.';
26
+ return result.metadata?.failureReason
27
+ ? `No usable evidence found: ${result.metadata.failureReason}`
28
+ : 'No usable evidence found.';
27
29
  }
28
30
  return [
29
31
  result.findings.join('\n\n'),