@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.
- package/CHANGELOG.md +82 -1
- package/README.md +21 -0
- package/package.json +23 -7
- package/src/cli/grievances-cli.ts +89 -4
- package/src/commands/grievances.ts +33 -7
- package/src/config/prompt-templates.ts +14 -7
- package/src/config/settings-schema.ts +595 -100
- package/src/config/settings.ts +46 -0
- package/src/discovery/helpers.ts +13 -6
- package/src/edit/index.ts +3 -3
- package/src/edit/line-hash.ts +73 -25
- package/src/edit/modes/hashline.lark +10 -3
- package/src/edit/modes/hashline.ts +104 -38
- package/src/edit/renderer.ts +3 -3
- package/src/hindsight/backend.ts +444 -0
- package/src/hindsight/bank.ts +131 -0
- package/src/hindsight/client.ts +445 -0
- package/src/hindsight/config.ts +165 -0
- package/src/hindsight/content.ts +205 -0
- package/src/hindsight/index.ts +6 -0
- package/src/hindsight/retain-queue.ts +166 -0
- package/src/hindsight/transcript.ts +71 -0
- package/src/main.ts +7 -10
- package/src/memories/index.ts +1 -1
- package/src/memory-backend/index.ts +4 -0
- package/src/memory-backend/local-backend.ts +30 -0
- package/src/memory-backend/off-backend.ts +16 -0
- package/src/memory-backend/resolve.ts +24 -0
- package/src/memory-backend/types.ts +69 -0
- package/src/modes/components/settings-defs.ts +50 -451
- package/src/modes/components/settings-selector.ts +4 -2
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line.ts +4 -1
- package/src/modes/controllers/command-controller.ts +6 -5
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/modes/controllers/mcp-command-controller.ts +23 -0
- package/src/modes/controllers/selector-controller.ts +10 -12
- package/src/modes/interactive-mode.ts +3 -2
- package/src/modes/theme/theme.ts +4 -0
- package/src/prompts/tools/github.md +3 -0
- package/src/prompts/tools/hashline.md +20 -16
- package/src/prompts/tools/read.md +10 -6
- package/src/prompts/tools/recall.md +5 -0
- package/src/prompts/tools/reflect.md +5 -0
- package/src/prompts/tools/retain.md +5 -0
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +12 -9
- package/src/session/agent-session.ts +75 -3
- package/src/slash-commands/builtin-registry.ts +2 -12
- package/src/ssh/connection-manager.ts +1 -1
- package/src/tools/ast-edit.ts +14 -5
- package/src/tools/ast-grep.ts +12 -3
- package/src/tools/find.ts +47 -7
- package/src/tools/gh-renderer.ts +10 -1
- package/src/tools/gh.ts +233 -5
- package/src/tools/hindsight-recall.ts +70 -0
- package/src/tools/hindsight-reflect.ts +57 -0
- package/src/tools/hindsight-retain.ts +63 -0
- package/src/tools/index.ts +17 -0
- package/src/tools/output-meta.ts +1 -0
- package/src/tools/path-utils.ts +55 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/search.ts +45 -8
package/src/config/settings.ts
CHANGED
|
@@ -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
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
808
|
-
|
|
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
|
-
|
|
932
|
-
invalidateFsCache(path.join(
|
|
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 {
|
|
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 `$
|
|
39
|
-
const hashlineGrammar =
|
|
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";
|
package/src/edit/line-hash.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
693
|
+
export const HL_HASH_RE_RAW = `[1-9]\\d*[a-z]{2}`;
|
|
694
694
|
|
|
695
695
|
/**
|
|
696
|
-
* Capture-group form of {@link
|
|
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
|
|
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
|
|
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
|
|
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
|
|
715
|
+
return HL_HASH_EXAMPLES.map(e => `"${linePrefix}${e}"`).join(", ");
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
/**
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
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
|
|
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
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
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
|
|
732
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)}${
|
|
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:
|
|
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:
|
|
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]*$
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
135
|
-
const
|
|
136
|
-
const
|
|
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
|
|
141
|
-
const
|
|
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(`^${
|
|
144
|
-
const LID_CAPTURE_RE = new RegExp(`^${
|
|
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(
|
|
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 (
|
|
197
|
-
if (
|
|
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 &&
|
|
222
|
-
return line.replace(
|
|
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 =
|
|
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 ${
|
|
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}${
|
|
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)}${
|
|
593
|
+
return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
|
|
589
594
|
case "-":
|
|
590
595
|
removedLines++;
|
|
591
|
-
return `-${lineNumber}--${
|
|
596
|
+
return `-${lineNumber}--${HL_BODY_SEP}${content}`;
|
|
592
597
|
default: {
|
|
593
598
|
const newLineNumber = lineNumber + addedLines - removedLines;
|
|
594
|
-
return ` ${newLineNumber}${computeLineHash(newLineNumber, 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+ "
|
|
611
|
+
// "+ ANCHOR" followed by 1+ "<sep>TEXT" payload lines — insert
|
|
607
612
|
// "- A..B" no payload — delete range
|
|
608
|
-
// "= A..B" followed by 1+ "
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
854
|
-
? entry.edit.
|
|
855
|
-
:
|
|
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")
|
|
924
|
-
|
|
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
|
|
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)
|
|
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(
|
|
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
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -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
|
|
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(
|
|
327
|
-
const body = line.slice(
|
|
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
|
}
|