@oh-my-pi/pi-coding-agent 14.8.0 → 14.9.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 (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/extensions/types.ts +10 -3
  13. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  14. package/src/hashline/anchors.ts +113 -0
  15. package/src/hashline/apply.ts +732 -0
  16. package/src/hashline/bigrams.json +649 -0
  17. package/src/hashline/constants.ts +8 -0
  18. package/src/hashline/diff-preview.ts +43 -0
  19. package/src/hashline/diff.ts +56 -0
  20. package/src/hashline/execute.ts +268 -0
  21. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  22. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  23. package/src/hashline/index.ts +14 -0
  24. package/src/hashline/input.ts +110 -0
  25. package/src/hashline/parser.ts +220 -0
  26. package/src/hashline/prefixes.ts +101 -0
  27. package/src/hashline/recovery.ts +72 -0
  28. package/src/hashline/stream.ts +123 -0
  29. package/src/hashline/types.ts +69 -0
  30. package/src/hashline/utils.ts +3 -0
  31. package/src/index.ts +1 -1
  32. package/src/lsp/index.ts +1 -1
  33. package/src/lsp/render.ts +4 -0
  34. package/src/memories/index.ts +13 -4
  35. package/src/modes/components/assistant-message.ts +55 -9
  36. package/src/modes/components/welcome.ts +114 -38
  37. package/src/modes/controllers/event-controller.ts +3 -1
  38. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  39. package/src/modes/controllers/input-controller.ts +8 -1
  40. package/src/modes/interactive-mode.ts +50 -11
  41. package/src/modes/prompt-action-autocomplete.ts +3 -0
  42. package/src/modes/rpc/rpc-client.ts +53 -2
  43. package/src/modes/rpc/rpc-mode.ts +67 -1
  44. package/src/modes/rpc/rpc-types.ts +17 -2
  45. package/src/modes/types.ts +4 -1
  46. package/src/modes/utils/ui-helpers.ts +3 -1
  47. package/src/prompts/agents/reviewer.md +14 -0
  48. package/src/prompts/tools/hashline.md +57 -10
  49. package/src/sdk.ts +4 -3
  50. package/src/session/agent-session.ts +195 -30
  51. package/src/session/compaction/branch-summarization.ts +4 -2
  52. package/src/session/compaction/compaction.ts +22 -3
  53. package/src/task/executor.ts +21 -2
  54. package/src/task/index.ts +4 -1
  55. package/src/tools/ast-edit.ts +1 -1
  56. package/src/tools/match-line-format.ts +1 -1
  57. package/src/tools/read.ts +1 -1
  58. package/src/utils/file-mentions.ts +1 -1
  59. package/src/utils/title-generator.ts +11 -0
  60. package/src/edit/modes/hashline.ts +0 -2039
@@ -1,4 +1,7 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
4
+ import * as url from "node:url";
2
5
 
3
6
  const LEGACY_PI_PACKAGE_MAP = {
4
7
  "@mariozechner/pi-agent-core": "@oh-my-pi/pi-agent-core",
@@ -54,6 +57,10 @@ function getResolvedSpecifier(specifier: string): string {
54
57
  return resolved;
55
58
  }
56
59
 
60
+ function toImportSpecifier(resolvedPath: string): string {
61
+ return url.pathToFileURL(resolvedPath).href;
62
+ }
63
+
57
64
  function rewriteLegacyPiImports(source: string): string {
58
65
  return source.replace(
59
66
  LEGACY_PI_IMPORT_SPECIFIER_REGEX,
@@ -63,42 +70,118 @@ function rewriteLegacyPiImports(source: string): string {
63
70
  return match;
64
71
  }
65
72
 
66
- return `${prefix}${getResolvedSpecifier(remappedSpecifier)}${suffix}`;
73
+ return `${prefix}${toImportSpecifier(getResolvedSpecifier(remappedSpecifier))}${suffix}`;
67
74
  },
68
75
  );
69
76
  }
70
77
 
71
- // Match `from "..."`, `from '...'`, `import("...")`, `import('...')` import specifiers.
78
+ // Match static `from "..."` / `from '...'` import specifiers.
79
+ const STATIC_IMPORT_SPECIFIER_REGEX = /(from\s+["'])([^"']+)(["'])/g;
80
+ // Match static imports plus dynamic `import("...")` / `import('...')` specifiers.
72
81
  const ANY_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])([^"']+)(["'])/g;
73
82
 
74
- /**
75
- * Resolves bare module specifiers in a legacy-namespaced extension source file
76
- * to absolute paths anchored at the extension's own directory. Without this,
77
- * imports inside files loaded via the `omp-legacy-pi-file:` namespace bypass
78
- * Node-style node_modules lookup, so an extension cannot use its own deps.
79
- * Relative paths and already-resolved absolute paths are left untouched.
80
- */
83
+ /** Resolve bare imports against the extension directory before loading mirrored legacy Pi files. */
84
+ function isUrlLikeSpecifier(specifier: string): boolean {
85
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
86
+ }
87
+
88
+ function shouldPreserveImportSpecifier(specifier: string): boolean {
89
+ return specifier.startsWith(".") || path.isAbsolute(specifier) || isUrlLikeSpecifier(specifier);
90
+ }
91
+
92
+ function toRewrittenImportSpecifier(resolvedPath: string): string {
93
+ return isUrlLikeSpecifier(resolvedPath) ? resolvedPath : toImportSpecifier(resolvedPath);
94
+ }
95
+
81
96
  function rewriteBareImportsForLegacyExtension(source: string, importerPath: string): string {
82
97
  const importerDir = path.dirname(importerPath);
83
98
  return source.replace(ANY_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => {
84
99
  // Skip relative, absolute, URL-style, and already-resolved Node specifiers.
85
- if (
86
- specifier.startsWith(".") ||
87
- specifier.startsWith("/") ||
88
- specifier.startsWith("node:") ||
89
- specifier.includes("://")
90
- ) {
100
+ if (shouldPreserveImportSpecifier(specifier)) {
91
101
  return match;
92
102
  }
93
103
  try {
94
104
  const resolved = Bun.resolveSync(specifier, importerDir);
95
- return `${prefix}${resolved}${suffix}`;
105
+ return `${prefix}${toRewrittenImportSpecifier(resolved)}${suffix}`;
96
106
  } catch {
97
107
  return match;
98
108
  }
99
109
  });
100
110
  }
101
111
 
112
+ interface LegacyPiMirrorState {
113
+ root: string;
114
+ seen: Map<string, string>;
115
+ }
116
+
117
+ function getMirrorPath(sourcePath: string, state: LegacyPiMirrorState): string {
118
+ const extension = path.extname(sourcePath) || ".js";
119
+ const digest = Bun.hash(sourcePath).toString(36);
120
+ return path.join(state.root, `${digest}${extension}`);
121
+ }
122
+
123
+ async function rewriteRelativeImportsForLegacyExtension(
124
+ source: string,
125
+ importerPath: string,
126
+ state: LegacyPiMirrorState,
127
+ ): Promise<string> {
128
+ const replacements = new Map<string, string>();
129
+
130
+ for (const match of source.matchAll(STATIC_IMPORT_SPECIFIER_REGEX)) {
131
+ const specifier = match[2];
132
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) {
133
+ continue;
134
+ }
135
+
136
+ const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
137
+ const mirrored = await mirrorLegacyPiFile(resolved, state);
138
+ replacements.set(specifier, toImportSpecifier(mirrored));
139
+ }
140
+
141
+ if (replacements.size === 0) {
142
+ return source;
143
+ }
144
+
145
+ return source.replace(STATIC_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => {
146
+ const replacement = replacements.get(specifier);
147
+ return replacement ? `${prefix}${replacement}${suffix}` : match;
148
+ });
149
+ }
150
+
151
+ async function rewriteLegacyPiImportsForRuntime(
152
+ source: string,
153
+ importerPath: string,
154
+ state: LegacyPiMirrorState,
155
+ ): Promise<string> {
156
+ const withRelativeResolved = await rewriteRelativeImportsForLegacyExtension(source, importerPath, state);
157
+ const withLegacyRemap = rewriteLegacyPiImports(withRelativeResolved);
158
+ return rewriteBareImportsForLegacyExtension(withLegacyRemap, importerPath);
159
+ }
160
+
161
+ async function mirrorLegacyPiFile(sourcePath: string, state: LegacyPiMirrorState): Promise<string> {
162
+ const resolvedPath = path.resolve(sourcePath);
163
+ const cached = state.seen.get(resolvedPath);
164
+ if (cached) {
165
+ return cached;
166
+ }
167
+
168
+ const mirrorPath = getMirrorPath(resolvedPath, state);
169
+ state.seen.set(resolvedPath, mirrorPath);
170
+
171
+ const raw = await Bun.file(resolvedPath).text();
172
+ const rewritten = await rewriteLegacyPiImportsForRuntime(raw, resolvedPath, state);
173
+ await Bun.write(mirrorPath, rewritten);
174
+ return mirrorPath;
175
+ }
176
+
177
+ export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
178
+ const root = path.join(os.tmpdir(), "omp-legacy-pi-file", Bun.hash(resolvedPath).toString(36));
179
+ await fs.rm(root, { recursive: true, force: true });
180
+ const state: LegacyPiMirrorState = { root, seen: new Map() };
181
+ const mirroredEntry = await mirrorLegacyPiFile(resolvedPath, state);
182
+ return import(`${toImportSpecifier(mirroredEntry)}?mtime=${Date.now()}`);
183
+ }
184
+
102
185
  function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
103
186
  if (path.endsWith(".tsx")) {
104
187
  return "tsx";
@@ -150,10 +233,6 @@ export function installLegacyPiSpecifierShim(): void {
150
233
 
151
234
  build.onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: LEGACY_PI_FILE_NAMESPACE }, async args => {
152
235
  const raw = await Bun.file(args.path).text();
153
- // Bare specifiers (e.g. "lodash", "@scope/pkg/sub") imported from a legacy-namespaced
154
- // extension file would otherwise bypass Node-style node_modules lookup because the
155
- // importer lives in a custom namespace. Pre-resolve them to absolute paths so the
156
- // extension's own node_modules are honored.
157
236
  const withLegacyRemap = rewriteLegacyPiImports(raw);
158
237
  const withBareResolved = rewriteBareImportsForLegacyExtension(withLegacyRemap, args.path);
159
238
  return {
@@ -0,0 +1,113 @@
1
+ import { formatCodeFrameLine } from "../tools/render-utils";
2
+ import { MISMATCH_CONTEXT } from "./constants";
3
+ import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash";
4
+ import type { HashMismatch } from "./types";
5
+
6
+ const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
7
+ const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
8
+ const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
9
+
10
+ export function formatFullAnchorRequirement(raw?: string): string {
11
+ const suffix = typeof raw === "string" ? raw.trim() : "";
12
+ const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
13
+ ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
14
+ `Copy the full anchor exactly as shown (for example, "160${suffix}").`
15
+ : "";
16
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
17
+ return (
18
+ `the full anchor exactly as shown by read/search output ` +
19
+ `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
20
+ );
21
+ }
22
+
23
+ export function parseTag(ref: string): { line: number; hash: string } {
24
+ const match = ref.match(PARSE_TAG_RE);
25
+ if (!match) {
26
+ throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
27
+ }
28
+ const line = Number.parseInt(match[1], 10);
29
+ if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
30
+ return { line, hash: match[2] };
31
+ }
32
+
33
+ function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
34
+ const displayLines = new Set<number>();
35
+ for (const mismatch of mismatches) {
36
+ const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
37
+ const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
38
+ for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
39
+ }
40
+ return [...displayLines].sort((a, b) => a - b);
41
+ }
42
+
43
+ export class HashlineMismatchError extends Error {
44
+ readonly remaps: ReadonlyMap<string, string>;
45
+
46
+ constructor(
47
+ public readonly mismatches: HashMismatch[],
48
+ public readonly fileLines: string[],
49
+ ) {
50
+ super(HashlineMismatchError.formatMessage(mismatches, fileLines));
51
+ this.name = "HashlineMismatchError";
52
+
53
+ const remaps = new Map<string, string>();
54
+ for (const mismatch of mismatches) {
55
+ const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
56
+ remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
57
+ }
58
+ this.remaps = remaps;
59
+ }
60
+
61
+ get displayMessage(): string {
62
+ return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
63
+ }
64
+
65
+ private static rejectionHeader(mismatches: HashMismatch[]): string[] {
66
+ const noun = mismatches.length > 1 ? "lines have" : "line has";
67
+ return [
68
+ `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`,
69
+ "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
70
+ ];
71
+ }
72
+
73
+ static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
74
+ const mismatchSet = new Set<number>(mismatches.map(m => m.line));
75
+ const displayLines = getMismatchDisplayLines(mismatches, fileLines);
76
+ const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
77
+
78
+ const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
79
+ let previous = -1;
80
+ for (const lineNum of displayLines) {
81
+ if (previous !== -1 && lineNum > previous + 1) out.push("...");
82
+ previous = lineNum;
83
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
84
+ out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
85
+ }
86
+ return out.join("\n");
87
+ }
88
+
89
+ static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
90
+ const mismatchSet = new Set<number>(mismatches.map(m => m.line));
91
+ const lines = HashlineMismatchError.rejectionHeader(mismatches);
92
+ let previous = -1;
93
+ for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
94
+ if (previous !== -1 && lineNum > previous + 1) lines.push("...");
95
+ previous = lineNum;
96
+ const text = fileLines[lineNum - 1] ?? "";
97
+ const hash = computeLineHash(lineNum, text);
98
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
99
+ lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
100
+ }
101
+ return lines.join("\n");
102
+ }
103
+ }
104
+
105
+ export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
106
+ if (ref.line < 1 || ref.line > fileLines.length) {
107
+ throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
108
+ }
109
+ const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
110
+ if (actualHash !== ref.hash) {
111
+ throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
112
+ }
113
+ }