@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.2
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 +67 -0
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/main.d.ts +3 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/controllers/event-controller.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/session-manager.d.ts +5 -2
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/yield.d.ts +8 -0
- package/package.json +9 -9
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +2 -4
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +4 -22
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings.ts +86 -41
- package/src/debug/index.ts +8 -0
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/eval/__tests__/llm-bridge.test.ts +20 -0
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/llm-bridge.ts +14 -3
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +61 -9
- package/src/main.ts +91 -65
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +2 -2
- package/src/modes/components/custom-editor.ts +143 -111
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/user-message.ts +1 -1
- package/src/modes/controllers/event-controller.ts +26 -0
- package/src/modes/controllers/input-controller.ts +46 -7
- package/src/modes/interactive-mode.ts +107 -20
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/types.ts +3 -0
- package/src/modes/workflow.ts +10 -10
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +2 -1
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +26 -9
- package/src/session/agent-session.ts +37 -12
- package/src/session/auth-storage.ts +2 -0
- package/src/session/session-manager.ts +96 -23
- package/src/task/executor.ts +71 -36
- package/src/task/render.ts +3 -4
- package/src/tools/bash.ts +7 -0
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/eval.ts +13 -2
- package/src/tools/find.ts +7 -0
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/path-utils.ts +28 -2
- package/src/tools/plan-mode-guard.ts +52 -7
- package/src/tools/read.ts +25 -12
- package/src/tools/search.ts +38 -3
- package/src/tools/write.ts +2 -2
- package/src/tools/yield.ts +10 -1
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/enhanced-paste.ts +30 -2
- package/src/web/search/providers/codex.ts +37 -8
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect cache-mutating `gh` subcommands inside a bash invocation and drop
|
|
3
|
+
* the matching `github-cache` rows so a subsequent `issue://<n>` or
|
|
4
|
+
* `pr://<n>` read sees the post-mutation state instead of the stale
|
|
5
|
+
* pre-mutation snapshot.
|
|
6
|
+
*
|
|
7
|
+
* Triggered before the bash command runs: on success the cache is now
|
|
8
|
+
* empty and the next read fetches fresh; on failure the worst case is one
|
|
9
|
+
* extra `gh` round-trip on the following read. That cost is bounded and
|
|
10
|
+
* eliminates the much-worse "issue shows OPEN for up to softTtlSec after
|
|
11
|
+
* `gh issue close`" failure mode reported by users.
|
|
12
|
+
*
|
|
13
|
+
* Detector scope: ops that change visible issue/PR state — `close`,
|
|
14
|
+
* `reopen`, `merge`, `delete`, `ready`, `lock`, `unlock`, `pin`, `unpin`,
|
|
15
|
+
* `transfer`, plus the comment/review/edit ops that change the rendered
|
|
16
|
+
* body. We deliberately over-invalidate (e.g. all matching rows for the
|
|
17
|
+
* number, all auth_keys) because the upside of staleness elimination
|
|
18
|
+
* dwarfs the cost of one cache miss.
|
|
19
|
+
*/
|
|
20
|
+
import { invalidateAllForNumber } from "./github-cache";
|
|
21
|
+
|
|
22
|
+
const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i;
|
|
23
|
+
const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i;
|
|
24
|
+
|
|
25
|
+
/** Subcommands that mutate the rendered issue/PR view in any meaningful way. */
|
|
26
|
+
const MUTATING_ISSUE_SUBCMDS: Record<string, true> = {
|
|
27
|
+
close: true,
|
|
28
|
+
reopen: true,
|
|
29
|
+
delete: true,
|
|
30
|
+
edit: true,
|
|
31
|
+
comment: true,
|
|
32
|
+
lock: true,
|
|
33
|
+
unlock: true,
|
|
34
|
+
pin: true,
|
|
35
|
+
unpin: true,
|
|
36
|
+
transfer: true,
|
|
37
|
+
develop: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MUTATING_PR_SUBCMDS: Record<string, true> = {
|
|
41
|
+
close: true,
|
|
42
|
+
reopen: true,
|
|
43
|
+
merge: true,
|
|
44
|
+
ready: true,
|
|
45
|
+
edit: true,
|
|
46
|
+
comment: true,
|
|
47
|
+
review: true,
|
|
48
|
+
lock: true,
|
|
49
|
+
unlock: true,
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Walk a single shell command's token stream looking for a top-level
|
|
53
|
+
* `gh (issue|pr) <subcmd> <id-or-url>` invocation and return the
|
|
54
|
+
* invalidation key when one is found. Returns `null` for non-matching
|
|
55
|
+
* commands so the caller can iterate cheaply.
|
|
56
|
+
*/
|
|
57
|
+
function detectGhMutation(tokens: readonly string[]): { number: number; repo?: string } | null {
|
|
58
|
+
const ghIdx = tokens.indexOf("gh");
|
|
59
|
+
if (ghIdx === -1) return null;
|
|
60
|
+
const subject = tokens[ghIdx + 1];
|
|
61
|
+
if (subject !== "issue" && subject !== "pr") return null;
|
|
62
|
+
const subcmd = tokens[ghIdx + 2];
|
|
63
|
+
if (!subcmd) return null;
|
|
64
|
+
const expected = subject === "issue" ? MUTATING_ISSUE_SUBCMDS : MUTATING_PR_SUBCMDS;
|
|
65
|
+
if (!expected[subcmd]) return null;
|
|
66
|
+
|
|
67
|
+
let repo: string | undefined;
|
|
68
|
+
// First pass: scan for --repo so it wins regardless of position relative
|
|
69
|
+
// to the issue/PR identifier (gh accepts the flag both before and after
|
|
70
|
+
// the positional argument).
|
|
71
|
+
for (let i = ghIdx + 3; i < tokens.length; i++) {
|
|
72
|
+
const token = tokens[i];
|
|
73
|
+
if (token === "-R" || token === "--repo") {
|
|
74
|
+
const next = tokens[i + 1];
|
|
75
|
+
if (next) repo = next;
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (token.startsWith("--repo=")) {
|
|
80
|
+
repo = token.slice("--repo=".length);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (let i = ghIdx + 3; i < tokens.length; i++) {
|
|
84
|
+
const token = tokens[i];
|
|
85
|
+
if (token === "-R" || token === "--repo") {
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.startsWith("-")) continue;
|
|
90
|
+
const direct = /^\d+$/.test(token) ? Number(token) : undefined;
|
|
91
|
+
if (direct !== undefined && Number.isSafeInteger(direct) && direct > 0) {
|
|
92
|
+
return repo !== undefined ? { number: direct, repo } : { number: direct };
|
|
93
|
+
}
|
|
94
|
+
const urlMatch = (subject === "pr" ? PR_URL_PATTERN : ISSUE_URL_PATTERN).exec(token);
|
|
95
|
+
if (urlMatch) {
|
|
96
|
+
const num = Number(urlMatch[2]);
|
|
97
|
+
if (Number.isSafeInteger(num) && num > 0) {
|
|
98
|
+
// URL carries its own repo and wins over a stray --repo flag.
|
|
99
|
+
return { number: num, repo: urlMatch[1] };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Conservative tokenizer that splits a bash command into individual word
|
|
108
|
+
* tokens. Handles single/double-quoted strings, backslash escapes, and
|
|
109
|
+
* standard operators (`;`, `&&`, `||`, `|`, `&`, newlines) as token
|
|
110
|
+
* boundaries that emit a sentinel `";"` so the caller treats the segments
|
|
111
|
+
* as independent command sequences. We do not attempt full POSIX shell
|
|
112
|
+
* parsing — heredocs, command substitution, and arithmetic expansion are
|
|
113
|
+
* out of scope; the detector simply falls through when it cannot find a
|
|
114
|
+
* clean `gh issue|pr <subcmd>` triple.
|
|
115
|
+
*/
|
|
116
|
+
function tokenize(command: string): string[][] {
|
|
117
|
+
const segments: string[][] = [];
|
|
118
|
+
let current: string[] = [];
|
|
119
|
+
let buffer = "";
|
|
120
|
+
let inSingle = false;
|
|
121
|
+
let inDouble = false;
|
|
122
|
+
const pushBuffer = () => {
|
|
123
|
+
if (buffer.length > 0) {
|
|
124
|
+
current.push(buffer);
|
|
125
|
+
buffer = "";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const pushSegment = () => {
|
|
129
|
+
pushBuffer();
|
|
130
|
+
if (current.length > 0) segments.push(current);
|
|
131
|
+
current = [];
|
|
132
|
+
};
|
|
133
|
+
for (let i = 0; i < command.length; i++) {
|
|
134
|
+
const ch = command[i];
|
|
135
|
+
if (inSingle) {
|
|
136
|
+
if (ch === "'") {
|
|
137
|
+
inSingle = false;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
buffer += ch;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (inDouble) {
|
|
144
|
+
if (ch === "\\" && i + 1 < command.length) {
|
|
145
|
+
const next = command[i + 1];
|
|
146
|
+
if (next === '"' || next === "\\" || next === "$" || next === "`") {
|
|
147
|
+
buffer += next;
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (ch === '"') {
|
|
153
|
+
inDouble = false;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
buffer += ch;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === "'") {
|
|
160
|
+
inSingle = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === '"') {
|
|
164
|
+
inDouble = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (ch === "\\" && i + 1 < command.length) {
|
|
168
|
+
buffer += command[i + 1];
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === " " || ch === "\t") {
|
|
173
|
+
pushBuffer();
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
|
|
177
|
+
pushSegment();
|
|
178
|
+
// `&&`, `||` already collapsed by the segment break above.
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
buffer += ch;
|
|
182
|
+
}
|
|
183
|
+
pushSegment();
|
|
184
|
+
return segments;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Drop `github-cache` rows for any `gh issue|pr <mutating-subcmd>` call
|
|
189
|
+
* embedded in `command`. Safe to invoke unconditionally; no-op when the
|
|
190
|
+
* command does not touch GitHub state.
|
|
191
|
+
*/
|
|
192
|
+
export function invalidateGithubCacheForBashCommand(command: string): void {
|
|
193
|
+
if (!command?.includes("gh")) return;
|
|
194
|
+
const segments = tokenize(command);
|
|
195
|
+
for (const segment of segments) {
|
|
196
|
+
const hit = detectGhMutation(segment);
|
|
197
|
+
if (!hit) continue;
|
|
198
|
+
invalidateAllForNumber(hit.number, hit.repo);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -316,6 +316,31 @@ export function invalidate(
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Drop every cached row for a given issue/PR number, regardless of repo,
|
|
321
|
+
* auth key, include_comments flag, or row kind ({@link CacheKind}). Best-effort:
|
|
322
|
+
* swallows DB failures the same way {@link invalidate} does.
|
|
323
|
+
*
|
|
324
|
+
* Used by the bash-side detector that reacts to `gh issue close` / `gh pr merge`
|
|
325
|
+
* style mutations. Repo + auth-key narrowing is intentionally skipped because
|
|
326
|
+
* the bash command often does not name the repo (defaults to cwd's `gh`
|
|
327
|
+
* config) and resolving the *current* repo from `cwd` for every bash call would
|
|
328
|
+
* be far more expensive than a write-amplified DELETE.
|
|
329
|
+
*/
|
|
330
|
+
export function invalidateAllForNumber(number: number, repo?: string): void {
|
|
331
|
+
const db = openDb();
|
|
332
|
+
if (!db) return;
|
|
333
|
+
try {
|
|
334
|
+
if (repo === undefined) {
|
|
335
|
+
db.prepare("DELETE FROM github_view_cache WHERE number = ?").run(number);
|
|
336
|
+
} else {
|
|
337
|
+
db.prepare("DELETE FROM github_view_cache WHERE number = ? AND repo = ?").run(number, normalizeRepo(repo));
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
logger.debug("github cache: invalidateAllForNumber failed", { err: String(err) });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
319
344
|
/** Drop every cached row. Test helper. */
|
|
320
345
|
export function clearAll(): void {
|
|
321
346
|
const db = openDb();
|
|
@@ -5,7 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
|
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
6
|
import { extractTextContent } from "../commit/utils";
|
|
7
7
|
|
|
8
|
-
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
|
|
8
|
+
import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver";
|
|
9
9
|
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
|
|
10
10
|
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
|
|
11
11
|
import {
|
|
@@ -72,7 +72,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
|
|
|
72
72
|
throw new ToolError("No models available for inspect_image.");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const matchPreferences =
|
|
75
|
+
const matchPreferences = getModelMatchPreferences(this.session.settings);
|
|
76
76
|
const resolvePattern = (pattern: string | undefined): Model<Api> | undefined => {
|
|
77
77
|
if (!pattern) return undefined;
|
|
78
78
|
const expanded = expandRoleAlias(pattern, this.session.settings);
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -601,6 +601,23 @@ export function parseSearchPath(filePath: string): ParsedSearchPath {
|
|
|
601
601
|
};
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Async sibling of {@link parseSearchPath} that prefers literal interpretation
|
|
606
|
+
* when a path containing glob metacharacters resolves to an existing entry on
|
|
607
|
+
* disk. Disambiguates Next.js/SvelteKit routes like `apps/[id]/page.tsx` —
|
|
608
|
+
* without this, `[id]` is parsed as a glob character class and silently
|
|
609
|
+
* matches nothing.
|
|
610
|
+
*/
|
|
611
|
+
export async function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath> {
|
|
612
|
+
if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
|
|
613
|
+
try {
|
|
614
|
+
await fs.promises.stat(resolveToCwd(filePath, cwd));
|
|
615
|
+
return { basePath: filePath };
|
|
616
|
+
} catch {
|
|
617
|
+
return parseSearchPath(filePath);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
604
621
|
// Parse a find pattern into a base directory path and a glob pattern.
|
|
605
622
|
// Examples:
|
|
606
623
|
// src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
|
|
@@ -707,7 +724,7 @@ async function resolveSearchPathItems(
|
|
|
707
724
|
|
|
708
725
|
const parsedItems = await Promise.all(
|
|
709
726
|
pathItems.map(async item => {
|
|
710
|
-
const parsedPath =
|
|
727
|
+
const parsedPath = await parseSearchPathPreferringLiteral(item, cwd);
|
|
711
728
|
const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
|
|
712
729
|
const stat = await fs.promises.stat(absoluteBasePath);
|
|
713
730
|
return { raw: item, parsedPath, absoluteBasePath, stat };
|
|
@@ -946,6 +963,15 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
|
|
|
946
963
|
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
|
947
964
|
throw new ToolError("`paths` must contain non-empty paths or globs");
|
|
948
965
|
}
|
|
966
|
+
// External (http/https/ftp/file) URLs are not searchable; route the caller
|
|
967
|
+
// to `read` instead of letting the path-resolver surface a confusing
|
|
968
|
+
// "Path not found" for a slash-stripped URL.
|
|
969
|
+
const externalUrl = rawPaths.find(rawPath => /^(?:https?|ftp|file|ws|wss):\/\//i.test(rawPath));
|
|
970
|
+
if (externalUrl) {
|
|
971
|
+
throw new ToolError(
|
|
972
|
+
`Cannot ${internalUrlAction} external URL: ${externalUrl}. Use \`read\` to fetch web content, then search the returned text.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
949
975
|
const internalRouter = InternalUrlRouter.instance();
|
|
950
976
|
const resolvedPathInputs: string[] = [];
|
|
951
977
|
const immutableSourcePaths = new Set<string>();
|
|
@@ -989,7 +1015,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
|
|
|
989
1015
|
let multiTargets: ResolvedSearchTarget[] | undefined;
|
|
990
1016
|
let exactFilePaths: string[] | undefined;
|
|
991
1017
|
if (effectivePaths.length === 1) {
|
|
992
|
-
const parsedPath =
|
|
1018
|
+
const parsedPath = await parseSearchPathPreferringLiteral(effectivePaths[0] ?? ".", cwd);
|
|
993
1019
|
searchPath = resolveToCwd(parsedPath.basePath, cwd);
|
|
994
1020
|
globFilter = parsedPath.glob;
|
|
995
1021
|
scopePath = formatPathRelativeToCwd(searchPath, cwd);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
|
|
2
4
|
import type { ToolSession } from ".";
|
|
3
5
|
import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
|
|
4
6
|
import { ToolError } from "./tool-errors";
|
|
@@ -6,11 +8,54 @@ import { ToolError } from "./tool-errors";
|
|
|
6
8
|
const VAULT_SCHEME_PREFIX = "vault:";
|
|
7
9
|
const LOCAL_SCHEME_PREFIX = "local:";
|
|
8
10
|
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/** Resolve the absolute path of the session's `local://` artifact sandbox.
|
|
12
|
+
* Returns `null` when the session has no artifact wiring (e.g. tests). */
|
|
13
|
+
function localSandboxRoot(session: ToolSession): string | null {
|
|
14
|
+
try {
|
|
15
|
+
return path.resolve(
|
|
16
|
+
resolveLocalRoot({
|
|
17
|
+
getArtifactsDir: session.getArtifactsDir,
|
|
18
|
+
getSessionId: session.getSessionId,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when `absolutePath` resolves inside `root` (== root or under it). */
|
|
27
|
+
function isWithinRoot(absolutePath: string, root: string): boolean {
|
|
28
|
+
if (absolutePath === root) return true;
|
|
29
|
+
const sep = `${root}${path.sep}`;
|
|
30
|
+
return absolutePath.startsWith(sep);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when `targetPath` addresses the session-local artifact sandbox.
|
|
34
|
+
* Accepts both `local://…` URLs and absolute paths pointing inside the
|
|
35
|
+
* resolved sandbox root — the latter is what `read local://…` echoes back
|
|
36
|
+
* in the `[path#tag]` header. Those files are not part of the working tree,
|
|
37
|
+
* so plan mode treats them as freely writable scratch/plan space. */
|
|
38
|
+
function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
|
|
39
|
+
const normalized = normalizeLocalScheme(targetPath);
|
|
40
|
+
if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
|
|
41
|
+
if (!path.isAbsolute(normalized)) return false;
|
|
42
|
+
const root = localSandboxRoot(session);
|
|
43
|
+
if (!root) return false;
|
|
44
|
+
// Compare both raw and realpath-normalized forms so that
|
|
45
|
+
// `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
|
|
46
|
+
// roots both resolve to the same sandbox identity.
|
|
47
|
+
const resolved = path.resolve(normalized);
|
|
48
|
+
if (isWithinRoot(resolved, root)) return true;
|
|
49
|
+
try {
|
|
50
|
+
const realRoot = fs.realpathSync.native(root);
|
|
51
|
+
if (isWithinRoot(resolved, realRoot)) return true;
|
|
52
|
+
// `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
|
|
53
|
+
// realpath the parent dir of `resolved` so we catch that direction too.
|
|
54
|
+
const realParent = fs.realpathSync.native(path.dirname(resolved));
|
|
55
|
+
return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
14
59
|
}
|
|
15
60
|
|
|
16
61
|
/**
|
|
@@ -55,7 +100,7 @@ export function enforcePlanModeWrite(
|
|
|
55
100
|
throw new ToolError("Plan mode: deleting files is not allowed.");
|
|
56
101
|
}
|
|
57
102
|
|
|
58
|
-
if (targetsLocalSandbox(targetPath)) return;
|
|
103
|
+
if (targetsLocalSandbox(session, targetPath)) return;
|
|
59
104
|
|
|
60
105
|
throw new ToolError(
|
|
61
106
|
"Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
|
package/src/tools/read.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
9
9
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
10
10
|
import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
|
11
11
|
import * as z from "zod/v4";
|
|
12
|
-
import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
|
|
12
|
+
import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
|
|
13
13
|
import { normalizeToLF } from "../edit/normalize";
|
|
14
14
|
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
|
|
15
15
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -131,7 +131,7 @@ function recordFullHashlineContext(
|
|
|
131
131
|
): HashlineHeaderContext | undefined {
|
|
132
132
|
if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
|
|
133
133
|
const normalized = normalizeToLF(fullText);
|
|
134
|
-
const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
|
|
134
|
+
const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
|
135
135
|
return {
|
|
136
136
|
header: formatHashlineHeader(displayPath, tag),
|
|
137
137
|
tag,
|
|
@@ -1750,15 +1750,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1750
1750
|
// Convert document via markit.
|
|
1751
1751
|
const result = await convertFileWithMarkit(absolutePath, signal);
|
|
1752
1752
|
if (result.ok) {
|
|
1753
|
-
//
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1753
|
+
// Route the converted markdown through the in-memory text builder
|
|
1754
|
+
// so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
|
|
1755
|
+
// raw mode apply against the converted output. Without this,
|
|
1756
|
+
// `file.pdf:50-100` silently returned the head of the document
|
|
1757
|
+
// because only `truncateHead` was being applied.
|
|
1758
|
+
if (isMultiRange(parsed) && parsed.kind === "lines") {
|
|
1759
|
+
return this.#buildInMemoryMultiRangeResult(result.content, parsed.ranges, {
|
|
1760
|
+
details: { resolvedPath: absolutePath },
|
|
1761
|
+
sourcePath: absolutePath,
|
|
1762
|
+
entityLabel: "document",
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1766
|
+
return this.#buildInMemoryTextResult(result.content, offset, limit, {
|
|
1767
|
+
details: { resolvedPath: absolutePath },
|
|
1768
|
+
sourcePath: absolutePath,
|
|
1769
|
+
entityLabel: "document",
|
|
1770
|
+
raw: isRawSelector(parsed),
|
|
1771
|
+
});
|
|
1762
1772
|
} else if (result.error) {
|
|
1763
1773
|
content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
|
|
1764
1774
|
} else {
|
|
@@ -1944,7 +1954,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1944
1954
|
// full file and any anchor validates while the file is unchanged.
|
|
1945
1955
|
const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
|
|
1946
1956
|
const tag = isWholeFile
|
|
1947
|
-
? getFileSnapshotStore(this.session).record(
|
|
1957
|
+
? getFileSnapshotStore(this.session).record(
|
|
1958
|
+
canonicalSnapshotKey(absolutePath),
|
|
1959
|
+
normalizeToLF(collectedLines.join("\n")),
|
|
1960
|
+
)
|
|
1948
1961
|
: await recordFileSnapshot(this.session, absolutePath);
|
|
1949
1962
|
if (tag) {
|
|
1950
1963
|
hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
|
package/src/tools/search.ts
CHANGED
|
@@ -83,6 +83,7 @@ const searchSchema = z
|
|
|
83
83
|
gitignore: z.boolean().optional().describe("respect gitignore"),
|
|
84
84
|
skip: z
|
|
85
85
|
.number()
|
|
86
|
+
.nullable()
|
|
86
87
|
.optional()
|
|
87
88
|
.describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"),
|
|
88
89
|
})
|
|
@@ -107,6 +108,10 @@ export const SINGLE_FILE_MATCHES = 200;
|
|
|
107
108
|
* (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
|
|
108
109
|
* pagination headroom so the caller can see total file count. */
|
|
109
110
|
const INTERNAL_TOTAL_CAP = 2000;
|
|
111
|
+
/** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep
|
|
112
|
+
* silently returns no matches for files larger than this; surface a warning
|
|
113
|
+
* when the caller explicitly targeted such a file so they know to chunk it. */
|
|
114
|
+
const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
110
115
|
|
|
111
116
|
/**
|
|
112
117
|
* Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
|
|
@@ -666,7 +671,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
666
671
|
throw new ToolError("Pattern must not be empty");
|
|
667
672
|
}
|
|
668
673
|
|
|
669
|
-
const normalizedSkip =
|
|
674
|
+
const normalizedSkip =
|
|
675
|
+
skip === undefined || skip === null ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
|
|
670
676
|
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
|
|
671
677
|
throw new ToolError("Skip must be a non-negative number");
|
|
672
678
|
}
|
|
@@ -728,7 +734,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
728
734
|
// reason instead of a downstream "path not found" from the scope resolver.
|
|
729
735
|
throw new ToolError(
|
|
730
736
|
`Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` +
|
|
731
|
-
`Read the
|
|
737
|
+
`Read the member with \`read <archive>:<member>\` and inspect the returned text, ` +
|
|
732
738
|
`or pass a UTF-8 text member.`,
|
|
733
739
|
);
|
|
734
740
|
}
|
|
@@ -991,6 +997,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
991
997
|
: "";
|
|
992
998
|
const { record: recordFile, list: fileList } = createFileRecorder();
|
|
993
999
|
const fileMatchCounts = new Map<string, number>();
|
|
1000
|
+
// Detect explicit file targets that exceed the native grep size cap.
|
|
1001
|
+
// Native silently returns no matches above the cap; without this note the
|
|
1002
|
+
// caller sees "no matches" for a literal pattern that visibly exists.
|
|
1003
|
+
const oversizedNote = await (async (): Promise<string | undefined> => {
|
|
1004
|
+
const explicitFileTargets: string[] = [];
|
|
1005
|
+
if (exactFilePaths) {
|
|
1006
|
+
explicitFileTargets.push(...exactFilePaths);
|
|
1007
|
+
} else if (searchablePaths.length > 0 && !isDirectory && !multiTargets) {
|
|
1008
|
+
explicitFileTargets.push(searchPath);
|
|
1009
|
+
}
|
|
1010
|
+
if (explicitFileTargets.length === 0) return undefined;
|
|
1011
|
+
const oversized: string[] = [];
|
|
1012
|
+
await Promise.all(
|
|
1013
|
+
explicitFileTargets.map(async target => {
|
|
1014
|
+
try {
|
|
1015
|
+
const st = await stat(target);
|
|
1016
|
+
if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) {
|
|
1017
|
+
oversized.push(path.relative(this.session.cwd, target) || target);
|
|
1018
|
+
}
|
|
1019
|
+
} catch {
|
|
1020
|
+
// Stat failures here are surfaced by other code paths.
|
|
1021
|
+
}
|
|
1022
|
+
}),
|
|
1023
|
+
);
|
|
1024
|
+
if (oversized.length === 0) return undefined;
|
|
1025
|
+
const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024));
|
|
1026
|
+
return `Skipped oversized files (>${limitMb}MB grep limit; split the file or narrow with \`read\`): ${oversized.join(", ")}`;
|
|
1027
|
+
})();
|
|
994
1028
|
const archiveNote =
|
|
995
1029
|
archiveUnreadable.length > 0
|
|
996
1030
|
? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}`
|
|
@@ -1002,7 +1036,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
1002
1036
|
const missingPathsNote =
|
|
1003
1037
|
missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined;
|
|
1004
1038
|
const warningNote =
|
|
1005
|
-
[missingPathsNote, archiveNote].filter((s): s is string => Boolean(s)).join("\n") ||
|
|
1039
|
+
[missingPathsNote, archiveNote, oversizedNote].filter((s): s is string => Boolean(s)).join("\n") ||
|
|
1040
|
+
undefined;
|
|
1006
1041
|
if (selectedMatches.length === 0) {
|
|
1007
1042
|
const details: SearchToolDetails = {
|
|
1008
1043
|
scopePath,
|
package/src/tools/write.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
8
8
|
import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
9
9
|
import * as z from "zod/v4";
|
|
10
10
|
|
|
11
|
-
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
11
|
+
import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
12
12
|
import { normalizeToLF } from "../edit/normalize";
|
|
13
13
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
14
14
|
import { InternalUrlRouter } from "../internal-urls";
|
|
@@ -132,7 +132,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
|
|
|
132
132
|
function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
|
|
133
133
|
if (!resolveFileDisplayMode(session).hashLines) return undefined;
|
|
134
134
|
const normalized = normalizeToLF(content);
|
|
135
|
-
const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
|
|
135
|
+
const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
|
136
136
|
return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
|
|
137
137
|
}
|
|
138
138
|
|
package/src/tools/yield.ts
CHANGED
|
@@ -20,6 +20,14 @@ export interface YieldDetails {
|
|
|
20
20
|
data: unknown;
|
|
21
21
|
status: "success" | "aborted";
|
|
22
22
|
error?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Set when the yield tool exhausted its in-tool schema-retry budget
|
|
25
|
+
* (MAX_SCHEMA_RETRIES) and accepted the data anyway. Surfaced so the
|
|
26
|
+
* executor's post-mortem finalizer can honor the override instead of
|
|
27
|
+
* re-rejecting the same payload with `schema_violation` — keeping the
|
|
28
|
+
* subagent's acceptance and the parent's view of the result in lockstep.
|
|
29
|
+
*/
|
|
30
|
+
schemaOverridden?: boolean;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
function formatSchema(schema: unknown): string {
|
|
@@ -237,7 +245,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
237
245
|
: "Result submitted.";
|
|
238
246
|
return {
|
|
239
247
|
content: [{ type: "text", text: responseText }],
|
|
240
|
-
details: { data, status, error: errorMessage },
|
|
248
|
+
details: { data, status, error: errorMessage, schemaOverridden: schemaValidationOverridden || undefined },
|
|
241
249
|
};
|
|
242
250
|
}
|
|
243
251
|
}
|
|
@@ -254,6 +262,7 @@ subprocessToolRegistry.register<YieldDetails>("yield", {
|
|
|
254
262
|
data: record.data,
|
|
255
263
|
status,
|
|
256
264
|
error: typeof record.error === "string" ? record.error : undefined,
|
|
265
|
+
schemaOverridden: record.schemaOverridden === true ? true : undefined,
|
|
257
266
|
};
|
|
258
267
|
},
|
|
259
268
|
shouldTerminate: event => !event.isError,
|
|
@@ -8,7 +8,7 @@ import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
|
8
8
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
9
9
|
|
|
10
10
|
import type { ModelRegistry } from "../config/model-registry";
|
|
11
|
-
import { resolveModelRoleValue } from "../config/model-resolver";
|
|
11
|
+
import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
|
|
12
12
|
import type { Settings } from "../config/settings";
|
|
13
13
|
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
|
14
14
|
import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
|
|
@@ -51,7 +51,7 @@ function getSmolModelCandidates(
|
|
|
51
51
|
candidates.push({ model, thinkingLevel });
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
const matchPreferences =
|
|
54
|
+
const matchPreferences = getModelMatchPreferences(settings);
|
|
55
55
|
const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
|
|
56
56
|
settings,
|
|
57
57
|
matchPreferences,
|
|
@@ -7,6 +7,8 @@ const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("bas
|
|
|
7
7
|
|
|
8
8
|
const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
9
9
|
const TEXT_MIME_TYPE = "text/plain";
|
|
10
|
+
/** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
|
|
11
|
+
const MIME_LISTING_TARGET = ".";
|
|
10
12
|
|
|
11
13
|
type PasteReadKind = "image" | "text";
|
|
12
14
|
|
|
@@ -18,6 +20,7 @@ export interface Osc5522Packet {
|
|
|
18
20
|
interface PasteListingState {
|
|
19
21
|
phase: "listing";
|
|
20
22
|
mimes: string[];
|
|
23
|
+
kittyDotPayload?: true;
|
|
21
24
|
pw?: string;
|
|
22
25
|
loc?: string;
|
|
23
26
|
}
|
|
@@ -144,6 +147,25 @@ export class EnhancedPasteController {
|
|
|
144
147
|
if (!mimeType) return;
|
|
145
148
|
|
|
146
149
|
if (state.phase === "listing") {
|
|
150
|
+
// Kitty (as of writing) implements the "list available MIME types"
|
|
151
|
+
// response shape by sending a single DATA packet with `mime="."` and
|
|
152
|
+
// the available types packed into the payload as a whitespace-
|
|
153
|
+
// separated list (see `fulfill_read_request` in
|
|
154
|
+
// kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
|
|
155
|
+
// spec instead encodes each type as its own DATA packet with an
|
|
156
|
+
// empty payload. Support both — fall through to the per-packet
|
|
157
|
+
// form when the dot sentinel has no payload, or when the packet
|
|
158
|
+
// already names a concrete MIME type.
|
|
159
|
+
if (mimeType === MIME_LISTING_TARGET) {
|
|
160
|
+
if (!packet.payload) return;
|
|
161
|
+
const listing = decodeBase64Utf8(packet.payload);
|
|
162
|
+
if (!listing) return;
|
|
163
|
+
state.kittyDotPayload = true;
|
|
164
|
+
for (const candidate of listing.split(/\s+/)) {
|
|
165
|
+
if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
147
169
|
state.mimes.push(mimeType);
|
|
148
170
|
return;
|
|
149
171
|
}
|
|
@@ -192,11 +214,17 @@ export class EnhancedPasteController {
|
|
|
192
214
|
chunks: [],
|
|
193
215
|
};
|
|
194
216
|
|
|
195
|
-
const
|
|
217
|
+
const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
|
|
218
|
+
const metadata = ["type=read"];
|
|
196
219
|
if (state.loc) metadata.push(`loc=${state.loc}`);
|
|
197
220
|
if (state.pw) {
|
|
198
221
|
metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
|
|
199
222
|
}
|
|
200
|
-
|
|
223
|
+
if (state.kittyDotPayload) {
|
|
224
|
+
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_BEL}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
metadata.push(`mime=${encodedMime}`);
|
|
228
|
+
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_BEL}`);
|
|
201
229
|
}
|
|
202
230
|
}
|