@oh-my-pi/pi-coding-agent 14.5.14 → 14.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/package.json +7 -7
- package/src/autoresearch/command-resume.md +5 -8
- package/src/autoresearch/git.ts +41 -51
- package/src/autoresearch/helpers.ts +43 -359
- package/src/autoresearch/index.ts +281 -273
- package/src/autoresearch/prompt-setup.md +43 -0
- package/src/autoresearch/prompt.md +52 -193
- package/src/autoresearch/resume-message.md +2 -8
- package/src/autoresearch/state.ts +59 -166
- package/src/autoresearch/storage.ts +687 -0
- package/src/autoresearch/tools/init-experiment.ts +201 -290
- package/src/autoresearch/tools/log-experiment.ts +304 -517
- package/src/autoresearch/tools/run-experiment.ts +117 -296
- package/src/autoresearch/tools/update-notes.ts +116 -0
- package/src/autoresearch/types.ts +16 -66
- package/src/config/settings-schema.ts +1 -1
- package/src/config/settings.ts +20 -1
- package/src/cursor.ts +1 -1
- package/src/edit/index.ts +9 -31
- package/src/edit/line-hash.ts +70 -43
- package/src/edit/modes/hashline.lark +26 -0
- package/src/edit/modes/hashline.ts +898 -1099
- package/src/edit/modes/patch.ts +0 -7
- package/src/edit/modes/replace.ts +0 -4
- package/src/edit/renderer.ts +22 -20
- package/src/edit/streaming.ts +8 -28
- package/src/eval/eval.lark +24 -30
- package/src/eval/js/context-manager.ts +5 -162
- package/src/eval/js/prelude.txt +0 -12
- package/src/eval/parse.ts +129 -129
- package/src/eval/py/prelude.py +1 -219
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +2 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +5 -2
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line.ts +3 -5
- package/src/modes/components/tree-selector.ts +4 -5
- package/src/modes/components/welcome.ts +11 -1
- package/src/modes/controllers/command-controller.ts +2 -6
- package/src/modes/controllers/event-controller.ts +1 -2
- package/src/modes/controllers/extension-ui-controller.ts +3 -15
- package/src/modes/controllers/input-controller.ts +0 -1
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +5 -7
- package/src/prompts/system/system-prompt.md +14 -38
- package/src/prompts/tools/ast-edit.md +8 -8
- package/src/prompts/tools/ast-grep.md +10 -10
- package/src/prompts/tools/eval.md +13 -31
- package/src/prompts/tools/find.md +2 -1
- package/src/prompts/tools/hashline.md +66 -57
- package/src/prompts/tools/search.md +2 -2
- package/src/session/session-manager.ts +17 -13
- package/src/tools/ast-edit.ts +141 -44
- package/src/tools/ast-grep.ts +112 -36
- package/src/tools/eval.ts +2 -53
- package/src/tools/find.ts +16 -15
- package/src/tools/path-utils.ts +36 -196
- package/src/tools/search.ts +56 -35
- package/src/utils/edit-mode.ts +2 -11
- package/src/utils/file-display-mode.ts +1 -1
- package/src/utils/git.ts +17 -0
- package/src/utils/session-color.ts +0 -12
- package/src/utils/title-generator.ts +22 -38
- package/src/autoresearch/apply-contract-to-state.ts +0 -24
- package/src/autoresearch/contract.ts +0 -288
- package/src/edit/modes/atom.lark +0 -29
- package/src/edit/modes/atom.ts +0 -1773
- package/src/prompts/tools/atom.md +0 -150
package/src/eval/parse.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EvalLanguage } from "./types";
|
|
2
2
|
|
|
3
|
-
export type EvalLanguageOrigin = "default" | "
|
|
3
|
+
export type EvalLanguageOrigin = "default" | "header";
|
|
4
4
|
|
|
5
5
|
export interface ParsedEvalCell {
|
|
6
6
|
index: number;
|
|
@@ -19,11 +19,11 @@ export interface ParsedEvalInput {
|
|
|
19
19
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Canonical
|
|
23
|
-
* case-insensitively.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Canonical language tokens we map onto our two backends. Matched
|
|
23
|
+
* case-insensitively. Unknown tokens are treated as title fragments rather
|
|
24
|
+
* than languages; this is intentional fallback behaviour and MUST NOT be
|
|
25
|
+
* advertised in the tool's prompt — the lark grammar describes the
|
|
26
|
+
* canonical surface we encourage callers to emit.
|
|
27
27
|
*/
|
|
28
28
|
const LANGUAGE_ALIASES: Record<string, EvalLanguage> = {
|
|
29
29
|
py: "python",
|
|
@@ -41,8 +41,8 @@ function resolveLanguageAlias(token: string): EvalLanguage | undefined {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Map an attribute key (from `key
|
|
45
|
-
* the three canonical roles. Canonical keys: `id`, `t`, `rst`. Fallback
|
|
44
|
+
* Map an attribute key (from `key:value` or bare `key` in a header) to one
|
|
45
|
+
* of the three canonical roles. Canonical keys: `id`, `t`, `rst`. Fallback
|
|
46
46
|
* aliases — accepted but not advertised in the prompt — cover common
|
|
47
47
|
* synonyms the LLM is likely to reach for instead of the short canonical.
|
|
48
48
|
*/
|
|
@@ -57,29 +57,22 @@ function classifyAttrKey(key: string): "id" | "t" | "rst" | null {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
interface
|
|
61
|
-
type: "raw";
|
|
62
|
-
lines: string[];
|
|
63
|
-
startLine: number;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface FencedBlock {
|
|
67
|
-
type: "fenced";
|
|
68
|
-
info: string;
|
|
69
|
-
codeLines: string[];
|
|
70
|
-
startLine: number;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type Block = RawBlock | FencedBlock;
|
|
74
|
-
|
|
75
|
-
interface FenceInfo {
|
|
60
|
+
interface HeaderInfo {
|
|
76
61
|
language?: EvalLanguage;
|
|
77
62
|
title?: string;
|
|
78
63
|
timeoutMs?: number;
|
|
79
64
|
reset?: boolean;
|
|
80
65
|
}
|
|
81
66
|
|
|
82
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Match a header line: `={5,} <info>? ={5,}`. Both bars MUST be on the
|
|
69
|
+
* same line and each MUST be at least five equal signs (lengths need not
|
|
70
|
+
* match — a 5/6 split is fine).
|
|
71
|
+
*/
|
|
72
|
+
const HEADER_RE = /^={5,}([^=].*?)?={5,}\s*$/;
|
|
73
|
+
const EMPTY_HEADER_RE = /^={5,}\s*$/;
|
|
74
|
+
|
|
75
|
+
const ATTR_TOKEN_RE = /^([a-zA-Z][\w-]*)(?::(?:"([^"]*)"|'([^']*)'|(.*)))?$/;
|
|
83
76
|
const DURATION_TOKEN_RE = /^\d+(?:ms|s|m)?$/;
|
|
84
77
|
|
|
85
78
|
function parseDurationMs(raw: string, lineNumber: number): number {
|
|
@@ -111,24 +104,26 @@ function trimOuterBlankLines(lines: string[]): string[] {
|
|
|
111
104
|
return lines.slice(start, end);
|
|
112
105
|
}
|
|
113
106
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Detect whether a line is a cell header. Returns the info string between
|
|
109
|
+
* the two bar runs (trimmed) when it is, or `null` otherwise. An empty
|
|
110
|
+
* header (`===== =====` or just `=====`) yields an empty info string.
|
|
111
|
+
*
|
|
112
|
+
* A line that contains text but only one bar (e.g. `===== title`) is NOT
|
|
113
|
+
* a header — it's normal code that happens to start with equal signs.
|
|
114
|
+
*/
|
|
115
|
+
function parseHeaderLine(line: string): string | null {
|
|
116
|
+
if (EMPTY_HEADER_RE.test(line)) return "";
|
|
117
|
+
const match = HEADER_RE.exec(line);
|
|
118
|
+
if (!match) return null;
|
|
119
|
+
return (match[1] ?? "").trim();
|
|
126
120
|
}
|
|
127
121
|
|
|
128
122
|
/**
|
|
129
|
-
* Tokenize a
|
|
130
|
-
* single or double quotes as a single token. The opening and closing
|
|
131
|
-
* characters are kept verbatim so attribute parsing can strip them
|
|
123
|
+
* Tokenize a header info string while preserving content inside matching
|
|
124
|
+
* single or double quotes as a single token. The opening and closing
|
|
125
|
+
* quote characters are kept verbatim so attribute parsing can strip them
|
|
126
|
+
* later.
|
|
132
127
|
*/
|
|
133
128
|
function tokenizeInfoString(info: string): string[] {
|
|
134
129
|
const tokens: string[] = [];
|
|
@@ -161,71 +156,83 @@ function tokenizeInfoString(info: string): string[] {
|
|
|
161
156
|
}
|
|
162
157
|
|
|
163
158
|
/**
|
|
164
|
-
* Decode a
|
|
165
|
-
*
|
|
166
|
-
* Layout (positional → kv, all optional):
|
|
167
|
-
* `<lang>? <duration>? <(title-fragment | key=value)>*`
|
|
159
|
+
* Decode a header info string into language, title, timeout, and reset flag.
|
|
168
160
|
*
|
|
169
|
-
*
|
|
170
|
-
* - `
|
|
171
|
-
* - `
|
|
172
|
-
* - `
|
|
161
|
+
* Token forms (all optional, any order):
|
|
162
|
+
* - `py` / `js` / `ts` bare language
|
|
163
|
+
* - `py:"..."` / `js:"..."` / `ts:"..."` language + title shorthand
|
|
164
|
+
* - `id:"..."` cell title
|
|
165
|
+
* - `t:<duration>` per-cell timeout
|
|
166
|
+
* - `<duration>` bare positional duration (lenient)
|
|
167
|
+
* - `rst` reset flag
|
|
168
|
+
* - `rst:true|false` reset flag with explicit value
|
|
173
169
|
*
|
|
174
|
-
*
|
|
175
|
-
* them when the LLM reaches for a more familiar key):
|
|
170
|
+
* Fallback aliases (accepted but not advertised in the prompt):
|
|
176
171
|
* - id: title, name, cell, file, label
|
|
177
172
|
* - t: timeout, duration, time
|
|
178
173
|
* - rst: reset
|
|
179
174
|
*
|
|
180
|
-
* Truly unknown keys are silently dropped. First occurrence wins when a
|
|
181
|
-
* is repeated (canonical or alias).
|
|
182
|
-
*
|
|
183
|
-
* - First token is consumed as a language alias when it matches one; otherwise
|
|
184
|
-
* it falls through to the title-fragment branch and the cell inherits the
|
|
185
|
-
* surrounding language.
|
|
186
|
-
* - The first remaining duration-shaped token (e.g. `15s`, `500ms`, `2m`,
|
|
187
|
-
* `30`) becomes the positional timeout. The `t=` attribute always wins.
|
|
188
|
-
* - Anything else accumulates as positional title fragments joined by spaces.
|
|
175
|
+
* Truly unknown keys are silently dropped. First occurrence wins when a
|
|
176
|
+
* key is repeated (canonical or alias). Anything that doesn't classify
|
|
177
|
+
* accumulates as a positional title fragment joined by spaces.
|
|
189
178
|
*/
|
|
190
|
-
function
|
|
191
|
-
const tokens = tokenizeInfoString(info
|
|
179
|
+
function parseHeaderInfo(info: string, lineNumber: number): HeaderInfo {
|
|
180
|
+
const tokens = tokenizeInfoString(info);
|
|
192
181
|
if (tokens.length === 0) return {};
|
|
193
182
|
|
|
194
183
|
let language: EvalLanguage | undefined;
|
|
184
|
+
let titleAttr: string | undefined;
|
|
195
185
|
let positionalDurationMs: number | undefined;
|
|
196
|
-
const titleParts: string[] = [];
|
|
197
|
-
let idAttr: string | undefined;
|
|
198
186
|
let tAttr: string | undefined;
|
|
199
187
|
let rstAttr: string | undefined;
|
|
188
|
+
let bareReset = false;
|
|
189
|
+
const titleParts: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const token of tokens) {
|
|
192
|
+
// Bare reset flag.
|
|
193
|
+
if (RST_KEYS.has(token.toLowerCase())) {
|
|
194
|
+
bareReset = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
200
197
|
|
|
201
|
-
for (let idx = 0; idx < tokens.length; idx++) {
|
|
202
|
-
const token = tokens[idx];
|
|
203
198
|
const attrMatch = ATTR_TOKEN_RE.exec(token);
|
|
204
|
-
if (attrMatch) {
|
|
199
|
+
if (attrMatch && token.includes(":")) {
|
|
205
200
|
const key = attrMatch[1].toLowerCase();
|
|
206
201
|
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
|
|
202
|
+
|
|
203
|
+
// Language-with-title shorthand: `py:"foo"` etc.
|
|
204
|
+
const langCandidate = resolveLanguageAlias(key);
|
|
205
|
+
if (langCandidate) {
|
|
206
|
+
if (language === undefined) language = langCandidate;
|
|
207
|
+
if (titleAttr === undefined && value !== "") titleAttr = value;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
207
211
|
const role = classifyAttrKey(key);
|
|
208
|
-
if (role === "id" &&
|
|
212
|
+
if (role === "id" && titleAttr === undefined) titleAttr = value;
|
|
209
213
|
else if (role === "t" && tAttr === undefined) tAttr = value;
|
|
210
214
|
else if (role === "rst" && rstAttr === undefined) rstAttr = value;
|
|
211
215
|
// unknown / repeated keys silently dropped
|
|
212
216
|
continue;
|
|
213
217
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
|
|
219
|
+
// Bare language token (no colon).
|
|
220
|
+
const lang = resolveLanguageAlias(token);
|
|
221
|
+
if (lang && language === undefined) {
|
|
222
|
+
language = lang;
|
|
223
|
+
continue;
|
|
220
224
|
}
|
|
225
|
+
|
|
226
|
+
// Bare positional duration (lenient — `t:` is canonical).
|
|
221
227
|
if (positionalDurationMs === undefined && DURATION_TOKEN_RE.test(token)) {
|
|
222
228
|
positionalDurationMs = parseDurationMs(token, lineNumber);
|
|
223
229
|
continue;
|
|
224
230
|
}
|
|
231
|
+
|
|
225
232
|
titleParts.push(token);
|
|
226
233
|
}
|
|
227
234
|
|
|
228
|
-
const explicitTitle = (
|
|
235
|
+
const explicitTitle = (titleAttr ?? "").trim();
|
|
229
236
|
const positionalTitle = titleParts.join(" ").trim();
|
|
230
237
|
const title = explicitTitle.length > 0 ? explicitTitle : positionalTitle.length > 0 ? positionalTitle : undefined;
|
|
231
238
|
|
|
@@ -243,55 +250,13 @@ function parseFenceInfo(info: string, lineNumber: number): FenceInfo {
|
|
|
243
250
|
throw new Error(`Eval line ${lineNumber}: invalid rst value \`${rstAttr}\`; use true or false.`);
|
|
244
251
|
}
|
|
245
252
|
reset = parsed;
|
|
253
|
+
} else if (bareReset) {
|
|
254
|
+
reset = true;
|
|
246
255
|
}
|
|
247
256
|
|
|
248
257
|
return { language, title, timeoutMs, reset };
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
/**
|
|
252
|
-
* Walk normalized lines and split into top-level fenced blocks and raw
|
|
253
|
-
* (between/around fences) blocks. Unclosed fences are leniently closed at
|
|
254
|
-
* end-of-input. Raw blocks with only blank lines are dropped.
|
|
255
|
-
*/
|
|
256
|
-
function splitIntoBlocks(lines: string[]): Block[] {
|
|
257
|
-
const blocks: Block[] = [];
|
|
258
|
-
let i = 0;
|
|
259
|
-
while (i < lines.length) {
|
|
260
|
-
const line = lines[i];
|
|
261
|
-
const opener = parseFenceOpener(line);
|
|
262
|
-
if (opener) {
|
|
263
|
-
const fenceStart = i + 1; // 1-indexed line number of opener
|
|
264
|
-
const codeLines: string[] = [];
|
|
265
|
-
let j = i + 1;
|
|
266
|
-
let closed = false;
|
|
267
|
-
while (j < lines.length) {
|
|
268
|
-
if (isFenceCloser(lines[j], opener.char, opener.count)) {
|
|
269
|
-
closed = true;
|
|
270
|
-
break;
|
|
271
|
-
}
|
|
272
|
-
codeLines.push(lines[j]);
|
|
273
|
-
j++;
|
|
274
|
-
}
|
|
275
|
-
blocks.push({ type: "fenced", info: opener.info, codeLines, startLine: fenceStart });
|
|
276
|
-
i = closed ? j + 1 : j;
|
|
277
|
-
} else {
|
|
278
|
-
const rawStart = i + 1;
|
|
279
|
-
const rawLines: string[] = [line];
|
|
280
|
-
let j = i + 1;
|
|
281
|
-
while (j < lines.length && !parseFenceOpener(lines[j])) {
|
|
282
|
-
rawLines.push(lines[j]);
|
|
283
|
-
j++;
|
|
284
|
-
}
|
|
285
|
-
const trimmed = trimOuterBlankLines(rawLines);
|
|
286
|
-
if (trimmed.length > 0) {
|
|
287
|
-
blocks.push({ type: "raw", lines: trimmed, startLine: rawStart });
|
|
288
|
-
}
|
|
289
|
-
i = j;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return blocks;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
260
|
interface ExpansionState {
|
|
296
261
|
language: EvalLanguage;
|
|
297
262
|
languageOrigin: EvalLanguageOrigin;
|
|
@@ -300,34 +265,69 @@ interface ExpansionState {
|
|
|
300
265
|
export function parseEvalInput(input: string): ParsedEvalInput {
|
|
301
266
|
const normalized = input.replace(/\r\n?/g, "\n");
|
|
302
267
|
const lines = normalized.split("\n");
|
|
303
|
-
|
|
268
|
+
// `split("\n")` produces a trailing empty element when the input ends with
|
|
269
|
+
// a newline. Drop it so we don't emit phantom blank trailing code lines.
|
|
270
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
304
271
|
|
|
305
272
|
const state: ExpansionState = { language: "python", languageOrigin: "default" };
|
|
306
273
|
const cells: ParsedEvalCell[] = [];
|
|
307
|
-
|
|
308
|
-
|
|
274
|
+
let i = 0;
|
|
275
|
+
|
|
276
|
+
// Lenient: leading content before any header forms an implicit
|
|
277
|
+
// default-language cell. Drop it if it's only blank lines.
|
|
278
|
+
if (i < lines.length && parseHeaderLine(lines[i]) === null) {
|
|
279
|
+
const buffer: string[] = [];
|
|
280
|
+
while (i < lines.length && parseHeaderLine(lines[i]) === null) {
|
|
281
|
+
buffer.push(lines[i]);
|
|
282
|
+
i++;
|
|
283
|
+
}
|
|
284
|
+
const trimmed = trimOuterBlankLines(buffer);
|
|
285
|
+
if (trimmed.length > 0) {
|
|
309
286
|
cells.push({
|
|
310
287
|
index: cells.length,
|
|
311
288
|
title: undefined,
|
|
312
|
-
code:
|
|
289
|
+
code: trimmed.join("\n"),
|
|
313
290
|
language: state.language,
|
|
314
291
|
languageOrigin: state.languageOrigin,
|
|
315
292
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
316
293
|
reset: false,
|
|
317
294
|
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
while (i < lines.length) {
|
|
299
|
+
const headerInfo = parseHeaderLine(lines[i]);
|
|
300
|
+
if (headerInfo === null) {
|
|
301
|
+
// Loop invariant guarantees this is a header line; guard anyway.
|
|
302
|
+
i++;
|
|
318
303
|
continue;
|
|
319
304
|
}
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
305
|
+
const headerLineNumber = i + 1;
|
|
306
|
+
const info = parseHeaderInfo(headerInfo, headerLineNumber);
|
|
307
|
+
i++; // consume header line
|
|
308
|
+
|
|
309
|
+
const codeLines: string[] = [];
|
|
310
|
+
while (i < lines.length && parseHeaderLine(lines[i]) === null) {
|
|
311
|
+
codeLines.push(lines[i]);
|
|
312
|
+
i++;
|
|
313
|
+
}
|
|
314
|
+
// Strip trailing blank lines so visual spacing between cells doesn't
|
|
315
|
+
// leak into the preceding cell's code.
|
|
316
|
+
while (codeLines.length > 0 && codeLines[codeLines.length - 1].trim() === "") {
|
|
317
|
+
codeLines.pop();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const language = info.language ?? state.language;
|
|
321
|
+
const languageOrigin: EvalLanguageOrigin = info.language ? "header" : state.languageOrigin;
|
|
322
|
+
|
|
323
323
|
cells.push({
|
|
324
324
|
index: cells.length,
|
|
325
|
-
title:
|
|
326
|
-
code:
|
|
325
|
+
title: info.title,
|
|
326
|
+
code: codeLines.join("\n"),
|
|
327
327
|
language,
|
|
328
328
|
languageOrigin,
|
|
329
|
-
timeoutMs:
|
|
330
|
-
reset:
|
|
329
|
+
timeoutMs: info.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
330
|
+
reset: info.reset ?? false,
|
|
331
331
|
});
|
|
332
332
|
state.language = language;
|
|
333
333
|
state.languageOrigin = languageOrigin;
|
package/src/eval/py/prelude.py
CHANGED
|
@@ -3,8 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
if "__omp_prelude_loaded__" not in globals():
|
|
4
4
|
__omp_prelude_loaded__ = True
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
import os,
|
|
7
|
-
from datetime import datetime
|
|
6
|
+
import os, json, shutil, subprocess
|
|
8
7
|
from IPython.display import display as _ipy_display, JSON
|
|
9
8
|
|
|
10
9
|
_PRESENTABLE_REPRS = (
|
|
@@ -80,184 +79,6 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
80
79
|
f.write(content)
|
|
81
80
|
_emit_status("append", path=str(p), chars=len(content))
|
|
82
81
|
return p
|
|
83
|
-
def _load_gitignore_patterns(base: Path) -> list[str]:
|
|
84
|
-
"""Load .gitignore patterns from base directory and parents."""
|
|
85
|
-
patterns: list[str] = []
|
|
86
|
-
# Always exclude these
|
|
87
|
-
patterns.extend(["**/.git", "**/.git/**", "**/node_modules", "**/node_modules/**"])
|
|
88
|
-
# Walk up to find .gitignore files
|
|
89
|
-
current = base.resolve()
|
|
90
|
-
for _ in range(20): # Limit depth
|
|
91
|
-
gitignore = current / ".gitignore"
|
|
92
|
-
if gitignore.exists():
|
|
93
|
-
try:
|
|
94
|
-
for line in gitignore.read_text().splitlines():
|
|
95
|
-
line = line.strip()
|
|
96
|
-
if line and not line.startswith("#"):
|
|
97
|
-
# Normalize pattern for fnmatch
|
|
98
|
-
if line.startswith("/"):
|
|
99
|
-
patterns.append(str(current / line[1:]))
|
|
100
|
-
else:
|
|
101
|
-
patterns.append(f"**/{line}")
|
|
102
|
-
except Exception:
|
|
103
|
-
pass
|
|
104
|
-
parent = current.parent
|
|
105
|
-
if parent == current:
|
|
106
|
-
break
|
|
107
|
-
current = parent
|
|
108
|
-
return patterns
|
|
109
|
-
|
|
110
|
-
def _match_gitignore(path: Path, patterns: list[str], base: Path) -> bool:
|
|
111
|
-
"""Check if path matches any gitignore pattern."""
|
|
112
|
-
import fnmatch
|
|
113
|
-
rel = str(path.relative_to(base)) if path.is_relative_to(base) else str(path)
|
|
114
|
-
abs_path = str(path.resolve())
|
|
115
|
-
for pat in patterns:
|
|
116
|
-
if pat.startswith("**/"):
|
|
117
|
-
# Match against any part of the path
|
|
118
|
-
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, pat[3:]):
|
|
119
|
-
return True
|
|
120
|
-
# Also check each path component
|
|
121
|
-
for part in path.parts:
|
|
122
|
-
if fnmatch.fnmatch(part, pat[3:]):
|
|
123
|
-
return True
|
|
124
|
-
elif fnmatch.fnmatch(abs_path, pat) or fnmatch.fnmatch(rel, pat):
|
|
125
|
-
return True
|
|
126
|
-
return False
|
|
127
|
-
|
|
128
|
-
def find(
|
|
129
|
-
pattern: str,
|
|
130
|
-
path: str | Path = ".",
|
|
131
|
-
*,
|
|
132
|
-
type: str = "file",
|
|
133
|
-
limit: int = 1000,
|
|
134
|
-
hidden: bool = False,
|
|
135
|
-
sort_by_mtime: bool = False,
|
|
136
|
-
maxdepth: int | None = None,
|
|
137
|
-
mindepth: int | None = None,
|
|
138
|
-
) -> list[Path]:
|
|
139
|
-
"""Recursive glob find. Respects .gitignore.
|
|
140
|
-
|
|
141
|
-
maxdepth/mindepth are relative to path (0 = path itself, 1 = direct children).
|
|
142
|
-
"""
|
|
143
|
-
p = Path(path).resolve()
|
|
144
|
-
base_depth = len(p.parts)
|
|
145
|
-
ignore_patterns = _load_gitignore_patterns(p)
|
|
146
|
-
matches: list[Path] = []
|
|
147
|
-
for m in p.rglob(pattern):
|
|
148
|
-
if len(matches) >= limit:
|
|
149
|
-
break
|
|
150
|
-
# Check depth constraints
|
|
151
|
-
rel_depth = len(m.resolve().parts) - base_depth
|
|
152
|
-
if maxdepth is not None and rel_depth > maxdepth:
|
|
153
|
-
continue
|
|
154
|
-
if mindepth is not None and rel_depth < mindepth:
|
|
155
|
-
continue
|
|
156
|
-
# Skip hidden files unless requested
|
|
157
|
-
if not hidden and any(part.startswith(".") for part in m.parts):
|
|
158
|
-
continue
|
|
159
|
-
# Skip gitignored paths
|
|
160
|
-
if _match_gitignore(m, ignore_patterns, p):
|
|
161
|
-
continue
|
|
162
|
-
# Filter by type
|
|
163
|
-
if type == "file" and m.is_dir():
|
|
164
|
-
continue
|
|
165
|
-
if type == "dir" and not m.is_dir():
|
|
166
|
-
continue
|
|
167
|
-
matches.append(m)
|
|
168
|
-
if sort_by_mtime:
|
|
169
|
-
matches.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
|
170
|
-
else:
|
|
171
|
-
matches.sort()
|
|
172
|
-
_emit_status("find", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
|
|
173
|
-
return matches
|
|
174
|
-
|
|
175
|
-
def grep(
|
|
176
|
-
pattern: str,
|
|
177
|
-
path: str | Path,
|
|
178
|
-
*,
|
|
179
|
-
ignore_case: bool = False,
|
|
180
|
-
literal: bool = False,
|
|
181
|
-
context: int = 0,
|
|
182
|
-
) -> list[dict]:
|
|
183
|
-
"""Grep a single file. Returns dicts with line/text fields."""
|
|
184
|
-
p = Path(path)
|
|
185
|
-
lines = p.read_text(encoding="utf-8").splitlines()
|
|
186
|
-
if literal:
|
|
187
|
-
if ignore_case:
|
|
188
|
-
match_fn = lambda line: pattern.lower() in line.lower()
|
|
189
|
-
else:
|
|
190
|
-
match_fn = lambda line: pattern in line
|
|
191
|
-
else:
|
|
192
|
-
flags = re.IGNORECASE if ignore_case else 0
|
|
193
|
-
rx = re.compile(pattern, flags)
|
|
194
|
-
match_fn = lambda line: rx.search(line) is not None
|
|
195
|
-
|
|
196
|
-
match_lines: set[int] = set()
|
|
197
|
-
for i, line in enumerate(lines, 1):
|
|
198
|
-
if match_fn(line):
|
|
199
|
-
match_lines.add(i)
|
|
200
|
-
|
|
201
|
-
# Expand with context
|
|
202
|
-
if context > 0:
|
|
203
|
-
expanded: set[int] = set()
|
|
204
|
-
for ln in match_lines:
|
|
205
|
-
for offset in range(-context, context + 1):
|
|
206
|
-
expanded.add(ln + offset)
|
|
207
|
-
output_lines = sorted(ln for ln in expanded if 1 <= ln <= len(lines))
|
|
208
|
-
else:
|
|
209
|
-
output_lines = sorted(match_lines)
|
|
210
|
-
|
|
211
|
-
hits = [{"line": ln, "text": lines[ln - 1]} for ln in output_lines]
|
|
212
|
-
_emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=hits[:10])
|
|
213
|
-
return hits
|
|
214
|
-
|
|
215
|
-
def rgrep(
|
|
216
|
-
pattern: str,
|
|
217
|
-
path: str | Path = ".",
|
|
218
|
-
*,
|
|
219
|
-
glob_pattern: str = "*",
|
|
220
|
-
ignore_case: bool = False,
|
|
221
|
-
literal: bool = False,
|
|
222
|
-
limit: int = 100,
|
|
223
|
-
hidden: bool = False,
|
|
224
|
-
) -> list[dict]:
|
|
225
|
-
"""Recursive grep across files matching glob_pattern. Returns dicts with file/line/text fields. Respects .gitignore."""
|
|
226
|
-
if literal:
|
|
227
|
-
if ignore_case:
|
|
228
|
-
match_fn = lambda line: pattern.lower() in line.lower()
|
|
229
|
-
else:
|
|
230
|
-
match_fn = lambda line: pattern in line
|
|
231
|
-
else:
|
|
232
|
-
flags = re.IGNORECASE if ignore_case else 0
|
|
233
|
-
rx = re.compile(pattern, flags)
|
|
234
|
-
match_fn = lambda line: rx.search(line) is not None
|
|
235
|
-
|
|
236
|
-
base = Path(path)
|
|
237
|
-
ignore_patterns = _load_gitignore_patterns(base)
|
|
238
|
-
hits: list[dict] = []
|
|
239
|
-
for file_path in base.rglob(glob_pattern):
|
|
240
|
-
if len(hits) >= limit:
|
|
241
|
-
break
|
|
242
|
-
if file_path.is_dir():
|
|
243
|
-
continue
|
|
244
|
-
# Skip hidden files unless requested
|
|
245
|
-
if not hidden and any(part.startswith(".") for part in file_path.parts):
|
|
246
|
-
continue
|
|
247
|
-
# Skip gitignored paths
|
|
248
|
-
if _match_gitignore(file_path, ignore_patterns, base):
|
|
249
|
-
continue
|
|
250
|
-
try:
|
|
251
|
-
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
252
|
-
except Exception:
|
|
253
|
-
continue
|
|
254
|
-
for i, line in enumerate(lines, 1):
|
|
255
|
-
if len(hits) >= limit:
|
|
256
|
-
break
|
|
257
|
-
if match_fn(line):
|
|
258
|
-
hits.append({"file": str(file_path), "line": i, "text": line})
|
|
259
|
-
_emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=hits[:10])
|
|
260
|
-
return hits
|
|
261
82
|
class ShellResult:
|
|
262
83
|
"""Result from shell command execution."""
|
|
263
84
|
__slots__ = ("args", "stdout", "stderr", "returncode")
|
|
@@ -409,20 +230,6 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
409
230
|
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
|
|
410
231
|
return out
|
|
411
232
|
|
|
412
|
-
def stat(path: str | Path) -> dict:
|
|
413
|
-
"""Get file/directory info."""
|
|
414
|
-
p = Path(path)
|
|
415
|
-
s = p.stat()
|
|
416
|
-
info = {
|
|
417
|
-
"path": str(p),
|
|
418
|
-
"size": s.st_size,
|
|
419
|
-
"is_file": p.is_file(),
|
|
420
|
-
"is_dir": p.is_dir(),
|
|
421
|
-
"mtime": datetime.fromtimestamp(s.st_mtime).isoformat(),
|
|
422
|
-
}
|
|
423
|
-
_emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir(), mtime=info["mtime"])
|
|
424
|
-
return info
|
|
425
|
-
|
|
426
233
|
def diff(a: str | Path, b: str | Path) -> str:
|
|
427
234
|
"""Compare two files, return unified diff."""
|
|
428
235
|
import difflib
|
|
@@ -434,31 +241,6 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
434
241
|
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
|
|
435
242
|
return out
|
|
436
243
|
|
|
437
|
-
def glob(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[str]:
|
|
438
|
-
"""Non-recursive glob (use find() for recursive). Respects .gitignore."""
|
|
439
|
-
p = Path(path)
|
|
440
|
-
ignore_patterns = _load_gitignore_patterns(p)
|
|
441
|
-
matches: list[Path] = []
|
|
442
|
-
for m in p.glob(pattern):
|
|
443
|
-
# Skip hidden files unless requested
|
|
444
|
-
if not hidden and m.name.startswith("."):
|
|
445
|
-
continue
|
|
446
|
-
# Skip gitignored paths
|
|
447
|
-
if _match_gitignore(m, ignore_patterns, p):
|
|
448
|
-
continue
|
|
449
|
-
matches.append(m)
|
|
450
|
-
matches = sorted(matches)
|
|
451
|
-
_emit_status("glob", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
|
|
452
|
-
return matches
|
|
453
|
-
|
|
454
|
-
def sed(path: str | Path, pattern: str, repl: str, *, flags: int = 0) -> int:
|
|
455
|
-
"""Regex replace in file (like sed -i). Returns count."""
|
|
456
|
-
p = Path(path)
|
|
457
|
-
data = p.read_text(encoding="utf-8")
|
|
458
|
-
new, count = re.subn(pattern, repl, data, flags=flags)
|
|
459
|
-
p.write_text(new, encoding="utf-8")
|
|
460
|
-
_emit_status("sed", path=str(p), count=count)
|
|
461
|
-
return count
|
|
462
244
|
def output(
|
|
463
245
|
*ids: str,
|
|
464
246
|
format: str = "raw",
|