@oh-my-pi/pi-coding-agent 1.338.0 → 1.341.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/CHANGELOG.md +60 -1
- package/package.json +3 -3
- package/src/cli/args.ts +8 -0
- package/src/core/agent-session.ts +32 -14
- package/src/core/export-html/index.ts +48 -15
- package/src/core/export-html/template.html +3 -11
- package/src/core/mcp/client.ts +43 -16
- package/src/core/mcp/config.ts +152 -6
- package/src/core/mcp/index.ts +6 -2
- package/src/core/mcp/loader.ts +30 -3
- package/src/core/mcp/manager.ts +69 -10
- package/src/core/mcp/types.ts +9 -3
- package/src/core/model-resolver.ts +101 -0
- package/src/core/sdk.ts +65 -18
- package/src/core/session-manager.ts +117 -14
- package/src/core/settings-manager.ts +107 -19
- package/src/core/title-generator.ts +94 -0
- package/src/core/tools/bash.ts +1 -2
- package/src/core/tools/edit-diff.ts +2 -2
- package/src/core/tools/edit.ts +43 -5
- package/src/core/tools/grep.ts +3 -2
- package/src/core/tools/index.ts +73 -13
- package/src/core/tools/lsp/client.ts +45 -20
- package/src/core/tools/lsp/config.ts +708 -34
- package/src/core/tools/lsp/index.ts +423 -23
- package/src/core/tools/lsp/types.ts +5 -0
- package/src/core/tools/task/bundled-agents/explore.md +1 -1
- package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
- package/src/core/tools/task/model-resolver.ts +52 -3
- package/src/core/tools/write.ts +67 -4
- package/src/index.ts +5 -0
- package/src/main.ts +23 -2
- package/src/modes/interactive/components/model-selector.ts +96 -18
- package/src/modes/interactive/components/session-selector.ts +20 -7
- package/src/modes/interactive/components/settings-defs.ts +59 -2
- package/src/modes/interactive/components/settings-selector.ts +8 -11
- package/src/modes/interactive/components/tool-execution.ts +18 -0
- package/src/modes/interactive/components/tree-selector.ts +2 -2
- package/src/modes/interactive/components/welcome.ts +40 -3
- package/src/modes/interactive/interactive-mode.ts +87 -10
- package/src/core/export-html/vendor/highlight.min.js +0 -1213
- package/src/core/export-html/vendor/marked.min.js +0 -6
|
@@ -28,6 +28,7 @@ export interface SessionHeader {
|
|
|
28
28
|
type: "session";
|
|
29
29
|
version?: number; // v1 sessions don't have this
|
|
30
30
|
id: string;
|
|
31
|
+
title?: string; // Auto-generated title from first message
|
|
31
32
|
timestamp: string;
|
|
32
33
|
cwd: string;
|
|
33
34
|
parentSession?: string;
|
|
@@ -56,8 +57,10 @@ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
|
56
57
|
|
|
57
58
|
export interface ModelChangeEntry extends SessionEntryBase {
|
|
58
59
|
type: "model_change";
|
|
59
|
-
provider
|
|
60
|
-
|
|
60
|
+
/** Model in "provider/modelId" format */
|
|
61
|
+
model: string;
|
|
62
|
+
/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
|
|
63
|
+
role?: string;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
@@ -149,12 +152,14 @@ export interface SessionTreeNode {
|
|
|
149
152
|
export interface SessionContext {
|
|
150
153
|
messages: AgentMessage[];
|
|
151
154
|
thinkingLevel: string;
|
|
152
|
-
|
|
155
|
+
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
156
|
+
models: Record<string, string>;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
export interface SessionInfo {
|
|
156
160
|
path: string;
|
|
157
161
|
id: string;
|
|
162
|
+
title?: string;
|
|
158
163
|
created: Date;
|
|
159
164
|
modified: Date;
|
|
160
165
|
messageCount: number;
|
|
@@ -290,7 +295,7 @@ export function buildSessionContext(
|
|
|
290
295
|
let leaf: SessionEntry | undefined;
|
|
291
296
|
if (leafId === null) {
|
|
292
297
|
// Explicitly null - return no messages (navigated to before first entry)
|
|
293
|
-
return { messages: [], thinkingLevel: "off",
|
|
298
|
+
return { messages: [], thinkingLevel: "off", models: {} };
|
|
294
299
|
}
|
|
295
300
|
if (leafId) {
|
|
296
301
|
leaf = byId.get(leafId);
|
|
@@ -301,7 +306,7 @@ export function buildSessionContext(
|
|
|
301
306
|
}
|
|
302
307
|
|
|
303
308
|
if (!leaf) {
|
|
304
|
-
return { messages: [], thinkingLevel: "off",
|
|
309
|
+
return { messages: [], thinkingLevel: "off", models: {} };
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
// Walk from leaf to root, collecting path
|
|
@@ -314,16 +319,21 @@ export function buildSessionContext(
|
|
|
314
319
|
|
|
315
320
|
// Extract settings and find compaction
|
|
316
321
|
let thinkingLevel = "off";
|
|
317
|
-
|
|
322
|
+
const models: Record<string, string> = {};
|
|
318
323
|
let compaction: CompactionEntry | null = null;
|
|
319
324
|
|
|
320
325
|
for (const entry of path) {
|
|
321
326
|
if (entry.type === "thinking_level_change") {
|
|
322
327
|
thinkingLevel = entry.thinkingLevel;
|
|
323
328
|
} else if (entry.type === "model_change") {
|
|
324
|
-
|
|
329
|
+
// New format: { model: "provider/id", role?: string }
|
|
330
|
+
if (entry.model) {
|
|
331
|
+
const role = entry.role ?? "default";
|
|
332
|
+
models[role] = entry.model;
|
|
333
|
+
}
|
|
325
334
|
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
326
|
-
|
|
335
|
+
// Infer default model from assistant messages
|
|
336
|
+
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
327
337
|
} else if (entry.type === "compaction") {
|
|
328
338
|
compaction = entry;
|
|
329
339
|
}
|
|
@@ -379,7 +389,7 @@ export function buildSessionContext(
|
|
|
379
389
|
}
|
|
380
390
|
}
|
|
381
391
|
|
|
382
|
-
return { messages, thinkingLevel,
|
|
392
|
+
return { messages, thinkingLevel, models };
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
/**
|
|
@@ -454,6 +464,67 @@ export function findMostRecentSession(sessionDir: string): string | null {
|
|
|
454
464
|
}
|
|
455
465
|
}
|
|
456
466
|
|
|
467
|
+
/** Recent session info for display */
|
|
468
|
+
export interface RecentSessionInfo {
|
|
469
|
+
name: string;
|
|
470
|
+
path: string;
|
|
471
|
+
timeAgo: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Format a time difference as a human-readable string */
|
|
475
|
+
function formatTimeAgo(date: Date): string {
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const diffMs = now - date.getTime();
|
|
478
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
479
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
480
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
481
|
+
|
|
482
|
+
if (diffMins < 1) return "just now";
|
|
483
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
484
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
485
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
486
|
+
return date.toLocaleDateString();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Get recent sessions for display in welcome screen */
|
|
490
|
+
export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
|
|
491
|
+
try {
|
|
492
|
+
const files = readdirSync(sessionDir)
|
|
493
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
494
|
+
.map((f) => join(sessionDir, f))
|
|
495
|
+
.filter(isValidSessionFile)
|
|
496
|
+
.map((path) => {
|
|
497
|
+
const stat = statSync(path);
|
|
498
|
+
// Try to get session title or id from first line
|
|
499
|
+
let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
|
|
500
|
+
try {
|
|
501
|
+
const content = readFileSync(path, "utf-8");
|
|
502
|
+
const firstLine = content.split("\n")[0];
|
|
503
|
+
if (firstLine) {
|
|
504
|
+
const header = JSON.parse(firstLine) as SessionHeader;
|
|
505
|
+
if (header.type === "session") {
|
|
506
|
+
// Prefer title over id
|
|
507
|
+
name = header.title ?? header.id ?? name;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// Use filename as fallback
|
|
512
|
+
}
|
|
513
|
+
return { path, name, mtime: stat.mtime };
|
|
514
|
+
})
|
|
515
|
+
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
|
516
|
+
.slice(0, limit);
|
|
517
|
+
|
|
518
|
+
return files.map((f) => ({
|
|
519
|
+
name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
|
|
520
|
+
path: f.path,
|
|
521
|
+
timeAgo: formatTimeAgo(f.mtime),
|
|
522
|
+
}));
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
457
528
|
/**
|
|
458
529
|
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
459
530
|
*
|
|
@@ -467,6 +538,7 @@ export function findMostRecentSession(sessionDir: string): string | null {
|
|
|
467
538
|
*/
|
|
468
539
|
export class SessionManager {
|
|
469
540
|
private sessionId: string = "";
|
|
541
|
+
private sessionTitle: string | undefined;
|
|
470
542
|
private sessionFile: string | undefined;
|
|
471
543
|
private sessionDir: string;
|
|
472
544
|
private cwd: string;
|
|
@@ -499,6 +571,7 @@ export class SessionManager {
|
|
|
499
571
|
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
500
572
|
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
501
573
|
this.sessionId = header?.id ?? crypto.randomUUID();
|
|
574
|
+
this.sessionTitle = header?.title;
|
|
502
575
|
|
|
503
576
|
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
504
577
|
this._rewriteFile();
|
|
@@ -579,6 +652,31 @@ export class SessionManager {
|
|
|
579
652
|
return this.sessionFile;
|
|
580
653
|
}
|
|
581
654
|
|
|
655
|
+
getSessionTitle(): string | undefined {
|
|
656
|
+
return this.sessionTitle;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
setSessionTitle(title: string): void {
|
|
660
|
+
this.sessionTitle = title;
|
|
661
|
+
// Update the session file header with the title
|
|
662
|
+
if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
|
|
663
|
+
try {
|
|
664
|
+
const content = readFileSync(this.sessionFile, "utf-8");
|
|
665
|
+
const lines = content.split("\n");
|
|
666
|
+
if (lines.length > 0) {
|
|
667
|
+
const header = JSON.parse(lines[0]) as SessionHeader;
|
|
668
|
+
if (header.type === "session") {
|
|
669
|
+
header.title = title;
|
|
670
|
+
lines[0] = JSON.stringify(header);
|
|
671
|
+
writeFileSync(this.sessionFile, lines.join("\n"));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
// Ignore errors updating title
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
582
680
|
_persist(entry: SessionEntry): void {
|
|
583
681
|
if (!this.persist || !this.sessionFile) return;
|
|
584
682
|
|
|
@@ -633,15 +731,19 @@ export class SessionManager {
|
|
|
633
731
|
return entry.id;
|
|
634
732
|
}
|
|
635
733
|
|
|
636
|
-
/**
|
|
637
|
-
|
|
734
|
+
/**
|
|
735
|
+
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
|
|
736
|
+
* @param model Model in "provider/modelId" format
|
|
737
|
+
* @param role Optional role (default: "default")
|
|
738
|
+
*/
|
|
739
|
+
appendModelChange(model: string, role?: string): string {
|
|
638
740
|
const entry: ModelChangeEntry = {
|
|
639
741
|
type: "model_change",
|
|
640
742
|
id: generateId(this.byId),
|
|
641
743
|
parentId: this.leafId,
|
|
642
744
|
timestamp: new Date().toISOString(),
|
|
643
|
-
|
|
644
|
-
|
|
745
|
+
model,
|
|
746
|
+
role,
|
|
645
747
|
};
|
|
646
748
|
this._appendEntry(entry);
|
|
647
749
|
return entry.id;
|
|
@@ -1061,7 +1163,7 @@ export class SessionManager {
|
|
|
1061
1163
|
if (lines.length === 0) continue;
|
|
1062
1164
|
|
|
1063
1165
|
// Check first line for valid session header
|
|
1064
|
-
let header: { type: string; id: string; timestamp: string } | null = null;
|
|
1166
|
+
let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
|
|
1065
1167
|
try {
|
|
1066
1168
|
const first = JSON.parse(lines[0]);
|
|
1067
1169
|
if (first.type === "session" && first.id) {
|
|
@@ -1107,6 +1209,7 @@ export class SessionManager {
|
|
|
1107
1209
|
sessions.push({
|
|
1108
1210
|
path: file,
|
|
1109
1211
|
id: header.id,
|
|
1212
|
+
title: header.title,
|
|
1110
1213
|
created: new Date(header.timestamp),
|
|
1111
1214
|
modified: stats.mtime,
|
|
1112
1215
|
messageCount,
|
|
@@ -47,12 +47,27 @@ export interface BashInterceptorSettings {
|
|
|
47
47
|
enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export interface MCPSettings {
|
|
51
|
+
enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LspSettings {
|
|
55
|
+
formatOnWrite?: boolean; // default: true (format files using LSP after write tool writes code files)
|
|
56
|
+
diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
|
|
57
|
+
diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EditSettings {
|
|
61
|
+
fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
export interface Settings {
|
|
51
65
|
lastChangelogVersion?: string;
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
/** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
67
|
+
modelRoles?: Record<string, string>;
|
|
54
68
|
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
55
69
|
queueMode?: "all" | "one-at-a-time";
|
|
70
|
+
interruptMode?: "immediate" | "wait";
|
|
56
71
|
theme?: string;
|
|
57
72
|
compaction?: CompactionSettings;
|
|
58
73
|
branchSummary?: BranchSummarySettings;
|
|
@@ -67,6 +82,9 @@ export interface Settings {
|
|
|
67
82
|
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
|
|
68
83
|
exa?: ExaSettings;
|
|
69
84
|
bashInterceptor?: BashInterceptorSettings;
|
|
85
|
+
mcp?: MCPSettings;
|
|
86
|
+
lsp?: LspSettings;
|
|
87
|
+
edit?: EditSettings;
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
|
@@ -195,28 +213,29 @@ export class SettingsManager {
|
|
|
195
213
|
this.save();
|
|
196
214
|
}
|
|
197
215
|
|
|
198
|
-
|
|
199
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Get model for a role. Returns "provider/modelId" string or undefined.
|
|
218
|
+
*/
|
|
219
|
+
getModelRole(role: string): string | undefined {
|
|
220
|
+
return this.settings.modelRoles?.[role];
|
|
200
221
|
}
|
|
201
222
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
setDefaultModel(modelId: string): void {
|
|
212
|
-
this.globalSettings.defaultModel = modelId;
|
|
223
|
+
/**
|
|
224
|
+
* Set model for a role. Model should be "provider/modelId" format.
|
|
225
|
+
*/
|
|
226
|
+
setModelRole(role: string, model: string): void {
|
|
227
|
+
if (!this.globalSettings.modelRoles) {
|
|
228
|
+
this.globalSettings.modelRoles = {};
|
|
229
|
+
}
|
|
230
|
+
this.globalSettings.modelRoles[role] = model;
|
|
213
231
|
this.save();
|
|
214
232
|
}
|
|
215
233
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Get all model roles.
|
|
236
|
+
*/
|
|
237
|
+
getModelRoles(): Record<string, string> {
|
|
238
|
+
return { ...this.settings.modelRoles };
|
|
220
239
|
}
|
|
221
240
|
|
|
222
241
|
getQueueMode(): "all" | "one-at-a-time" {
|
|
@@ -228,6 +247,15 @@ export class SettingsManager {
|
|
|
228
247
|
this.save();
|
|
229
248
|
}
|
|
230
249
|
|
|
250
|
+
getInterruptMode(): "immediate" | "wait" {
|
|
251
|
+
return this.settings.interruptMode || "immediate";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
setInterruptMode(mode: "immediate" | "wait"): void {
|
|
255
|
+
this.globalSettings.interruptMode = mode;
|
|
256
|
+
this.save();
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
getTheme(): string | undefined {
|
|
232
260
|
return this.settings.theme;
|
|
233
261
|
}
|
|
@@ -457,4 +485,64 @@ export class SettingsManager {
|
|
|
457
485
|
this.globalSettings.bashInterceptor.enabled = enabled;
|
|
458
486
|
this.save();
|
|
459
487
|
}
|
|
488
|
+
|
|
489
|
+
getMCPProjectConfigEnabled(): boolean {
|
|
490
|
+
return this.settings.mcp?.enableProjectConfig ?? true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
setMCPProjectConfigEnabled(enabled: boolean): void {
|
|
494
|
+
if (!this.globalSettings.mcp) {
|
|
495
|
+
this.globalSettings.mcp = {};
|
|
496
|
+
}
|
|
497
|
+
this.globalSettings.mcp.enableProjectConfig = enabled;
|
|
498
|
+
this.save();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
getLspFormatOnWrite(): boolean {
|
|
502
|
+
return this.settings.lsp?.formatOnWrite ?? true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setLspFormatOnWrite(enabled: boolean): void {
|
|
506
|
+
if (!this.globalSettings.lsp) {
|
|
507
|
+
this.globalSettings.lsp = {};
|
|
508
|
+
}
|
|
509
|
+
this.globalSettings.lsp.formatOnWrite = enabled;
|
|
510
|
+
this.save();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
getLspDiagnosticsOnWrite(): boolean {
|
|
514
|
+
return this.settings.lsp?.diagnosticsOnWrite ?? true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
setLspDiagnosticsOnWrite(enabled: boolean): void {
|
|
518
|
+
if (!this.globalSettings.lsp) {
|
|
519
|
+
this.globalSettings.lsp = {};
|
|
520
|
+
}
|
|
521
|
+
this.globalSettings.lsp.diagnosticsOnWrite = enabled;
|
|
522
|
+
this.save();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
getLspDiagnosticsOnEdit(): boolean {
|
|
526
|
+
return this.settings.lsp?.diagnosticsOnEdit ?? false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
setLspDiagnosticsOnEdit(enabled: boolean): void {
|
|
530
|
+
if (!this.globalSettings.lsp) {
|
|
531
|
+
this.globalSettings.lsp = {};
|
|
532
|
+
}
|
|
533
|
+
this.globalSettings.lsp.diagnosticsOnEdit = enabled;
|
|
534
|
+
this.save();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
getEditFuzzyMatch(): boolean {
|
|
538
|
+
return this.settings.edit?.fuzzyMatch ?? true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
setEditFuzzyMatch(enabled: boolean): void {
|
|
542
|
+
if (!this.globalSettings.edit) {
|
|
543
|
+
this.globalSettings.edit = {};
|
|
544
|
+
}
|
|
545
|
+
this.globalSettings.edit.fuzzyMatch = enabled;
|
|
546
|
+
this.save();
|
|
547
|
+
}
|
|
460
548
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate session titles using a smol, fast model.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
6
|
+
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import type { ModelRegistry } from "./model-registry.js";
|
|
8
|
+
import { findSmolModel } from "./model-resolver.js";
|
|
9
|
+
|
|
10
|
+
const TITLE_SYSTEM_PROMPT = `Generate a very short title (3-6 words) for a coding session based on the user's first message. The title should capture the main task or topic. Output ONLY the title, nothing else. No quotes, no punctuation at the end.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
- "Fix TypeScript compilation errors"
|
|
14
|
+
- "Add user authentication"
|
|
15
|
+
- "Refactor database queries"
|
|
16
|
+
- "Debug payment webhook"
|
|
17
|
+
- "Update React components"`;
|
|
18
|
+
|
|
19
|
+
const MAX_INPUT_CHARS = 2000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Find the best available model for title generation.
|
|
23
|
+
* Uses the configured smol model if set, otherwise auto-discovers using priority chain.
|
|
24
|
+
*
|
|
25
|
+
* @param registry Model registry
|
|
26
|
+
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
|
|
27
|
+
*/
|
|
28
|
+
export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<any> | null> {
|
|
29
|
+
const model = await findSmolModel(registry, savedSmolModel);
|
|
30
|
+
return model ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate a title for a session based on the first user message.
|
|
35
|
+
*
|
|
36
|
+
* @param firstMessage The first user message
|
|
37
|
+
* @param registry Model registry
|
|
38
|
+
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
|
|
39
|
+
*/
|
|
40
|
+
export async function generateSessionTitle(
|
|
41
|
+
firstMessage: string,
|
|
42
|
+
registry: ModelRegistry,
|
|
43
|
+
savedSmolModel?: string,
|
|
44
|
+
): Promise<string | null> {
|
|
45
|
+
const model = await findTitleModel(registry, savedSmolModel);
|
|
46
|
+
if (!model) return null;
|
|
47
|
+
|
|
48
|
+
const apiKey = await registry.getApiKey(model);
|
|
49
|
+
if (!apiKey) return null;
|
|
50
|
+
|
|
51
|
+
// Truncate message if too long
|
|
52
|
+
const truncatedMessage =
|
|
53
|
+
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await completeSimple(
|
|
57
|
+
model,
|
|
58
|
+
{
|
|
59
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
60
|
+
messages: [{ role: "user", content: truncatedMessage, timestamp: Date.now() }],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
apiKey,
|
|
64
|
+
maxTokens: 30,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Extract title from response text content
|
|
69
|
+
let title = "";
|
|
70
|
+
for (const content of response.content) {
|
|
71
|
+
if (content.type === "text") {
|
|
72
|
+
title += content.text;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
title = title.trim();
|
|
76
|
+
|
|
77
|
+
if (!title || title.length > 60) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Clean up: remove quotes, trailing punctuation
|
|
82
|
+
return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Set the terminal title using ANSI escape sequences.
|
|
90
|
+
*/
|
|
91
|
+
export function setTerminalTitle(title: string): void {
|
|
92
|
+
// OSC 2 sets the window title
|
|
93
|
+
process.stdout.write(`\x1b]2;${title}\x07`);
|
|
94
|
+
}
|
package/src/core/tools/bash.ts
CHANGED
|
@@ -66,8 +66,7 @@ Usage notes:
|
|
|
66
66
|
- If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel.
|
|
67
67
|
- If the commands depend on each other and must run sequentially, use a single bash call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
|
68
68
|
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
|
69
|
-
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
|
70
|
-
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.`,
|
|
69
|
+
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)`,
|
|
71
70
|
parameters: bashSchema,
|
|
72
71
|
execute: async (
|
|
73
72
|
_toolCallId: string,
|
|
@@ -271,7 +271,7 @@ export function formatEditMatchError(
|
|
|
271
271
|
? options.fuzzyMatches && options.fuzzyMatches > 1
|
|
272
272
|
? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
|
|
273
273
|
: `Closest match was below the ${thresholdPercent}% similarity threshold.`
|
|
274
|
-
: "
|
|
274
|
+
: "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
|
|
275
275
|
|
|
276
276
|
return [
|
|
277
277
|
options.allowFuzzy
|
|
@@ -409,7 +409,7 @@ export async function computeEditDiff(
|
|
|
409
409
|
oldText: string,
|
|
410
410
|
newText: string,
|
|
411
411
|
cwd: string,
|
|
412
|
-
fuzzy =
|
|
412
|
+
fuzzy = true,
|
|
413
413
|
): Promise<EditDiffResult | EditDiffError> {
|
|
414
414
|
const absolutePath = resolveToCwd(path, cwd);
|
|
415
415
|
|
package/src/core/tools/edit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
restoreLineEndings,
|
|
13
13
|
stripBom,
|
|
14
14
|
} from "./edit-diff.js";
|
|
15
|
+
import type { FileDiagnosticsResult } from "./lsp/index.js";
|
|
15
16
|
import { resolveToCwd } from "./path-utils.js";
|
|
16
17
|
|
|
17
18
|
const editSchema = Type.Object({
|
|
@@ -27,9 +28,21 @@ export interface EditToolDetails {
|
|
|
27
28
|
diff: string;
|
|
28
29
|
/** Line number of the first change in the new file (for editor navigation) */
|
|
29
30
|
firstChangedLine?: number;
|
|
31
|
+
/** Whether LSP diagnostics were retrieved */
|
|
32
|
+
hasDiagnostics?: boolean;
|
|
33
|
+
/** Diagnostic result (if available) */
|
|
34
|
+
diagnostics?: FileDiagnosticsResult;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
export
|
|
37
|
+
export interface EditToolOptions {
|
|
38
|
+
/** Whether to accept high-confidence fuzzy matches for whitespace/indentation (default: true) */
|
|
39
|
+
fuzzyMatch?: boolean;
|
|
40
|
+
/** Callback to get LSP diagnostics after editing a file */
|
|
41
|
+
getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createEditTool(cwd: string, options: EditToolOptions = {}): AgentTool<typeof editSchema> {
|
|
45
|
+
const allowFuzzy = options.fuzzyMatch ?? true;
|
|
33
46
|
return {
|
|
34
47
|
name: "edit",
|
|
35
48
|
label: "Edit",
|
|
@@ -108,7 +121,7 @@ Usage:
|
|
|
108
121
|
const normalizedNewText = normalizeToLF(newText);
|
|
109
122
|
|
|
110
123
|
const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
|
|
111
|
-
allowFuzzy
|
|
124
|
+
allowFuzzy,
|
|
112
125
|
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
|
|
113
126
|
});
|
|
114
127
|
|
|
@@ -131,7 +144,7 @@ Usage:
|
|
|
131
144
|
reject(
|
|
132
145
|
new Error(
|
|
133
146
|
formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
|
|
134
|
-
allowFuzzy
|
|
147
|
+
allowFuzzy,
|
|
135
148
|
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
|
|
136
149
|
fuzzyMatches: matchOutcome.fuzzyMatches,
|
|
137
150
|
}),
|
|
@@ -179,14 +192,39 @@ Usage:
|
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
|
|
195
|
+
|
|
196
|
+
// Get LSP diagnostics if callback provided
|
|
197
|
+
let diagnosticsResult: FileDiagnosticsResult | undefined;
|
|
198
|
+
if (options.getDiagnostics) {
|
|
199
|
+
try {
|
|
200
|
+
diagnosticsResult = await options.getDiagnostics(absolutePath);
|
|
201
|
+
} catch {
|
|
202
|
+
// Ignore diagnostics errors - don't fail the edit
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build result text
|
|
207
|
+
let resultText = `Successfully replaced text in ${path}.`;
|
|
208
|
+
|
|
209
|
+
// Append diagnostics if available and there are issues
|
|
210
|
+
if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
|
|
211
|
+
resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
|
|
212
|
+
resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
resolve({
|
|
183
216
|
content: [
|
|
184
217
|
{
|
|
185
218
|
type: "text",
|
|
186
|
-
text:
|
|
219
|
+
text: resultText,
|
|
187
220
|
},
|
|
188
221
|
],
|
|
189
|
-
details: {
|
|
222
|
+
details: {
|
|
223
|
+
diff: diffResult.diff,
|
|
224
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
225
|
+
hasDiagnostics: diagnosticsResult?.available ?? false,
|
|
226
|
+
diagnostics: diagnosticsResult,
|
|
227
|
+
},
|
|
190
228
|
});
|
|
191
229
|
} catch (error: any) {
|
|
192
230
|
// Clean up abort handler
|
package/src/core/tools/grep.ts
CHANGED
|
@@ -68,10 +68,11 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
|
|
|
68
68
|
|
|
69
69
|
Usage:
|
|
70
70
|
- ALWAYS use grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a bash command. The grep tool has been optimized for correct permissions and access.
|
|
71
|
+
- Searches recursively by default - no need for -r flag
|
|
71
72
|
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
|
72
|
-
- Filter files with glob parameter (e.g., "*.
|
|
73
|
+
- Filter files with glob parameter (e.g., "*.ts", "**/*.spec.ts") or type parameter (e.g., "ts", "py", "rust") - equivalent to grep's --include
|
|
73
74
|
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
|
|
74
|
-
- Use
|
|
75
|
+
- Pagination: Use headLimit to limit results (like \`| head -N\`), offset to skip first N results
|
|
75
76
|
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
|
|
76
77
|
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\``,
|
|
77
78
|
parameters: grepSchema,
|