@dreb/coding-agent 2.19.3 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/core/settings-manager.d.ts +6 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +3 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +7 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +24 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/tab-title.d.ts +51 -0
- package/dist/modes/interactive/tab-title.d.ts.map +1 -0
- package/dist/modes/interactive/tab-title.js +159 -0
- package/dist/modes/interactive/tab-title.js.map +1 -0
- package/docs/settings.md +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generates a terminal tab title from session context after a threshold
|
|
3
|
+
* number of tool calls. Uses a lightweight single-shot LLM call to produce a
|
|
4
|
+
* concise ≤30 character title, then sets it via the terminal's OSC 0 escape.
|
|
5
|
+
*
|
|
6
|
+
* Fires at most once per session. Failures are swallowed silently.
|
|
7
|
+
*/
|
|
8
|
+
import type { Api, Model } from "@dreb/ai";
|
|
9
|
+
import type { ModelRegistry } from "../../core/model-registry.js";
|
|
10
|
+
import type { TabTitleSettings } from "../../core/settings-manager.js";
|
|
11
|
+
export interface TabTitleDeps {
|
|
12
|
+
/** Set the terminal tab title (OSC 0). */
|
|
13
|
+
setTitle: (title: string) => void;
|
|
14
|
+
/** Get the current session messages (for context). */
|
|
15
|
+
getMessages: () => Array<{
|
|
16
|
+
role: string;
|
|
17
|
+
content?: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
/** Get the current model (parent session model — used as fallback). */
|
|
20
|
+
getModel: () => Model<Api> | undefined;
|
|
21
|
+
/** Get model registry for API key resolution. */
|
|
22
|
+
getModelRegistry: () => ModelRegistry;
|
|
23
|
+
/** Get the parent provider name. */
|
|
24
|
+
getProvider: () => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare class TabTitleGenerator {
|
|
27
|
+
private readonly settings;
|
|
28
|
+
private readonly deps;
|
|
29
|
+
private toolCallCount;
|
|
30
|
+
private fired;
|
|
31
|
+
private readonly threshold;
|
|
32
|
+
constructor(settings: TabTitleSettings | undefined, deps: TabTitleDeps);
|
|
33
|
+
/** Whether this generator is enabled. */
|
|
34
|
+
get enabled(): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Called on each tool_execution_end event. Increments the counter and fires
|
|
37
|
+
* the title generation when threshold is reached.
|
|
38
|
+
*/
|
|
39
|
+
onToolEnd(): void;
|
|
40
|
+
/** Exposed for testing — the current tool call count. */
|
|
41
|
+
get currentCount(): number;
|
|
42
|
+
/** Exposed for testing — whether the title has been generated. */
|
|
43
|
+
get hasFired(): boolean;
|
|
44
|
+
private generateTitle;
|
|
45
|
+
private resolveModel;
|
|
46
|
+
private getExploreAgentModels;
|
|
47
|
+
private buildContext;
|
|
48
|
+
/** Clean up LLM response to a usable tab title. */
|
|
49
|
+
private sanitizeTitle;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=tab-title.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tab-title.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/tab-title.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,GAAG,EAAW,KAAK,EAAE,MAAM,UAAU,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAClE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAWvE,MAAM,WAAW,YAAY;IAC5B,0CAA0C;IAC1C,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,sDAAsD;IACtD,WAAW,EAAE,MAAM,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC9D,yEAAuE;IACvE,QAAQ,EAAE,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACvC,iDAAiD;IACjD,gBAAgB,EAAE,MAAM,aAAa,CAAC;IACtC,oCAAoC;IACpC,WAAW,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACtC;AAED,qBAAa,iBAAiB;IAM5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI;IANtB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YACkB,QAAQ,EAAE,gBAAgB,GAAG,SAAS,EACtC,IAAI,EAAE,YAAY,EAGnC;IAED,yCAAyC;IACzC,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;OAGG;IACH,SAAS,IAAI,IAAI,CAShB;IAED,2DAAyD;IACzD,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,oEAAkE;IAClE,IAAI,QAAQ,IAAI,OAAO,CAEtB;YAEa,aAAa;YA8Bb,YAAY;IA2B1B,OAAO,CAAC,qBAAqB;IAc7B,OAAO,CAAC,YAAY;IAyBpB,mDAAmD;IACnD,OAAO,CAAC,aAAa;CAsBrB","sourcesContent":["/**\n * Auto-generates a terminal tab title from session context after a threshold\n * number of tool calls. Uses a lightweight single-shot LLM call to produce a\n * concise ≤30 character title, then sets it via the terminal's OSC 0 escape.\n *\n * Fires at most once per session. Failures are swallowed silently.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { Api, Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { getPackageDir } from \"../../config.js\";\nimport type { ModelRegistry } from \"../../core/model-registry.js\";\nimport type { TabTitleSettings } from \"../../core/settings-manager.js\";\nimport { parseAgentFrontmatter, resolveModelForSubagentSpawn } from \"../../core/tools/subagent.js\";\n\nconst DEFAULT_TRIGGER_AFTER = 3;\nconst MAX_TITLE_LENGTH = 30;\nconst TITLE_GENERATION_TIMEOUT_MS = 60_000;\n\nconst TITLE_PROMPT =\n\t\"Summarize this session's task in ≤30 characters for a terminal tab title. \" +\n\t\"Output ONLY the title text, nothing else. No quotes, no explanation.\";\n\nexport interface TabTitleDeps {\n\t/** Set the terminal tab title (OSC 0). */\n\tsetTitle: (title: string) => void;\n\t/** Get the current session messages (for context). */\n\tgetMessages: () => Array<{ role: string; content?: unknown }>;\n\t/** Get the current model (parent session model — used as fallback). */\n\tgetModel: () => Model<Api> | undefined;\n\t/** Get model registry for API key resolution. */\n\tgetModelRegistry: () => ModelRegistry;\n\t/** Get the parent provider name. */\n\tgetProvider: () => string | undefined;\n}\n\nexport class TabTitleGenerator {\n\tprivate toolCallCount = 0;\n\tprivate fired = false;\n\tprivate readonly threshold: number;\n\n\tconstructor(\n\t\tprivate readonly settings: TabTitleSettings | undefined,\n\t\tprivate readonly deps: TabTitleDeps,\n\t) {\n\t\tthis.threshold = settings?.triggerAfter ?? DEFAULT_TRIGGER_AFTER;\n\t}\n\n\t/** Whether this generator is enabled. */\n\tget enabled(): boolean {\n\t\treturn this.settings?.enabled !== false;\n\t}\n\n\t/**\n\t * Called on each tool_execution_end event. Increments the counter and fires\n\t * the title generation when threshold is reached.\n\t */\n\tonToolEnd(): void {\n\t\tif (this.fired || !this.enabled) return;\n\n\t\tthis.toolCallCount++;\n\t\tif (this.toolCallCount >= this.threshold) {\n\t\t\tthis.fired = true;\n\t\t\t// Fire-and-forget — never surfaces errors to the user\n\t\t\tthis.generateTitle().catch(() => {});\n\t\t}\n\t}\n\n\t/** Exposed for testing — the current tool call count. */\n\tget currentCount(): number {\n\t\treturn this.toolCallCount;\n\t}\n\n\t/** Exposed for testing — whether the title has been generated. */\n\tget hasFired(): boolean {\n\t\treturn this.fired;\n\t}\n\n\tprivate async generateTitle(): Promise<void> {\n\t\t// Single timeout bounds the entire pipeline (model probing + API key + LLM call)\n\t\tconst signal = AbortSignal.timeout(TITLE_GENERATION_TIMEOUT_MS);\n\n\t\tconst model = await this.resolveModel(signal);\n\t\tif (!model) return;\n\n\t\tconst registry = this.deps.getModelRegistry();\n\t\tconst apiKey = await registry.getApiKey(model);\n\n\t\tconst userContext = this.buildContext();\n\t\tif (!userContext) return;\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: TITLE_PROMPT,\n\t\t\tmessages: [{ role: \"user\", content: userContext, timestamp: Date.now() }],\n\t\t};\n\n\t\tconst response = await completeSimple(model, context, {\n\t\t\tapiKey,\n\t\t\tmaxRetryDelayMs: 0,\n\t\t\tsignal,\n\t\t});\n\n\t\tconst title = this.sanitizeTitle(response);\n\t\tif (title) {\n\t\t\tthis.deps.setTitle(`dreb - ${title}`);\n\t\t}\n\t}\n\n\tprivate async resolveModel(signal?: AbortSignal): Promise<Model<Api> | undefined> {\n\t\t// Try to get the Explore agent's model fallback list\n\t\tconst exploreModels = this.getExploreAgentModels();\n\t\tconst parentModel = this.deps.getModel();\n\t\tconst parentProvider = this.deps.getProvider();\n\t\tconst registry = this.deps.getModelRegistry();\n\n\t\tif (exploreModels) {\n\t\t\tconst resolution = await resolveModelForSubagentSpawn(\n\t\t\t\texploreModels,\n\t\t\t\tparentProvider,\n\t\t\t\tregistry,\n\t\t\t\tparentModel?.id,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (resolution.ok) {\n\t\t\t\t// Find the resolved model in registry\n\t\t\t\tconst available = registry.getAvailable();\n\t\t\t\tconst found = available.find((m) => m.id === resolution.modelId);\n\t\t\t\tif (found) return found;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to parent session model\n\t\treturn parentModel;\n\t}\n\n\tprivate getExploreAgentModels(): string | string[] | undefined {\n\t\ttry {\n\t\t\tconst agentFile = join(getPackageDir(), \"agents\", \"explore.md\");\n\t\t\tconst content = readFileSync(agentFile, \"utf-8\");\n\t\t\tconst parsed = parseAgentFrontmatter(content);\n\t\t\tif (parsed.ok && parsed.config.model) {\n\t\t\t\treturn parsed.config.model;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Fall through to parent model\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate buildContext(): string | undefined {\n\t\tconst messages = this.deps.getMessages();\n\t\tif (messages.length === 0) return undefined;\n\n\t\t// Extract the first user message for context\n\t\tconst firstUser = messages.find((m) => m.role === \"user\");\n\t\tif (!firstUser) return undefined;\n\n\t\tconst content =\n\t\t\ttypeof firstUser.content === \"string\"\n\t\t\t\t? firstUser.content\n\t\t\t\t: Array.isArray(firstUser.content)\n\t\t\t\t\t? firstUser.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\\n\")\n\t\t\t\t\t: \"\";\n\n\t\tif (!content) return undefined;\n\n\t\t// Truncate long messages — the LLM only needs a summary\n\t\tconst truncated = content.length > 500 ? `${content.slice(0, 500)}...` : content;\n\t\treturn `Session task:\\n${truncated}`;\n\t}\n\n\t/** Clean up LLM response to a usable tab title. */\n\tprivate sanitizeTitle(response: unknown): string | undefined {\n\t\tif (!response || typeof response !== \"object\") return undefined;\n\n\t\tconst msg = response as { content?: Array<{ type: string; text?: string }> };\n\t\tif (!msg.content || !Array.isArray(msg.content)) return undefined;\n\n\t\tconst textPart = msg.content.find((c) => c.type === \"text\");\n\t\tif (!textPart?.text) return undefined;\n\n\t\tlet title = textPart.text.trim();\n\t\t// Strip surrounding quotes if present\n\t\tif ((title.startsWith('\"') && title.endsWith('\"')) || (title.startsWith(\"'\") && title.endsWith(\"'\"))) {\n\t\t\ttitle = title.slice(1, -1).trim();\n\t\t}\n\t\t// Remove newlines\n\t\ttitle = title.replace(/[\\r\\n]+/g, \" \").trim();\n\t\t// Truncate to max length\n\t\tif (title.length > MAX_TITLE_LENGTH) {\n\t\t\ttitle = title.slice(0, MAX_TITLE_LENGTH);\n\t\t}\n\t\treturn title || undefined;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generates a terminal tab title from session context after a threshold
|
|
3
|
+
* number of tool calls. Uses a lightweight single-shot LLM call to produce a
|
|
4
|
+
* concise ≤30 character title, then sets it via the terminal's OSC 0 escape.
|
|
5
|
+
*
|
|
6
|
+
* Fires at most once per session. Failures are swallowed silently.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { completeSimple } from "@dreb/ai";
|
|
11
|
+
import { getPackageDir } from "../../config.js";
|
|
12
|
+
import { parseAgentFrontmatter, resolveModelForSubagentSpawn } from "../../core/tools/subagent.js";
|
|
13
|
+
const DEFAULT_TRIGGER_AFTER = 3;
|
|
14
|
+
const MAX_TITLE_LENGTH = 30;
|
|
15
|
+
const TITLE_GENERATION_TIMEOUT_MS = 60_000;
|
|
16
|
+
const TITLE_PROMPT = "Summarize this session's task in ≤30 characters for a terminal tab title. " +
|
|
17
|
+
"Output ONLY the title text, nothing else. No quotes, no explanation.";
|
|
18
|
+
export class TabTitleGenerator {
|
|
19
|
+
settings;
|
|
20
|
+
deps;
|
|
21
|
+
toolCallCount = 0;
|
|
22
|
+
fired = false;
|
|
23
|
+
threshold;
|
|
24
|
+
constructor(settings, deps) {
|
|
25
|
+
this.settings = settings;
|
|
26
|
+
this.deps = deps;
|
|
27
|
+
this.threshold = settings?.triggerAfter ?? DEFAULT_TRIGGER_AFTER;
|
|
28
|
+
}
|
|
29
|
+
/** Whether this generator is enabled. */
|
|
30
|
+
get enabled() {
|
|
31
|
+
return this.settings?.enabled !== false;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Called on each tool_execution_end event. Increments the counter and fires
|
|
35
|
+
* the title generation when threshold is reached.
|
|
36
|
+
*/
|
|
37
|
+
onToolEnd() {
|
|
38
|
+
if (this.fired || !this.enabled)
|
|
39
|
+
return;
|
|
40
|
+
this.toolCallCount++;
|
|
41
|
+
if (this.toolCallCount >= this.threshold) {
|
|
42
|
+
this.fired = true;
|
|
43
|
+
// Fire-and-forget — never surfaces errors to the user
|
|
44
|
+
this.generateTitle().catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Exposed for testing — the current tool call count. */
|
|
48
|
+
get currentCount() {
|
|
49
|
+
return this.toolCallCount;
|
|
50
|
+
}
|
|
51
|
+
/** Exposed for testing — whether the title has been generated. */
|
|
52
|
+
get hasFired() {
|
|
53
|
+
return this.fired;
|
|
54
|
+
}
|
|
55
|
+
async generateTitle() {
|
|
56
|
+
// Single timeout bounds the entire pipeline (model probing + API key + LLM call)
|
|
57
|
+
const signal = AbortSignal.timeout(TITLE_GENERATION_TIMEOUT_MS);
|
|
58
|
+
const model = await this.resolveModel(signal);
|
|
59
|
+
if (!model)
|
|
60
|
+
return;
|
|
61
|
+
const registry = this.deps.getModelRegistry();
|
|
62
|
+
const apiKey = await registry.getApiKey(model);
|
|
63
|
+
const userContext = this.buildContext();
|
|
64
|
+
if (!userContext)
|
|
65
|
+
return;
|
|
66
|
+
const context = {
|
|
67
|
+
systemPrompt: TITLE_PROMPT,
|
|
68
|
+
messages: [{ role: "user", content: userContext, timestamp: Date.now() }],
|
|
69
|
+
};
|
|
70
|
+
const response = await completeSimple(model, context, {
|
|
71
|
+
apiKey,
|
|
72
|
+
maxRetryDelayMs: 0,
|
|
73
|
+
signal,
|
|
74
|
+
});
|
|
75
|
+
const title = this.sanitizeTitle(response);
|
|
76
|
+
if (title) {
|
|
77
|
+
this.deps.setTitle(`dreb - ${title}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async resolveModel(signal) {
|
|
81
|
+
// Try to get the Explore agent's model fallback list
|
|
82
|
+
const exploreModels = this.getExploreAgentModels();
|
|
83
|
+
const parentModel = this.deps.getModel();
|
|
84
|
+
const parentProvider = this.deps.getProvider();
|
|
85
|
+
const registry = this.deps.getModelRegistry();
|
|
86
|
+
if (exploreModels) {
|
|
87
|
+
const resolution = await resolveModelForSubagentSpawn(exploreModels, parentProvider, registry, parentModel?.id, signal);
|
|
88
|
+
if (resolution.ok) {
|
|
89
|
+
// Find the resolved model in registry
|
|
90
|
+
const available = registry.getAvailable();
|
|
91
|
+
const found = available.find((m) => m.id === resolution.modelId);
|
|
92
|
+
if (found)
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Fall back to parent session model
|
|
97
|
+
return parentModel;
|
|
98
|
+
}
|
|
99
|
+
getExploreAgentModels() {
|
|
100
|
+
try {
|
|
101
|
+
const agentFile = join(getPackageDir(), "agents", "explore.md");
|
|
102
|
+
const content = readFileSync(agentFile, "utf-8");
|
|
103
|
+
const parsed = parseAgentFrontmatter(content);
|
|
104
|
+
if (parsed.ok && parsed.config.model) {
|
|
105
|
+
return parsed.config.model;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Fall through to parent model
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
buildContext() {
|
|
114
|
+
const messages = this.deps.getMessages();
|
|
115
|
+
if (messages.length === 0)
|
|
116
|
+
return undefined;
|
|
117
|
+
// Extract the first user message for context
|
|
118
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
119
|
+
if (!firstUser)
|
|
120
|
+
return undefined;
|
|
121
|
+
const content = typeof firstUser.content === "string"
|
|
122
|
+
? firstUser.content
|
|
123
|
+
: Array.isArray(firstUser.content)
|
|
124
|
+
? firstUser.content
|
|
125
|
+
.filter((c) => c.type === "text")
|
|
126
|
+
.map((c) => c.text)
|
|
127
|
+
.join("\n")
|
|
128
|
+
: "";
|
|
129
|
+
if (!content)
|
|
130
|
+
return undefined;
|
|
131
|
+
// Truncate long messages — the LLM only needs a summary
|
|
132
|
+
const truncated = content.length > 500 ? `${content.slice(0, 500)}...` : content;
|
|
133
|
+
return `Session task:\n${truncated}`;
|
|
134
|
+
}
|
|
135
|
+
/** Clean up LLM response to a usable tab title. */
|
|
136
|
+
sanitizeTitle(response) {
|
|
137
|
+
if (!response || typeof response !== "object")
|
|
138
|
+
return undefined;
|
|
139
|
+
const msg = response;
|
|
140
|
+
if (!msg.content || !Array.isArray(msg.content))
|
|
141
|
+
return undefined;
|
|
142
|
+
const textPart = msg.content.find((c) => c.type === "text");
|
|
143
|
+
if (!textPart?.text)
|
|
144
|
+
return undefined;
|
|
145
|
+
let title = textPart.text.trim();
|
|
146
|
+
// Strip surrounding quotes if present
|
|
147
|
+
if ((title.startsWith('"') && title.endsWith('"')) || (title.startsWith("'") && title.endsWith("'"))) {
|
|
148
|
+
title = title.slice(1, -1).trim();
|
|
149
|
+
}
|
|
150
|
+
// Remove newlines
|
|
151
|
+
title = title.replace(/[\r\n]+/g, " ").trim();
|
|
152
|
+
// Truncate to max length
|
|
153
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
154
|
+
title = title.slice(0, MAX_TITLE_LENGTH);
|
|
155
|
+
}
|
|
156
|
+
return title || undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=tab-title.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tab-title.js","sourceRoot":"","sources":["../../../src/modes/interactive/tab-title.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Auto-generates a terminal tab title from session context after a threshold\n * number of tool calls. Uses a lightweight single-shot LLM call to produce a\n * concise ≤30 character title, then sets it via the terminal's OSC 0 escape.\n *\n * Fires at most once per session. Failures are swallowed silently.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { Api, Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { getPackageDir } from \"../../config.js\";\nimport type { ModelRegistry } from \"../../core/model-registry.js\";\nimport type { TabTitleSettings } from \"../../core/settings-manager.js\";\nimport { parseAgentFrontmatter, resolveModelForSubagentSpawn } from \"../../core/tools/subagent.js\";\n\nconst DEFAULT_TRIGGER_AFTER = 3;\nconst MAX_TITLE_LENGTH = 30;\nconst TITLE_GENERATION_TIMEOUT_MS = 60_000;\n\nconst TITLE_PROMPT =\n\t\"Summarize this session's task in ≤30 characters for a terminal tab title. \" +\n\t\"Output ONLY the title text, nothing else. No quotes, no explanation.\";\n\nexport interface TabTitleDeps {\n\t/** Set the terminal tab title (OSC 0). */\n\tsetTitle: (title: string) => void;\n\t/** Get the current session messages (for context). */\n\tgetMessages: () => Array<{ role: string; content?: unknown }>;\n\t/** Get the current model (parent session model — used as fallback). */\n\tgetModel: () => Model<Api> | undefined;\n\t/** Get model registry for API key resolution. */\n\tgetModelRegistry: () => ModelRegistry;\n\t/** Get the parent provider name. */\n\tgetProvider: () => string | undefined;\n}\n\nexport class TabTitleGenerator {\n\tprivate toolCallCount = 0;\n\tprivate fired = false;\n\tprivate readonly threshold: number;\n\n\tconstructor(\n\t\tprivate readonly settings: TabTitleSettings | undefined,\n\t\tprivate readonly deps: TabTitleDeps,\n\t) {\n\t\tthis.threshold = settings?.triggerAfter ?? DEFAULT_TRIGGER_AFTER;\n\t}\n\n\t/** Whether this generator is enabled. */\n\tget enabled(): boolean {\n\t\treturn this.settings?.enabled !== false;\n\t}\n\n\t/**\n\t * Called on each tool_execution_end event. Increments the counter and fires\n\t * the title generation when threshold is reached.\n\t */\n\tonToolEnd(): void {\n\t\tif (this.fired || !this.enabled) return;\n\n\t\tthis.toolCallCount++;\n\t\tif (this.toolCallCount >= this.threshold) {\n\t\t\tthis.fired = true;\n\t\t\t// Fire-and-forget — never surfaces errors to the user\n\t\t\tthis.generateTitle().catch(() => {});\n\t\t}\n\t}\n\n\t/** Exposed for testing — the current tool call count. */\n\tget currentCount(): number {\n\t\treturn this.toolCallCount;\n\t}\n\n\t/** Exposed for testing — whether the title has been generated. */\n\tget hasFired(): boolean {\n\t\treturn this.fired;\n\t}\n\n\tprivate async generateTitle(): Promise<void> {\n\t\t// Single timeout bounds the entire pipeline (model probing + API key + LLM call)\n\t\tconst signal = AbortSignal.timeout(TITLE_GENERATION_TIMEOUT_MS);\n\n\t\tconst model = await this.resolveModel(signal);\n\t\tif (!model) return;\n\n\t\tconst registry = this.deps.getModelRegistry();\n\t\tconst apiKey = await registry.getApiKey(model);\n\n\t\tconst userContext = this.buildContext();\n\t\tif (!userContext) return;\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: TITLE_PROMPT,\n\t\t\tmessages: [{ role: \"user\", content: userContext, timestamp: Date.now() }],\n\t\t};\n\n\t\tconst response = await completeSimple(model, context, {\n\t\t\tapiKey,\n\t\t\tmaxRetryDelayMs: 0,\n\t\t\tsignal,\n\t\t});\n\n\t\tconst title = this.sanitizeTitle(response);\n\t\tif (title) {\n\t\t\tthis.deps.setTitle(`dreb - ${title}`);\n\t\t}\n\t}\n\n\tprivate async resolveModel(signal?: AbortSignal): Promise<Model<Api> | undefined> {\n\t\t// Try to get the Explore agent's model fallback list\n\t\tconst exploreModels = this.getExploreAgentModels();\n\t\tconst parentModel = this.deps.getModel();\n\t\tconst parentProvider = this.deps.getProvider();\n\t\tconst registry = this.deps.getModelRegistry();\n\n\t\tif (exploreModels) {\n\t\t\tconst resolution = await resolveModelForSubagentSpawn(\n\t\t\t\texploreModels,\n\t\t\t\tparentProvider,\n\t\t\t\tregistry,\n\t\t\t\tparentModel?.id,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (resolution.ok) {\n\t\t\t\t// Find the resolved model in registry\n\t\t\t\tconst available = registry.getAvailable();\n\t\t\t\tconst found = available.find((m) => m.id === resolution.modelId);\n\t\t\t\tif (found) return found;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to parent session model\n\t\treturn parentModel;\n\t}\n\n\tprivate getExploreAgentModels(): string | string[] | undefined {\n\t\ttry {\n\t\t\tconst agentFile = join(getPackageDir(), \"agents\", \"explore.md\");\n\t\t\tconst content = readFileSync(agentFile, \"utf-8\");\n\t\t\tconst parsed = parseAgentFrontmatter(content);\n\t\t\tif (parsed.ok && parsed.config.model) {\n\t\t\t\treturn parsed.config.model;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Fall through to parent model\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate buildContext(): string | undefined {\n\t\tconst messages = this.deps.getMessages();\n\t\tif (messages.length === 0) return undefined;\n\n\t\t// Extract the first user message for context\n\t\tconst firstUser = messages.find((m) => m.role === \"user\");\n\t\tif (!firstUser) return undefined;\n\n\t\tconst content =\n\t\t\ttypeof firstUser.content === \"string\"\n\t\t\t\t? firstUser.content\n\t\t\t\t: Array.isArray(firstUser.content)\n\t\t\t\t\t? firstUser.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\\n\")\n\t\t\t\t\t: \"\";\n\n\t\tif (!content) return undefined;\n\n\t\t// Truncate long messages — the LLM only needs a summary\n\t\tconst truncated = content.length > 500 ? `${content.slice(0, 500)}...` : content;\n\t\treturn `Session task:\\n${truncated}`;\n\t}\n\n\t/** Clean up LLM response to a usable tab title. */\n\tprivate sanitizeTitle(response: unknown): string | undefined {\n\t\tif (!response || typeof response !== \"object\") return undefined;\n\n\t\tconst msg = response as { content?: Array<{ type: string; text?: string }> };\n\t\tif (!msg.content || !Array.isArray(msg.content)) return undefined;\n\n\t\tconst textPart = msg.content.find((c) => c.type === \"text\");\n\t\tif (!textPart?.text) return undefined;\n\n\t\tlet title = textPart.text.trim();\n\t\t// Strip surrounding quotes if present\n\t\tif ((title.startsWith('\"') && title.endsWith('\"')) || (title.startsWith(\"'\") && title.endsWith(\"'\"))) {\n\t\t\ttitle = title.slice(1, -1).trim();\n\t\t}\n\t\t// Remove newlines\n\t\ttitle = title.replace(/[\\r\\n]+/g, \" \").trim();\n\t\t// Truncate to max length\n\t\tif (title.length > MAX_TITLE_LENGTH) {\n\t\t\ttitle = title.slice(0, MAX_TITLE_LENGTH);\n\t\t}\n\t\treturn title || undefined;\n\t}\n}\n"]}
|
package/docs/settings.md
CHANGED
|
@@ -47,6 +47,24 @@ Edit directly or use `/settings` for common options.
|
|
|
47
47
|
| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
|
|
48
48
|
| `showHardwareCursor` | boolean | `false` | Show terminal cursor |
|
|
49
49
|
|
|
50
|
+
### Tab Title
|
|
51
|
+
|
|
52
|
+
| Setting | Type | Default | Description |
|
|
53
|
+
|---------|------|---------|-------------|
|
|
54
|
+
| `tabTitle.enabled` | boolean | `true` | Auto-generate terminal tab title from session task |
|
|
55
|
+
| `tabTitle.triggerAfter` | number | `3` | Number of tool calls before generating title |
|
|
56
|
+
|
|
57
|
+
After the configured number of tool calls, dreb fires a single background LLM call to summarize the session's task into a short (≤30 character) terminal tab title, then sets it via OSC 0. Only fires once per session. If the LLM call fails, the default title remains.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"tabTitle": {
|
|
62
|
+
"enabled": true,
|
|
63
|
+
"triggerAfter": 3
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
50
68
|
### Compaction
|
|
51
69
|
|
|
52
70
|
| Setting | Type | Default | Description |
|