@oh-my-pi/pi-coding-agent 14.6.1 → 14.6.3

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 (63) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +21 -0
  3. package/package.json +23 -7
  4. package/src/cli/grievances-cli.ts +89 -4
  5. package/src/commands/grievances.ts +33 -7
  6. package/src/config/prompt-templates.ts +14 -7
  7. package/src/config/settings-schema.ts +595 -100
  8. package/src/config/settings.ts +46 -0
  9. package/src/discovery/helpers.ts +13 -6
  10. package/src/edit/index.ts +3 -3
  11. package/src/edit/line-hash.ts +73 -25
  12. package/src/edit/modes/hashline.lark +10 -3
  13. package/src/edit/modes/hashline.ts +104 -38
  14. package/src/edit/renderer.ts +3 -3
  15. package/src/hindsight/backend.ts +444 -0
  16. package/src/hindsight/bank.ts +131 -0
  17. package/src/hindsight/client.ts +445 -0
  18. package/src/hindsight/config.ts +165 -0
  19. package/src/hindsight/content.ts +205 -0
  20. package/src/hindsight/index.ts +6 -0
  21. package/src/hindsight/retain-queue.ts +166 -0
  22. package/src/hindsight/transcript.ts +71 -0
  23. package/src/main.ts +7 -10
  24. package/src/memories/index.ts +1 -1
  25. package/src/memory-backend/index.ts +4 -0
  26. package/src/memory-backend/local-backend.ts +30 -0
  27. package/src/memory-backend/off-backend.ts +16 -0
  28. package/src/memory-backend/resolve.ts +24 -0
  29. package/src/memory-backend/types.ts +69 -0
  30. package/src/modes/components/settings-defs.ts +50 -451
  31. package/src/modes/components/settings-selector.ts +4 -2
  32. package/src/modes/components/status-line/presets.ts +1 -1
  33. package/src/modes/components/status-line.ts +4 -1
  34. package/src/modes/controllers/command-controller.ts +6 -5
  35. package/src/modes/controllers/event-controller.ts +12 -0
  36. package/src/modes/controllers/mcp-command-controller.ts +23 -0
  37. package/src/modes/controllers/selector-controller.ts +10 -12
  38. package/src/modes/interactive-mode.ts +3 -2
  39. package/src/modes/theme/theme.ts +4 -0
  40. package/src/prompts/tools/github.md +3 -0
  41. package/src/prompts/tools/hashline.md +20 -16
  42. package/src/prompts/tools/read.md +10 -6
  43. package/src/prompts/tools/recall.md +5 -0
  44. package/src/prompts/tools/reflect.md +5 -0
  45. package/src/prompts/tools/retain.md +5 -0
  46. package/src/prompts/tools/search.md +1 -1
  47. package/src/sdk.ts +12 -9
  48. package/src/session/agent-session.ts +75 -3
  49. package/src/slash-commands/builtin-registry.ts +2 -12
  50. package/src/ssh/connection-manager.ts +1 -1
  51. package/src/tools/ast-edit.ts +14 -5
  52. package/src/tools/ast-grep.ts +12 -3
  53. package/src/tools/find.ts +47 -7
  54. package/src/tools/gh-renderer.ts +10 -1
  55. package/src/tools/gh.ts +233 -5
  56. package/src/tools/hindsight-recall.ts +70 -0
  57. package/src/tools/hindsight-reflect.ts +57 -0
  58. package/src/tools/hindsight-retain.ts +63 -0
  59. package/src/tools/index.ts +17 -0
  60. package/src/tools/output-meta.ts +1 -0
  61. package/src/tools/path-utils.ts +55 -0
  62. package/src/tools/read.ts +1 -1
  63. package/src/tools/search.ts +45 -8
@@ -568,6 +568,48 @@ export class Settings {
568
568
  }
569
569
  }
570
570
 
