@bubblebrain-ai/bubble 0.0.11 → 0.0.12

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 (39) hide show
  1. package/dist/agent.js +1 -2
  2. package/dist/feishu/agent-host/run-driver.js +13 -6
  3. package/dist/feishu/agent-host/runtime-deps.d.ts +2 -2
  4. package/dist/feishu/router/commands.js +2 -1
  5. package/dist/feishu/scope/session-binder.js +1 -1
  6. package/dist/feishu/serve.js +3 -3
  7. package/dist/main.js +20 -3
  8. package/dist/prompt/compose.js +3 -3
  9. package/dist/prompt/environment.js +2 -0
  10. package/dist/prompt/reminders.js +1 -1
  11. package/dist/provider-openai-codex.d.ts +8 -1
  12. package/dist/provider-openai-codex.js +33 -9
  13. package/dist/provider.d.ts +2 -0
  14. package/dist/session-title.d.ts +16 -0
  15. package/dist/session-title.js +134 -0
  16. package/dist/session-types.d.ts +5 -0
  17. package/dist/session.d.ts +5 -0
  18. package/dist/session.js +75 -9
  19. package/dist/skills/invocation.js +0 -18
  20. package/dist/skills/registry.d.ts +1 -0
  21. package/dist/skills/registry.js +2 -0
  22. package/dist/slash-commands/commands.js +2 -22
  23. package/dist/slash-commands/registry.js +1 -1
  24. package/dist/text-display.d.ts +3 -0
  25. package/dist/text-display.js +25 -0
  26. package/dist/tools/index.d.ts +1 -0
  27. package/dist/tools/index.js +3 -1
  28. package/dist/tools/skill-search.d.ts +10 -0
  29. package/dist/tools/skill-search.js +134 -0
  30. package/dist/tools/skill.js +1 -4
  31. package/dist/tui-ink/app.js +54 -65
  32. package/dist/tui-ink/input-box.d.ts +22 -1
  33. package/dist/tui-ink/input-box.js +105 -11
  34. package/dist/tui-ink/message-list.js +3 -2
  35. package/dist/tui-ink/model-picker.d.ts +18 -0
  36. package/dist/tui-ink/model-picker.js +80 -23
  37. package/dist/tui-ink/session-picker.js +5 -7
  38. package/dist/tui-ink/theme.js +2 -2
  39. package/package.json +1 -1
package/dist/session.js CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Session Manager - Append-only JSONL persistence over a structured session log.
3
3
  */
4
+ import { randomUUID } from "node:crypto";
4
5
  import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
6
  import { basename, dirname, join } from "node:path";
6
7
  import { getBubbleHome } from "./bubble-home.js";
7
8
  import { compactSessionEntries } from "./context/compact.js";
8
9
  import { SessionLog } from "./session-log.js";
10
+ import { normalizeSingleLine, truncateVisual } from "./text-display.js";
11
+ import { deterministicTitleFromUserContent } from "./session-title.js";
9
12
  const AUTO_COMPACT_ENTRY_THRESHOLD = 180;
10
13
  const AUTO_COMPACT_KEEP_RECENT_TURNS = 3;
