@possumtech/rummy 0.2.1

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 (120) hide show
  1. package/.env.example +55 -0
  2. package/LICENSE +21 -0
  3. package/PLUGINS.md +302 -0
  4. package/README.md +41 -0
  5. package/SPEC.md +524 -0
  6. package/lang/en.json +34 -0
  7. package/migrations/001_initial_schema.sql +226 -0
  8. package/package.json +54 -0
  9. package/service.js +143 -0
  10. package/src/agent/AgentLoop.js +553 -0
  11. package/src/agent/ContextAssembler.js +29 -0
  12. package/src/agent/KnownStore.js +254 -0
  13. package/src/agent/ProjectAgent.js +101 -0
  14. package/src/agent/ResponseHealer.js +134 -0
  15. package/src/agent/TurnExecutor.js +457 -0
  16. package/src/agent/XmlParser.js +247 -0
  17. package/src/agent/known_checks.sql +42 -0
  18. package/src/agent/known_queries.sql +80 -0
  19. package/src/agent/known_store.sql +161 -0
  20. package/src/agent/messages.js +17 -0
  21. package/src/agent/prompt_queue.sql +39 -0
  22. package/src/agent/runs.sql +114 -0
  23. package/src/agent/schemes.sql +3 -0
  24. package/src/agent/sessions.sql +51 -0
  25. package/src/agent/tokens.js +28 -0
  26. package/src/agent/turns.sql +36 -0
  27. package/src/hooks/HookRegistry.js +72 -0
  28. package/src/hooks/Hooks.js +115 -0
  29. package/src/hooks/PluginContext.js +116 -0
  30. package/src/hooks/RummyContext.js +181 -0
  31. package/src/hooks/ToolRegistry.js +83 -0
  32. package/src/llm/LlmProvider.js +107 -0
  33. package/src/llm/OllamaClient.js +88 -0
  34. package/src/llm/OpenAiClient.js +80 -0
  35. package/src/llm/OpenRouterClient.js +78 -0
  36. package/src/llm/XaiClient.js +113 -0
  37. package/src/plugins/ask_user/README.md +18 -0
  38. package/src/plugins/ask_user/ask_user.js +48 -0
  39. package/src/plugins/ask_user/docs.md +2 -0
  40. package/src/plugins/cp/README.md +18 -0
  41. package/src/plugins/cp/cp.js +55 -0
  42. package/src/plugins/cp/docs.md +2 -0
  43. package/src/plugins/current/README.md +14 -0
  44. package/src/plugins/current/current.js +48 -0
  45. package/src/plugins/engine/README.md +12 -0
  46. package/src/plugins/engine/engine.sql +18 -0
  47. package/src/plugins/engine/turn_context.sql +51 -0
  48. package/src/plugins/env/README.md +14 -0
  49. package/src/plugins/env/docs.md +2 -0
  50. package/src/plugins/env/env.js +32 -0
  51. package/src/plugins/file/README.md +25 -0
  52. package/src/plugins/file/file.js +85 -0
  53. package/src/plugins/get/README.md +19 -0
  54. package/src/plugins/get/docs.md +6 -0
  55. package/src/plugins/get/get.js +53 -0
  56. package/src/plugins/hedberg/README.md +72 -0
  57. package/src/plugins/hedberg/docs.md +9 -0
  58. package/src/plugins/hedberg/edits.js +65 -0
  59. package/src/plugins/hedberg/hedberg.js +89 -0
  60. package/src/plugins/hedberg/matcher.js +181 -0
  61. package/src/plugins/hedberg/normalize.js +41 -0
  62. package/src/plugins/hedberg/patterns.js +452 -0
  63. package/src/plugins/hedberg/sed.js +48 -0
  64. package/src/plugins/helpers.js +22 -0
  65. package/src/plugins/index.js +180 -0
  66. package/src/plugins/instructions/README.md +11 -0
  67. package/src/plugins/instructions/instructions.js +37 -0
  68. package/src/plugins/instructions/preamble.md +12 -0
  69. package/src/plugins/known/README.md +18 -0
  70. package/src/plugins/known/docs.md +3 -0
  71. package/src/plugins/known/known.js +57 -0
  72. package/src/plugins/mv/README.md +18 -0
  73. package/src/plugins/mv/docs.md +2 -0
  74. package/src/plugins/mv/mv.js +56 -0
  75. package/src/plugins/previous/README.md +15 -0
  76. package/src/plugins/previous/previous.js +50 -0
  77. package/src/plugins/progress/README.md +17 -0
  78. package/src/plugins/progress/progress.js +44 -0
  79. package/src/plugins/prompt/README.md +16 -0
  80. package/src/plugins/prompt/prompt.js +45 -0
  81. package/src/plugins/rm/README.md +18 -0
  82. package/src/plugins/rm/docs.md +4 -0
  83. package/src/plugins/rm/rm.js +51 -0
  84. package/src/plugins/rpc/README.md +45 -0
  85. package/src/plugins/rpc/rpc.js +587 -0
  86. package/src/plugins/set/README.md +32 -0
  87. package/src/plugins/set/docs.md +4 -0
  88. package/src/plugins/set/set.js +268 -0
  89. package/src/plugins/sh/README.md +18 -0
  90. package/src/plugins/sh/docs.md +2 -0
  91. package/src/plugins/sh/sh.js +32 -0
  92. package/src/plugins/skills/README.md +25 -0
  93. package/src/plugins/skills/skills.js +175 -0
  94. package/src/plugins/store/README.md +20 -0
  95. package/src/plugins/store/docs.md +5 -0
  96. package/src/plugins/store/store.js +52 -0
  97. package/src/plugins/summarize/README.md +18 -0
  98. package/src/plugins/summarize/docs.md +4 -0
  99. package/src/plugins/summarize/summarize.js +24 -0
  100. package/src/plugins/telemetry/README.md +19 -0
  101. package/src/plugins/telemetry/rpc_log.sql +28 -0
  102. package/src/plugins/telemetry/telemetry.js +186 -0
  103. package/src/plugins/unknown/README.md +23 -0
  104. package/src/plugins/unknown/docs.md +5 -0
  105. package/src/plugins/unknown/unknown.js +31 -0
  106. package/src/plugins/update/README.md +18 -0
  107. package/src/plugins/update/docs.md +4 -0
  108. package/src/plugins/update/update.js +24 -0
  109. package/src/server/ClientConnection.js +228 -0
  110. package/src/server/RpcRegistry.js +52 -0
  111. package/src/server/SocketServer.js +43 -0
  112. package/src/sql/file_constraints.sql +15 -0
  113. package/src/sql/functions/countTokens.js +7 -0
  114. package/src/sql/functions/hedmatch.js +8 -0
  115. package/src/sql/functions/hedreplace.js +8 -0
  116. package/src/sql/functions/hedsearch.js +8 -0
  117. package/src/sql/functions/schemeOf.js +7 -0
  118. package/src/sql/functions/slugify.js +6 -0
  119. package/src/sql/v_model_context.sql +101 -0
  120. package/src/sql/v_run_log.sql +23 -0