571
+ // Map legacy `memories.enabled` boolean to the explicit `memory.backend`
572
+ // enum if the latter hasn't been set yet. Idempotent: subsequent
573
+ // migrations are no-ops once memory.backend is materialised.
574
+ const memoryBackendObj = raw.memory as Record<string, unknown> | undefined;
575
+ const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string";
576
+ const memoriesObj = raw.memories as Record<string, unknown> | undefined;
577
+ if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") {
578
+ const next = memoriesObj.enabled ? "local" : "off";
579
+ const memoryRoot = (memoryBackendObj ?? {}) as Record<string, unknown>;
580
+ memoryRoot.backend = next;
581
+ raw.memory = memoryRoot;
582
+ }
583
+
584
+ // hindsight: dynamicBankId/agentName -> scoping enum + bankId
585
+ // - dynamicBankId=true → scoping="per-project" (closest semantic match;
586
+ // the legacy `agent::project::channel::user` tuple was per-project in
587
+ // practice — the channel/user env vars were rarely set).
588
+ // - hindsight.agentName was only used as the agent slot in the legacy
589
+ // dynamic tuple; if the user customised it we surface it as the new
590
+ // bankId base when no explicit bankId is set.
591
+ const hindsightObj = raw.hindsight as Record<string, unknown> | undefined;
592
+ if (hindsightObj) {
593
+ if ("dynamicBankId" in hindsightObj) {
594
+ if (!("scoping" in hindsightObj) && hindsightObj.dynamicBankId === true) {
595
+ hindsightObj.scoping = "per-project";
596
+ }
597
+ delete hindsightObj.dynamicBankId;
598
+ }
599
+ if ("agentName" in hindsightObj) {
600
+ const agentName = hindsightObj.agentName;
601
+ if (
602
+ !("bankId" in hindsightObj) &&
603
+ typeof agentName === "string" &&
604
+ agentName.trim().length > 0 &&
605
+ agentName !== "omp"
606
+ ) {
607
+ hindsightObj.bankId = agentName;
608
+ }
609
+ delete hindsightObj.agentName;
610
+ }
611
+ }
612
+
571
613
  return raw;
572
614
  }
573
615
 
@@ -713,6 +755,10 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
713
755
  let globalInstance: Settings | null = null;
714
756
  let globalInstancePromise: Promise<Settings> | null = null;
715
757
 
758
+ export function isSettingsInitialized(): boolean {
759
+ return globalInstance !== null;
760
+ }
761
+
716
762
  /**
717
763
  * Reset the global singleton for testing.
718
764
  * @internal
@@ -3,7 +3,14 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { FileType, glob } from "@oh-my-pi/pi-natives";
6
- import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
6
+ import {
7
+ CONFIG_DIR_NAME,
8
+ getConfigDirName,
9
+ getPluginsDir,
10
+ getProjectDir,
11
+ parseFrontmatter,
12
+ tryParseJson,
13
+ } from "@oh-my-pi/pi-utils";
7
14
  import type { ExtensionModule } from "../capability/extension-module";
8
15
  import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../capability/fs";
9
16
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
@@ -804,8 +811,9 @@ export async function listClaudePluginRoots(
804
811
 
805
812
  // ── OMP installed plugins registry ───────────────────────────────────────
806
813
  // OMP registry is authoritative: its entries replace Claude's entries for the same plugin ID.
807
- // Path derived from `home` (not os.homedir()) so test isolation works when home is overridden.
808
- const ompRegistryPath = path.join(home, getConfigDirName(), "plugins", "installed_plugins.json");
814
+ // getPluginsDir() resolves to the same path the marketplace writer uses
815
+ // (XDG-aware via the dir resolver), so reads and writes always agree.
816
+ const ompRegistryPath = path.join(getPluginsDir(), "installed_plugins.json");
809
817
  const ompContent = await readFile(ompRegistryPath);
810
818
  if (ompContent) {
811
819
  const ompRegistry = parseClaudePluginsRegistry(ompContent);
@@ -928,9 +936,8 @@ export function clearClaudePluginRootsCache(): void {
928
936
  * installing/uninstalling/enabling/disabling plugins.
929
937
  */