11
14
  export class SessionManager {
@@ -107,10 +110,28 @@ export class SessionManager {
107
110
  getMetadata() {
108
111
  return this.log.getMetadata();
109
112
  }
113
+ getOrCreatePromptCacheKey() {
114
+ const existing = this.log.getMetadata().promptCacheKey;
115
+ if (existing)
116
+ return existing;
117
+ const promptCacheKey = randomUUID();
118
+ this.updateMetadata({ promptCacheKey });
119
+ return promptCacheKey;
120
+ }
110
121
  setMetadata(metadata) {
111
122
  const nextEntries = this.log.setMetadata(metadata);
112
123
  this.rewrite(nextEntries);
113
124
  }
125
+ updateMetadata(patch) {
126
+ this.setMetadata({
127
+ ...this.log.getMetadata(),
128
+ ...dropUndefined(patch),
129
+ });
130
+ }
131
+ clearTitleMetadata() {
132
+ const { title: _title, titleSource: _titleSource, titleUpdatedAt: _titleUpdatedAt, titleUserMessageId: _titleUserMessageId, ...metadata } = this.log.getMetadata();
133
+ this.setMetadata(metadata);
134
+ }
114
135
  appendMessage(message) {
115
136
  const entries = this.log.appendMessage(message);
116
137
  this.persist(entries);
@@ -192,21 +213,23 @@ function summarizeSessionFile(file, cwdDir) {
192
213
  const log = new SessionLog();
193
214
  log.load(lines);
194
215
  const metadata = log.getMetadata();
216
+ const entries = log.list();
195
217
  const messages = log.toMessages();
196
- const firstUser = messages.find((m) => m.role === "user");
197
- let firstUserText = "";
198
- if (firstUser) {
199
- firstUserText = typeof firstUser.content === "string"
200
- ? firstUser.content
201
- : firstUser.content.map((part) => part.type === "text" ? part.text : "").join("");
202
- }
203
- const snippet = firstUserText.trim().replace(/\s+/g, " ").slice(0, 80);
218
+ const firstUserEntry = firstUserEntryAfterLatestClear(entries);
219
+ const firstUserText = firstUserEntry ? messageText(firstUserEntry.message) : "";
220
+ const preview = firstUserText
221
+ ? sessionPreviewFromText(firstUserText)
222
+ : (messages.length > 0 ? "No user message" : "No messages");
223
+ const title = usableStoredTitle(metadata, entries)
224
+ ?? (firstUserEntry ? deterministicTitleFromUserContent(firstUserEntry.message.content) : (messages.length > 0 ? "Assistant-only session" : "Empty session"));
204
225
  return {
205
226
  file,
206
227
  name: basename(file).replace(/\.jsonl$/, ""),
207
228
  cwd: metadata.cwd,
208
229
  cwdLabel: metadata.cwd ?? decodeCwdDir(cwdDir),
209
- firstUserMessage: snippet || "(no user message)",
230
+ title,
231
+ preview,
232
+ firstUserMessage: preview,
210
233
  messageCount: messages.length,
211
234
  mtime: stat.mtimeMs,
212
235
  };
@@ -219,3 +242,46 @@ function decodeCwdDir(safe) {
219
242
  return "/" + safe.slice(1).replace(/_/g, "/");
220
243
  return safe.replace(/_/g, "/");
221
244
  }
245
+ function dropUndefined(value) {
246
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
247
+ }
248
+ function firstUserEntryAfterLatestClear(entries) {
249
+ const startIndex = latestClearIndex(entries) + 1;
250
+ for (let i = startIndex; i < entries.length; i++) {
251
+ const entry = entries[i];
252
+ if (entry.type === "user_message")
253
+ return entry;
254
+ }
255
+ return undefined;
256
+ }
257
+ function latestClearIndex(entries) {
258
+ for (let i = entries.length - 1; i >= 0; i--) {
259
+ const entry = entries[i];
260
+ if (entry.type === "marker" && entry.kind === "conversation_clear")
261
+ return i;
262
+ }
263
+ return -1;
264
+ }
265
+ function usableStoredTitle(metadata, entries) {
266
+ const title = normalizeSingleLine(metadata.title ?? "");
267
+ if (!title)
268
+ return undefined;
269
+ if (!metadata.titleUserMessageId)
270
+ return title;
271
+ const anchorIndex = entries.findIndex((entry) => entry.id === metadata.titleUserMessageId);
272
+ if (anchorIndex < 0)
273
+ return undefined;
274
+ if (anchorIndex <= latestClearIndex(entries))
275
+ return undefined;
276
+ return title;
277
+ }
278
+ function messageText(message) {
279
+ if (message.role !== "user")
280
+ return "";
281
+ if (typeof message.content === "string")
282
+ return message.content;
283
+ return message.content.map((part) => part.type === "text" ? part.text : "").join("\n");
284
+ }
285
+ function sessionPreviewFromText(text) {
286
+ return truncateVisual(normalizeSingleLine(text), 100) || "No user message";
287
+ }
@@ -5,24 +5,6 @@ export function parseSkillInvocation(input, registry) {
5
5
  const withoutSlash = trimmed.slice(1).trim();
6
6
  if (!withoutSlash)
7
7
  return undefined;
8
- if (withoutSlash.startsWith("skill ")) {
9
- const rest = withoutSlash.slice("skill ".length).trim();
10
- const firstSpace = rest.indexOf(" ");
11
- if (firstSpace === -1)
12
- return undefined;
13
- const skillName = rest.slice(0, firstSpace).trim();
14
- const task = rest.slice(firstSpace + 1).trim();
15
- if (!skillName || !task)
16
- return undefined;
17
- const skill = registry.get(skillName);
18
- if (!skill)
19
- return undefined;
20
- return {
21
- skill,
22
- task,
23
- actualPrompt: buildSkillExecutionPrompt(skill, task),
24
- };
25
- }
26
8
  const firstSpace = withoutSlash.indexOf(" ");
27
9
  if (firstSpace === -1)
28
10
  return undefined;
@@ -3,6 +3,7 @@ export interface SkillRegistryOptions {
3
3
  cwd?: string;
4
4
  bubbleHome?: string;
5
5
  agentsHome?: string;
6
+ claudeHome?: string;
6
7
  skillPaths?: string[];
7
8
  }
8
9
  export declare class SkillRegistry {
@@ -9,9 +9,11 @@ export class SkillRegistry {
9
9
  const cwd = options.cwd ?? process.cwd();
10
10
  const bubbleHome = options.bubbleHome ?? getBubbleHome();
11
11
  const agentsHome = options.agentsHome ?? join(homedir(), ".agents");
12
+ const claudeHome = options.claudeHome ?? join(homedir(), ".claude");
12
13
  const roots = [
13
14
  { path: join(bubbleHome, "skills"), source: "user" },
14
15
  { path: join(agentsHome, "skills"), source: "user" },
16
+ { path: join(claudeHome, "skills"), source: "user" },
15
17
  { path: join(cwd, ".bubble", "skills"), source: "project" },
16
18
  ...(options.skillPaths ?? []).map((path) => ({ path, source: "configured" })),
17
19
  ];
@@ -6,7 +6,6 @@ import { parseRule } from "../permissions/rule.js";
6
6
  import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
7
7
  import { getAvailableThinkingLevels, normalizeThinkingLevel } from "../provider-transform.js";
8
8
  import { buildSystemPrompt } from "../system-prompt.js";
9
- import { formatLoadedSkill } from "../tools/skill.js";
10
9
  import { isThinkingLevel } from "../variant/thinking-level.js";
11
10
  import { buildMemoryPrompt, getMemoryStatus, isMemoryDisabled, resetMemory, searchMemory, } from "../memory/index.js";
12
11
  import { feishuCommand } from "./feishu.js";
@@ -55,7 +54,7 @@ function persistSelectedModel(model, ctx) {
55
54
  userConfig.setDefaultThinkingLevel(ctx.agent.thinking);
56
55
  userConfig.pushRecentModel(model);
57
56
  if (ctx.sessionManager) {
58
- ctx.sessionManager.setMetadata({ model, thinkingLevel: ctx.agent.thinking, reasoningEffort: ctx.agent.thinking });
57
+ ctx.sessionManager.updateMetadata({ model, thinkingLevel: ctx.agent.thinking, reasoningEffort: ctx.agent.thinking });
59
58
  ctx.sessionManager.appendMarker("model_switch", model);
60
59
  }
61
60
  }
@@ -68,7 +67,6 @@ function syncSystemPrompt(ctx, model) {
68
67
  configuredModelId: model,
69
68
  thinkingLevel: ctx.agent.thinking,
70
69
  workingDir: ctx.cwd,
71
- skills: ctx.skillRegistry.summaries(),
72
70
  memoryPrompt: buildMemoryPrompt(ctx.cwd),
73
71
  }));
74
72
  }
@@ -257,25 +255,6 @@ const builtinSlashCommandEntries = [
257
255
  ctx.openPicker("skill");
258
256
  },
259
257
  },
260
- {
261
- name: "skill",
262
- description: "Load a skill explicitly. Usage: /skill <name>",
263
- async handler(args, ctx) {
264
- const name = args.trim();
265
- if (!name) {
266
- return "Usage: /skill <name>";
267
- }
268
- const skill = ctx.skillRegistry.get(name);
269
- if (!skill) {
270
- const available = ctx.skillRegistry.summaries().map((item) => item.name).join(", ");
271
- return available
272
- ? `Unknown skill "${name}". Available skills: ${available}`
273
- : `Unknown skill "${name}". No skills are currently available.`;
274
- }
275
- ctx.sessionManager?.appendMarker("skill_activated", skill.meta.name);
276
- return formatLoadedSkill(skill);
277
- },
278
- },
279
258
  {
280
259
  name: "help",
281
260
  description: "Show available slash commands",
@@ -339,6 +318,7 @@ const builtinSlashCommandEntries = [
339
318
  async handler(args, ctx) {
340
319
  ctx.agent.messages = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
341
320
  ctx.sessionManager?.appendMarker("conversation_clear", "");
321
+ ctx.sessionManager?.clearTitleMetadata?.();
342
322
  if (ctx.agent.getTodos().length > 0) {
343
323
  ctx.agent.setTodos([]);
344
324
  }
@@ -48,7 +48,7 @@ export class SlashCommandRegistry {
48
48
  if (skill) {
49
49
  return {
50
50
  handled: true,
51
- result: `Skill "${skill.meta.name}": ${skill.meta.description}\nUse /${skill.meta.name} <your request> to run with this skill, or /skill ${skill.meta.name} to inspect it.`,
51
+ result: `Skill "${skill.meta.name}": ${skill.meta.description}\nUse /${skill.meta.name} <your request> to run with this skill, or /skills to choose from the picker.`,
52
52
  };
53
53
  }
54
54
  return {
@@ -0,0 +1,3 @@
1
+ export declare function normalizeSingleLine(text: string): string;
2
+ export declare function truncateVisual(text: string, maxWidth: number): string;
3
+ export declare function padVisual(text: string, width: number): string;
@@ -0,0 +1,25 @@
1
+ import stringWidth from "string-width";
2
+ export function normalizeSingleLine(text) {
3
+ return text.replace(/\s+/g, " ").trim();
4
+ }
5
+ export function truncateVisual(text, maxWidth) {
6
+ if (maxWidth <= 0)
7
+ return "";
8
+ if (stringWidth(text) <= maxWidth)
9
+ return text;
10
+ if (maxWidth === 1)
11
+ return "…";
12
+ let out = "";
13
+ let width = 0;
14
+ for (const ch of text) {
15
+ const chWidth = stringWidth(ch);
16
+ if (width + chWidth > maxWidth - 1)
17
+ break;
18
+ out += ch;
19
+ width += chWidth;
20
+ }
21
+ return `${out}…`;
22
+ }
23
+ export function padVisual(text, width) {
24
+ return `${text}${" ".repeat(Math.max(0, width - stringWidth(text)))}`;
25
+ }
@@ -11,6 +11,7 @@ export { createLspTool } from "./lsp.js";
11
11
  export { createWebFetchTool } from "./web-fetch.js";
12
12
  export { createWebSearchTool } from "./web-search.js";
13
13
  export { createSkillTool } from "./skill.js";
14
+ export { createSkillSearchTool } from "./skill-search.js";
14
15
  export { createAgentLifecycleTools, createCloseAgentTool, createSendInputTool, createSpawnAgentTool, createWaitAgentTool } from "./agent-lifecycle.js";
15
16
  export { createTodoTool, type TodoStore } from "./todo.js";
16
17
  export { createExitPlanModeTool, type PlanController } from "./exit-plan-mode.js";
@@ -11,6 +11,7 @@ export { createLspTool } from "./lsp.js";
11
11
  export { createWebFetchTool } from "./web-fetch.js";
12
12
  export { createWebSearchTool } from "./web-search.js";
13
13
  export { createSkillTool } from "./skill.js";
14
+ export { createSkillSearchTool } from "./skill-search.js";
14
15
  export { createAgentLifecycleTools, createCloseAgentTool, createSendInputTool, createSpawnAgentTool, createWaitAgentTool } from "./agent-lifecycle.js";
15
16
  export { createTodoTool } from "./todo.js";
16
17
  export { createExitPlanModeTool } from "./exit-plan-mode.js";
@@ -26,6 +27,7 @@ import { getLspService } from "../lsp/index.js";
26
27
  import { createLspTool } from "./lsp.js";
27
28
  import { createReadTool } from "./read.js";
28
29
  import { createSkillTool } from "./skill.js";
30
+ import { createSkillSearchTool } from "./skill-search.js";
29
31
  import { createAgentLifecycleTools } from "./agent-lifecycle.js";
30
32
  import { createTodoTool } from "./todo.js";
31
33
  import { createToolSearchTool } from "./tool-search.js";
@@ -53,7 +55,7 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
53
55
  createMemoryReadSummaryTool(cwd),
54
56
  ...createAgentLifecycleTools(),
55
57
  ...(options.questionController ? [createQuestionTool(options.questionController)] : []),
56
- ...(skillRegistry ? [createSkillTool(skillRegistry)] : []),
58
+ ...(skillRegistry ? [createSkillSearchTool(skillRegistry), createSkillTool(skillRegistry)] : []),
57
59
  ...(options.todoStore ? [createTodoTool(options.todoStore)] : []),
58
60
  ...(options.planController ? [createExitPlanModeTool(options.planController)] : []),
59
61
  ...(options.toolSearchController ? [createToolSearchTool(options.toolSearchController)] : []),
@@ -0,0 +1,10 @@
1
+ import type { SkillRegistry } from "../skills/registry.js";
2
+ import type { SkillSummary } from "../skills/types.js";
3
+ import type { ToolRegistryEntry } from "../types.js";
4
+ interface SkillSearchMatch {
5
+ skill: SkillSummary;
6
+ score: number;
7
+ }
8
+ export declare function createSkillSearchTool(registry: SkillRegistry): ToolRegistryEntry;
9
+ export declare function searchSkillSummaries(skills: SkillSummary[], query: string): SkillSearchMatch[];
10
+ export {};
@@ -0,0 +1,134 @@
1
+ const DEFAULT_MAX_RESULTS = 8;
2
+ const MAX_RESULTS = 25;
3
+ const SOURCE_PRIORITY = {
4
+ project: 0,
5
+ configured: 1,
6
+ user: 2,
7
+ };
8
+ export function createSkillSearchTool(registry) {
9
+ return {
10
+ name: "skill_search",
11
+ readOnly: true,
12
+ effect: "read",
13
+ description: "Search available skills by name, description, tags, and source. Use this before loading a skill when a task may match a specialized workflow.",
14
+ parameters: {
15
+ type: "object",
16
+ properties: {
17
+ query: {
18
+ type: "string",
19
+ description: "Search terms describing the desired skill or workflow.",
20
+ },
21
+ max_results: {
22
+ type: "number",
23
+ description: `Maximum number of matches to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS}).`,
24
+ },
25
+ },
26
+ required: ["query"],
27
+ additionalProperties: false,
28
+ },
29
+ async execute(args) {
30
+ const query = typeof args.query === "string" ? args.query.trim() : "";
31
+ const maxResults = typeof args.max_results === "number" && args.max_results > 0
32
+ ? Math.min(Math.floor(args.max_results), MAX_RESULTS)
33
+ : DEFAULT_MAX_RESULTS;
34
+ const skills = registry.summaries();
35
+ if (skills.length === 0) {
36
+ return { content: "No skills are currently available." };
37
+ }
38
+ const matches = searchSkillSummaries(skills, query).slice(0, maxResults);
39
+ if (matches.length === 0) {
40
+ return {
41
+ content: `No skills matched "${query}". Try broader terms or use /skills to browse all skills manually.`,
42
+ };
43
+ }
44
+ return {
45
+ content: formatSkillSearchResults(matches, skills.length, query),
46
+ };
47
+ },
48
+ };
49
+ }
50
+ export function searchSkillSummaries(skills, query) {
51
+ const terms = normalizeTerms(query);
52
+ const scored = [];
53
+ for (const skill of skills) {
54
+ const score = scoreSkill(skill, terms, query);
55
+ if (score > 0 || terms.length === 0) {
56
+ scored.push({ skill, score });
57
+ }
58
+ }
59
+ scored.sort((a, b) => {
60
+ if (b.score !== a.score)
61
+ return b.score - a.score;
62
+ const ap = SOURCE_PRIORITY[a.skill.source ?? "user"] ?? 3;
63
+ const bp = SOURCE_PRIORITY[b.skill.source ?? "user"] ?? 3;
64
+ if (ap !== bp)
65
+ return ap - bp;
66
+ return a.skill.name.localeCompare(b.skill.name);
67
+ });
68
+ return scored;
69
+ }
70
+ function scoreSkill(skill, terms, rawQuery) {
71
+ const name = skill.name.toLowerCase();
72
+ const desc = (skill.description ?? "").toLowerCase();
73
+ const tags = (skill.tags ?? []).map((tag) => tag.toLowerCase());
74
+ const source = skill.source ?? "user";
75
+ const sourceBonus = source === "project" ? 4 : source === "configured" ? 2 : 0;
76
+ const query = rawQuery.trim().toLowerCase();
77
+ if (terms.length === 0)
78
+ return 1 + sourceBonus;
79
+ let score = 0;
80
+ if (name === query)
81
+ score += 80;
82
+ if (name.includes(query) && query.length > 0)
83
+ score += 30;
84
+ for (const term of terms) {
85
+ if (name === term)
86
+ score += 30;
87
+ else if (name.includes(term))
88
+ score += 12;
89
+ if (tags.some((tag) => tag === term))
90
+ score += 10;
91
+ else if (tags.some((tag) => tag.includes(term)))
92
+ score += 6;
93
+ if (desc.includes(term))
94
+ score += 3;
95
+ if (source.includes(term))
96
+ score += 2;
97
+ }
98
+ return score > 0 ? score + sourceBonus : 0;
99
+ }
100
+ function normalizeTerms(query) {
101
+ const rawTerms = query
102
+ .toLowerCase()
103
+ .split(/[^a-z0-9_\-\u3000-\u9fff]+/i)
104
+ .map((term) => term.trim())
105
+ .filter(Boolean);
106
+ const terms = new Set();
107
+ for (const term of rawTerms) {
108
+ terms.add(term);
109
+ const chars = Array.from(term);
110
+ if (chars.some(isCjkChar) && chars.length > 2) {
111
+ for (let i = 0; i < chars.length - 1; i++) {
112
+ terms.add(`${chars[i]}${chars[i + 1]}`);
113
+ }
114
+ }
115
+ }
116
+ return [...terms];
117
+ }
118
+ function isCjkChar(ch) {
119
+ const code = ch.codePointAt(0) ?? 0;
120
+ return code >= 0x3000 && code <= 0x9fff;
121
+ }
122
+ function formatSkillSearchResults(matches, total, query) {
123
+ const lines = [
124
+ query ? `Skill search results for "${query}" (${matches.length} of ${total}):` : `Available skills (${matches.length} of ${total}):`,
125
+ ];
126
+ for (const { skill } of matches) {
127
+ const tags = skill.tags && skill.tags.length > 0 ? ` [tags: ${skill.tags.join(", ")}]` : "";
128
+ const source = skill.source ? ` (${skill.source})` : "";
129
+ lines.push(`- ${skill.name}${source}: ${skill.description}${tags}`);
130
+ }
131
+ lines.push("");
132
+ lines.push("Call skill with the exact name to load a selected skill.");
133
+ return lines.join("\n");
134
+ }
@@ -38,11 +38,8 @@ export function createSkillTool(registry) {
38
38
  }
39
39
  const skill = registry.get(name);
40
40
  if (!skill) {
41
- const available = registry.summaries().map((item) => item.name).join(", ");
42
41
  return {
43
- content: available
44
- ? `Error: Unknown skill "${name}". Available skills: ${available}`
45
- : `Error: Unknown skill "${name}". No skills are currently available.`,
42
+ content: `Error: Unknown skill "${name}". Use skill_search to find available skills, then retry with the exact skill name.`,
46
43
  isError: true,
47
44
  };
48
45
  }