@bubblebrain-ai/bubble 0.0.59 → 0.0.61

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 (51) hide show
  1. package/dist/agent/subagent/runtime.d.ts +28 -0
  2. package/dist/agent/subagent/runtime.js +4 -24
  3. package/dist/agent.d.ts +7 -6
  4. package/dist/agent.js +18 -1
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +25 -2
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.js +7 -0
  9. package/dist/context/compact.js +6 -2
  10. package/dist/context/tool-images.d.ts +3 -0
  11. package/dist/context/tool-images.js +35 -0
  12. package/dist/context/usage.js +22 -5
  13. package/dist/feishu/agent-host/run-driver.js +3 -0
  14. package/dist/lsp/service.js +3 -2
  15. package/dist/main.js +11 -1
  16. package/dist/model-catalog.js +6 -0
  17. package/dist/model-pricing.js +17 -0
  18. package/dist/permissions/shell-command.js +35 -3
  19. package/dist/prompt/compose.d.ts +3 -0
  20. package/dist/prompt/compose.js +17 -3
  21. package/dist/provider-ai-sdk.d.ts +2 -1
  22. package/dist/provider-ai-sdk.js +26 -9
  23. package/dist/provider-openai-codex.d.ts +4 -0
  24. package/dist/provider-openai-codex.js +28 -9
  25. package/dist/provider-registry.js +4 -2
  26. package/dist/sdk/index.d.ts +21 -2
  27. package/dist/sdk/index.js +92 -11
  28. package/dist/session.d.ts +5 -1
  29. package/dist/session.js +13 -5
  30. package/dist/skills/format.d.ts +31 -1
  31. package/dist/skills/format.js +45 -24
  32. package/dist/slash-commands/commands.d.ts +3 -0
  33. package/dist/slash-commands/commands.js +6 -4
  34. package/dist/system-prompt.d.ts +3 -1
  35. package/dist/tools/edit-apply.d.ts +0 -3
  36. package/dist/tools/edit-apply.js +45 -249
  37. package/dist/tools/edit.js +3 -3
  38. package/dist/tools/grep.js +20 -1
  39. package/dist/tools/skill.d.ts +2 -0
  40. package/dist/tools/skill.js +34 -2
  41. package/dist/tui/app.js +6 -0
  42. package/dist/tui/components/skills-panel.d.ts +4 -0
  43. package/dist/tui/components/skills-panel.js +15 -1
  44. package/dist/tui/components/tasks-pane.d.ts +38 -0
  45. package/dist/tui/components/tasks-pane.js +182 -32
  46. package/dist/tui/controller/controller.d.ts +7 -0
  47. package/dist/tui/controller/controller.js +13 -0
  48. package/dist/types.d.ts +7 -0
  49. package/package.json +1 -1
  50. package/dist/prompt/skills.d.ts +0 -2
  51. package/dist/prompt/skills.js +0 -4
@@ -182,16 +182,18 @@ function persistSelectedModel(model, ctx) {
182
182
  ctx.sessionManager.appendMarker("model_switch", model);
183
183
  }
184
184
  }