930
938
  export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void {
931
- const home = os.homedir();
932
- invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
933
- invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
939
+ invalidateFsCache(path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json"));
940
+ invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json"));
934
941
  for (const p of extraPaths ?? []) invalidateFsCache(p);
935
942
  clearClaudePluginRootsCache();
936
943
  }
package/src/edit/index.ts CHANGED
@@ -16,7 +16,7 @@ import type { ToolSession } from "../tools";
16
16
  import { VimTool, vimSchema } from "../tools/vim";
17
17
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
18
18
  import type { VimToolDetails } from "../vim/types";
19
- import { resolveLarkLidPlaceholders } from "./line-hash";
19
+ import { resolveHashlineGrammarPlaceholders } from "./line-hash";
20
20
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
21
21
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
22
22
  import {
@@ -35,8 +35,8 @@ export * from "./apply-patch";
35
35
  export * from "./diff";
36
36
  export * from "./line-hash";
37
37
 
38
- // Resolve the `$HASHFMT$` placeholder in the hashline Lark grammar.
39
- const hashlineGrammar = resolveLarkLidPlaceholders(hashlineGrammarTemplate);
38
+ // Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar.
39
+ const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
40
40
 
41
41
  export * from "./modes/apply-patch";
42
42
  export * from "./modes/hashline";
@@ -15,7 +15,7 @@
15
15
  * Order is stable forever — changing it would invalidate every saved
16
16
  * `LINE+ID` reference in transcripts and prompts.
17
17
  */
18
- export const HASHLINE_BIGRAMS = [
18
+ export const HL_BIGRAMS = [
19
19
  "aa",
20
20
  "ab",
21
21
  "ac",
@@ -665,7 +665,7 @@ export const HASHLINE_BIGRAMS = [
665
665
  "zz",
666
666
  ] as const;
667
667
 
668
- export const HASHLINE_BIGRAMS_COUNT = HASHLINE_BIGRAMS.length;
668
+ export const HL_BIGRAMS_COUNT = HL_BIGRAMS.length;
669
669
 
670
670
  /**
671
671
  * Decoration prefix that may precede a `LINE+HASH` anchor in tool output:
@@ -675,7 +675,7 @@ export const HASHLINE_BIGRAMS_COUNT = HASHLINE_BIGRAMS.length;
675
675
  * regex stays liberal because anchor-ref parsers accept whatever the model
676
676
  * echoes back.
677
677
  */
678
- export const HASHLINE_ANCHOR_DECORATION_RE_SRC = `\\s*[>+\\-*]*\\s*`;
678
+ export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>+\\-*]*\\s*`;
679
679
 
680
680
  /**
681
681
  * Capture-group regex source for a decorated `LINE+HASH` anchor. Group 1
@@ -683,62 +683,110 @@ export const HASHLINE_ANCHOR_DECORATION_RE_SRC = `\\s*[>+\\-*]*\\s*`;
683
683
  * source is intentionally unanchored — anchoring with `^` (or composing into a
684
684
  * larger pattern) is the caller's responsibility.
685
685
  */
686
- export const HASHLINE_ANCHOR_RE_SRC = `${HASHLINE_ANCHOR_DECORATION_RE_SRC}(\\d+)([a-z]{2})`;
686
+ export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)([a-z]{2})`;
687
687
 
688
688
  /**
689
689
  * Bare `LINE+HASH` Lid (no decorations, no captures, no anchors). Use for
690
690
  * embedding inside larger patterns where the line+hash unit appears as a
691
691
  * literal (e.g. range bounds, alternation arms, op-line heuristics).
692
692
  */
693
- export const HASHLINE_LID_RE_SRC = `[1-9]\\d*[a-z]{2}`;
693
+ export const HL_HASH_RE_RAW = `[1-9]\\d*[a-z]{2}`;
694
694
 
695
695
  /**
696
- * Capture-group form of {@link HASHLINE_LID_RE_SRC}: group 1 captures the
696
+ * Capture-group form of {@link HL_HASH_RE_RAW}: group 1 captures the
697
697
  * line number, group 2 captures the hash.
698
698
  */
699
- export const HASHLINE_LID_CAPTURE_RE_SRC = `([1-9]\\d*)([a-z]{2})`;
699
+ export const HL_HASH_CAPTURE_RE_RAW = `([1-9]\\d*)([a-z]{2})`;
700
700
 
701
701
  /** Width of a hash in display characters. */
702
- export const HASHLINE_HASH_WIDTH = 2;
702
+ export const HL_HASH_WIDTH = 2;
703
703
 
704
704
  /**
705
705
  * Representative hash suffixes for use in user-facing error messages and
706
706
  * prompt examples.
707
707
  */
708
- export const HASHLINE_HASH_EXAMPLES = ["sr", "ab", "th"] as const;
708
+ export const HL_HASH_EXAMPLES = ["sr", "ab", "th"] as const;
709
709
 
710
710
  /**
711
711
  * Format a comma-separated list of example anchors with an optional line-number
712
712
  * prefix, quoted for inclusion in error messages: `"160sr", "160ab", "160th"`.
713
713
  */
714
714
  export function describeAnchorExamples(linePrefix = ""): string {
715
- return HASHLINE_HASH_EXAMPLES.map(e => `"${linePrefix}${e}"`).join(", ");
715
+ return HL_HASH_EXAMPLES.map(e => `"${linePrefix}${e}"`).join(", ");
716
716
  }
717
717
 
718
718
  /**
719
- * Sentinel token that the hashline Lark grammar uses for the hash
720
- * regex source. Replaced at module-load time by {@link resolveLarkLidPlaceholders}
721
- * so the grammar is re-derived from a single source of truth alongside its
722
- * TypeScript consumers. Update the placeholder name here and in the grammar together.
719
+ * Substitute every grammar placeholder with the value derived from its
720
+ * TypeScript counterpart. Grammars that don't reference these placeholders
721
+ * pass through unchanged.
723
722
  */
724
- export const LARK_LID_HASH_PLACEHOLDER = "$HASHFMT$";
723
+ export function resolveHashlineGrammarPlaceholders(grammar: string): string {
724
+ return grammar.replaceAll("$HFMT$", "[a-z]{2}").replaceAll("$HSEP$", JSON.stringify(HL_EDIT_SEP));
725
+ }
726
+
727
+ /** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */
728
+ export const resolveLarkLidPlaceholders = resolveHashlineGrammarPlaceholders;
729
+
730
+ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
725
731
 
726
732
  /**
727
- * Substitute the LID hash placeholder in a Lark grammar text with the
728
- * `[a-z]{2}` hash regex source. Grammars that don't reference Lids pass
729
- * through unchanged.
733
+ * Single source of truth for the hashline edit payload separator. This is the
734
+ * configured separator that starts inserted/replacement payload lines in
735
+ * hashline edit input (`<separator>TEXT`) and separates inline modify ops from
736
+ * their appended/prepended text.
737
+ *
738
+ * Override at runtime with the `PI_HL_SEP` env var (e.g.
739
+ * `PI_HL_SEP=">"`, `PI_HL_SEP="\\"`). The value is read once at module load;
740
+ * the edit grammar, prompt helper, and edit parser derive from it.
741
+ *
742
+ * Default is `~`, chosen empirically. Benchmark across 8 candidate separators
743
+ * x 3 models (glm-4.7:nitro, gpt-5.4-nano, claude-sonnet-4-6), 24-48 runs per
744
+ * cell, hashline variant, 12 sampled tasks per run:
745
+ *
746
+ * sep | task ✓ | edit ✓ | patch fail | tok/run
747
+ * ----|--------|--------|-----------------|--------
748
+ * + | 70.8% | 78.0% | 27/125 (21.6%) | 32,127
749
+ * ÷ | 70.7% | 90.6% | 22/211 (10.4%) | 31,666
750
+ * ~ | 69.4% | 94.9% | 6/107 ( 5.6%) | 30,529 <-- default
751
+ * > | 69.2% | 91.5% | 21/219 ( 9.6%) | 30,777
752
+ * : | 66.7% | 86.4% | 20/126 (15.9%) | 33,900
753
+ * | | 65.9% | 86.9% | 20/127 (15.7%) | 34,589
754
+ * \ | 65.5% | 89.8% | 16/124 (12.9%) | 36,010
755
+ * % | 63.9% | 92.8% | 11/125 ( 8.8%) | 36,530
756
+ *
757
+ * `~` wins because:
758
+ * - highest edit-tool success rate (94.9%) of any tested separator
759
+ * - lowest patch-failure rate (5.6%) — model rarely emits a malformed payload
760
+ * - cheapest in tokens alongside `>` (no retry overhead from format collisions)
761
+ * - no line-leading role in any mainstream language, markdown, diff, regex,
762
+ * or shell, so payload lines are unambiguous to both the parser and models
763
+ * - task-success is statistically tied with `>` and `÷` (within run-to-run
764
+ * noise), so the edit-reliability win is free
765
+ *
766
+ * `+` and `÷` lead on raw task-success but at the cost of ~2-4x more patch
767
+ * failures (the model retries until it lands a valid edit). `:`, `|`, `\`
768
+ * collide with line-leading syntax (label/object-key, body separator, escape)
769
+ * and degrade both edit reliability and intent-match.
730
770
  */
731
- export function resolveLarkLidPlaceholders(grammar: string): string {
732
- return grammar.replaceAll(LARK_LID_HASH_PLACEHOLDER, "[a-z]{2}");
733
- }
771
+ export const HL_EDIT_SEP = (() => {
772
+ const sep = process.env.PI_HL_SEP?.trim();
773
+ return sep?.length === 1 ? sep : "~";
774
+ })();
775
+
776
+ /** Regex-escaped form of {@link HL_EDIT_SEP}, safe for regexes. */
777
+ export const HL_EDIT_SEP_RE_RAW = regexEscape(HL_EDIT_SEP);
778
+
779
+ /** Stable separator for read/search/hashline display output. Intentionally not configurable. */
780
+ export const HL_BODY_SEP = "|";
734
781
 
735
- export const HASHLINE_CONTENT_SEPARATOR = "|";
782
+ /** Regex-escaped form of {@link HL_BODY_SEP}, safe for embedding inside a regex. */
783
+ export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
736
784
 
737
785
  const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
738
786
 
739
787
  /**
740
788
  * Compute a 2-character hash of a single line via xxHash32 mod 647 over
741
- * {@link HASHLINE_BIGRAMS}. Lines with no letter or digit (e.g. bare `}`,
789
+ * {@link HL_BIGRAMS}. Lines with no letter or digit (e.g. bare `}`,
742
790
  * bare `{`) mix the line number into the seed so adjacent identical
743
791
  * brace-only lines get distinct hashes; lines with significant content stay
744
792
  * line-number-independent so a line is identifiable across small shifts.
@@ -748,7 +796,7 @@ const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
748
796
  export function computeLineHash(idx: number, line: string): string {
749
797
  line = line.replace(/\r/g, "").trimEnd();
750
798
  const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
751
- return HASHLINE_BIGRAMS[Bun.hash.xxHash32(line, seed) % HASHLINE_BIGRAMS_COUNT];
799
+ return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
752
800
  }
753
801
 
754
802
  /**
@@ -765,7 +813,7 @@ export function formatLineHash(line: number, lines: string): string {
765
813
  * Returns `LINE+ID|TEXT` (e.g., `42sr|function hi() {`, `3ab|}`).
766
814
  */
767
815
  export function formatHashLine(lineNumber: number, line: string): string {
768
- return `${lineNumber}${computeLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
816
+ return `${lineNumber}${computeLineHash(lineNumber, line)}${HL_BODY_SEP}${line}`;
769
817
  }
770
818
 
771
819
  /**
@@ -1,4 +1,5 @@
1
1
  %import common.LF
2
+ %import common.WS_INLINE
2
3
 
3
4
  start: section+
4
5
 
@@ -6,21 +7,27 @@ section: file_header line_op*
6
7
 
7
8
  file_header: "@" path LF
8
9
 
9
- line_op: insert_before_op payload+
10
+ line_op: inline_before_op payload*
11
+ | inline_after_op payload*
12
+ | insert_before_op payload+
10
13
  | insert_after_op payload+
11
14
  | replace_op payload*
12
15
  | delete_op
13
16
  | blank
14
17
 
18
+ inline_before_op: "<" LID $HSEP$ line_text? LF
19
+ inline_after_op: "+" LID $HSEP$ line_text? LF
15
20
  insert_before_op: "<" insert_target LF
16
21
  insert_after_op: "+" insert_target LF
17
22
  replace_op: "=" range LF
18
23
  delete_op: "-" range LF
19
- payload: "|" /[^\r\n]*/ LF
24
+ payload: $HSEP$ line_text? LF
25
+
26
+ line_text: /[^\r\n]+/
20
27
 
21
28
  insert_target: LID | "EOF" | "BOF"
22
29
  range: LID (".." LID)?
23
30
 
24
31
  path: /(?:[^\s\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/
25
- LID: /[1-9][0-9]*$HASHFMT$/
32
+ LID: /[1-9][0-9]*$HFMT$/
26
33
  blank: LF
@@ -44,9 +44,12 @@ import {
44
44
  computeLineHash,
45
45
  describeAnchorExamples,
46
46
  formatHashLine,
47
- HASHLINE_ANCHOR_RE_SRC,
48
- HASHLINE_CONTENT_SEPARATOR,
49
- HASHLINE_LID_CAPTURE_RE_SRC,
47
+ HL_ANCHOR_RE_RAW,
48
+ HL_BODY_SEP,
49
+ HL_BODY_SEP_RE_RAW,
50
+ HL_EDIT_SEP,
51
+ HL_EDIT_SEP_RE_RAW,
52
+ HL_HASH_CAPTURE_RE_RAW,
50
53
  } from "../line-hash";
51
54
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
52
55
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
@@ -75,7 +78,8 @@ type HashlineCursor =
75
78
 
76
79
  export type HashlineEdit =
77
80
  | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
78
- | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
81
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
82
+ | { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
79
83
 
80
84
  export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
81
85
  export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
@@ -131,17 +135,18 @@ const RANGE_INTERIOR_HASH = "**";
131
135
  /** Header marker introducing a new file section in multi-section input. */
132
136
  const FILE_HEADER_PREFIX = "@";
133
137
 
134
- const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
135
- const HASHLINE_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
136
- const HASHLINE_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
138
+ const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
139
+ const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
140
+ const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
141
+ const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
137
142
  const DIFF_PLUS_RE = /^[+](?![+])/;
138
143
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
139
144
 
140
- const HASHLINE_HASH_HINT_RE = /^[a-z]{2}$/i;
141
- const HASHLINE_ANCHOR_EXAMPLES = describeAnchorExamples("160");
145
+ const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
146
+ const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
142
147
 
143
- const PARSE_TAG_RE = new RegExp(`^${HASHLINE_ANCHOR_RE_SRC}`);
144
- const LID_CAPTURE_RE = new RegExp(`^${HASHLINE_LID_CAPTURE_RE_SRC}$`);
148
+ const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
149
+ const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
145
150
 
146
151
  // ───────────────────────────────────────────────────────────────────────────
147
152
  // 4. Small string utilities
@@ -156,7 +161,7 @@ function stripLeadingHashlinePrefixes(line: string): string {
156
161
  let previous: string;
157
162
  do {
158
163
  previous = result;
159
- result = result.replace(HASHLINE_PREFIX_RE, "");
164
+ result = result.replace(HL_PREFIX_RE, "");
160
165
  } while (result !== previous);
161
166
  return result;
162
167
  }
@@ -193,8 +198,8 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
193
198
  continue;
194
199
  }
195
200
  stats.nonEmpty++;
196
- if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
197
- if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
201
+ if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
202
+ if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
198
203
  if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
199
204
  }
200
205
  return stats;
@@ -218,8 +223,8 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
218
223
  .map(line => {
219
224
  if (stripHash) return stripLeadingHashlinePrefixes(line);
220
225
  if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
221
- if (stats.diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
222
- return line.replace(HASHLINE_PREFIX_RE, "");
226
+ if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
227
+ return line.replace(HL_PREFIX_RE, "");
223
228
  }
224
229
  return line;
225
230
  });
@@ -379,14 +384,14 @@ export async function* streamHashLinesFromUtf8(
379
384
 
380
385
  export function formatFullAnchorRequirement(raw?: string): string {
381
386
  const suffix = typeof raw === "string" ? raw.trim() : "";
382
- const hashOnlyHint = HASHLINE_HASH_HINT_RE.test(suffix)
387
+ const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
383
388
  ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
384
389
  `Copy the full anchor exactly as shown (for example, "160${suffix}").`
385
390
  : "";
386
391
  const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
387
392
  return (
388
393
  `the full anchor exactly as shown by read/search output ` +
389
- `(line number + hash, for example ${HASHLINE_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
394
+ `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
390
395
  );
391
396
  }
392
397
 
@@ -527,7 +532,7 @@ export class HashlineMismatchError extends Error {
527
532
  const text = fileLines[lineNum - 1] ?? "";
528
533
  const hash = computeLineHash(lineNum, text);
529
534
  const marker = mismatchSet.has(lineNum) ? "*" : " ";
530
- lines.push(`${marker}${lineNum}${hash}${HASHLINE_CONTENT_SEPARATOR}${text}`);
535
+ lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
531
536
  }
532
537
  return lines.join("\n");
533
538
  }
@@ -585,13 +590,13 @@ export function buildCompactHashlineDiffPreview(
585
590
  switch (kind) {
586
591
  case "+":
587
592
  addedLines++;
588
- return `+${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
593
+ return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
589
594
  case "-":
590
595
  removedLines++;
591
- return `-${lineNumber}--${HASHLINE_CONTENT_SEPARATOR}${content}`;
596
+ return `-${lineNumber}--${HL_BODY_SEP}${content}`;
592
597
  default: {
593
598
  const newLineNumber = lineNumber + addedLines - removedLines;
594
- return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
599
+ return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
595
600
  }
596
601
  }
597
602
  });
@@ -603,9 +608,9 @@ export function buildCompactHashlineDiffPreview(
603
608
  // 10. Edit DSL parsing
604
609
  //
605
610
  // Grammar (one op per "block"):
606
- // "+ ANCHOR" followed by 1+ "|TEXT" payload lines — insert
611
+ // "+ ANCHOR" followed by 1+ "<sep>TEXT" payload lines — insert
607
612
  // "- A..B" no payload — delete range
608
- // "= A..B" followed by 1+ "|TEXT" payload lines — replace
613
+ // "= A..B" followed by 1+ "<sep>TEXT" payload lines — replace
609
614
  //
610
615
  // ANCHOR is `LINE<hash>`, e.g. `160ab`. BOF / EOF are also valid insert targets.
611
616
  // ───────────────────────────────────────────────────────────────────────────
@@ -614,6 +619,8 @@ const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
614
619
  const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
615
620
  const DELETE_OP_RE = /^-\s*(\S+)$/;
616
621
  const REPLACE_OP_RE = /^=\s*(\S+)$/;
622
+ const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
623
+ const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
617
624
 
618
625
  function cloneCursor(cursor: HashlineCursor): HashlineCursor {
619
626
  if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
@@ -631,12 +638,12 @@ function collectPayload(
631
638
  let index = startIndex;
632
639
  while (index < lines.length) {
633
640
  const line = stripTrailingCarriageReturn(lines[index]);
634
- if (!line.startsWith("|")) break;
641
+ if (!line.startsWith(HL_EDIT_SEP)) break;
635
642
  payload.push(line.slice(1));
636
643
  index++;
637
644
  }
638
645
  if (payload.length === 0 && requirePayload) {
639
- throw new Error(`line ${opLineNum}: + and < operations require at least one |TEXT payload line.`);
646
+ throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
640
647
  }
641
648
  return { payload, nextIndex: index };
642
649
  }
@@ -647,6 +654,7 @@ export function parseHashline(diff: string): HashlineEdit[] {
647
654
 
648
655
  export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
649
656
  const edits: HashlineEdit[] = [];
657
+ const warnings: string[] = [];
650
658
  const lines = diff.split("\n");
651
659
  let editIndex = 0;
652
660
 
@@ -662,10 +670,46 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
662
670
  i++;
663
671
  continue;
664
672
  }
665
- if (line.startsWith("|")) {
673
+ if (line.startsWith(HL_EDIT_SEP)) {
666
674
  throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
667
675
  }
668
676
 
677
+ const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
678
+ if (inlineBeforeMatch) {
679
+ const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
680
+ edits.push({
681
+ kind: "modify",
682
+ anchor,
683
+ prefix: inlineBeforeMatch[3],
684
+ suffix: "",
685
+ lineNum,
686
+ index: editIndex++,
687
+ });
688
+ const cursor: HashlineCursor = { kind: "before_anchor", anchor };
689
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
690
+ for (const text of payload) pushInsert(cursor, text, lineNum);
691
+ i = nextIndex;
692
+ continue;
693
+ }
694
+
695
+ const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
696
+ if (inlineAfterMatch) {
697
+ const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
698
+ edits.push({
699
+ kind: "modify",
700
+ anchor,
701
+ prefix: "",
702
+ suffix: inlineAfterMatch[3],
703
+ lineNum,
704
+ index: editIndex++,
705
+ });
706
+ const cursor: HashlineCursor = { kind: "after_anchor", anchor };
707
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
708
+ for (const text of payload) pushInsert(cursor, text, lineNum);
709
+ i = nextIndex;
710
+ continue;
711
+ }
712
+
669
713
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
670
714
  if (insertBeforeMatch) {
671
715
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
@@ -716,12 +760,12 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
716
760
  }
717
761
 
718
762
  throw new Error(
719
- `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or |TEXT payload lines. ` +
763
+ `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
720
764
  `Got ${JSON.stringify(line)}.`,
721
765
  );
722
766
  }
723
767
 
724
- return { edits, warnings: [] };
768
+ return { edits, warnings };
725
769
  }
726
770
 
727
771
  // ───────────────────────────────────────────────────────────────────────────
@@ -751,6 +795,7 @@ interface IndexedEdit {
751
795
 
752
796
  function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
753
797
  if (edit.kind === "delete") return [edit.anchor];
798
+ if (edit.kind === "modify") return [edit.anchor];
754
799
  if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
755
800
  if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
756
801
  return [];
@@ -850,9 +895,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
850
895
  const line =
851
896
  entry.edit.kind === "delete"
852
897
  ? entry.edit.anchor.line
853
- : entry.edit.cursor.kind === "before_anchor"
854
- ? entry.edit.cursor.anchor.line
855
- : 0;
898
+ : entry.edit.kind === "modify"
899
+ ? entry.edit.anchor.line
900
+ : entry.edit.cursor.kind === "before_anchor"
901
+ ? entry.edit.cursor.anchor.line
902
+ : 0;
856
903
  const bucket = byLine.get(line);
857
904
  if (bucket) bucket.push(entry);
858
905
  else byLine.set(line, [entry]);
@@ -918,16 +965,34 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
918
965
  const currentLine = fileLines[idx] ?? "";
919
966
  const beforeLines: string[] = [];
920
967
  let deleteLine = false;
968
+ let prefix = "";
969
+ let suffix = "";
970
+ let modified = false;
921
971
 
922
972
  for (const { edit } of bucket) {
923
- if (edit.kind === "insert") beforeLines.push(edit.text);
924
- else deleteLine = true;
973
+ if (edit.kind === "insert") {
974
+ beforeLines.push(edit.text);
975
+ } else if (edit.kind === "delete") {
976
+ deleteLine = true;
977
+ } else if (edit.kind === "modify") {
978
+ prefix = edit.prefix + prefix;
979
+ suffix = suffix + edit.suffix;
980
+ modified = true;
981
+ }
982
+ }
983
+ if (beforeLines.length === 0 && !deleteLine && !modified) continue;
984
+ if (deleteLine && modified) {
985
+ throw new Error(
986
+ `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
987
+ );
925
988
  }
926
- if (beforeLines.length === 0 && !deleteLine) continue;
927
989
 
928
- const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
990
+ const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
991
+ const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
929
992
  const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
930
- if (!deleteLine) origins[origins.length - 1] = lineOrigins[idx] ?? "original";
993
+ if (!deleteLine) {
994
+ origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
995
+ }
931
996
 
932
997
  fileLines.splice(idx, 1, ...replacement);
933
998
  lineOrigins.splice(idx, 1, ...origins);
@@ -1000,7 +1065,7 @@ function stripLeadingBlankLines(input: string): string {
1000
1065
  function containsRecognizableHashlineOperations(input: string): boolean {
1001
1066
  for (const rawLine of input.split("\n")) {
1002
1067
  const line = stripTrailingCarriageReturn(rawLine);
1003
- if (/^[+<=-]\s+/.test(line) || line.startsWith("|")) return true;
1068
+ if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
1004
1069
  }
1005
1070
  return false;
1006
1071
  }
@@ -1119,6 +1184,7 @@ async function readHashlineFile(absolutePath: string): Promise<ReadHashlineFileR
1119
1184
  function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
1120
1185
  return edits.some(edit => {
1121
1186
  if (edit.kind === "delete") return true;
1187
+ if (edit.kind === "modify") return true;
1122
1188
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
1123
1189
  });
1124
1190
  }
@@ -309,7 +309,7 @@ function getCallPreview(
309
309
  }
310
310
 
311
311
  const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
312
- const HASHLINE_INPUT_HEADER_PREFIX = "@";
312
+ const HL_INPUT_HEADER_PREFIX = "@";
313
313
 
314
314
  function normalizeHashlineInputPreviewPath(rawPath: string): string {
315
315
  const trimmed = rawPath.trim();
@@ -323,8 +323,8 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
323
323
  }
324
324
 
325
325
  function parseHashlineInputPreviewHeader(line: string): string | null {
326
- if (!line.startsWith(HASHLINE_INPUT_HEADER_PREFIX)) return null;
327
- const body = line.slice(HASHLINE_INPUT_HEADER_PREFIX.length).trim();
326
+ if (!line.startsWith(HL_INPUT_HEADER_PREFIX)) return null;
327
+ const body = line.slice(HL_INPUT_HEADER_PREFIX.length).trim();
328
328
  const previewPath = normalizeHashlineInputPreviewPath(body);
329
329
  return previewPath.length > 0 ? previewPath : null;
330
330
  }