@oh-my-pi/pi-coding-agent 16.3.4 → 16.3.6
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 +36 -0
- package/dist/cli.js +3522 -3485
- package/dist/types/edit/file-snapshot-store.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +0 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/shared-events.d.ts +8 -1
- package/dist/types/hindsight/content.d.ts +4 -0
- package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
- package/dist/types/internal-urls/types.d.ts +10 -0
- package/dist/types/lsp/config.d.ts +4 -0
- package/dist/types/lsp/edits.d.ts +2 -0
- package/dist/types/mcp/oauth-discovery.d.ts +30 -0
- package/dist/types/modes/components/assistant-message.d.ts +15 -10
- package/dist/types/modes/components/bash-execution.d.ts +6 -0
- package/dist/types/modes/components/eval-execution.d.ts +6 -0
- package/dist/types/modes/components/login-dialog.d.ts +10 -2
- package/dist/types/modes/components/tool-execution.d.ts +3 -13
- package/dist/types/modes/components/transcript-container.d.ts +26 -29
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
- package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/subprocess/worker-runtime.d.ts +11 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/bash.d.ts +0 -2
- package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -2
- package/dist/types/tools/renderers.d.ts +0 -20
- package/dist/types/tools/ssh.d.ts +0 -2
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/utils/git.d.ts +16 -0
- package/package.json +12 -12
- package/src/cli/auth-broker-cli.ts +13 -2
- package/src/cli/tiny-models-cli.ts +12 -5
- package/src/edit/file-snapshot-store.ts +5 -2
- package/src/edit/renderer.ts +0 -5
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/shared-events.ts +9 -1
- package/src/hindsight/content.ts +31 -0
- package/src/internal-urls/artifact-protocol.ts +97 -53
- package/src/internal-urls/types.ts +10 -0
- package/src/lsp/config.ts +15 -0
- package/src/lsp/edits.ts +28 -7
- package/src/lsp/index.ts +46 -4
- package/src/mcp/oauth-discovery.ts +88 -18
- package/src/mnemopi/state.ts +26 -2
- package/src/modes/components/assistant-message.ts +134 -82
- package/src/modes/components/bash-execution.ts +9 -0
- package/src/modes/components/chat-transcript-builder.ts +8 -4
- package/src/modes/components/eval-execution.ts +9 -0
- package/src/modes/components/login-dialog.ts +16 -2
- package/src/modes/components/mcp-add-wizard.ts +9 -1
- package/src/modes/components/tool-execution.ts +4 -50
- package/src/modes/components/transcript-container.ts +82 -458
- package/src/modes/components/tree-selector.ts +9 -3
- package/src/modes/controllers/command-controller.ts +0 -3
- package/src/modes/controllers/event-controller.ts +74 -14
- package/src/modes/controllers/extension-ui-controller.ts +0 -1
- package/src/modes/controllers/input-controller.ts +4 -10
- package/src/modes/controllers/mcp-command-controller.ts +106 -29
- package/src/modes/controllers/selector-controller.ts +9 -1
- package/src/modes/interactive-mode.ts +12 -8
- package/src/modes/rpc/rpc-client.ts +8 -4
- package/src/modes/rpc/rpc-mode.ts +1 -0
- package/src/modes/rpc/rpc-types.ts +13 -1
- package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
- package/src/modes/utils/transcript-render-helpers.ts +40 -13
- package/src/modes/utils/ui-helpers.ts +12 -7
- package/src/prompts/tools/read.md +1 -1
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +148 -1
- package/src/session/session-context.ts +7 -0
- package/src/subprocess/worker-runtime.ts +219 -2
- package/src/task/worktree.ts +28 -6
- package/src/tiny/worker.ts +14 -4
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/bash.ts +0 -4
- package/src/tools/browser/tab-supervisor.ts +47 -3
- package/src/tools/eval-render.ts +0 -20
- package/src/tools/grep.ts +19 -2
- package/src/tools/path-utils.ts +4 -0
- package/src/tools/read.ts +261 -21
- package/src/tools/renderers.ts +0 -20
- package/src/tools/ssh.ts +0 -16
- package/src/tools/write.ts +13 -6
- package/src/utils/git.ts +20 -0
- package/src/utils/open.ts +51 -6
|
@@ -324,6 +324,22 @@ export declare const patch: {
|
|
|
324
324
|
};
|
|
325
325
|
export declare const cherryPick: ((cwd: string, revision: string, signal?: AbortSignal) => Promise<void>) & {
|
|
326
326
|
abort(cwd: string, signal?: AbortSignal): Promise<void>;
|
|
327
|
+
/**
|
|
328
|
+
* Skip the current commit of an in-progress cherry-pick sequence and
|
|
329
|
+
* continue with the rest of the range. Use after {@link isEmptyError}
|
|
330
|
+
* reports the current attempt collapsed to a no-op — the alternative,
|
|
331
|
+
* `--abort`, throws away every remaining commit in the range.
|
|
332
|
+
*/
|
|
333
|
+
skip(cwd: string, signal?: AbortSignal): Promise<void>;
|
|
334
|
+
/**
|
|
335
|
+
* True when a cherry-pick failure was caused by the current commit
|
|
336
|
+
* being empty against HEAD — either redundant with an already-applied
|
|
337
|
+
* change, or auto-resolved to HEAD by a 3-way merge. Callers should
|
|
338
|
+
* `--skip` in this case to advance the sequencer rather than aborting
|
|
339
|
+
* the whole range: an empty commit is not a merge conflict, and any
|
|
340
|
+
* later commits in the range still deserve to land.
|
|
341
|
+
*/
|
|
342
|
+
isEmptyError(err: unknown): boolean;
|
|
327
343
|
};
|
|
328
344
|
export declare const stash: {
|
|
329
345
|
/** Stash working tree + index changes. Returns true when git created a new stash entry. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.6",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,17 +56,17 @@
|
|
|
56
56
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
57
57
|
"@babel/parser": "^7.29.7",
|
|
58
58
|
"@mozilla/readability": "^0.6.0",
|
|
59
|
-
"@oh-my-pi/hashline": "16.3.
|
|
60
|
-
"@oh-my-pi/omp-stats": "16.3.
|
|
61
|
-
"@oh-my-pi/pi-agent-core": "16.3.
|
|
62
|
-
"@oh-my-pi/pi-ai": "16.3.
|
|
63
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
64
|
-
"@oh-my-pi/pi-mnemopi": "16.3.
|
|
65
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
66
|
-
"@oh-my-pi/pi-tui": "16.3.
|
|
67
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
68
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
69
|
-
"@oh-my-pi/snapcompact": "16.3.
|
|
59
|
+
"@oh-my-pi/hashline": "16.3.6",
|
|
60
|
+
"@oh-my-pi/omp-stats": "16.3.6",
|
|
61
|
+
"@oh-my-pi/pi-agent-core": "16.3.6",
|
|
62
|
+
"@oh-my-pi/pi-ai": "16.3.6",
|
|
63
|
+
"@oh-my-pi/pi-catalog": "16.3.6",
|
|
64
|
+
"@oh-my-pi/pi-mnemopi": "16.3.6",
|
|
65
|
+
"@oh-my-pi/pi-natives": "16.3.6",
|
|
66
|
+
"@oh-my-pi/pi-tui": "16.3.6",
|
|
67
|
+
"@oh-my-pi/pi-utils": "16.3.6",
|
|
68
|
+
"@oh-my-pi/pi-wire": "16.3.6",
|
|
69
|
+
"@oh-my-pi/snapcompact": "16.3.6",
|
|
70
70
|
"@opentelemetry/api": "^1.9.1",
|
|
71
71
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
72
72
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
|
@@ -222,8 +222,19 @@ async function runLocalLogin(provider: OAuthProvider): Promise<void> {
|
|
|
222
222
|
// for non-paste-code providers, so this is defense-in-depth on the same gate.
|
|
223
223
|
const usesManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(provider);
|
|
224
224
|
await storage.login(provider, {
|
|
225
|
-
onAuth({ url, instructions }) {
|
|
226
|
-
process.stdout.write(
|
|
225
|
+
onAuth({ url, launchUrl, instructions }) {
|
|
226
|
+
process.stdout.write("\nOpen this URL in your browser:\n");
|
|
227
|
+
// Full URL first so the CLI works from any machine, including SSH
|
|
228
|
+
// sessions where a `launchUrl` (loopback `/launch` on the OMP
|
|
229
|
+
// host) would resolve against the caller's browser and fail.
|
|
230
|
+
// Headless capture is unaffected: it reads the first URL line.
|
|
231
|
+
process.stdout.write(`${url}\n`);
|
|
232
|
+
if (launchUrl && launchUrl !== url) {
|
|
233
|
+
// Local shortcut for the machine running OMP. Terminals or
|
|
234
|
+
// screen-scrapers narrower than the full URL still get an
|
|
235
|
+
// unbroken copy target here.
|
|
236
|
+
process.stdout.write(`Local shortcut (this machine only): ${launchUrl}\n`);
|
|
237
|
+
}
|
|
227
238
|
if (instructions) process.stdout.write(`${instructions}\n`);
|
|
228
239
|
process.stdout.write("\n");
|
|
229
240
|
},
|
|
@@ -35,12 +35,19 @@ function writeLine(text = ""): void {
|
|
|
35
35
|
process.stdout.write(`${text}\n`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const ACTIONABLE_DOWNLOAD_ERROR_LINE = /PI_TINY_|CUDA|cuDNN|cudnn|libcudnn|tiny-title-runtime|onnxruntime-node/i;
|
|
39
|
+
|
|
38
40
|
function downloadErrorSummary(error: string | undefined): string | undefined {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const lines =
|
|
42
|
+
error
|
|
43
|
+
?.split(/\r?\n/)
|
|
44
|
+
.map(line => line.trim().replace(/^Error:\s*/, ""))
|
|
45
|
+
.filter(line => line.length > 0) ?? [];
|
|
46
|
+
const first = lines[0];
|
|
47
|
+
if (!first) return undefined;
|
|
48
|
+
const details = lines.slice(1).filter(line => ACTIONABLE_DOWNLOAD_ERROR_LINE.test(line));
|
|
49
|
+
if (details.length === 0) return first;
|
|
50
|
+
return [first, ...details].join("\n");
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
export function resolveModels(model: string | undefined): TinyLocalModelKey[] {
|
|
@@ -74,17 +74,20 @@ export function canonicalSnapshotKey(absolutePath: string): string {
|
|
|
74
74
|
* Producers that only displayed a slice of the file (range reads, search hits)
|
|
75
75
|
* use this to mint a whole-file tag: the displayed lines stay partial, but the
|
|
76
76
|
* tag fingerprints the entire file so a follow-up edit anchored at any line
|
|
77
|
-
* validates whenever the live file is byte-identical to what was read.
|
|
77
|
+
* validates whenever the live file is byte-identical to what was read. Raw
|
|
78
|
+
* reads pass `seenLines` even though they do not emit a header, letting a prior
|
|
79
|
+
* or later same-content hashline tag inherit the raw range's provenance.
|
|
78
80
|
*/
|
|
79
81
|
export async function recordFileSnapshot(
|
|
80
82
|
session: FileSnapshotStoreOwner,
|
|
81
83
|
absolutePath: string,
|
|
84
|
+
seenLines?: Iterable<number>,
|
|
82
85
|
): Promise<string | undefined> {
|
|
83
86
|
try {
|
|
84
87
|
const file = Bun.file(absolutePath);
|
|
85
88
|
if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
|
|
86
89
|
const normalized = normalizeToLF(await file.text());
|
|
87
|
-
return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
|
90
|
+
return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized, seenLines);
|
|
88
91
|
} catch {
|
|
89
92
|
return undefined;
|
|
90
93
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -696,11 +696,6 @@ function wrapEditRendererLine(line: string, width: number): string[] {
|
|
|
696
696
|
|
|
697
697
|
export const editToolRenderer = {
|
|
698
698
|
mergeCallAndResult: true,
|
|
699
|
-
// Pending preview is a TAIL window of the streamed diff ("… N more lines
|
|
700
|
-
// above" + last rows); the result render re-anchors the block top-first, so
|
|
701
|
-
// committing the preview's settled head would strand a stale call-box
|
|
702
|
-
// fragment in native scrollback.
|
|
703
|
-
provisionalPendingPreview: true,
|
|
704
699
|
|
|
705
700
|
renderCall(
|
|
706
701
|
args: EditRenderArgs,
|
|
@@ -26,6 +26,7 @@ import type * as PiCodingAgent from "../../index";
|
|
|
26
26
|
import type { Theme } from "../../modes/theme/theme";
|
|
27
27
|
import type { ReadonlySessionManager } from "../../session/session-manager";
|
|
28
28
|
import type { TodoItem } from "../../tools/todo";
|
|
29
|
+
import type { RecoveredRetryError } from "../shared-events";
|
|
29
30
|
import type * as TypeBox from "../typebox";
|
|
30
31
|
|
|
31
32
|
/** Alias for clarity */
|
|
@@ -133,6 +134,7 @@ export type CustomToolSessionEvent =
|
|
|
133
134
|
success: boolean;
|
|
134
135
|
attempt: number;
|
|
135
136
|
finalError?: string;
|
|
137
|
+
recoveredErrors?: RecoveredRetryError[];
|
|
136
138
|
}
|
|
137
139
|
| {
|
|
138
140
|
reason: "ttsr_triggered";
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
16
16
|
import type { CompactionPreparation, CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
|
|
17
|
-
import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
17
|
+
import type { AssistantRetryRecovery, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
18
18
|
import type { Rule } from "../capability/rule";
|
|
19
19
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
20
20
|
import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-entries";
|
|
@@ -241,12 +241,20 @@ export interface AutoRetryStartEvent {
|
|
|
241
241
|
errorId?: number;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
export interface RecoveredRetryError {
|
|
245
|
+
entryId: string;
|
|
246
|
+
persistenceKey?: string;
|
|
247
|
+
note: string;
|
|
248
|
+
retryRecovery: AssistantRetryRecovery;
|
|
249
|
+
}
|
|
250
|
+
|
|
244
251
|
/** Fired when auto-retry ends */
|
|
245
252
|
export interface AutoRetryEndEvent {
|
|
246
253
|
type: "auto_retry_end";
|
|
247
254
|
success: boolean;
|
|
248
255
|
attempt: number;
|
|
249
256
|
finalError?: string;
|
|
257
|
+
recoveredErrors?: RecoveredRetryError[];
|
|
250
258
|
}
|
|
251
259
|
|
|
252
260
|
// ============================================================================
|
package/src/hindsight/content.ts
CHANGED
|
@@ -25,6 +25,7 @@ const LEGACY_HINDSIGHT_MEMORIES_REGEX = /<hindsight_memories>[\s\S]*?<\/hindsigh
|
|
|
25
25
|
const LEGACY_RELEVANT_MEMORIES_REGEX = /<relevant_memories>[\s\S]*?<\/relevant_memories>/g;
|
|
26
26
|
const MENTAL_MODELS_REGEX = /<mental_models>[\s\S]*?<\/mental_models>/g;
|
|
27
27
|
|
|
28
|
+
const RETENTION_PROTOCOL_MARKER_REGEX = /^\[(?:role:\s*[-_a-zA-Z0-9]+|[-_a-zA-Z0-9]+:end)\]$/;
|
|
28
29
|
/**
|
|
29
30
|
* Strip `<memories>`, `<mental_models>`, and legacy memory blocks.
|
|
30
31
|
*
|
|
@@ -205,6 +206,32 @@ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscr
|
|
|
205
206
|
return { transcript, messageCount: parts.length };
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
function formatEmbeddableRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
|
|
210
|
+
const parts: string[] = [];
|
|
211
|
+
for (const msg of messages) {
|
|
212
|
+
const content = stripRetentionProtocolMarkers(stripMemoryTags(msg.content)).trim();
|
|
213
|
+
if (!hasSubstantiveContent(content)) continue;
|
|
214
|
+
parts.push(content);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
218
|
+
|
|
219
|
+
const transcript = parts.join("\n\n");
|
|
220
|
+
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
221
|
+
|
|
222
|
+
return { transcript, messageCount: parts.length };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Remove retention framing lines from a stored coding-agent episode transcript. */
|
|
226
|
+
export function stripRetentionProtocolMarkers(content: string): string {
|
|
227
|
+
return content
|
|
228
|
+
.split(/\r?\n/)
|
|
229
|
+
.filter(line => !RETENTION_PROTOCOL_MARKER_REGEX.test(line.trim()))
|
|
230
|
+
.join("\n")
|
|
231
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
232
|
+
.trim();
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
export function prepareRetentionTranscript(
|
|
209
236
|
messages: HindsightMessage[],
|
|
210
237
|
retainFullWindow = false,
|
|
@@ -229,6 +256,10 @@ export function prepareRetentionTranscript(
|
|
|
229
256
|
return formatRetentionMessages(targetMessages);
|
|
230
257
|
}
|
|
231
258
|
|
|
259
|
+
/** Format all retention messages without protocol markers for embedding, FTS, and recall display. */
|
|
260
|
+
export function prepareEmbeddableRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
|
|
261
|
+
return formatEmbeddableRetentionMessages(messages);
|
|
262
|
+
}
|
|
232
263
|
/** Format only user-authored messages for memory fact/entity extraction. */
|
|
233
264
|
export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
|
|
234
265
|
return formatRetentionMessages(messages.filter(message => message.role === "user"));
|
|
@@ -15,75 +15,119 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
|
15
15
|
import { artifactsDirsFromRegistry } from "./registry-helpers";
|
|
16
16
|
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
readonly scheme = "artifact";
|
|
20
|
-
readonly immutable = true;
|
|
18
|
+
const MAX_INLINE_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
/** Filesystem location for a session artifact, resolved without materializing its content. */
|
|
21
|
+
export interface ResolvedArtifactFile {
|
|
22
|
+
id: string;
|
|
23
|
+
path: string;
|
|
24
|
+
size: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArtifactId(url: InternalUrl): string {
|
|
28
|
+
const id = url.rawHost || url.hostname;
|
|
29
|
+
if (!id) {
|
|
30
|
+
throw new Error("artifact:// URL requires a numeric ID: artifact://0");
|
|
31
|
+
}
|
|
32
|
+
if (!/^\d+$/.test(id)) {
|
|
33
|
+
throw new Error(`artifact:// ID must be numeric, got: ${id}`);
|
|
34
|
+
}
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve an `artifact://` URL to its backing file without reading artifact bytes. */
|
|
39
|
+
export async function resolveArtifactFile(url: InternalUrl, context?: ResolveContext): Promise<ResolvedArtifactFile> {
|
|
40
|
+
const id = parseArtifactId(url);
|
|
41
|
+
|
|
42
|
+
// Artifact ids are per-session counters; in multi-session hosts the same
|
|
43
|
+
// id exists in several dirs. Pin resolution to the calling session's
|
|
44
|
+
// artifacts dir first so `artifact://3` means *this* session's #3.
|
|
45
|
+
const dirs = artifactsDirsFromRegistry();
|
|
46
|
+
const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
|
|
47
|
+
if (pinnedDir) {
|
|
48
|
+
const pinnedIndex = dirs.indexOf(pinnedDir);
|
|
49
|
+
if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
|
|
50
|
+
dirs.unshift(pinnedDir);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (dirs.length === 0) {
|
|
54
|
+
throw new Error("No session - artifacts unavailable");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let foundPath: string | undefined;
|
|
58
|
+
let anyDirExists = false;
|
|
59
|
+
const availableIds = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
let files: string[];
|
|
63
|
+
try {
|
|
64
|
+
files = await fs.readdir(dir);
|
|
65
|
+
anyDirExists = true;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (isEnoent(err)) continue;
|
|
68
|
+
throw err;
|
|
26
69
|
}
|
|
27
|
-
|
|
28
|
-
|
|
70
|
+
const match = files.find(f => f.startsWith(`${id}.`));
|
|
71
|
+
if (match) {
|
|
72
|
+
foundPath = path.join(dir, match);
|
|
73
|
+
break;
|
|
29
74
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// artifacts dir first so `artifact://3` means *this* session's #3.
|
|
34
|
-
const dirs = artifactsDirsFromRegistry();
|
|
35
|
-
const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
|
|
36
|
-
if (pinnedDir) {
|
|
37
|
-
const pinnedIndex = dirs.indexOf(pinnedDir);
|
|
38
|
-
if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
|
|
39
|
-
dirs.unshift(pinnedDir);
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
const m = f.match(/^(\d+)\./);
|
|
77
|
+
if (m) availableIds.add(m[1]);
|
|
40
78
|
}
|
|
79
|
+
}
|
|
41
80
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
if (!anyDirExists) {
|
|
82
|
+
throw new Error("No artifacts directory found");
|
|
83
|
+
}
|
|
45
84
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
85
|
+
if (!foundPath) {
|
|
86
|
+
const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
|
|
87
|
+
const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
|
|
88
|
+
throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
|
|
89
|
+
}
|
|
49
90
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
for (const f of files) {
|
|
65
|
-
const m = f.match(/^(\d+)\./);
|
|
66
|
-
if (m) availableIds.add(m[1]);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
91
|
+
const stat = await Bun.file(foundPath).stat();
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
throw new Error(`Artifact ${id} resolved to a directory, not a file`);
|
|
94
|
+
}
|
|
95
|
+
return { id, path: foundPath, size: stat.size };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class ArtifactProtocolHandler implements ProtocolHandler {
|
|
99
|
+
readonly scheme = "artifact";
|
|
100
|
+
readonly immutable = true;
|
|
101
|
+
|
|
102
|
+
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
103
|
+
const artifact = await resolveArtifactFile(url, context);
|
|
69
104
|
|
|
70
|
-
|
|
71
|
-
|
|
105
|
+
// Path-only callers (search/grep, bash URL expansion) never touch the
|
|
106
|
+
// artifact bytes. Return the resource shape so those flows keep working
|
|
107
|
+
// on artifacts of any size — only content materialization is gated.
|
|
108
|
+
if (context?.pathOnly) {
|
|
109
|
+
return {
|
|
110
|
+
url: url.href,
|
|
111
|
+
content: "",
|
|
112
|
+
contentType: "text/plain",
|
|
113
|
+
size: artifact.size,
|
|
114
|
+
sourcePath: artifact.path,
|
|
115
|
+
};
|
|
72
116
|
}
|
|
73
117
|
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
118
|
+
if (artifact.size > MAX_INLINE_ARTIFACT_BYTES) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Artifact ${artifact.id} is ${artifact.size} bytes; full internal resolution is blocked. Use read selectors such as artifact://${artifact.id}:1-3000 or artifact://${artifact.id}:raw:1-3000, and use the artifact file path for search/copy workflows: ${artifact.path}`,
|
|
121
|
+
);
|
|
78
122
|
}
|
|
79
123
|
|
|
80
|
-
const content = await Bun.file(
|
|
124
|
+
const content = await Bun.file(artifact.path).text();
|
|
81
125
|
return {
|
|
82
126
|
url: url.href,
|
|
83
127
|
content,
|
|
84
128
|
contentType: "text/plain",
|
|
85
|
-
size:
|
|
86
|
-
sourcePath:
|
|
129
|
+
size: artifact.size,
|
|
130
|
+
sourcePath: artifact.path,
|
|
87
131
|
};
|
|
88
132
|
}
|
|
89
133
|
|
|
@@ -107,6 +107,16 @@ export interface ResolveContext {
|
|
|
107
107
|
* reject directory resources, so they never need the listing.
|
|
108
108
|
*/
|
|
109
109
|
skipDirectoryListing?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* When set, handlers that would otherwise materialize expensive content
|
|
112
|
+
* (e.g. reading a multi-MiB artifact into memory just to expose its
|
|
113
|
+
* `sourcePath`) may return the resource shape without content. Callers
|
|
114
|
+
* that only need `sourcePath` — search/grep, bash URL expansion — pass
|
|
115
|
+
* this so a large `artifact://` still resolves to its backing file
|
|
116
|
+
* without OOM risk. Handlers that cannot separate path from content
|
|
117
|
+
* ignore the flag.
|
|
118
|
+
*/
|
|
119
|
+
pathOnly?: boolean;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
/**
|
package/src/lsp/config.ts
CHANGED
|
@@ -201,6 +201,21 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
|
|
|
201
201
|
return false;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Check whether any ancestor directory of a file is an LSP project root.
|
|
206
|
+
*/
|
|
207
|
+
export function hasRootMarkerAncestor(filePath: string, markers: string[]): boolean {
|
|
208
|
+
if (markers.length === 0) return false;
|
|
209
|
+
|
|
210
|
+
let dir = path.dirname(path.resolve(filePath));
|
|
211
|
+
while (true) {
|
|
212
|
+
if (hasRootMarkers(dir, markers)) return true;
|
|
213
|
+
const parent = path.dirname(dir);
|
|
214
|
+
if (parent === dir) return false;
|
|
215
|
+
dir = parent;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
204
219
|
// =============================================================================
|
|
205
220
|
// Local Binary Resolution
|
|
206
221
|
// =============================================================================
|
package/src/lsp/edits.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
2
|
+
import * as path from "node:path";
|
|
3
3
|
import { formatPathRelativeToCwd } from "../tools/path-utils";
|
|
4
4
|
import { ToolError } from "../tools/tool-errors";
|
|
5
5
|
import type {
|
|
@@ -48,6 +48,17 @@ export function applyTextEditsToString(content: string, edits: TextEdit[]): stri
|
|
|
48
48
|
function comparePosition(a: Position, b: Position): number {
|
|
49
49
|
return a.line === b.line ? a.character - b.character : a.line - b.line;
|
|
50
50
|
}
|
|
51
|
+
function positionsEqual(a: Position, b: Position): boolean {
|
|
52
|
+
return a.line === b.line && a.character === b.character;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rangesEqual(a: Range, b: Range): boolean {
|
|
56
|
+
return positionsEqual(a.start, b.start) && positionsEqual(a.end, b.end);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isEmptyRange(range: Range): boolean {
|
|
60
|
+
return positionsEqual(range.start, range.end);
|
|
61
|
+
}
|
|
51
62
|
|
|
52
63
|
function formatRange(range: Range): string {
|
|
53
64
|
return `${range.start.line + 1}:${range.start.character + 1}-${range.end.line + 1}:${range.end.character + 1}`;
|
|
@@ -63,6 +74,8 @@ export function rangesOverlap(a: Range, b: Range): boolean {
|
|
|
63
74
|
* Equal start positions tiebreak by original array index descending so that,
|
|
64
75
|
* applied bottom-up, inserts at the same position land in array order
|
|
65
76
|
* (LSP spec: the order of edits in the array defines the order in the result).
|
|
77
|
+
* Byte-identical non-empty range edits are idempotent, so duplicate server
|
|
78
|
+
* output is collapsed before overlap validation.
|
|
66
79
|
*/
|
|
67
80
|
export function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[] {
|
|
68
81
|
const sorted = edits
|
|
@@ -77,21 +90,29 @@ export function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[] {
|
|
|
77
90
|
return b.index - a.index;
|
|
78
91
|
})
|
|
79
92
|
.map(entry => entry.edit);
|
|
93
|
+
const unique: TextEdit[] = [];
|
|
94
|
+
for (const edit of sorted) {
|
|
95
|
+
const prev = unique[unique.length - 1];
|
|
96
|
+
if (prev && !isEmptyRange(edit.range) && rangesEqual(prev.range, edit.range) && prev.newText === edit.newText) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
unique.push(edit);
|
|
100
|
+
}
|
|
80
101
|
|
|
81
102
|
// Detect overlapping ranges: in reverse-sorted order, each edit's start
|
|
82
103
|
// must be >= the next edit's end. If not, the edits would clobber each other
|
|
83
|
-
// once applied bottom-up
|
|
84
|
-
for (let i = 0; i <
|
|
85
|
-
const later =
|
|
86
|
-
const earlier =
|
|
104
|
+
// once applied bottom-up.
|
|
105
|
+
for (let i = 0; i < unique.length - 1; i++) {
|
|
106
|
+
const later = unique[i].range;
|
|
107
|
+
const earlier = unique[i + 1].range;
|
|
87
108
|
if (comparePosition(earlier.end, later.start) > 0) {
|
|
88
109
|
throw new ToolError(
|
|
89
|
-
`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)};
|
|
110
|
+
`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; LSP produced inconsistent edits`,
|
|
90
111
|
);
|
|
91
112
|
}
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
return
|
|
115
|
+
return unique;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
/**
|
package/src/lsp/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
waitForProjectLoaded,
|
|
32
32
|
} from "./client";
|
|
33
33
|
import { getLinterClient } from "./clients";
|
|
34
|
-
import { getServersForFile, type LspConfig, loadConfig } from "./config";
|
|
34
|
+
import { getServersForFile, hasRootMarkerAncestor, type LspConfig, loadConfig } from "./config";
|
|
35
35
|
import {
|
|
36
36
|
applyTextEdits,
|
|
37
37
|
applyTextEditsToString,
|
|
@@ -357,6 +357,41 @@ function limitDiagnosticMessages(messages: string[]): string[] {
|
|
|
357
357
|
return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
const ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES: Record<number, true> = {
|
|
361
|
+
1375: true,
|
|
362
|
+
1378: true,
|
|
363
|
+
2307: true,
|
|
364
|
+
2580: true,
|
|
365
|
+
2591: true,
|
|
366
|
+
2792: true,
|
|
367
|
+
2867: true,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function diagnosticCodeNumber(diagnostic: Diagnostic): number | null {
|
|
371
|
+
if (typeof diagnostic.code === "number") return diagnostic.code;
|
|
372
|
+
if (typeof diagnostic.code === "string" && /^\d+$/.test(diagnostic.code)) return Number(diagnostic.code);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
function isTypeScriptProjectDiagnostic(serverName: string, diagnostic: Diagnostic): boolean {
|
|
376
|
+
if (diagnostic.source !== "typescript" && !serverName.toLowerCase().includes("typescript")) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const code = diagnosticCodeNumber(diagnostic);
|
|
380
|
+
return code !== null && ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES[code] === true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function filterOrphanProjectDiagnostics(
|
|
384
|
+
absolutePath: string,
|
|
385
|
+
serverName: string,
|
|
386
|
+
serverConfig: ServerConfig,
|
|
387
|
+
diagnostics: Diagnostic[],
|
|
388
|
+
): Diagnostic[] {
|
|
389
|
+
if (!serverConfig.rootMarkers.length || hasRootMarkerAncestor(absolutePath, serverConfig.rootMarkers)) {
|
|
390
|
+
return diagnostics;
|
|
391
|
+
}
|
|
392
|
+
return diagnostics.filter(diagnostic => !isTypeScriptProjectDiagnostic(serverName, diagnostic));
|
|
393
|
+
}
|
|
394
|
+
|
|
360
395
|
const LOCATION_CONTEXT_LINES = 1;
|
|
361
396
|
const REFERENCE_CONTEXT_LIMIT = 50;
|
|
362
397
|
|
|
@@ -709,7 +744,7 @@ async function getDiagnosticsForFile(
|
|
|
709
744
|
if (serverConfig.createClient) {
|
|
710
745
|
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
|
711
746
|
const diagnostics = await linterClient.lint(absolutePath);
|
|
712
|
-
return { serverName, diagnostics };
|
|
747
|
+
return { serverName, serverConfig, diagnostics };
|
|
713
748
|
}
|
|
714
749
|
|
|
715
750
|
// Default: use LSP
|
|
@@ -728,14 +763,21 @@ async function getDiagnosticsForFile(
|
|
|
728
763
|
minVersion,
|
|
729
764
|
expectedDocumentVersion,
|
|
730
765
|
});
|
|
731
|
-
return { serverName, diagnostics };
|
|
766
|
+
return { serverName, serverConfig, diagnostics };
|
|
732
767
|
}),
|
|
733
768
|
);
|
|
734
769
|
|
|
735
770
|
for (const result of results) {
|
|
736
771
|
if (result.status === "fulfilled") {
|
|
737
772
|
serverNames.push(result.value.serverName);
|
|
738
|
-
allDiagnostics.push(
|
|
773
|
+
allDiagnostics.push(
|
|
774
|
+
...filterOrphanProjectDiagnostics(
|
|
775
|
+
absolutePath,
|
|
776
|
+
result.value.serverName,
|
|
777
|
+
result.value.serverConfig,
|
|
778
|
+
result.value.diagnostics,
|
|
779
|
+
),
|
|
780
|
+
);
|
|
739
781
|
}
|
|
740
782
|
}
|
|
741
783
|
|