@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.5
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 +68 -0
- package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-tt9k4jpr.md} +68 -0
- package/dist/cli.js +3146 -3153
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/config/model-discovery.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/debug/report-bundle.d.ts +7 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -3
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +22 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
- package/dist/types/mcp/config.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +3 -1
- package/dist/types/registry/agent-lifecycle.d.ts +11 -0
- package/dist/types/registry/agent-registry.d.ts +4 -0
- package/dist/types/session/agent-session-types.d.ts +0 -2
- package/dist/types/session/agent-session.d.ts +20 -0
- package/dist/types/session/date-cwd-reminder.d.ts +22 -0
- package/dist/types/session/irc-bridge.d.ts +2 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-loader.d.ts +18 -11
- package/dist/types/session/session-maintenance.d.ts +6 -2
- package/dist/types/session/session-manager.d.ts +19 -3
- package/dist/types/session/session-tools.d.ts +0 -1
- package/dist/types/session/turn-recovery.d.ts +7 -7
- package/dist/types/tools/browser/attach.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +2 -2
- package/dist/types/tools/browser.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +6 -0
- package/dist/types/tools/hub/types.d.ts +6 -0
- package/dist/types/vibe/runtime.d.ts +3 -3
- package/package.json +13 -13
- package/src/auto-thinking/classifier.ts +37 -23
- package/src/cli/models-cli.ts +1 -1
- package/src/commit/analysis/conventional.ts +15 -9
- package/src/commit/analysis/summary.ts +15 -9
- package/src/commit/changelog/generate.ts +15 -9
- package/src/commit/map-reduce/map-phase.ts +3 -19
- package/src/commit/map-reduce/reduce-phase.ts +15 -9
- package/src/config/model-discovery.ts +3 -3
- package/src/config/model-registry.ts +29 -16
- package/src/config/settings-schema.ts +13 -0
- package/src/debug/report-bundle.ts +25 -59
- package/src/discovery/claude-plugins.ts +2 -1
- package/src/discovery/claude.ts +4 -2
- package/src/discovery/helpers.ts +5 -0
- package/src/edit/renderer.ts +48 -19
- package/src/extensibility/extensions/loader.ts +9 -5
- package/src/extensibility/extensions/runner.ts +124 -21
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/extensibility/extensions/wrapper.ts +16 -10
- package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
- package/src/extensibility/plugins/marketplace/manager.ts +15 -18
- package/src/irc/bus.ts +1 -1
- package/src/launch/broker.ts +8 -1
- package/src/launch/presence.ts +77 -1
- package/src/lsp/client.ts +35 -3
- package/src/lsp/clients/biome-client.ts +47 -93
- package/src/lsp/servers.ts +17 -11
- package/src/lsp/tool.ts +2 -4
- package/src/lsp/workspace-diagnostics.ts +24 -2
- package/src/mcp/config.ts +50 -6
- package/src/memories/index.ts +29 -25
- package/src/mnemopi/backend.ts +13 -11
- package/src/modes/components/ask-dialog.ts +25 -5
- package/src/modes/components/tool-execution.ts +10 -6
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +52 -32
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/modes/controllers/tan-command-controller.ts +1 -0
- package/src/modes/interactive-mode.ts +49 -10
- package/src/modes/theme/defaults/birch.json +2 -2
- package/src/prompts/system/checkpoint-active-notice.md +5 -0
- package/src/prompts/system/date-cwd-reminder.md +3 -0
- package/src/prompts/system/project-prompt.md +0 -1
- package/src/prompts/system/rewind-report.md +1 -3
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/rewind.md +1 -13
- package/src/registry/agent-lifecycle.ts +34 -0
- package/src/registry/agent-registry.ts +27 -2
- package/src/registry/persisted-agents.ts +40 -20
- package/src/sdk.ts +32 -2
- package/src/session/agent-session-types.ts +0 -2
- package/src/session/agent-session.ts +178 -46
- package/src/session/date-cwd-reminder.ts +77 -0
- package/src/session/irc-bridge.ts +5 -0
- package/src/session/messages.ts +5 -1
- package/src/session/session-loader.ts +106 -64
- package/src/session/session-maintenance.ts +189 -115
- package/src/session/session-manager.ts +142 -35
- package/src/session/session-persistence.ts +4 -4
- package/src/session/session-storage.ts +18 -5
- package/src/session/session-tools.ts +5 -10
- package/src/session/turn-recovery.ts +102 -20
- package/src/session/unexpected-stop-classifier.ts +33 -21
- package/src/system-prompt.ts +0 -5
- package/src/task/executor.ts +4 -15
- package/src/task/persisted-revive.ts +3 -6
- package/src/tools/ask.ts +10 -3
- package/src/tools/browser/attach.ts +80 -25
- package/src/tools/browser/launch.ts +44 -15
- package/src/tools/browser/relay/daemon.ts +3 -7
- package/src/tools/browser/render.ts +1 -1
- package/src/tools/browser/shared-daemon.ts +3 -7
- package/src/tools/browser.ts +5 -5
- package/src/tools/builtin-names.ts +7 -3
- package/src/tools/checkpoint.ts +1 -7
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +20 -3
- package/src/tools/hub/types.ts +6 -0
- package/src/tools/render-utils.ts +53 -21
- package/src/tools/think.ts +15 -3
- package/src/tts/speech-enhancer.ts +19 -14
- package/src/utils/commit-message-generator.ts +14 -12
- package/src/utils/title-generator.ts +23 -19
- package/src/vibe/runtime.ts +32 -17
package/src/launch/presence.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
4
5
|
import { daemonRuntimeDir } from "./paths";
|
|
5
6
|
|
|
6
7
|
const CLIENTS_DIR = "clients";
|
|
8
|
+
const BROKER_PID_FILE = "broker.pid";
|
|
9
|
+
const GLOBAL_DAEMON_DIR = "global";
|
|
10
|
+
/**
|
|
11
|
+
* Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
|
|
12
|
+
* deleting a scope whose owning omp process is mid-startup (token written, broker
|
|
13
|
+
* not yet spawned, presence not yet registered). The leak this reclaims is a
|
|
14
|
+
* weeks-scale accumulation, so a few minutes of slack costs nothing.
|
|
15
|
+
*/
|
|
16
|
+
const DAEMON_RUNTIME_STALE_GRACE_MS = 5 * 60_000;
|
|
7
17
|
|
|
8
18
|
/** Handle keeping one omp process registered in a project daemon scope. */
|
|
9
19
|
export interface DaemonProjectPresence {
|
|
@@ -80,3 +90,69 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<
|
|
|
80
90
|
}
|
|
81
91
|
return live;
|
|
82
92
|
}
|
|
93
|
+
|
|
94
|
+
/** Whether a runtime dir's recorded broker PID is still alive. */
|
|
95
|
+
async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
|
|
96
|
+
let raw: unknown;
|
|
97
|
+
try {
|
|
98
|
+
raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json();
|
|
99
|
+
} catch {
|
|
100
|
+
return false; // Missing or malformed broker.pid => no owning broker.
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
process.kill(raw.pid, 0);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remove sibling project daemon runtime directories whose broker is dead and
|
|
115
|
+
* whose client-presence set is empty, reclaiming the disk that short-lived
|
|
116
|
+
* project directories leave behind (issue #8674).
|
|
117
|
+
*
|
|
118
|
+
* Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
|
|
119
|
+
* is absent/dead, no live client presence remains, and it has been untouched
|
|
120
|
+
* for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
|
|
121
|
+
* and the machine-global daemon container are always skipped.
|
|
122
|
+
*/
|
|
123
|
+
export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
|
|
124
|
+
const root = path.dirname(currentRuntimeDir);
|
|
125
|
+
if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
|
|
126
|
+
const current = path.resolve(currentRuntimeDir);
|
|
127
|
+
let entries: Dirent[];
|
|
128
|
+
try {
|
|
129
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!isEnoent(error)) {
|
|
132
|
+
logger.warn("Failed to scan daemon runtime root for pruning", {
|
|
133
|
+
root,
|
|
134
|
+
error: error instanceof Error ? error.message : String(error),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
|
|
142
|
+
const dir = path.join(root, entry.name);
|
|
143
|
+
if (path.resolve(dir) === current) continue;
|
|
144
|
+
try {
|
|
145
|
+
const stat = await fs.stat(dir);
|
|
146
|
+
if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue;
|
|
147
|
+
if (await hasLiveDaemonBroker(dir)) continue;
|
|
148
|
+
if (await hasLiveDaemonProjectPresence(dir)) continue;
|
|
149
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (isEnoent(error)) continue;
|
|
152
|
+
logger.warn("Failed to prune dead daemon runtime dir", {
|
|
153
|
+
dir,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -62,13 +62,32 @@ export function setIdleTimeout(ms: number | null | undefined): void {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Whether a client may be reaped by the idle checker.
|
|
67
|
+
*
|
|
68
|
+
* A client with in-flight requests is *busy*, never idle. `lastActivity` is
|
|
69
|
+
* stamped when a request is written, not while it is outstanding, so a single
|
|
70
|
+
* request that runs longer than the idle timeout used to look like silence:
|
|
71
|
+
* the checker tore the client down mid-flight and `shutdownClientInstance`
|
|
72
|
+
* rejected the caller's still-pending promise with "LSP client shutdown"
|
|
73
|
+
* (issue #8390). Requests that settle refresh `lastActivity`, so a client
|
|
74
|
+
* becomes eligible again only after the final one lands and the full idle
|
|
75
|
+
* window then elapses.
|
|
76
|
+
*
|
|
77
|
+
* Exported for tests; the idle checker is the only production caller.
|
|
78
|
+
*/
|
|
79
|
+
export function isIdleClient(client: LspClient, now: number, timeoutMs: number): boolean {
|
|
80
|
+
if (client.pendingRequests.size > 0) return false;
|
|
81
|
+
return now - client.lastActivity > timeoutMs;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
function startIdleChecker(): void {
|
|
66
85
|
if (idleCheckInterval) return;
|
|
67
86
|
idleCheckInterval = setInterval(() => {
|
|
68
87
|
if (!idleTimeoutMs) return;
|
|
69
88
|
const now = Date.now();
|
|
70
89
|
for (const [key, client] of Array.from(clients.entries())) {
|
|
71
|
-
if (
|
|
90
|
+
if (isIdleClient(client, now, idleTimeoutMs)) {
|
|
72
91
|
void shutdownClient(key);
|
|
73
92
|
}
|
|
74
93
|
}
|
|
@@ -746,7 +765,13 @@ function commandBasename(command: string): string {
|
|
|
746
765
|
return separator === -1 ? command : command.slice(separator + 1);
|
|
747
766
|
}
|
|
748
767
|
|
|
749
|
-
|
|
768
|
+
/**
|
|
769
|
+
* True when this client speaks the rust-analyzer protocol, detected by the
|
|
770
|
+
* command basename (`rust-analyzer[.exe]`) of the configured or resolved
|
|
771
|
+
* binary. Callers use it to gate rust-analyzer-only requests such as
|
|
772
|
+
* `rust-analyzer/reloadWorkspace` (see {@link reloadServer}).
|
|
773
|
+
*/
|
|
774
|
+
export function isRustAnalyzerClient(client: LspClient): boolean {
|
|
750
775
|
return (
|
|
751
776
|
commandBasename(client.config.command) === "rust-analyzer" ||
|
|
752
777
|
(client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
|
|
@@ -1456,15 +1481,22 @@ export async function sendRequest(
|
|
|
1456
1481
|
}
|
|
1457
1482
|
}
|
|
1458
1483
|
|
|
1459
|
-
// Register pending request with timeout wrapper
|
|
1484
|
+
// Register pending request with timeout wrapper.
|
|
1485
|
+
// Settling stamps `lastActivity`: the idle window must be measured from when
|
|
1486
|
+
// the exchange finished, not from when it started. Without this a request
|
|
1487
|
+
// that outlives the timeout would leave the client instantly reapable the
|
|
1488
|
+
// moment it lands, so the next idle sweep would kill a server that had just
|
|
1489
|
+
// answered (issue #8390).
|
|
1460
1490
|
client.pendingRequests.set(id, {
|
|
1461
1491
|
resolve: result => {
|
|
1462
1492
|
if (timeout) clearTimeout(timeout);
|
|
1493
|
+
client.lastActivity = Date.now();
|
|
1463
1494
|
cleanup();
|
|
1464
1495
|
resolve(result);
|
|
1465
1496
|
},
|
|
1466
1497
|
reject: err => {
|
|
1467
1498
|
if (timeout) clearTimeout(timeout);
|
|
1499
|
+
client.lastActivity = Date.now();
|
|
1468
1500
|
cleanup();
|
|
1469
1501
|
reject(err);
|
|
1470
1502
|
},
|
|
@@ -14,14 +14,21 @@ interface BiomeJsonOutput {
|
|
|
14
14
|
diagnostics: BiomeDiagnostic[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A single diagnostic from Biome's `--reporter=json` output (Biome 2.x).
|
|
19
|
+
*
|
|
20
|
+
* Positions are 1-indexed `{ line, column }` pairs; the path is a plain string
|
|
21
|
+
* relative to the CLI's cwd. Older releases used byte-offset `span`s, which
|
|
22
|
+
* this client no longer parses.
|
|
23
|
+
*/
|
|
17
24
|
interface BiomeDiagnostic {
|
|
18
25
|
category: string; // e.g., "lint/correctness/noUnusedVariables"
|
|
19
|
-
severity: "error" | "warning" | "info" | "hint"
|
|
20
|
-
|
|
26
|
+
severity: string; // "error" | "warning" | "info" | "hint"
|
|
27
|
+
message: string;
|
|
21
28
|
location?: {
|
|
22
|
-
path?:
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
path?: string;
|
|
30
|
+
start?: { line: number; column: number };
|
|
31
|
+
end?: { line: number; column: number };
|
|
25
32
|
};
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -29,43 +36,6 @@ interface BiomeDiagnostic {
|
|
|
29
36
|
// Helpers
|
|
30
37
|
// =============================================================================
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
* Convert byte offsets to line:column positions in a single pass over the source.
|
|
34
|
-
*/
|
|
35
|
-
function offsetsToPositions(source: string, offsets: number[]): Map<number, { line: number; column: number }> {
|
|
36
|
-
const sorted = [...new Set(offsets)].sort((a, b) => a - b);
|
|
37
|
-
const result = new Map<number, { line: number; column: number }>();
|
|
38
|
-
let line = 1;
|
|
39
|
-
let column = 1;
|
|
40
|
-
let byteIndex = 0;
|
|
41
|
-
let next = 0;
|
|
42
|
-
|
|
43
|
-
for (const ch of source) {
|
|
44
|
-
if (next >= sorted.length) break;
|
|
45
|
-
const cp = ch.codePointAt(0) as number;
|
|
46
|
-
const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
47
|
-
while (next < sorted.length && byteIndex + byteLen > sorted[next]) {
|
|
48
|
-
result.set(sorted[next], { line, column });
|
|
49
|
-
next++;
|
|
50
|
-
}
|
|
51
|
-
if (ch === "\n") {
|
|
52
|
-
line++;
|
|
53
|
-
column = 1;
|
|
54
|
-
} else {
|
|
55
|
-
column++;
|
|
56
|
-
}
|
|
57
|
-
byteIndex += byteLen;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Offsets at or past end-of-file map to the final position.
|
|
61
|
-
while (next < sorted.length) {
|
|
62
|
-
result.set(sorted[next], { line, column });
|
|
63
|
-
next++;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
39
|
/**
|
|
70
40
|
* Parse Biome severity to LSP DiagnosticSeverity.
|
|
71
41
|
*/
|
|
@@ -176,8 +146,6 @@ export class BiomeClient implements LinterClient {
|
|
|
176
146
|
* Parse Biome's JSON output into LSP Diagnostics.
|
|
177
147
|
*/
|
|
178
148
|
#parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] {
|
|
179
|
-
const diagnostics: Diagnostic[] = [];
|
|
180
|
-
|
|
181
149
|
let parsed: BiomeJsonOutput;
|
|
182
150
|
try {
|
|
183
151
|
parsed = JSON.parse(jsonOutput);
|
|
@@ -186,61 +154,36 @@ export class BiomeClient implements LinterClient {
|
|
|
186
154
|
cwd: this.cwd,
|
|
187
155
|
file: targetFile,
|
|
188
156
|
});
|
|
189
|
-
return
|
|
157
|
+
return [];
|
|
190
158
|
}
|
|
191
159
|
|
|
160
|
+
const emitted = parsed.diagnostics ?? [];
|
|
192
161
|
const target = path.resolve(targetFile);
|
|
193
|
-
const
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const location = diag.location;
|
|
199
|
-
if (!location?.path?.file) continue;
|
|
200
|
-
|
|
201
|
-
// Resolve file path
|
|
202
|
-
const diagFile = path.isAbsolute(location.path.file)
|
|
203
|
-
? location.path.file
|
|
204
|
-
: path.join(this.cwd, location.path.file);
|
|
205
|
-
|
|
206
|
-
// Only include diagnostics for the target file
|
|
207
|
-
if (path.resolve(diagFile) !== target) {
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
162
|
+
const diagnostics: Diagnostic[] = [];
|
|
163
|
+
// Biome's JSON reporter is experimental and may reshape its output in
|
|
164
|
+
// patch releases. Track whether any diagnostic carried a usable location
|
|
165
|
+
// so a schema drift surfaces as a warning instead of a silent empty list.
|
|
166
|
+
let sawUsableLocation = false;
|
|
210
167
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
}
|
|
168
|
+
for (const diag of emitted) {
|
|
169
|
+
const location = diag.location;
|
|
170
|
+
const filePath = location?.path;
|
|
171
|
+
if (!filePath) continue;
|
|
172
|
+
sawUsableLocation = true;
|
|
218
173
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
positionsBySource.set(source, offsetsToPositions(source, offsets));
|
|
222
|
-
}
|
|
174
|
+
// Biome reports paths relative to its cwd.
|
|
175
|
+
const diagFile = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath);
|
|
223
176
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
let startLine = 1;
|
|
227
|
-
let startColumn = 1;
|
|
228
|
-
let endLine = 1;
|
|
229
|
-
let endColumn = 1;
|
|
177
|
+
// Only include diagnostics for the target file.
|
|
178
|
+
if (path.resolve(diagFile) !== target) continue;
|
|
230
179
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
if (endPos) {
|
|
240
|
-
endLine = endPos.line;
|
|
241
|
-
endColumn = endPos.column;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
180
|
+
// Biome positions are 1-indexed; LSP ranges are 0-indexed.
|
|
181
|
+
const start = location.start;
|
|
182
|
+
const end = location.end ?? start;
|
|
183
|
+
const startLine = start?.line ?? 1;
|
|
184
|
+
const startColumn = start?.column ?? 1;
|
|
185
|
+
const endLine = end?.line ?? startLine;
|
|
186
|
+
const endColumn = end?.column ?? startColumn;
|
|
244
187
|
|
|
245
188
|
diagnostics.push({
|
|
246
189
|
range: {
|
|
@@ -248,12 +191,23 @@ export class BiomeClient implements LinterClient {
|
|
|
248
191
|
end: { line: endLine - 1, character: endColumn - 1 },
|
|
249
192
|
},
|
|
250
193
|
severity: parseSeverity(diag.severity),
|
|
251
|
-
message: diag.
|
|
194
|
+
message: diag.message,
|
|
252
195
|
source: "biome",
|
|
253
196
|
code: diag.category,
|
|
254
197
|
});
|
|
255
198
|
}
|
|
256
199
|
|
|
200
|
+
// Non-empty output whose diagnostics all lacked a recognizable location
|
|
201
|
+
// means the reporter schema changed out from under us — warn loudly
|
|
202
|
+
// instead of masking the regression as "no lint issues".
|
|
203
|
+
if (emitted.length > 0 && !sawUsableLocation) {
|
|
204
|
+
warnBiomeOnce(
|
|
205
|
+
`schema:${this.cwd}`,
|
|
206
|
+
"Biome diagnostics had no recognizable location; reporter schema may have changed",
|
|
207
|
+
{ cwd: this.cwd, file: targetFile, count: emitted.length },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
257
211
|
return diagnostics;
|
|
258
212
|
}
|
|
259
213
|
|
package/src/lsp/servers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getActiveClients,
|
|
5
5
|
getActiveOrPendingClient,
|
|
6
6
|
getOrCreateClient,
|
|
7
|
+
isRustAnalyzerClient,
|
|
7
8
|
type LspServerStatus,
|
|
8
9
|
notifySaved,
|
|
9
10
|
sendNotification,
|
|
@@ -256,17 +257,22 @@ export function isMethodNotFoundError(err: unknown): boolean {
|
|
|
256
257
|
|
|
257
258
|
export async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
|
|
258
259
|
throwIfAborted(signal);
|
|
259
|
-
// rust-analyzer exposes a real reload request.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// method
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
260
|
+
// rust-analyzer exposes a real reload request. Only rust-analyzer implements
|
|
261
|
+
// it, so gate the request on the binary (or registered name) rather than
|
|
262
|
+
// probing every server: some servers (Roslyn) crash the whole process on an
|
|
263
|
+
// unknown method instead of replying with method-not-found — killing the
|
|
264
|
+
// server `lsp reload` was meant to refresh (issue #8571, dotnet/roslyn#84890).
|
|
265
|
+
// A caller cancel or tool timeout must propagate, never be mistaken for an
|
|
266
|
+
// unsupported method and swallowed into a bogus "Restarted" (issue #6369).
|
|
267
|
+
if (isRustAnalyzerClient(client) || serverName === "rust-analyzer") {
|
|
268
|
+
try {
|
|
269
|
+
await sendRequest(client, "rust-analyzer/reloadWorkspace", undefined, signal);
|
|
270
|
+
return `Reloaded ${serverName}`;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
throwIfAborted(signal);
|
|
273
|
+
if (!isMethodNotFoundError(err)) throw err;
|
|
274
|
+
// Method not supported — fall through to the generic reload.
|
|
275
|
+
}
|
|
270
276
|
}
|
|
271
277
|
// workspace/didChangeConfiguration is a notification per spec; sending it
|
|
272
278
|
// as a request hangs until the tool deadline on servers that route it to
|
package/src/lsp/tool.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ensureFileOpen,
|
|
22
22
|
getActiveClients,
|
|
23
23
|
getOrCreateClient,
|
|
24
|
+
isRustAnalyzerClient,
|
|
24
25
|
type LspServerStatus,
|
|
25
26
|
refreshFile,
|
|
26
27
|
sendNotification,
|
|
@@ -1075,10 +1076,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1075
1076
|
try {
|
|
1076
1077
|
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
1077
1078
|
const targetFile = resolvedFile;
|
|
1078
|
-
const isRustAnalyzerServer =
|
|
1079
|
-
serverName === "rust-analyzer" ||
|
|
1080
|
-
path.basename(serverConfig.command) === "rust-analyzer" ||
|
|
1081
|
-
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
|
|
1079
|
+
const isRustAnalyzerServer = isRustAnalyzerClient(client) || serverName === "rust-analyzer";
|
|
1082
1080
|
const needsProjectIndex =
|
|
1083
1081
|
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
|
|
1084
1082
|
const rustWorkspaceWait =
|
|
@@ -114,6 +114,17 @@ async function detectProjectType(cwd: string, signal?: AbortSignal): Promise<Pro
|
|
|
114
114
|
return { type: "unknown", description: "Unknown project type" };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** Interpret an empty checker result without mistaking a crash for a clean workspace. */
|
|
118
|
+
export function interpretEmptyDiagnosticsResult(
|
|
119
|
+
exitCode: number,
|
|
120
|
+
signalCode: string | null,
|
|
121
|
+
command: readonly string[],
|
|
122
|
+
): string {
|
|
123
|
+
if (exitCode === 0) return "No issues found";
|
|
124
|
+
const detail = signalCode ? `was killed by ${signalCode}` : `exited with code ${exitCode}`;
|
|
125
|
+
return `Failed to run ${command.join(" ")}: the checker ${detail} without reporting anything, so the workspace was not verified`;
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
/** Run workspace diagnostics command and parse output */
|
|
118
129
|
export async function runWorkspaceDiagnostics(
|
|
119
130
|
cwd: string,
|
|
@@ -146,11 +157,22 @@ export async function runWorkspaceDiagnostics(
|
|
|
146
157
|
new Response(proc.stdout).text(),
|
|
147
158
|
new Response(proc.stderr).text(),
|
|
148
159
|
]);
|
|
149
|
-
await proc.exited;
|
|
160
|
+
const exitCode = await proc.exited;
|
|
150
161
|
throwIfAborted(signal);
|
|
151
162
|
const combined = (stdout + stderr).trim();
|
|
152
163
|
if (!combined) {
|
|
153
|
-
|
|
164
|
+
// A checker that exits non-zero without writing a single byte never
|
|
165
|
+
// inspected the workspace: it failed to start (missing toolchain),
|
|
166
|
+
// crashed, or was killed (OOM). Reporting "No issues found" there
|
|
167
|
+
// tells the agent the workspace is clean when nothing actually
|
|
168
|
+
// checked it. A non-zero exit *with* output is the normal way
|
|
169
|
+
// tsc/cargo/pyright report diagnostics and still falls through to
|
|
170
|
+
// the branch below. Mirrors the exit-status gate
|
|
171
|
+
// `resolveGoWorkspaceDiagnosticsCommand` already applies above.
|
|
172
|
+
return {
|
|
173
|
+
output: interpretEmptyDiagnosticsResult(exitCode, proc.signalCode, projectType.command),
|
|
174
|
+
projectType,
|
|
175
|
+
};
|
|
154
176
|
}
|
|
155
177
|
// Limit output length
|
|
156
178
|
const lines = combined.split("\n");
|
package/src/mcp/config.ts
CHANGED
|
@@ -224,6 +224,43 @@ export function extractExaApiKey(config: MCPServerConfig): string | undefined {
|
|
|
224
224
|
return undefined;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** Exa MCP tools already covered by the native Exa integration. */
|
|
228
|
+
const NATIVE_EXA_MCP_TOOLS: Record<string, true> = { web_search_exa: true };
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Parse the comma-separated `tools` restriction from an Exa MCP config.
|
|
232
|
+
* Returns `null` when the config does not restrict its tool set.
|
|
233
|
+
*/
|
|
234
|
+
function getRequestedExaMcpTools(config: MCPServerConfig): string[] | null {
|
|
235
|
+
const raw = (() => {
|
|
236
|
+
if (config.type === "http" || config.type === "sse") {
|
|
237
|
+
const httpConfig = config as { url?: string };
|
|
238
|
+
if (!httpConfig.url) return undefined;
|
|
239
|
+
try {
|
|
240
|
+
return new URL(httpConfig.url).searchParams.get("tools") ?? undefined;
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!config.type || config.type === "stdio") {
|
|
246
|
+
const stdioConfig = config as { args?: string[] };
|
|
247
|
+
const args = stdioConfig.args ?? [];
|
|
248
|
+
for (let i = 0; i < args.length; i++) {
|
|
249
|
+
if (/^--?tools$/i.test(args[i])) return args[i + 1];
|
|
250
|
+
const match = args[i].match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? args[i].match(/--?tools[=\s]([^\s]+)/i);
|
|
251
|
+
if (match) return match[1];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
})();
|
|
256
|
+
if (!raw) return null;
|
|
257
|
+
const tools = raw
|
|
258
|
+
.split(",")
|
|
259
|
+
.map(tool => tool.trim())
|
|
260
|
+
.filter(tool => tool.length > 0);
|
|
261
|
+
return tools.length > 0 ? tools : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
227
264
|
/** Result of filtering Exa MCP servers */
|
|
228
265
|
export interface ExaFilterResult {
|
|
229
266
|
/** Configs with Exa servers removed */
|
|
@@ -236,7 +273,9 @@ export interface ExaFilterResult {
|
|
|
236
273
|
|
|
237
274
|
/**
|
|
238
275
|
* Filter out Exa MCP servers and extract their API keys.
|
|
239
|
-
* Since we have native Exa integration, we don't need the MCP server
|
|
276
|
+
* Since we have native Exa integration, we don't need the MCP server —
|
|
277
|
+
* unless the config explicitly requests Exa tools the native integration
|
|
278
|
+
* does not provide (e.g. `web_fetch_exa`, `web_search_advanced_exa`).
|
|
240
279
|
*/
|
|
241
280
|
export function filterExaMCPServers(
|
|
242
281
|
configs: Record<string, MCPServerConfig>,
|
|
@@ -248,17 +287,22 @@ export function filterExaMCPServers(
|
|
|
248
287
|
|
|
249
288
|
for (const [name, config] of Object.entries(configs)) {
|
|
250
289
|
if (isExaMCPServer(name, config)) {
|
|
251
|
-
// Extract API key
|
|
290
|
+
// Extract API key for the native Exa integration even when the MCP
|
|
291
|
+
// server is kept below for its extra tools.
|
|
252
292
|
const apiKey = extractExaApiKey(config);
|
|
253
293
|
if (apiKey) {
|
|
254
294
|
exaApiKeys.push(apiKey);
|
|
255
295
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
296
|
+
const requested = getRequestedExaMcpTools(config);
|
|
297
|
+
const hasExtraTools = requested?.some(tool => !NATIVE_EXA_MCP_TOOLS[tool.toLowerCase()]) ?? false;
|
|
298
|
+
if (!hasExtraTools) {
|
|
299
|
+
continue;
|
|
260
300
|
}
|
|
261
301
|
}
|
|
302
|
+
filtered[name] = config;
|
|
303
|
+
if (sources[name]) {
|
|
304
|
+
filteredSources[name] = sources[name];
|
|
305
|
+
}
|
|
262
306
|
}
|
|
263
307
|
|
|
264
308
|
return { configs: filtered, exaApiKeys, sources: filteredSources };
|
package/src/memories/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type * as fsNode from "node:fs";
|
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
6
|
-
import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
|
6
|
+
import { type ApiKey, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
8
8
|
import { getAgentDbPath, getMemoriesDir, isEnoent, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
|
|
9
9
|
|
|
@@ -750,18 +750,20 @@ async function runStage1Job(options: {
|
|
|
750
750
|
response_items_json: truncatedItems,
|
|
751
751
|
});
|
|
752
752
|
|
|
753
|
-
const response = await
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
753
|
+
const response = await retryTransientCompletion(() =>
|
|
754
|
+
completeSimple(
|
|
755
|
+
model,
|
|
756
|
+
{
|
|
757
|
+
systemPrompt: [stageOneSystemTemplate],
|
|
758
|
+
messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
apiKey,
|
|
762
|
+
metadata: options.metadata,
|
|
763
|
+
maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
|
|
764
|
+
reasoning: clampThinkingLevelForModel(model, Effort.Low),
|
|
765
|
+
},
|
|
766
|
+
),
|
|
765
767
|
);
|
|
766
768
|
|
|
767
769
|
if (response.stopReason === "error") {
|
|
@@ -887,18 +889,20 @@ async function runConsolidationModel(options: {
|
|
|
887
889
|
rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000),
|
|
888
890
|
});
|
|
889
891
|
|
|
890
|
-
const response = await
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
892
|
+
const response = await retryTransientCompletion(() =>
|
|
893
|
+
completeSimple(
|
|
894
|
+
model,
|
|
895
|
+
{
|
|
896
|
+
systemPrompt: [consolidationSystemTemplate],
|
|
897
|
+
messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
apiKey,
|
|
901
|
+
metadata: options.metadata,
|
|
902
|
+
maxTokens: 8192,
|
|
903
|
+
reasoning: clampThinkingLevelForModel(model, Effort.Medium),
|
|
904
|
+
},
|
|
905
|
+
),
|
|
902
906
|
);
|
|
903
907
|
if (response.stopReason === "error") {
|
|
904
908
|
throw new Error(response.errorMessage || "phase2 model error");
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
5
5
|
import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
|
|
6
6
|
import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
|
|
@@ -545,16 +545,18 @@ async function resolveMnemopiProviderOptions(
|
|
|
545
545
|
});
|
|
546
546
|
return null;
|
|
547
547
|
}
|
|
548
|
-
const message = await
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
548
|
+
const message = await retryTransientCompletion(() =>
|
|
549
|
+
completeSimple(
|
|
550
|
+
model,
|
|
551
|
+
{
|
|
552
|
+
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
apiKey: modelRegistry.resolver(model, sessionId),
|
|
556
|
+
maxTokens: opts?.maxTokens,
|
|
557
|
+
temperature: opts?.temperature,
|
|
558
|
+
},
|
|
559
|
+
),
|
|
558
560
|
);
|
|
559
561
|
return message.content
|
|
560
562
|
.filter(
|