@@ -0,0 +1,85 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+
3
+ export default class File {
4
+ #core;
5
+
6
+ constructor(core) {
7
+ this.#core = core;
8
+ core.registerScheme({
9
+ fidelity: "turn",
10
+ validStates: ["full", "summary", "index", "stored"],
11
+ category: "file",
12
+ });
13
+ core.registerScheme({
14
+ name: "http",
15
+ fidelity: "turn",
16
+ validStates: ["full", "summary", "stored"],
17
+ category: "file",
18
+ });
19
+ core.registerScheme({
20
+ name: "https",
21
+ fidelity: "turn",
22
+ validStates: ["full", "summary", "stored"],
23
+ category: "file",
24
+ });
25
+ core.on("full", this.full.bind(this));
26
+
27
+ // Register identity projections for schemes that just pass through body
28
+ for (const scheme of ["known", "skill", "ask", "act", "progress"]) {
29
+ core.hooks.tools.onView(scheme, (entry) => entry.body);
30
+ }
31
+ }
32
+
33
+ full(entry) {
34
+ return entry.body;
35
+ }
36
+
37
+ static async activate(
38
+ db,
39
+ knownStore,
40
+ projectId,
41
+ pattern,
42
+ visibility = "active",
43
+ ) {
44
+ const path = await normalizePath(db, projectId, pattern);
45
+ if (!path) return { status: "ok" };
46
+
47
+ await db.upsert_file_constraint.run({
48
+ project_id: projectId,
49
+ pattern: path,
50
+ visibility,
51
+ });
52
+
53
+ if (visibility === "ignore") {
54
+ const runs = await db.get_all_runs.all({ project_id: projectId });
55
+ for (const run of runs) {
56
+ await knownStore.demoteByPattern(run.id, path, null);
57
+ }
58
+ }
59
+
60
+ return { status: "ok" };
61
+ }
62
+
63
+ static async ignore(db, knownStore, projectId, pattern) {
64
+ return File.activate(db, knownStore, projectId, pattern, "ignore");
65
+ }
66
+
67
+ static async drop(db, projectId, pattern) {
68
+ const path = await normalizePath(db, projectId, pattern);
69
+ if (!path) return { status: "ok" };
70
+
71
+ await db.delete_file_constraint.run({
72
+ project_id: projectId,
73
+ pattern: path,
74
+ });
75
+
76
+ return { status: "ok" };
77
+ }
78
+ }
79
+
80
+ async function normalizePath(db, projectId, path) {
81
+ if (!isAbsolute(path)) return path;
82
+ const project = await db.get_project_by_id.get({ id: projectId });
83
+ if (!project) return path;
84
+ return relative(project.project_root, path);
85
+ }
@@ -0,0 +1,19 @@
1
+ # get
2
+
3
+ Retrieves and promotes entries by path or glob pattern.
4
+
5
+ ## Registration
6
+
7
+ - **Tool**: `get`
8
+ - **Modes**: ask, act
9
+ - **Category**: ask
10
+ - **Handler**: Fetches matching entries via `getEntriesByPattern`, promotes them with `promoteByPattern`, and records the result.
11
+
12
+ ## Projection
13
+
14
+ Shows `get {path}` followed by the entry body.
15
+
16
+ ## Behavior
17
+
18
+ - Pattern queries (globs or body filters) produce a summary of matched paths.
19
+ - Exact path queries report the path and token count, or "not found".
@@ -0,0 +1,6 @@
1
+ ## <get>[path/to/file]</get> - Load a file or entry into context
2
+ Example: <get>docs/example.txt</get>
3
+ Example: <get>known://auth_flow</get>
4
+ * Entries at state="index" or state="summary" are promoted to full content.
5
+ * Use "known://" paths to recall stored information.
6
+ * When irrelevant or resolved, use <store/> to remove from context.
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { storePatternResult } from "../helpers.js";
3
+
4
+ export default class Get {
5
+ #core;
6
+
7
+ constructor(core) {
8
+ this.#core = core;
9
+ core.registerScheme({ validStates: ["full", "read", "pattern"] });
10
+ core.on("handler", this.handler.bind(this));
11
+ core.on("full", this.full.bind(this));
12
+ core.on("summary", this.summary.bind(this));
13
+ const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
14
+ core.filter("instructions.toolDocs", async (content) =>
15
+ content ? `${content}\n\n${docs}` : docs,
16
+ );
17
+ }
18
+
19
+ async handler(entry, rummy) {
20
+ const { entries: store, sequence: turn, runId } = rummy;
21
+ const target = entry.attributes.path;
22
+ const bodyFilter = entry.attributes.body || null;
23
+ const isPattern = bodyFilter || target.includes("*");
24
+ const matches = await store.getEntriesByPattern(runId, target, bodyFilter);
25
+ await store.promoteByPattern(runId, target, bodyFilter, turn);
26
+
27
+ if (isPattern) {
28
+ await storePatternResult(
29
+ store,
30
+ runId,
31
+ turn,
32
+ "get",
33
+ target,
34
+ bodyFilter,
35
+ matches,
36
+ );
37
+ } else {
38
+ const total = matches.reduce((s, m) => s + m.tokens_full, 0);
39
+ const paths = matches.map((m) => m.path).join(", ");
40
+ const body =
41
+ matches.length > 0 ? `${paths} ${total} tokens` : `${target} not found`;
42
+ await store.upsert(runId, turn, entry.resultPath, body, "read");
43
+ }
44
+ }
45
+
46
+ full(entry) {
47
+ return `# get ${entry.attributes.path || entry.path}\n${entry.body}`;
48
+ }
49
+
50
+ summary() {
51
+ return "";
52
+ }
53
+ }
@@ -0,0 +1,72 @@
1
+ # hedberg
2
+
3
+ The interpretation boundary between stochastic model output and
4
+ deterministic system operations.
5
+
6
+ Models speak in whatever syntax they were trained on — sed regex,
7
+ SEARCH/REPLACE blocks, escaped characters, regex anchors, malformed
8
+ XML. Hedberg normalizes all of it into clean, deterministic operations.
9
+
10
+ ## Usage
11
+
12
+ Any plugin can access hedberg via `core.hooks.hedberg`:
13
+
14
+ ```js
15
+ constructor(core) {
16
+ const { match, replace, parseSed } = core.hooks.hedberg;
17
+ }
18
+ ```
19
+
20
+ ## API (available on core.hooks.hedberg)
21
+
22
+ | Method | Purpose |
23
+ |--------|---------|
24
+ | `match(pattern, string)` | Full-string pattern match (glob, regex, literal) |
25
+ | `search(pattern, string)` | Substring search, returns `{ found, match, index }` |
26
+ | `replace(body, search, replacement, opts?)` | Apply replacement (sed regex → literal → heuristic) |
27
+ | `parseSed(input)` | Parse sed syntax into `[{ search, replace, flags, sed }]` |
28
+ | `parseEdits(content)` | Detect edit format (merge conflict, udiff, Claude XML) |
29
+ | `normalizeAttrs(attrs)` | Heal model attribute names (value→body, unknown→path) |
30
+ | `generatePatch(path, old, new)` | Generate unified diff |
31
+
32
+ ### Hedberg.replace(body, search, replacement, options?)
33
+
34
+ Apply a replacement to text. Tries sed regex (if `sed: true`), then
35
+ literal match, then heuristic fuzzy match.
36
+
37
+ ```js
38
+ const result = Hedberg.replace(fileContent, "7 - a =$", "7 - a = 5", {
39
+ sed: true,
40
+ flags: "g",
41
+ });
42
+ // result: { patch, searchText, replaceText, warning, error }
43
+ ```
44
+
45
+ ### parseSed(input)
46
+
47
+ Parse sed syntax into blocks. Handles escaped delimiters, chained
48
+ commands, flag extraction.
49
+
50
+ ```js
51
+ const blocks = parseSed("s/foo/bar/g s/baz\\/qux/hello/");
52
+ // [{ search: "foo", replace: "bar", flags: "g", sed: true },
53
+ // { search: "baz/qux", replace: "hello", flags: "", sed: true }]
54
+ ```
55
+
56
+ ### Pattern functions (hedmatch, hedsearch, hedreplace)
57
+
58
+ Auto-detect pattern type and match/search/replace accordingly.
59
+ Used by SQL functions for database-level pattern operations.
60
+
61
+ ## Files
62
+
63
+ - **hedberg.js** — plugin class, `replace()` method
64
+ - **patterns.js** — pattern type detection (regex, glob, jsonpath, xpath, literal)
65
+ - **matcher.js** — heuristic fuzzy matching, diff generation
66
+ - **sed.js** — sed syntax parsing
67
+
68
+ ## Future
69
+
70
+ This will become a separate npm package (`@possumtech/rummy.hedberg`)
71
+ to isolate the stochastic interpretation logic from the deterministic
72
+ core service.
@@ -0,0 +1,9 @@
1
+ # Advanced Patterns
2
+ * Paths accept globs: `src/**/*.js`, `known://api_*`
3
+ * Body attributes filter by content: `<get path="src/*.js" body="TODO"/>`
4
+ * Regex patterns use /slashes/: `<get path="/\.test\.js$/" preview/>`
5
+ * Adding `preview` shows matches without making changes
6
+ * Chain multiple replacements: `s/old/new/ s/foo/bar/`
7
+ Example: <get path="src/**/*.js" body="TODO" preview/> (list js files containing TODO)
8
+ Example: <store path="src/**/*.test.js"/> (store all test files)
9
+ Example: <rm path="known://temp_*" preview/> (preview which temp entries would be deleted)
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Edit format detection. Identifies the edit syntax the model used
3
+ * and normalizes it into { search, replace } blocks.
4
+ *
5
+ * Supported formats:
6
+ * 1. SEARCH/REPLACE merge conflict blocks
7
+ * 2. Replace-only blocks (no search)
8
+ * 3. Unified diff
9
+ * 4. Claude XML (<old_text>/<new_text>)
10
+ */
11
+
12
+ export function parseEditContent(content) {
13
+ const blocks = [];
14
+
15
+ // Format 1: Git merge conflict style (3-12 marker chars)
16
+ const mergeRe =
17
+ /<{3,12} SEARCH\n([\s\S]*?)\n={3,12}\n([\s\S]*?)\n>{3,12} REPLACE/g;
18
+ for (const m of content.matchAll(mergeRe)) {
19
+ blocks.push({ search: m[1], replace: m[2] });
20
+ }
21
+ if (blocks.length > 0) return blocks;
22
+
23
+ // Format 2: Replace-only (no search block, 3-12 marker chars)
24
+ const replaceOnly = /^={3,12}\n([\s\S]*?)\n>{3,12} REPLACE/gm;
25
+ for (const m of content.matchAll(replaceOnly)) {
26
+ blocks.push({ search: null, replace: m[1] });
27
+ }
28
+ if (blocks.length > 0) return blocks;
29
+
30
+ // Format 3: Unified diff
31
+ if (
32
+ content.includes("@@") &&
33
+ (content.includes("\n-") || content.includes("\n+"))
34
+ ) {
35
+ const hunks = content.split(/^@@[^@]*@@/m).slice(1);
36
+ for (const hunk of hunks) {
37
+ const oldLines = [];
38
+ const newLines = [];
39
+ for (const line of hunk.split("\n")) {
40
+ if (line.startsWith("-")) oldLines.push(line.slice(1));
41
+ else if (line.startsWith("+")) newLines.push(line.slice(1));
42
+ else if (line.startsWith(" ")) {
43
+ oldLines.push(line.slice(1));
44
+ newLines.push(line.slice(1));
45
+ }
46
+ }
47
+ if (oldLines.length > 0 || newLines.length > 0) {
48
+ blocks.push({
49
+ search: oldLines.join("\n"),
50
+ replace: newLines.join("\n"),
51
+ });
52
+ }
53
+ }
54
+ }
55
+ if (blocks.length > 0) return blocks;
56
+
57
+ // Format 4: Claude XML style
58
+ const claudeRe =
59
+ /<old_text>([\s\S]*?)<\/old_text>\s*<new_text>([\s\S]*?)<\/new_text>/g;
60
+ for (const m of content.matchAll(claudeRe)) {
61
+ blocks.push({ search: m[1], replace: m[2] });
62
+ }
63
+
64
+ return blocks;
65
+ }
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseEditContent } from "./edits.js";
3
+ import HeuristicMatcher, { generatePatch } from "./matcher.js";
4
+ import { normalizeAttrs } from "./normalize.js";
5
+ import { hedmatch, hedsearch } from "./patterns.js";
6
+ import { parseSed } from "./sed.js";
7
+
8
+ /**
9
+ * Hedberg: the interpretation boundary between stochastic model output
10
+ * and deterministic system operations.
11
+ *
12
+ * Registers its functions on core.hedberg so any plugin can call them:
13
+ * core.hedberg.match(pattern, string)
14
+ * core.hedberg.search(pattern, string)
15
+ * core.hedberg.replace(body, search, replacement, options?)
16
+ * core.hedberg.parseSed(input)
17
+ * core.hedberg.parseEdits(content)
18
+ * core.hedberg.normalizeAttrs(attrs)
19
+ * core.hedberg.generatePatch(path, old, new)
20
+ */
21
+ export default class Hedberg {
22
+ #core;
23
+
24
+ constructor(core) {
25
+ this.#core = core;
26
+
27
+ core.hooks.hedberg = {
28
+ match: hedmatch,
29
+ search: hedsearch,
30
+ replace: Hedberg.replace,
31
+ parseSed,
32
+ parseEdits: parseEditContent,
33
+ normalizeAttrs,
34
+ generatePatch,
35
+ };
36
+
37
+ const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
38
+ core.filter("instructions.toolDocs", async (content) =>
39
+ content ? `${content}\n\n${docs}` : docs,
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Apply a replacement to text. Handles sed regex, literal match,
45
+ * and heuristic fuzzy match — in that order.
46
+ *
47
+ * Returns { patch, searchText, replaceText, warning, error }
48
+ */
49
+ static replace(body, search, replacement, { sed = false, flags = "" } = {}) {
50
+ let patch = null;
51
+ let warning = null;
52
+ let error = null;
53
+ const searchText = search;
54
+ const replaceText = replacement;
55
+
56
+ if (sed) {
57
+ try {
58
+ const re = new RegExp(
59
+ searchText,
60
+ flags.includes("g") ? flags : `${flags}g`,
61
+ );
62
+ patch = body.replace(re, replaceText);
63
+ if (patch === body) patch = null;
64
+ } catch {
65
+ // Invalid regex — fall through to literal/heuristic interpretation
66
+ }
67
+ }
68
+
69
+ if (!patch && body.includes(searchText)) {
70
+ patch = body.replaceAll(searchText, replaceText);
71
+ }
72
+
73
+ if (!patch) {
74
+ const matched = HeuristicMatcher.matchAndPatch(
75
+ "",
76
+ body,
77
+ searchText,
78
+ replaceText,
79
+ );
80
+ patch = matched.newContent;
81
+ warning = matched.warning;
82
+ error = matched.error;
83
+ }
84
+
85
+ return { patch, searchText, replaceText, warning, error };
86
+ }
87
+ }
88
+
89
+ export { generatePatch };
@@ -0,0 +1,181 @@
1
+ import { execSync } from "node:child_process";
2
+ import { unlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export function generatePatch(filePath, oldContent, newContent) {
7
+ const id = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
8
+ const oldPath = join(tmpdir(), `rummy_diff_old_${id}`);
9
+ const newPath = join(tmpdir(), `rummy_diff_new_${id}`);
10
+
11
+ try {
12
+ writeFileSync(oldPath, oldContent);
13
+ writeFileSync(newPath, newContent);
14
+
15
+ const result = execSync(
16
+ `diff -u --label "${filePath}\told" --label "${filePath}\tnew" "${oldPath}" "${newPath}"`,
17
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
18
+ );
19
+ return result;
20
+ } catch (err) {
21
+ // diff exits 1 when files differ — that's the success case
22
+ if (err.stdout) return err.stdout;
23
+ return "";
24
+ } finally {
25
+ try {
26
+ unlinkSync(oldPath);
27
+ } catch {}
28
+ try {
29
+ unlinkSync(newPath);
30
+ } catch {}
31
+ }
32
+ }
33
+
34
+ export default class HeuristicMatcher {
35
+ static matchAndPatch(filePath, fileContent, searchBlock, replaceBlock) {
36
+ // Unescape common regex escapes (models often escape brackets, parens, etc.)
37
+ const unescaped = searchBlock.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
38
+ if (unescaped !== searchBlock && fileContent.includes(unescaped)) {
39
+ searchBlock = unescaped;
40
+ }
41
+
42
+ const searchLines = searchBlock.split(/\r?\n/);
43
+ const fileLines = fileContent.split(/\r?\n/);
44
+
45
+ // 1. Exact Match Attempt (line-boundary substring search)
46
+ let exactIdx = fileContent.indexOf(searchBlock);
47
+ let lastExactIdx = -1;
48
+ let exactCount = 0;
49
+ while (exactIdx !== -1) {
50
+ const atLineBoundary =
51
+ exactIdx === 0 || fileContent[exactIdx - 1] === "\n";
52
+ if (atLineBoundary) {
53
+ exactCount++;
54
+ lastExactIdx = exactIdx;
55
+ }
56
+ exactIdx = fileContent.indexOf(searchBlock, exactIdx + 1);
57
+ }
58
+
59
+ if (exactCount > 0) {
60
+ const useIdx = lastExactIdx;
61
+ const newContent =
62
+ fileContent.slice(0, useIdx) +
63
+ replaceBlock +
64
+ fileContent.slice(useIdx + searchBlock.length);
65
+ const patch = generatePatch(filePath, fileContent, newContent);
66
+ const warning =
67
+ exactCount > 1
68
+ ? `SEARCH block matched ${exactCount} locations. Edit was applied to the last occurrence. Use more surrounding context in future edits to avoid ambiguity.`
69
+ : null;
70
+ return { patch, newContent, warning, error: null };
71
+ }
72
+
73
+ // 2. Fuzzy Tokenized Match (Ignore leading/trailing whitespace per line)
74
+ const searchTokens = searchLines
75
+ .map((l) => l.trim())
76
+ .filter((l) => l !== "");
77
+ const fileTokens = fileLines.map((l) => l.trim());
78
+
79
+ if (searchTokens.length === 0) {
80
+ // Empty SEARCH = append REPLACE to end of file
81
+ const trailing = fileContent.endsWith("\n") ? "" : "\n";
82
+ const newContent = `${fileContent + trailing + replaceBlock}\n`;
83
+ const patch = generatePatch(filePath, fileContent, newContent);
84
+ return { patch, newContent, warning: null, error: null };
85
+ }
86
+
87
+ let matchStartIndex = -1;
88
+ let matchEndIndex = -1;
89
+ let matchCount = 0;
90
+
91
+ for (let i = 0; i < fileTokens.length; i++) {
92
+ if (fileTokens[i] === "" && searchTokens[0] !== "") continue;
93
+
94
+ let searchIdx = 0;
95
+ let fileIdx = i;
96
+
97
+ while (searchIdx < searchTokens.length && fileIdx < fileTokens.length) {
98
+ if (fileTokens[fileIdx] === "" && searchTokens[searchIdx] !== "") {
99
+ fileIdx++;
100
+ continue;
101
+ }
102
+
103
+ if (fileTokens[fileIdx] === searchTokens[searchIdx]) {
104
+ searchIdx++;
105
+ fileIdx++;
106
+ } else {
107
+ break;
108
+ }
109
+ }
110
+
111
+ if (searchIdx === searchTokens.length) {
112
+ matchCount++;
113
+ matchStartIndex = i;
114
+ matchEndIndex = fileIdx - 1;
115
+ }
116
+ }
117
+
118
+ if (matchCount === 0) {
119
+ return {
120
+ patch: null,
121
+ warning: null,
122
+ error:
123
+ "Could not find the SEARCH block in the file. Ensure you are providing an exact match of the existing code, without truncating lines with '...'.",
124
+ };
125
+ }
126
+
127
+ const fuzzyAmbiguous = matchCount > 1;
128
+
129
+ // 3. Indentation Healing
130
+ const matchedFileLines = fileLines.slice(
131
+ matchStartIndex,
132
+ matchEndIndex + 1,
133
+ );
134
+
135
+ const firstFileIndentedLine = matchedFileLines.find((l) => l.trim() !== "");
136
+ const fileIndentMatch = firstFileIndentedLine
137
+ ? firstFileIndentedLine.match(/^(\s*)/)
138
+ : null;
139
+ const fileIndent = fileIndentMatch ? fileIndentMatch[1] : "";
140
+
141
+ const firstSearchIndentedLine = searchLines.find((l) => l.trim() !== "");
142
+ const searchIndentMatch = firstSearchIndentedLine
143
+ ? firstSearchIndentedLine.match(/^(\s*)/)
144
+ : null;
145
+ const searchIndent = searchIndentMatch ? searchIndentMatch[1] : "";
146
+
147
+ let healedReplaceBlock = replaceBlock;
148
+ let warning = null;
149
+
150
+ if (fileIndent !== searchIndent) {
151
+ warning = `Indentation healing applied. The file has different indentation ('${fileIndent.replace(/\t/g, "\\t").replace(/ /g, "s")}') than your SEARCH block. Please try to match indentation exactly in future edits.`;
152
+
153
+ const replaceLines = replaceBlock.split(/\r?\n/);
154
+ const healedLines = replaceLines.map((line) => {
155
+ if (line.trim() === "") return line;
156
+ if (line.startsWith(searchIndent)) {
157
+ return fileIndent + line.substring(searchIndent.length);
158
+ }
159
+ return fileIndent + line.trimStart();
160
+ });
161
+ healedReplaceBlock = healedLines.join("\n");
162
+ }
163
+
164
+ const newFileLines = [
165
+ ...fileLines.slice(0, matchStartIndex),
166
+ healedReplaceBlock,
167
+ ...fileLines.slice(matchEndIndex + 1),
168
+ ];
169
+ const newContent = newFileLines.join("\n");
170
+
171
+ const patch = generatePatch(filePath, fileContent, newContent);
172
+
173
+ if (fuzzyAmbiguous) {
174
+ warning =
175
+ (warning ? `${warning} ` : "") +
176
+ `SEARCH block matched ${matchCount} locations. Edit was applied to the last occurrence. Use more surrounding context in future edits to avoid ambiguity.`;
177
+ }
178
+
179
+ return { patch, newContent, warning, error: null };
180
+ }
181
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Attribute normalization. Heals legacy and alternative attribute names
3
+ * from model output into canonical form.
4
+ *
5
+ * - value="" → body=""
6
+ * - file="" or key="" → path="" (first unrecognized attr becomes path)
7
+ * - preview="" → preview=true
8
+ */
9
+
10
+ const KNOWN_ATTRS = new Set([
11
+ "path",
12
+ "body",
13
+ "preview",
14
+ "question",
15
+ "options",
16
+ "search",
17
+ "replace",
18
+ "to",
19
+ "results",
20
+ "command",
21
+ "warn",
22
+ ]);
23
+
24
+ export function normalizeAttrs(attrs) {
25
+ const out = { ...attrs };
26
+ if ("value" in out && !("body" in out)) {
27
+ out.body = out.value;
28
+ delete out.value;
29
+ }
30
+ if (!out.path) {
31
+ for (const [k, v] of Object.entries(out)) {
32
+ if (!KNOWN_ATTRS.has(k) && v) {
33
+ out.path = v;
34
+ delete out[k];
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ if ("preview" in out) out.preview = true;
40
+ return out;
41
+ }