@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/agent.js CHANGED
@@ -1061,7 +1061,6 @@ export class Agent {
1061
1061
  mode: "plan",
1062
1062
  workingDir: cwd,
1063
1063
  tools: childToolNames,
1064
- skills: childToolNames.includes("skill") ? this.skillSummaries : undefined,
1065
1064
  memoryPrompt: childToolNames.some((name) => name === "memory_search" || name === "memory_read_summary")
1066
1065
  ? this.memoryPrompt
1067
1066
  : undefined,
@@ -1257,7 +1256,7 @@ export class Agent {
1257
1256
  if (this._mode === "plan" && !tool.readOnly) {
1258
1257
  return {
1259
1258
  content: `Error: Tool "${toolCall.name}" is not allowed in plan mode. ` +
1260
- `In plan mode you may only use read-only tools (read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill, todo_write, tool_search, question, exit_plan_mode). ` +
1259
+ `In plan mode you may only use read-only tools (read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill_search, skill, todo_write, tool_search, question, exit_plan_mode). ` +
1261
1260
  `To modify files or run commands, present your proposal and call exit_plan_mode so the user can review and approve it.`,
1262
1261
  isError: true,
1263
1262
  };
@@ -23,6 +23,7 @@ import { createAllTools } from "../../tools/index.js";
23
23
  import { displayModel, encodeModel, decodeModel } from "../../provider-registry.js";
24
24
  import { buildMemoryPrompt, recordMemoryCitations } from "../../memory/index.js";
25
25
  import { getDefaultThinkingLevel } from "../../provider-transform.js";
26
+ import { createSessionTitleUpdater } from "../../session-title.js";
26
27
  import { applyCardBudget } from "../card/budget.js";
27
28
  import { renderCard } from "../card/renderer.js";
28
29
  import { createInitialRunState } from "../card/run-state-types.js";
@@ -78,7 +79,8 @@ export class RunDriver {
78
79
  // the question tool to the agent.
79
80
  });
80
81
  tools.push(...this.opts.deps.mcpManager.getToolEntries());
81
- const { provider, providerId, model } = await this.resolveProvider(session);
82
+ const promptCacheKey = session.manager.getOrCreatePromptCacheKey();
83
+ const { provider, providerId, model } = await this.resolveProvider(session, promptCacheKey);
82
84
  const skills = this.opts.deps.skillRegistry.summaries();
83
85
  const memoryPrompt = buildMemoryPrompt(session.cwd);
84
86
  const thinkingLevel = this.opts.deps.userConfig.getDefaultThinkingLevel()
@@ -93,10 +95,10 @@ export class RunDriver {
93
95
  mode: initialMode,
94
96
  workingDir: session.cwd,
95
97
  tools: tools.map((t) => t.name),
96
- skills,
97
98
  memoryPrompt,
98
99
  });
99
100
  const budgetLedger = new BudgetLedger();
101
+ let sessionTitleUpdater;
100
102
  const agent = new Agent({
101
103
  provider,
102
104
  providerId,
@@ -112,6 +114,7 @@ export class RunDriver {
112
114
  if (message.role === "system" || message.role === "meta")
113
115
  return;
114
116
  session.manager.appendMessage(message);
117
+ sessionTitleUpdater?.handlePersistedMessage(message);
115
118
  if (message.role === "assistant") {
116
119
  recordMemoryCitations(session.cwd, message.content);
117
120
  }
@@ -133,11 +136,15 @@ export class RunDriver {
133
136
  memoryPrompt,
134
137
  fileStateTracker,
135
138
  agentCategories: this.opts.deps.userConfig.getAgentCategories(),
136
- providerFactory: this.opts.deps.createProviderForRoute,
139
+ providerFactory: (route) => this.opts.deps.createProviderForRoute(route, promptCacheKey),
140
+ });
141
+ sessionTitleUpdater = createSessionTitleUpdater({
142
+ sessionManager: session.manager,
143
+ complete: (messages, completeOptions) => agent.complete(messages, completeOptions),
137
144
  });
138
145
  agentRef = agent;
139
146
  agentForPlan = agent;
140
- session.manager.setMetadata({
147
+ session.manager.updateMetadata({
141
148
  ...(agent.model ? { model: agent.model } : {}),
142
149
  cwd: session.cwd,
143
150
  thinkingLevel: agent.thinking,
@@ -245,7 +252,7 @@ export class RunDriver {
245
252
  this.opts.approvalUI.cancelForChat(req.chatId, "Run ended");
246
253
  }
247
254
  }
248
- async resolveProvider(session) {
255
+ async resolveProvider(session, promptCacheKey) {
249
256
  const registry = this.opts.deps.providerRegistry;
250
257
  const userConfig = this.opts.deps.userConfig;
251
258
  // Read session metadata for an explicit model preference, fall back to
@@ -272,7 +279,7 @@ export class RunDriver {
272
279
  const activeModel = effectiveModelId
273
280
  ? encodeModel(activeProviderId, effectiveModelId)
274
281
  : "";
275
- const provider = this.opts.deps.createProvider(activeProviderId, target.apiKey, target.baseURL);
282
+ const provider = this.opts.deps.createProvider(activeProviderId, target.apiKey, target.baseURL, promptCacheKey);
276
283
  return { provider, providerId: activeProviderId, model: activeModel };
277
284
  }
278
285
  }
@@ -23,11 +23,11 @@ export interface FeishuRuntimeDeps {
23
23
  /** Live MCP tool source; tools list includes MCP entries. */
24
24
  mcpManager: McpManager;
25
25
  /** Factory used by main provider + subagent routes. */
26
- createProvider: (providerId: string, apiKey: string, baseURL: string) => Provider;
26
+ createProvider: (providerId: string, apiKey: string, baseURL: string, promptCacheKey?: string) => Provider;
27
27
  createProviderForRoute: (route: {
28
28
  providerId: string;
29
29
  model: string;
30
- }) => Promise<Provider>;
30
+ }, promptCacheKey?: string) => Promise<Provider>;
31
31
  /** Resolved owner open_id (from config.app.ownerOpenId). */
32
32
  ownerOpenId: string;
33
33
  }
@@ -192,7 +192,7 @@ register({
192
192
  const lines = ["最近 session:", ""];
193
193
  for (const s of recent) {
194
194
  const stamp = new Date(s.mtime).toISOString().slice(0, 19).replace("T", " ");
195
- lines.push(`- \`${s.name}\` · ${stamp} · ${s.messageCount} msgs · ${s.firstUserMessage.slice(0, 50)}`);
195
+ lines.push(`- \`${s.name}\` · ${stamp} · ${s.messageCount} msgs · ${s.title.slice(0, 50)}`);
196
196
  }
197
197
  lines.push("");
198
198
  lines.push("用 `/resume <name>` 恢复。");
@@ -239,6 +239,7 @@ register({
239
239
  }
240
240
  const opened = ctx.sessionBinder.openOrBootstrap(input.scopeKey, entry.cwd, entry.permissionMode);
241
241
  opened.manager.appendMarker("conversation_clear", String(Date.now()));
242
+ opened.manager.clearTitleMetadata();
242
243
  await ctx.channel.send(input.chatId, { text: "🧹 已插入清除标记。下次发消息从空上下文开始。" });
243
244
  },
244
245
  });
@@ -37,7 +37,7 @@ export class SessionBinder {
37
37
  // Persist metadata immediately so the on-disk file exists from this point
38
38
  // on — otherwise openOrBootstrap() on the next call would see the pointer
39
39
  // but no file and re-bootstrap, losing the pointer.
40
- manager.setMetadata({ cwd });
40
+ manager.updateMetadata({ cwd });
41
41
  const entry = {
42
42
  sessionFile: manager.getSessionFile(),
43
43
  cwd,
@@ -91,13 +91,13 @@ export async function serveFeishu(opts = {}) {
91
91
  if (mcpLoaded.servers.length > 0) {
92
92
  await mcpManager.start();
93
93
  }
94
- const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({ providerId, apiKey, baseURL });
95
- const createProviderForRoute = async (route) => {
94
+ const createProvider = (providerId, apiKey, baseURL, promptCacheKey) => createProviderInstance({ providerId, apiKey, baseURL, promptCacheKey });
95
+ const createProviderForRoute = async (route, promptCacheKey) => {
96
96
  const target = providerRegistry.getConfigured().find((p) => p.id === route.providerId);
97
97
  if (!target?.apiKey) {
98
98
  throw new Error(`Subagent route requires provider "${route.providerId}", not configured.`);
99
99
  }
100
- return createProvider(route.providerId, target.apiKey, target.baseURL);
100
+ return createProvider(route.providerId, target.apiKey, target.baseURL, promptCacheKey);
101
101
  };
102
102
  const deps = {
103
103
  settingsManager,
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ import { createProviderInstance, createUnavailableProvider } from "./provider.js
11
11
  import { getDefaultThinkingLevel } from "./provider-transform.js";
12
12
  import { ProviderRegistry, displayModel, encodeModel, decodeModel } from "./provider-registry.js";
13
13
  import { SessionManager } from "./session.js";
14
+ import { createSessionTitleUpdater } from "./session-title.js";
14
15
  import { buildSystemPrompt } from "./system-prompt.js";
15
16
  import { SkillRegistry } from "./skills/registry.js";
16
17
  import { createAllTools } from "./tools/index.js";
@@ -61,15 +62,23 @@ async function main() {
61
62
  }
62
63
  const defaultProvider = registry.getDefault();
63
64
  const unavailableProviderMessage = "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.";
65
+ let sessionPromptCacheKey;
64
66
  const provider = defaultProvider
65
67
  ? createProviderInstance({
66
68
  providerId: defaultProvider.id,
67
69
  apiKey: defaultProvider.apiKey,
68
70
  baseURL: defaultProvider.baseURL,
69
71
  thinkingLevel: args.thinkingLevel,
72
+ promptCacheKey: sessionPromptCacheKey,
70
73
  })
71
74
  : createUnavailableProvider(unavailableProviderMessage);
72
- const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({ providerId, apiKey, baseURL, thinkingLevel: args.thinkingLevel });
75
+ const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({
76
+ providerId,
77
+ apiKey,
78
+ baseURL,
79
+ thinkingLevel: args.thinkingLevel,
80
+ promptCacheKey: sessionPromptCacheKey,
81
+ });
73
82
  const createProviderForRoute = async (route) => {
74
83
  const providerId = route.providerId;
75
84
  if (!providerId) {
@@ -216,6 +225,7 @@ async function main() {
216
225
  : SessionManager.createFresh(args.cwd);
217
226
  resumedExistingSession = false;
218
227
  }
228
+ sessionPromptCacheKey = sessionManager.getOrCreatePromptCacheKey();
219
229
  // Model resolution:
220
230
  // 1. Session metadata 2. User-configured default model 3. CLI flag
221
231
  // No implicit built-in model fallback.
@@ -262,10 +272,10 @@ async function main() {
262
272
  mode: initialMode,
263
273
  workingDir: args.cwd,
264
274
  tools: tools.map((tool) => tool.name),
265
- skills: skillSummaries,
266
275
  memoryPrompt,
267
276
  });
268
277
  const budgetLedger = new BudgetLedger();
278
+ let sessionTitleUpdater;
269
279
  const agent = new Agent({
270
280
  provider: activeProvider
271
281
  ? createProvider(activeProviderId, activeProvider.apiKey, activeProvider.baseURL)
@@ -289,6 +299,7 @@ async function main() {
289
299
  if (message.role === "meta")
290
300
  return;
291
301
  sessionManager.appendMessage(message);
302
+ sessionTitleUpdater?.handlePersistedMessage(message);
292
303
  if (message.role === "assistant") {
293
304
  recordMemoryCitations(args.cwd, message.content);
294
305
  }
@@ -318,7 +329,13 @@ async function main() {
318
329
  });
319
330
  agentRef = agent;
320
331
  if (sessionManager) {
321
- sessionManager.setMetadata({
332
+ sessionTitleUpdater = createSessionTitleUpdater({
333
+ sessionManager,
334
+ complete: (messages, completeOptions) => agent.complete(messages, completeOptions),
335
+ });
336
+ }
337
+ if (sessionManager) {
338
+ sessionManager.updateMetadata({
322
339
  ...(agent.model ? { model: agent.model } : {}),
323
340
  cwd: args.cwd,
324
341
  thinkingLevel: agent.thinking,
@@ -8,7 +8,6 @@ import { buildGptProviderPrompt } from "./provider-prompts/gpt.js";
8
8
  import { buildKimiProviderPrompt } from "./provider-prompts/kimi.js";
9
9
  import { buildEnvironmentPrompt, defaultToolNames } from "./environment.js";
10
10
  import { buildRuntimePrompt } from "./runtime.js";
11
- import { buildSkillsPrompt } from "./skills.js";
12
11
  export function composeSystemPrompt(options = {}) {
13
12
  const agentName = options.agentName ?? "Bubble";
14
13
  const providerPrompt = buildProviderPrompt(agentName, options.configuredProvider, options.configuredModelId, options.configuredModel);
@@ -26,14 +25,12 @@ export function composeSystemPrompt(options = {}) {
26
25
  mode: options.mode,
27
26
  guidelines: buildGuidelines(options.tools ?? defaultToolNames, options.guidelines ?? []),
28
27
  });
29
- const skillsPrompt = buildSkillsPrompt(options.skills ?? []);
30
28
  return [
31
29
  providerPrompt,
32
30
  environmentPrompt,
33
31
  runtimePrompt,
34
32
  options.agentProfilePrompt,
35
33
  options.memoryPrompt,
36
- skillsPrompt,
37
34
  ].filter(Boolean).join("\n\n");
38
35
  }
39
36
  function buildProviderPrompt(agentName, providerId, modelId, modelName) {
@@ -80,6 +77,9 @@ function buildGuidelines(tools, extraGuidelines) {
80
77
  if (tools.includes("question")) {
81
78
  add("When the user is explicitly discussing, brainstorming, or shaping an approach instead of asking for immediate execution, use the question tool for targeted clarification or preference choices when it would materially improve the discussion; do not use it for generic permission-to-proceed questions");
82
79
  }
80
+ if (tools.includes("skill_search") && tools.includes("skill")) {
81
+ add("Skills may provide specialized workflows. When a task appears to match a specialized workflow, call skill_search to find relevant skills, then call skill with the exact name to load the selected skill before applying it");
82
+ }
83
83
  if (tools.includes("todo_write")) {
84
84
  add("Use todo_write to plan any task that needs three or more concrete steps before you start. Mark each item completed as soon as it is done; do not batch updates");
85
85
  }
@@ -14,6 +14,7 @@ export const defaultToolSnippets = {
14
14
  send_input: "Send follow-up input to an existing subagent thread",
15
15
  close_agent: "Close or cancel a spawned subagent thread",
16
16
  question: "Ask the user structured questions when clarification or preference choices would materially improve the work",
17
+ skill_search: "Search available skills by name, description, tags, and source",
17
18
  skill: "Load a named skill with specialized instructions and bundled resources",
18
19
  todo_write: "Plan and track multi-step work. Mark each task completed as soon as it is done — do not batch.",
19
20
  };
@@ -32,6 +33,7 @@ export const defaultToolNames = [
32
33
  "send_input",
33
34
  "close_agent",
34
35
  "question",
36
+ "skill_search",
35
37
  "skill",
36
38
  "todo_write",
37
39
  ];
@@ -20,7 +20,7 @@ const PLAN_MODE_ENTER = `
20
20
  Plan mode is now ACTIVE.
21
21
 
22
22
  Rules while in plan mode:
23
- - Only read-only tools are allowed, including read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill, todo_write, tool_search, question, and exit_plan_mode.
23
+ - Only read-only tools are allowed, including read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill_search, skill, todo_write, tool_search, question, and exit_plan_mode.
24
24
  - Writes, edits, and shell commands WILL be rejected by the harness; do not try them.
25
25
  - Do not edit files or claim implementation is complete while plan mode is active.
26
26
  - Investigate the codebase, then use the question tool to clarify important ambiguities, tradeoffs, requirements, or preference choices that would materially change the plan.
@@ -1,4 +1,4 @@
1
- import type { Provider, ReasoningEffort, ThinkingLevel } from "./types.js";
1
+ import type { Provider, ReasoningEffort, ThinkingLevel, TokenUsage } from "./types.js";
2
2
  export interface CodexModelDescriptor {
3
3
  id: string;
4
4
  displayName?: string;
@@ -17,7 +17,14 @@ export declare function createOpenAICodexProvider(options: {
17
17
  apiKey: string;
18
18
  baseURL: string;
19
19
  thinkingLevel?: ThinkingLevel;
20
+ promptCacheKey?: string;
20
21
  }): Provider;
22
+ export declare function normalizeOpenAICodexUsage(usage: any): TokenUsage;
23
+ export declare function buildOpenAICodexPromptCacheKey(input: {
24
+ seed?: string;
25
+ providerId?: string;
26
+ model: string;
27
+ }): string | undefined;
21
28
  export declare function fetchOpenAICodexModels(options: {
22
29
  baseURL: string;
23
30
  accessToken: string;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { listBuiltinModels } from "./model-catalog.js";
2
3
  import { resolveProviderRequestConfig } from "./provider-transform.js";
3
4
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
@@ -53,6 +54,8 @@ export function createOpenAICodexProvider(options) {
53
54
  tools: chatOptions.tools,
54
55
  reasoningEffort: requestConfig.reasoningEffort,
55
56
  sessionId,
57
+ providerId: options.providerId,
58
+ promptCacheKey: options.promptCacheKey,
56
59
  })),
57
60
  });
58
61
  if (!response.ok) {
@@ -164,14 +167,7 @@ export function createOpenAICodexProvider(options) {
164
167
  if (usage) {
165
168
  yield {
166
169
  type: "usage",
167
- usage: {
168
- promptTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
169
- completionTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : 0,
170
- reasoningTokens: typeof usage.output_tokens_details?.reasoning_tokens === "number"
171
- ? usage.output_tokens_details.reasoning_tokens
172
- : undefined,
173
- totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : undefined,
174
- },
170
+ usage: normalizeOpenAICodexUsage(usage),
175
171
  };
176
172
  }
177
173
  continue;
@@ -195,6 +191,30 @@ export function createOpenAICodexProvider(options) {
195
191
  }
196
192
  return { streamChat, complete };
197
193
  }
194
+ export function normalizeOpenAICodexUsage(usage) {
195
+ const promptTokens = typeof usage?.input_tokens === "number" ? usage.input_tokens : 0;
196
+ const cachedTokens = typeof usage?.input_tokens_details?.cached_tokens === "number"
197
+ ? usage.input_tokens_details.cached_tokens
198
+ : undefined;
199
+ return {
200
+ promptTokens,
201
+ completionTokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : 0,
202
+ promptCacheHitTokens: cachedTokens,
203
+ promptCacheMissTokens: cachedTokens !== undefined ? Math.max(0, promptTokens - cachedTokens) : undefined,
204
+ reasoningTokens: typeof usage?.output_tokens_details?.reasoning_tokens === "number"
205
+ ? usage.output_tokens_details.reasoning_tokens
206
+ : undefined,
207
+ totalTokens: typeof usage?.total_tokens === "number" ? usage.total_tokens : undefined,
208
+ };
209
+ }
210
+ export function buildOpenAICodexPromptCacheKey(input) {
211
+ const seed = input.seed?.trim();
212
+ if (!seed)
213
+ return undefined;
214
+ return createHash("sha256")
215
+ .update(`bubble:${input.providerId || "openai-codex"}:${input.model}:${seed}`)
216
+ .digest("hex");
217
+ }
198
218
  export async function fetchOpenAICodexModels(options) {
199
219
  const accountId = extractChatGptAccountId(options.accessToken);
200
220
  if (!accountId) {
@@ -228,7 +248,11 @@ function buildRequestBody(messages, options) {
228
248
  instructions: instructions || undefined,
229
249
  input,
230
250
  include: ["reasoning.encrypted_content"],
231
- prompt_cache_key: options.sessionId,
251
+ prompt_cache_key: buildOpenAICodexPromptCacheKey({
252
+ seed: options.promptCacheKey ?? options.sessionId,
253
+ providerId: options.providerId,
254
+ model: options.model,
255
+ }),
232
256
  tool_choice: "auto",
233
257
  parallel_tool_calls: true,
234
258
  text: { verbosity: "medium" },
@@ -21,6 +21,8 @@ export interface ProviderInstanceOptions {
21
21
  baseURL: string;
22
22
  /** Requested thinking level */
23
23
  thinkingLevel?: ThinkingLevel;
24
+ /** Stable per-session seed for provider prompt caches. */
25
+ promptCacheKey?: string;
24
26
  }
25
27
  export declare function createUnavailableProvider(message: string): Provider;
26
28
  export declare function createProviderInstance(options: ProviderInstanceOptions): Provider;
@@ -0,0 +1,16 @@
1
+ import type { SessionManager } from "./session.js";
2
+ import type { ContentPart, Message, ProviderMessage, ThinkingLevel } from "./types.js";
3
+ export interface SessionTitleUpdater {
4
+ handlePersistedMessage(message: Message): void;
5
+ }
6
+ export declare function createSessionTitleUpdater(options: {
7
+ sessionManager: SessionManager;
8
+ complete: (messages: ProviderMessage[], options?: {
9
+ model?: string;
10
+ temperature?: number;
11
+ thinkingLevel?: ThinkingLevel;
12
+ abortSignal?: AbortSignal;
13
+ }) => Promise<string>;
14
+ }): SessionTitleUpdater;
15
+ export declare function cleanGeneratedTitle(raw: string): string;
16
+ export declare function deterministicTitleFromUserContent(content: string | ContentPart[]): string;
@@ -0,0 +1,134 @@
1
+ import { normalizeSingleLine, truncateVisual } from "./text-display.js";
2
+ const LONG_PASTE_CHAR_THRESHOLD = 1000;
3
+ const LONG_PASTE_LINE_THRESHOLD = 20;
4
+ const TITLE_INPUT_MAX_CHARS = 4000;
5
+ const TITLE_MAX_WIDTH = 80;
6
+ const TITLE_SYSTEM_PROMPT = [
7
+ "You are a title generator. Output ONLY a conversation title.",
8
+ "",
9
+ "Rules:",
10
+ "- Single line only.",
11
+ "- Use the same language as the user message.",
12
+ "- Keep it brief and useful for finding this conversation later.",
13
+ "- Do not answer the user's request.",
14
+ "- Do not mention tools unless the tool itself is the topic.",
15
+ "- No explanations, no markdown, no quotes.",
16
+ ].join("\n");
17
+ export function createSessionTitleUpdater(options) {
18
+ let pending;
19
+ let inFlight = false;
20
+ const run = async (candidate) => {
21
+ const raw = await options.complete(buildTitleMessages(candidate.input), {
22
+ temperature: 0.3,
23
+ thinkingLevel: "off",
24
+ });
25
+ const title = cleanGeneratedTitle(raw);
26
+ if (!title)
27
+ return;
28
+ if (!isCandidateCurrent(options.sessionManager.getEntries(), candidate.userMessageId))
29
+ return;
30
+ if (options.sessionManager.getMetadata().title?.trim())
31
+ return;
32
+ options.sessionManager.updateMetadata({
33
+ title,
34
+ titleSource: "llm",
35
+ titleUpdatedAt: Date.now(),
36
+ titleUserMessageId: candidate.userMessageId,
37
+ });
38
+ };
39
+ return {
40
+ handlePersistedMessage(message) {
41
+ if (message.role === "user") {
42
+ if (pending || inFlight)
43
+ return;
44
+ if (options.sessionManager.getMetadata().title?.trim())
45
+ return;
46
+ if (currentUserMessageCount(options.sessionManager.getMessages()) !== 1)
47
+ return;
48
+ const userEntryId = latestUserMessageEntryId(options.sessionManager.getEntries());
49
+ if (!userEntryId)
50
+ return;
51
+ const input = titleInputFromUserContent(message.content);
52
+ if (!input)
53
+ return;
54
+ pending = { input, userMessageId: userEntryId };
55
+ return;
56
+ }
57
+ if (message.role !== "assistant" || !pending || inFlight)
58
+ return;
59
+ const candidate = pending;
60
+ pending = undefined;
61
+ inFlight = true;
62
+ void run(candidate).catch(() => undefined).finally(() => {
63
+ inFlight = false;
64
+ });
65
+ },
66
+ };
67
+ }
68
+ export function cleanGeneratedTitle(raw) {
69
+ const withoutThinking = raw.replace(/<think>[\s\S]*?<\/think>/gi, "");
70
+ const withoutFences = withoutThinking.replace(/```[a-zA-Z0-9_-]*\s*/g, "").replace(/```/g, "");
71
+ const line = withoutFences
72
+ .split(/\r?\n/)
73
+ .map((item) => item.trim())
74
+ .find(Boolean);
75
+ if (!line)
76
+ return "";
77
+ const unquoted = line.replace(/^["'“”‘’]+|["'“”‘’]+$/g, "");
78
+ return truncateVisual(normalizeSingleLine(unquoted), TITLE_MAX_WIDTH);
79
+ }
80
+ export function deterministicTitleFromUserContent(content) {
81
+ const text = userContentText(content);
82
+ if (!text)
83
+ return "User message";
84
+ const charCount = text.length;
85
+ const lineCount = text.split(/\r?\n/).length;
86
+ if (charCount > LONG_PASTE_CHAR_THRESHOLD || lineCount > LONG_PASTE_LINE_THRESHOLD) {
87
+ return `[Pasted Content ${charCount} chars]`;
88
+ }
89
+ return truncateVisual(normalizeSingleLine(text), TITLE_MAX_WIDTH) || "User message";
90
+ }
91
+ function titleInputFromUserContent(content) {
92
+ const title = deterministicTitleFromUserContent(content);
93
+ const text = userContentText(content);
94
+ if (!text)
95
+ return title;
96
+ return normalizeSingleLine(text).slice(0, TITLE_INPUT_MAX_CHARS);
97
+ }
98
+ function userContentText(content) {
99
+ if (typeof content === "string")
100
+ return content;
101
+ const text = content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n");
102
+ if (text.trim())
103
+ return text;
104
+ return content.some((part) => part.type === "image_url") ? "Image attachment" : "";
105
+ }
106
+ function buildTitleMessages(input) {
107
+ return [
108
+ { role: "system", content: TITLE_SYSTEM_PROMPT },
109
+ { role: "user", content: `Generate a title for this conversation:\n\n${input}` },
110
+ ];
111
+ }
112
+ function currentUserMessageCount(messages) {
113
+ return messages.filter((message) => message.role === "user").length;
114
+ }
115
+ function latestUserMessageEntryId(entries) {
116
+ for (let i = entries.length - 1; i >= 0; i--) {
117
+ const entry = entries[i];
118
+ if (entry.type === "user_message")
119
+ return entry.id;
120
+ }
121
+ return undefined;
122
+ }
123
+ function isCandidateCurrent(entries, userMessageId) {
124
+ let clearIndex = -1;
125
+ let userIndex = -1;
126
+ for (let i = 0; i < entries.length; i++) {
127
+ const entry = entries[i];
128
+ if (entry.type === "marker" && entry.kind === "conversation_clear")
129
+ clearIndex = i;
130
+ if (entry.id === userMessageId)
131
+ userIndex = i;
132
+ }
133
+ return userIndex > clearIndex;
134
+ }
@@ -4,6 +4,11 @@ export interface SessionMetadata {
4
4
  thinkingLevel?: ThinkingLevel;
5
5
  reasoningEffort?: ThinkingLevel;
6
6
  cwd?: string;
7
+ title?: string;
8
+ titleSource?: "llm" | "manual";
9
+ titleUpdatedAt?: number;
10
+ titleUserMessageId?: string;
11
+ promptCacheKey?: string;
7
12
  }
8
13
  export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch" | "conversation_clear";
9
14
  interface BaseSessionLogEntry {
package/dist/session.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface SessionSummary {
9
9
  name: string;
10
10
  cwd?: string;
11
11
  cwdLabel: string;
12
+ title: string;
13
+ preview: string;
12
14
  firstUserMessage: string;
13
15
  messageCount: number;
14
16
  mtime: number;
@@ -28,7 +30,10 @@ export declare class SessionManager {
28
30
  private persist;
29
31
  private rewrite;
30
32
  getMetadata(): SessionMetadata;
33
+ getOrCreatePromptCacheKey(): string;
31
34
  setMetadata(metadata: SessionMetadata): void;
35
+ updateMetadata(patch: Partial<SessionMetadata>): void;
36
+ clearTitleMetadata(): void;
32
37
  appendMessage(message: Message): void;
33
38
  appendCompaction(summary: string): void;
34
39
  appendMarker(kind: SessionMarkerKind, value: string): void;