@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/config/settings-schema.ts +1 -1
  18. package/src/config/settings.ts +20 -1
  19. package/src/cursor.ts +1 -1
  20. package/src/edit/index.ts +9 -31
  21. package/src/edit/line-hash.ts +70 -43
  22. package/src/edit/modes/hashline.lark +26 -0
  23. package/src/edit/modes/hashline.ts +898 -1099
  24. package/src/edit/modes/patch.ts +0 -7
  25. package/src/edit/modes/replace.ts +0 -4
  26. package/src/edit/renderer.ts +22 -20
  27. package/src/edit/streaming.ts +8 -28
  28. package/src/eval/eval.lark +24 -30
  29. package/src/eval/js/context-manager.ts +5 -162
  30. package/src/eval/js/prelude.txt +0 -12
  31. package/src/eval/parse.ts +129 -129
  32. package/src/eval/py/prelude.py +1 -219
  33. package/src/export/html/template.generated.ts +1 -1
  34. package/src/export/html/template.js +2 -2
  35. package/src/internal-urls/docs-index.generated.ts +1 -1
  36. package/src/modes/components/session-observer-overlay.ts +5 -2
  37. package/src/modes/components/status-line/segments.ts +1 -1
  38. package/src/modes/components/status-line.ts +3 -5
  39. package/src/modes/components/tree-selector.ts +4 -5
  40. package/src/modes/components/welcome.ts +11 -1
  41. package/src/modes/controllers/command-controller.ts +2 -6
  42. package/src/modes/controllers/event-controller.ts +1 -2
  43. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  44. package/src/modes/controllers/input-controller.ts +0 -1
  45. package/src/modes/controllers/selector-controller.ts +1 -1
  46. package/src/modes/interactive-mode.ts +5 -7
  47. package/src/prompts/system/system-prompt.md +14 -38
  48. package/src/prompts/tools/ast-edit.md +8 -8
  49. package/src/prompts/tools/ast-grep.md +10 -10
  50. package/src/prompts/tools/eval.md +13 -31
  51. package/src/prompts/tools/find.md +2 -1
  52. package/src/prompts/tools/hashline.md +66 -57
  53. package/src/prompts/tools/search.md +2 -2
  54. package/src/session/session-manager.ts +17 -13
  55. package/src/tools/ast-edit.ts +141 -44
  56. package/src/tools/ast-grep.ts +112 -36
  57. package/src/tools/eval.ts +2 -53
  58. package/src/tools/find.ts +16 -15
  59. package/src/tools/path-utils.ts +36 -196
  60. package/src/tools/search.ts +56 -35
  61. package/src/utils/edit-mode.ts +2 -11
  62. package/src/utils/file-display-mode.ts +1 -1
  63. package/src/utils/git.ts +17 -0
  64. package/src/utils/session-color.ts +0 -12
  65. package/src/utils/title-generator.ts +22 -38
  66. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  67. package/src/autoresearch/contract.ts +0 -288
  68. package/src/edit/modes/atom.lark +0 -29
  69. package/src/edit/modes/atom.ts +0 -1773
  70. 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" | "fence";
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 fenced-language tokens we map onto our two backends. Matched
23
- * case-insensitively. Anything else found in a fence info string is treated as
24
- * a title fragment rather than a language; this is intentional fallback
25
- * behaviour and MUST NOT be advertised in the tool's prompt — the lark grammar
26
- * describes the canonical surface we encourage callers to emit.
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=value` in a fence info string) to one of
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 RawBlock {
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
- const ATTR_TOKEN_RE = /^([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|(.*))$/;
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
- function parseFenceOpener(line: string): { char: "`" | "~"; count: number; info: string } | null {
115
- const opener = /^(`{3,}|~{3,})(.*)$/.exec(line);
116
- if (!opener) return null;
117
- const run = opener[1];
118
- return { char: run[0] as "`" | "~", count: run.length, info: opener[2].trim() };
119
- }
120
-
121
- function isFenceCloser(line: string, char: "`" | "~", minCount: number): boolean {
122
- let count = 0;
123
- while (count < line.length && line[count] === char) count++;
124
- if (count < minCount) return false;
125
- return line.slice(count).trim() === "";
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 fence info string while preserving content inside matching
130
- * single or double quotes as a single token. The opening and closing quote
131
- * characters are kept verbatim so attribute parsing can strip them later.
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 fence info string into language, title, timeout, and reset flag.
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
- * Canonical attribute keys (the only ones surfaced in the lark grammar):
170
- * - `id` cell title
171
- * - `t` per-cell timeout
172
- * - `rst` → boolean reset for this cell's kernel
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
- * Lenient fallback aliases (NOT advertised in the prompt; we silently accept
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 key
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 parseFenceInfo(info: string, lineNumber: number): FenceInfo {
191
- const tokens = tokenizeInfoString(info.trim());
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" && idAttr === undefined) idAttr = value;
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
- if (idx === 0) {
215
- const lang = resolveLanguageAlias(token);
216
- if (lang) {
217
- language = lang;
218
- continue;
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 = (idAttr ?? "").trim();
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
- const blocks = splitIntoBlocks(lines);
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
- for (const block of blocks) {
308
- if (block.type === "raw") {
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: block.lines.join("\n"),
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 fence = parseFenceInfo(block.info, block.startLine);
321
- const language = fence.language ?? state.language;
322
- const languageOrigin: EvalLanguageOrigin = fence.language ? "fence" : state.languageOrigin;
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: fence.title,
326
- code: block.codeLines.join("\n"),
325
+ title: info.title,
326
+ code: codeLines.join("\n"),
327
327
  language,
328
328
  languageOrigin,
329
- timeoutMs: fence.timeoutMs ?? DEFAULT_TIMEOUT_MS,
330
- reset: fence.reset ?? false,
329
+ timeoutMs: info.timeoutMs ?? DEFAULT_TIMEOUT_MS,
330
+ reset: info.reset ?? false,
331
331
  });
332
332
  state.language = language;
333
333
  state.languageOrigin = languageOrigin;
@@ -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, re, json, shutil, subprocess
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",