185
- function syncSystemPrompt(ctx, model) {
185
+ /** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
186
+ export function syncSystemPrompt(ctx, model) {
186
187
  const { providerId } = decodeModel(model);
187
188
  const toolPromptOptions = typeof ctx.agent.getSystemPromptToolOptions === "function"
188
189
  ? ctx.agent.getSystemPromptToolOptions()
189
190
  : {};
190
191
  ctx.agent.setSystemPrompt(buildSystemPrompt({
191
192
  agentName: "Bubble",
192
- configuredProvider: providerId,
193
- configuredModel: displayModel(model),
194
- configuredModelId: model,
193
+ // Same placeholders as startup when no model is configured yet.
194
+ configuredProvider: providerId || "none",
195
+ configuredModel: model ? displayModel(model) : "none",
196
+ configuredModelId: model || "none",
195
197
  thinkingLevel: ctx.agent.thinking,
196
198
  workingDir: ctx.cwd,
197
199
  ...toolPromptOptions,
@@ -26,8 +26,10 @@ export interface SystemPromptOptions {
26
26
  mode?: PermissionMode;
27
27
  /** Current date override */
28
28
  currentDate?: string;
29
- /** Prompt-visible skills */
29
+ /** Prompt-visible skills, listed as the Skills catalog when `skill` is a tool */
30
30
  skills?: SkillSummary[];
31
+ /** Catalog budget in characters; 0 turns the catalog off */
32
+ skillCatalogChars?: number;
31
33
  /** Prompt-visible memory guidance and summaries */
32
34
  memoryPrompt?: string;
33
35
  /** Durable child-agent profile prompt used for subagents. */
@@ -2,13 +2,11 @@ export interface EditOperation {
2
2
  oldText: string;
3
3
  newText: string;
4
4
  }
5
- export type EditMatchMode = "exact" | "trimmed" | "unescaped" | "normalized-line" | "smart-line" | "markdown-table" | "single-line-whitespace";
6
5
  export interface EditApplyOptions {
7
6
  path?: string;
8
7
  }
9
8
  export interface EditMatchInfo {
10
9
  editIndex: number;
11
- mode: EditMatchMode;
12
10
  start: number;
13
11
  end: number;
14
12
  }
@@ -25,4 +23,3 @@ export declare class EditApplyError extends Error {
25
23
  constructor(message: string, status?: "no_match" | "blocked");
26
24
  }
27
25
  export declare function applyEditsToContent(rawContent: string, edits: EditOperation[], options?: EditApplyOptions): AppliedEditResult;
28
- export declare function formatEditMatchNotes(matches: EditMatchInfo[]): string;
@@ -21,38 +21,36 @@ function stripBom(content) {
21
21
  function normalizeToLF(text) {
22
22
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
23
23
  }
24
- function unescapeOverEscaped(text) {
25
- return text
26
- .replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
27
- .replace(/\\n/g, "\n")
28
- .replace(/\\t/g, "\t")
29
- .replace(/\\r/g, "\r")
30
- .replace(/\\f/g, "\f")
31
- .replace(/\\b/g, "\b")
32
- .replace(/\\v/g, "\v")
33
- .replace(/\\"/g, '"')
34
- .replace(/\\\\/g, "\\");
35
- }
36
- function addTextCandidate(candidates, seen, text, mode) {
37
- if (text.length === 0 || seen.has(text))
38
- return;
39
- seen.add(text);
40
- candidates.push({ text, mode });
41
- }
42
- function generateTextCandidates(oldText) {
43
- const candidates = [];
44
- const seen = new Set();
45
- const trimmed = oldText.trim();
46
- const unescaped = normalizeToLF(unescapeOverEscaped(oldText));
47
- const unescapedTrimmed = normalizeToLF(unescapeOverEscaped(trimmed));
48
- addTextCandidate(candidates, seen, oldText, "exact");
49
- addTextCandidate(candidates, seen, trimmed, "trimmed");
50
- addTextCandidate(candidates, seen, unescaped, "unescaped");
51
- addTextCandidate(candidates, seen, unescapedTrimmed, "unescaped");
52
- return candidates;
53
- }
54
- function restoreLineEndings(text, lineEnding) {
55
- return lineEnding === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
24
+ function buildOriginalOffsets(text) {
25
+ const offsets = [];
26
+ for (let i = 0; i < text.length; i++) {
27
+ offsets.push(i);
28
+ if (text[i] === "\r" && text[i + 1] === "\n")
29
+ i++;
30
+ }
31
+ offsets.push(text.length);
32
+ return offsets;
33
+ }
34
+ function lineEndingAt(text, offset, fallback) {
35
+ const after = text.slice(offset).match(/\r\n|\r|\n/);
36
+ if (after)
37
+ return after[0];
38
+ const before = text.slice(0, offset).match(/(\r\n|\r|\n)[^\r\n]*$/);
39
+ return before ? before[1] : fallback;
40
+ }
41
+ // Splices replacements into the original text so untouched lines keep their own
42
+ // terminators; inserted text uses the terminator of the line being edited.
43
+ function spliceOriginal(text, matches, edits, fallbackEnding) {
44
+ const offsets = buildOriginalOffsets(text);
45
+ let out = "";
46
+ let cursor = 0;
47
+ for (const match of [...matches].sort((a, b) => a.start - b.start)) {
48
+ const start = offsets[match.start];
49
+ const ending = lineEndingAt(text, start, fallbackEnding);
50
+ out += text.slice(cursor, start) + edits[match.editIndex].newText.replace(/\n/g, ending);
51
+ cursor = offsets[match.end];
52
+ }
53
+ return out + text.slice(cursor);
56
54
  }
57
55
  function normalizeLineForMatch(line) {
58
56
  return line
@@ -63,9 +61,6 @@ function normalizeLineForMatch(line) {
63
61
  .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
64
62
  .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
65
63
  }
66
- function normalizeLeadingWhitespaceForMatch(line) {
67
- return normalizeLineForMatch(line).replace(/^\s+/, " ");
68
- }
69
64
  function findAllOccurrences(content, needle) {
70
65
  const indexes = [];
71
66
  if (needle.length === 0)
@@ -104,130 +99,6 @@ function normalizedOldNonBlankLines(oldText) {
104
99
  .map((line) => normalizeLineForMatch(line.text))
105
100
  .filter((line) => line.trim().length > 0);
106
101
  }
107
- function singleNonBlankOldLine(oldText) {
108
- const lines = normalizedOldNonBlankLines(oldText);
109
- return lines.length === 1 ? lines[0] : undefined;
110
- }
111
- function findNormalizedLineMatches(content, oldText) {
112
- const contentLines = splitLines(content);
113
- const searchable = nonBlankLines(contentLines);
114
- const oldLines = normalizedOldNonBlankLines(oldText);
115
- if (oldLines.length === 0)
116
- return [];
117
- const matches = [];
118
- for (let i = 0; i <= searchable.length - oldLines.length; i++) {
119
- let matched = true;
120
- for (let j = 0; j < oldLines.length; j++) {
121
- if (searchable[i + j].normalized !== oldLines[j]) {
122
- matched = false;
123
- break;
124
- }
125
- }
126
- if (matched) {
127
- const first = contentLines[searchable[i].lineIndex];
128
- const last = contentLines[searchable[i + oldLines.length - 1].lineIndex];
129
- matches.push({ start: first.start, end: last.endNoNewline });
130
- }
131
- }
132
- return matches;
133
- }
134
- function findSmartLineMatches(content, oldText) {
135
- const contentLines = splitLines(content);
136
- const oldLines = splitLines(oldText).map((line) => normalizeLeadingWhitespaceForMatch(line.text));
137
- if (oldLines.length === 0 || oldLines.every((line) => line.length === 0))
138
- return [];
139
- const matches = [];
140
- for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
141
- let matched = true;
142
- for (let j = 0; j < oldLines.length; j++) {
143
- const actual = normalizeLeadingWhitespaceForMatch(contentLines[i + j].text);
144
- if (actual !== oldLines[j]) {
145
- matched = false;
146
- break;
147
- }
148
- }
149
- if (matched) {
150
- const first = contentLines[i];
151
- const last = contentLines[i + oldLines.length - 1];
152
- matches.push({ start: first.start, end: last.endNoNewline });
153
- }
154
- }
155
- return matches;
156
- }
157
- function splitMarkdownTableCells(line) {
158
- const normalized = normalizeLineForMatch(line).trim();
159
- if (!normalized.startsWith("|") || !normalized.endsWith("|"))
160
- return undefined;
161
- const parts = [];
162
- let current = "";
163
- let escaped = false;
164
- for (const char of normalized) {
165
- if (escaped) {
166
- current += char;
167
- escaped = false;
168
- continue;
169
- }
170
- if (char === "\\") {
171
- current += char;
172
- escaped = true;
173
- continue;
174
- }
175
- if (char === "|") {
176
- parts.push(current);
177
- current = "";
178
- continue;
179
- }
180
- current += char;
181
- }
182
- parts.push(current);
183
- if (parts.length < 4 || parts[0] !== "" || parts[parts.length - 1] !== "")
184
- return undefined;
185
- const cells = parts.slice(1, -1).map((cell) => cell.trim());
186
- return cells.length >= 2 ? cells : undefined;
187
- }
188
- function sameCells(a, b) {
189
- return a.length === b.length && a.every((cell, index) => cell === b[index]);
190
- }
191
- function findMarkdownTableMatches(content, oldText) {
192
- const oldLine = singleNonBlankOldLine(oldText);
193
- if (!oldLine)
194
- return [];
195
- const oldCells = splitMarkdownTableCells(oldLine);
196
- if (!oldCells)
197
- return [];
198
- const matches = [];
199
- for (const line of splitLines(content)) {
200
- const cells = splitMarkdownTableCells(line.text);
201
- if (cells && sameCells(cells, oldCells)) {
202
- matches.push({ start: line.start, end: line.endNoNewline });
203
- }
204
- }
205
- return matches;
206
- }
207
- function collapseInlineWhitespace(text) {
208
- return normalizeLineForMatch(text).trim().replace(/[ \t]+/g, " ");
209
- }
210
- function isDocumentLikePath(path) {
211
- return !!path && /\.(?:md|mdx|markdown|txt|rst|adoc)$/i.test(path);
212
- }
213
- function findSingleLineWhitespaceMatches(content, oldText, options) {
214
- if (!isDocumentLikePath(options?.path))
215
- return [];
216
- const oldLine = singleNonBlankOldLine(oldText);
217
- if (!oldLine)
218
- return [];
219
- const normalizedOld = collapseInlineWhitespace(oldLine);
220
- if (normalizedOld.length === 0)
221
- return [];
222
- const matches = [];
223
- for (const line of splitLines(content)) {
224
- const collapsed = collapseInlineWhitespace(line.text);
225
- if (collapsed === normalizedOld && line.text !== oldLine) {
226
- matches.push({ start: line.start, end: line.endNoNewline });
227
- }
228
- }
229
- return matches;
230
- }
231
102
  function summarizeOldText(oldText) {
232
103
  const firstLine = normalizeToLF(oldText).split("\n").find((line) => line.trim().length > 0) ?? oldText;
233
104
  return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
@@ -326,88 +197,18 @@ function matchEdit(content, edit, index, total, options) {
326
197
  ].join("\n"));
327
198
  }
328
199
  const oldText = normalizeToLF(edit.oldText);
329
- const candidates = generateTextCandidates(oldText);
330
- for (const candidate of candidates) {
331
- const exact = findAllOccurrences(content, candidate.text);
332
- if (exact.length === 1) {
333
- return { editIndex: index, mode: candidate.mode, start: exact[0], end: exact[0] + candidate.text.length };
334
- }
335
- if (exact.length > 1) {
336
- const recovery = [
337
- "",
338
- "Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
339
- ].join("\n");
340
- const duplicateReason = candidate.mode === "exact"
341
- ? `appears ${exact.length} times in file`
342
- : `matched ${exact.length} times after ${candidate.mode} matching`;
343
- throw new EditApplyError(total === 1
344
- ? `Error: oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
345
- : `Error: edits[${index}].oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
346
- }
347
- }
348
- for (const candidate of candidates) {
349
- const normalizedLineMatches = findNormalizedLineMatches(content, candidate.text);
350
- if (normalizedLineMatches.length === 1) {
351
- return {
352
- editIndex: index,
353
- mode: candidate.mode === "exact" ? "normalized-line" : candidate.mode,
354
- start: normalizedLineMatches[0].start,
355
- end: normalizedLineMatches[0].end,
356
- };
357
- }
358
- if (normalizedLineMatches.length > 1) {
359
- throw new EditApplyError(total === 1
360
- ? `Error: oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`
361
- : `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
362
- }
363
- }
364
- for (const candidate of candidates) {
365
- const markdownTableMatches = findMarkdownTableMatches(content, candidate.text);
366
- if (markdownTableMatches.length === 1) {
367
- return {
368
- editIndex: index,
369
- mode: "markdown-table",
370
- start: markdownTableMatches[0].start,
371
- end: markdownTableMatches[0].end,
372
- };
373
- }
374
- if (markdownTableMatches.length > 1) {
375
- throw new EditApplyError(total === 1
376
- ? `Error: oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`
377
- : `Error: edits[${index}].oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`);
378
- }
379
- }
380
- for (const candidate of candidates) {
381
- const whitespaceMatches = findSingleLineWhitespaceMatches(content, candidate.text, options);
382
- if (whitespaceMatches.length === 1) {
383
- return {
384
- editIndex: index,
385
- mode: "single-line-whitespace",
386
- start: whitespaceMatches[0].start,
387
- end: whitespaceMatches[0].end,
388
- };
389
- }
390
- if (whitespaceMatches.length > 1) {
391
- throw new EditApplyError(total === 1
392
- ? `Error: oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`
393
- : `Error: edits[${index}].oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`);
394
- }
200
+ const occurrences = findAllOccurrences(content, oldText);
201
+ if (occurrences.length === 1) {
202
+ return { editIndex: index, start: occurrences[0], end: occurrences[0] + oldText.length };
395
203
  }
396
- for (const candidate of candidates) {
397
- const smartLineMatches = findSmartLineMatches(content, candidate.text);
398
- if (smartLineMatches.length === 1) {
399
- return {
400
- editIndex: index,
401
- mode: "smart-line",
402
- start: smartLineMatches[0].start,
403
- end: smartLineMatches[0].end,
404
- };
405
- }
406
- if (smartLineMatches.length > 1) {
407
- throw new EditApplyError(total === 1
408
- ? `Error: oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`
409
- : `Error: edits[${index}].oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`);
410
- }
204
+ if (occurrences.length > 1) {
205
+ const recovery = [
206
+ "",
207
+ "Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
208
+ ].join("\n");
209
+ throw new EditApplyError(total === 1
210
+ ? `Error: oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
211
+ : `Error: edits[${index}].oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
411
212
  }
412
213
  const hint = findBestLineHint(content, oldText);
413
214
  const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
@@ -439,9 +240,10 @@ export function applyEditsToContent(rawContent, edits, options) {
439
240
  const { bom, text } = stripBom(rawContent);
440
241
  const lineEnding = detectLineEnding(text);
441
242
  const normalizedOriginal = normalizeToLF(text);
243
+ const dropCopiedBom = (value) => (bom ? stripBom(value).text : value);
442
244
  const normalizedEdits = edits.map((edit) => ({
443
- oldText: normalizeToLF(edit.oldText),
444
- newText: normalizeToLF(edit.newText),
245
+ oldText: normalizeToLF(dropCopiedBom(edit.oldText)),
246
+ newText: normalizeToLF(dropCopiedBom(edit.newText)),
445
247
  }));
446
248
  const matches = normalizedEdits.map((edit, index) => matchEdit(normalizedOriginal, edit, index, normalizedEdits.length, options));
447
249
  assertNoOverlaps(matches);
@@ -462,7 +264,7 @@ export function applyEditsToContent(rawContent, edits, options) {
462
264
  ].join("\n"));
463
265
  }
464
266
  return {
465
- content: bom + restoreLineEndings(normalizedNext, lineEnding),
267
+ content: bom + spliceOriginal(text, matches, normalizedEdits, lineEnding),
466
268
  normalizedOriginal,
467
269
  normalizedNext,
468
270
  bom,
@@ -470,9 +272,3 @@ export function applyEditsToContent(rawContent, edits, options) {
470
272
  matches,
471
273
  };
472
274
  }
473
- export function formatEditMatchNotes(matches) {
474
- const normalizedCount = matches.filter((match) => match.mode !== "exact").length;
475
- if (normalizedCount === 0)
476
- return "";
477
- return `\n\nNote: ${normalizedCount} edit${normalizedCount === 1 ? "" : "s"} applied using normalized line matching for whitespace/formatting differences.`;
478
- }
@@ -9,7 +9,7 @@ import { createTwoFilesPatch } from "diff";
9
9
  import { gateToolAction } from "../approval/tool-helper.js";
10
10
  import { countUnifiedDiffChanges } from "../diff-stats.js";
11
11
  import { formatDiagnosticBlocks } from "../lsp/index.js";
12
- import { applyEditsToContent, EditApplyError, formatEditMatchNotes } from "./edit-apply.js";
12
+ import { applyEditsToContent, EditApplyError } from "./edit-apply.js";
13
13
  import { withFileMutationQueue } from "./file-mutation-queue.js";
14
14
  import { isWithinWorkspace } from "./file-state.js";
15
15
  import { resolveToolPath } from "./path-utils.js";
@@ -65,7 +65,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
65
65
  name: "edit",
66
66
  effect: "write_direct",
67
67
  requiresApproval: true,
68
- description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
68
+ description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes. Matching is exact; a UTF-8 BOM and CRLF line endings are handled automatically and preserved, so write oldText/newText with plain \\n line breaks.",
69
69
  promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
70
70
  promptGuidelines: [
71
71
  "Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
@@ -173,7 +173,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
173
173
  await checkpoints?.()?.captureBefore(filePath, original);
174
174
  await writeFile(filePath, applied.content, "utf-8");
175
175
  await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
176
- let output = `Edited ${filePath}${formatEditMatchNotes(applied.matches)}\n\nDiff:\n${diff}`;
176
+ let output = `Edited ${filePath}\n\nDiff:\n${diff}`;
177
177
  if (lsp) {
178
178
  try {
179
179
  await lsp.touchFile(filePath, "document");
@@ -113,6 +113,18 @@ function executeBoundedGrep(options) {
113
113
  });
114
114
  const decoder = new StringDecoder("utf8");
115
115
  const matches = [];
116
+ // The root check alone is not enough: ripgrep searches inside an explicit
117
+ // hidden root, so `path: ~/.aws` would reach ~/.aws/credentials. Every
118
+ // matched file gets the same check the read tool applies (cached per file).
119
+ const sensitiveByPath = new Map();
120
+ const isSensitiveMatch = (absolutePath) => {
121
+ let sensitive = sensitiveByPath.get(absolutePath);
122
+ if (sensitive === undefined) {
123
+ sensitive = isSensitivePath(absolutePath);
124
+ sensitiveByPath.set(absolutePath, sensitive);
125
+ }
126
+ return sensitive;
127
+ };
116
128
  let stdoutBuffer = "";
117
129
  let stdoutBytes = 0;
118
130
  let stderrBytes = 0;
@@ -142,6 +154,13 @@ function executeBoundedGrep(options) {
142
154
  if (event.type !== "match")
143
155
  return;
144
156
  const rawPath = decodeRgField(event.data?.path);
157
+ // A path that is not valid UTF-8 cannot be checked (or read back by
158
+ // the model), so its matches are dropped rather than shown unchecked.
159
+ if (event.data?.path && !rawPath)
160
+ return;
161
+ const absolutePath = rawPath ? resolvePath(options.cwd, rawPath) : undefined;
162
+ if (absolutePath && isSensitiveMatch(absolutePath))
163
+ return;
145
164
  const lineNumber = event.data?.line_number;
146
165
  const rawText = decodeRgField(event.data?.lines, "[non-UTF-8 match bytes omitted]") ?? "";
147
166
  const displayPath = rawPath || "<non-UTF-8 path>";
@@ -149,7 +168,7 @@ function executeBoundedGrep(options) {
149
168
  const rendered = truncateCodePoints(`${displayPath}:${typeof lineNumber === "number" ? lineNumber : "?"}: ${normalizedText}`, MAX_RENDERED_LINE_CODE_POINTS);
150
169
  matches.push({
151
170
  rendered,
152
- ...(rawPath ? { absolutePath: resolvePath(options.cwd, rawPath) } : {}),
171
+ ...(absolutePath ? { absolutePath } : {}),
153
172
  });
154
173
  // We stop at N rather than pretending we proved there are N+1 matches.
155
174
  // The notice says "limit reached / may be incomplete", which is honest
@@ -3,3 +3,5 @@ import type { SkillRegistry } from "../skills/registry.js";
3
3
  import type { ToolRegistryEntry } from "../types.js";
4
4
  export declare function formatLoadedSkill(skill: SkillRecord): string;
5
5
  export declare function createSkillTool(registry: SkillRegistry): ToolRegistryEntry;
6
+ /** Up to three known names closest to a mistyped one (substring first, then edit distance). */
7
+ export declare function closestSkillNames(name: string, names: string[]): string[];
@@ -22,7 +22,7 @@ export function createSkillTool(registry) {
22
22
  name: "skill",
23
23
  readOnly: true,
24
24
  effect: "read",
25
- description: "Load a named skill on demand. Use this when a task clearly matches one of the available skills.",
25
+ description: "Load a skill by its exact name, as listed in the Skills section of the system prompt. Returns the skill's instructions; follow them for the task.",
26
26
  parameters: {
27
27
  type: "object",
28
28
  properties: {
@@ -38,8 +38,11 @@ export function createSkillTool(registry) {
38
38
  }
39
39
  const skill = registry.get(name);
40
40
  if (!skill) {
41
+ const close = closestSkillNames(name, registry.summaries().map((s) => s.name));
41
42
  return {
42
- content: `Error: Unknown skill "${name}". Use skill_search to find available skills, then retry with the exact skill name.`,
43
+ content: `Error: Unknown skill "${name}".` +
44
+ (close.length ? ` Did you mean: ${close.join(", ")}?` : "") +
45
+ " Use the exact name from the Skills section.",
43
46
  isError: true,
44
47
  };
45
48
  }
@@ -47,3 +50,32 @@ export function createSkillTool(registry) {
47
50
  },
48
51
  };
49
52
  }
53
+ /** Up to three known names closest to a mistyped one (substring first, then edit distance). */
54
+ export function closestSkillNames(name, names) {
55
+ const target = name.toLowerCase();
56
+ const scored = names.map((candidate) => {
57
+ const lower = candidate.toLowerCase();
58
+ // Substring matches only for real fragments; a 1–2 letter typo would match everything.
59
+ const contains = target.length >= 3 && (lower.includes(target) || target.includes(lower));
60
+ return { candidate, score: contains ? 0 : editDistance(target, lower) };
61
+ });
62
+ const limit = Math.max(2, Math.floor(target.length / 3));
63
+ return scored
64
+ .filter((s) => s.score <= limit)
65
+ .sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
66
+ .slice(0, 3)
67
+ .map((s) => s.candidate);
68
+ }
69
+ function editDistance(a, b) {
70
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
71
+ for (let i = 1; i <= a.length; i++) {
72
+ let prev = row[0];
73
+ row[0] = i;
74
+ for (let j = 1; j <= b.length; j++) {
75
+ const cur = row[j];
76
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
77
+ prev = cur;
78
+ }
79
+ }
80
+ return row[b.length];
81
+ }
package/dist/tui/app.js CHANGED
@@ -33,6 +33,7 @@ import { SkillsPanelComponent } from "./components/skills-panel.js";
33
33
  import { SessionPickerComponent } from "./components/session-picker.js";
34
34
  import { ComposerImagePreviewComponent, ImageViewerComponent } from "./components/image-preview.js";
35
35
  import { registry as slashRegistry } from "../slash-commands/index.js";
36
+ import { syncSystemPrompt } from "../slash-commands/commands.js";
36
37
  import { collectUsageStatsBundle } from "../stats/usage.js";
37
38
  import { ComposerController, } from "./controller/composer-controller.js";
38
39
  import { OverlayRequestController } from "./controller/overlay-controller.js";
@@ -208,6 +209,7 @@ export class PiTuiApp {
208
209
  groups: this.options.controller.getSubagentGroups?.() ?? [],
209
210
  workflows: this.options.controller.getWorkflows?.() ?? [],
210
211
  tasks: this.options.controller.getBackgroundTasks?.() ?? [],
212
+ turnStartedAt: this.options.controller.getTurnStartedAt?.(),
211
213
  }), () => this.tui.terminal.rows, {
212
214
  // Pane navigation only changes local presentation state. Reprojecting
213
215
  // the live trace/Markdown here is both unnecessary and expensive.
@@ -853,8 +855,12 @@ export class PiTuiApp {
853
855
  getTerminalRows: () => this.tui.terminal.rows,
854
856
  onClose: () => handle?.hide(),
855
857
  onRender: () => this.renderSnapshot(),
858
+ getCatalogChars: () => this.options.agent.getSkillCatalogChars?.(),
856
859
  onSkillsChanged: () => {
857
860
  this.options.agent.setSkillSummaries(skillRegistry.summaries());
861
+ // The Skills catalog lives in the system prompt; rebuild it so the next turn sees the change.
862
+ const ctx = this.buildSlashContext();
863
+ syncSystemPrompt(ctx, ctx.agent.model ?? "");
858
864
  this.editor.refreshAutocomplete();
859
865
  this.renderSnapshot();
860
866
  },
@@ -6,6 +6,8 @@ export interface SkillsPanelComponentOptions {
6
6
  onClose(): void;
7
7
  onRender(): void;
8
8
  onSkillsChanged(): void;
9
+ /** Catalog budget in characters (the agent's setting); undefined uses the default. */
10
+ getCatalogChars?(): number | undefined;
9
11
  theme?: Theme;
10
12
  }
11
13
  /** Grok-style centered Skill browser/manager opened by /skills. */
@@ -37,6 +39,8 @@ export declare class SkillsPanelComponent implements Component, Focusable {
37
39
  handleMouse(event: TuiMouseEvent): boolean;
38
40
  invalidate(): void;
39
41
  private frameLine;
42
+ /** How much of the always-present catalog the enabled skills use, so users know when to prune. */
43
+ private catalogSummary;
40
44
  private renderSearch;
41
45
  private renderFooter;
42
46
  private buildRows;
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname } from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { Input, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@bubblebrain-ai/pi-tui";
5
+ import { buildSkillCatalog } from "../../skills/format.js";
5
6
  import { parseTerminalMouseWheel } from "../model/terminal-mouse.js";
6
7
  import { darkTheme } from "../model/theme.js";
7
8
  import { themeBackground, themeDim, themeForeground } from "../model/theme-style.js";
@@ -116,7 +117,7 @@ export class SkillsPanelComponent {
116
117
  const top = themeForeground(theme.border, `┌${"─".repeat(topDashCount)}`) + ` ${close} ` + themeForeground(theme.border, "┐");
117
118
  const bottom = themeForeground(theme.border, `└${horizontal}┘`);
118
119
  const separator = themeForeground(theme.border, `├${horizontal}┤`);
119
- const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))}`, innerWidth);
120
+ const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))} ${themeForeground(theme.muted, this.catalogSummary())}`, innerWidth);
120
121
  const search = this.frameLine(this.renderSearch(innerWidth), innerWidth);
121
122
  const footerRows = this.searchActive ? 0 : 2;
122
123
  this.bodyRows = Math.max(1, frameHeight - 5 - footerRows);
@@ -284,6 +285,19 @@ export class SkillsPanelComponent {
284
285
  const theme = this.options.theme ?? darkTheme;
285
286
  return `${themeForeground(theme.border, "│")}${themeBackground(theme.backgroundPanel, fit(content, width))}${themeForeground(theme.border, "│")}`;
286
287
  }
288
+ /** How much of the always-present catalog the enabled skills use, so users know when to prune. */
289
+ catalogSummary() {
290
+ const catalog = buildSkillCatalog(this.registry.summaries(), { budgetChars: this.options.getCatalogChars?.() });
291
+ if (catalog.budgetChars === 0)
292
+ return "catalog off · skill_search only";
293
+ const duplicates = this.registry.getDiagnostics().filter((d) => d.message.startsWith("Duplicate skill name")).length;
294
+ return [
295
+ `catalog ${catalog.chars.toLocaleString("en-US")}/${catalog.budgetChars.toLocaleString("en-US")} chars`,
296
+ `${catalog.listed} listed`,
297
+ catalog.unlisted ? `${catalog.unlisted} not listed` : "",
298
+ duplicates ? `${duplicates} duplicate${duplicates === 1 ? "" : "s"} ignored` : "",
299
+ ].filter(Boolean).join(" · ");
300
+ }
287
301
  renderSearch(innerWidth) {
288
302
  const theme = this.options.theme ?? darkTheme;
289
303
  const filter = filterLabel(this.filter);