@vibgrate/cli 2026.722.2 → 2026.727.2

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 (145) hide show
  1. package/DOCS.md +418 -213
  2. package/README.md +34 -8
  3. package/dist/baseline-TG4ATZGZ.js +8 -0
  4. package/dist/{baseline-BD3A7EXD.js.map → baseline-TG4ATZGZ.js.map} +1 -1
  5. package/dist/chunk-2MBL43EJ.js +82 -0
  6. package/dist/chunk-2MBL43EJ.js.map +1 -0
  7. package/dist/chunk-2XQDMLKB.js +465 -0
  8. package/dist/chunk-2XQDMLKB.js.map +1 -0
  9. package/dist/{chunk-PY3DNX5H.js → chunk-3Q7QBWFW.js} +13 -2
  10. package/dist/chunk-3Q7QBWFW.js.map +1 -0
  11. package/dist/chunk-3V24O47U.js +478 -0
  12. package/dist/chunk-3V24O47U.js.map +1 -0
  13. package/dist/chunk-5I4VSBWQ.js +764 -0
  14. package/dist/chunk-5I4VSBWQ.js.map +1 -0
  15. package/dist/{chunk-WNIIKCNF.js → chunk-6ABXHZRN.js} +59 -311
  16. package/dist/chunk-6ABXHZRN.js.map +1 -0
  17. package/dist/chunk-6AYDFFP5.js +136 -0
  18. package/dist/chunk-6AYDFFP5.js.map +1 -0
  19. package/dist/chunk-6UQMXIAG.js +122 -0
  20. package/dist/chunk-6UQMXIAG.js.map +1 -0
  21. package/dist/chunk-7N4Y3E47.js +108 -0
  22. package/dist/chunk-7N4Y3E47.js.map +1 -0
  23. package/dist/chunk-BPF3YX2U.js +64 -0
  24. package/dist/chunk-BPF3YX2U.js.map +1 -0
  25. package/dist/chunk-BWPBB45M.js +88 -0
  26. package/dist/chunk-BWPBB45M.js.map +1 -0
  27. package/dist/{chunk-6CXTPC74.js → chunk-CY4WKXB2.js} +27 -6
  28. package/dist/chunk-CY4WKXB2.js.map +1 -0
  29. package/dist/chunk-DGUM43GV.js +10 -0
  30. package/dist/chunk-DGUM43GV.js.map +1 -0
  31. package/dist/{chunk-NONYJLOJ.js → chunk-EX4PQT6W.js} +3 -3
  32. package/dist/{chunk-NONYJLOJ.js.map → chunk-EX4PQT6W.js.map} +1 -1
  33. package/dist/chunk-IS7VTH2Q.js +627 -0
  34. package/dist/chunk-IS7VTH2Q.js.map +1 -0
  35. package/dist/chunk-IZDH67DX.js +289 -0
  36. package/dist/chunk-IZDH67DX.js.map +1 -0
  37. package/dist/chunk-J4RFRKZH.js +2496 -0
  38. package/dist/chunk-J4RFRKZH.js.map +1 -0
  39. package/dist/chunk-J7LKEB2R.js +51 -0
  40. package/dist/chunk-J7LKEB2R.js.map +1 -0
  41. package/dist/chunk-JIBOJTGS.js +313 -0
  42. package/dist/chunk-JIBOJTGS.js.map +1 -0
  43. package/dist/chunk-K3MZIVZW.js +88 -0
  44. package/dist/chunk-K3MZIVZW.js.map +1 -0
  45. package/dist/chunk-K7REHDQS.js +776 -0
  46. package/dist/chunk-K7REHDQS.js.map +1 -0
  47. package/dist/{chunk-2O5YZVZV.js → chunk-KCKYKDMS.js} +23 -19
  48. package/dist/chunk-KCKYKDMS.js.map +1 -0
  49. package/dist/{chunk-3MXNNBLI.js → chunk-KNELIQJO.js} +1647 -2042
  50. package/dist/chunk-KNELIQJO.js.map +1 -0
  51. package/dist/chunk-KSBHGWRV.js +613 -0
  52. package/dist/chunk-KSBHGWRV.js.map +1 -0
  53. package/dist/chunk-LKVMI67M.js +1695 -0
  54. package/dist/chunk-LKVMI67M.js.map +1 -0
  55. package/dist/chunk-NDMDSPFX.js +69 -0
  56. package/dist/chunk-NDMDSPFX.js.map +1 -0
  57. package/dist/{chunk-CS37OBE3.js → chunk-OFUC6RYN.js} +791 -783
  58. package/dist/chunk-OFUC6RYN.js.map +1 -0
  59. package/dist/chunk-OOIG3RUM.js +276 -0
  60. package/dist/chunk-OOIG3RUM.js.map +1 -0
  61. package/dist/chunk-PMYWZRRS.js +62 -0
  62. package/dist/chunk-PMYWZRRS.js.map +1 -0
  63. package/dist/chunk-PXHNTC6D.js +382 -0
  64. package/dist/chunk-PXHNTC6D.js.map +1 -0
  65. package/dist/{chunk-I7GY7MVN.js → chunk-RNP3SMTV.js} +60 -12
  66. package/dist/chunk-RNP3SMTV.js.map +1 -0
  67. package/dist/chunk-TCDAZZNP.js +843 -0
  68. package/dist/chunk-TCDAZZNP.js.map +1 -0
  69. package/dist/{chunk-5BDHQ7DD.js → chunk-WGUL45AV.js} +24 -17
  70. package/dist/chunk-WGUL45AV.js.map +1 -0
  71. package/dist/cli.d.ts +3 -0
  72. package/dist/cli.js +3373 -617
  73. package/dist/cli.js.map +1 -1
  74. package/dist/execution-env-ZU45JZ4G.js +4 -0
  75. package/dist/execution-env-ZU45JZ4G.js.map +1 -0
  76. package/dist/federation-SZWZIFZ7.js +7 -0
  77. package/dist/federation-SZWZIFZ7.js.map +1 -0
  78. package/dist/{fs-IPY4FJKH.js → fs-B5HZ2MIU.js} +3 -2
  79. package/dist/fs-B5HZ2MIU.js.map +1 -0
  80. package/dist/{fs-KCABDURV.js → fs-O5IQYXHO.js} +3 -2
  81. package/dist/fs-O5IQYXHO.js.map +1 -0
  82. package/dist/git-ref-XDXB6JP5.js +4 -0
  83. package/dist/git-ref-XDXB6JP5.js.map +1 -0
  84. package/dist/graph-backend-73URGTFY.js +11 -0
  85. package/dist/graph-backend-73URGTFY.js.map +1 -0
  86. package/dist/index.d.ts +472 -137
  87. package/dist/index.js +15 -7
  88. package/dist/index.js.map +1 -1
  89. package/dist/{interactive-A4DISSIW.js → interactive-372NLI5C.js} +186 -226
  90. package/dist/interactive-372NLI5C.js.map +1 -0
  91. package/dist/llm-host-7GWDO6IX.js +9 -0
  92. package/dist/llm-host-7GWDO6IX.js.map +1 -0
  93. package/dist/load-I333TGNI.js +8 -0
  94. package/dist/load-I333TGNI.js.map +1 -0
  95. package/dist/local-runtime-5O2NN3GA.js +5 -0
  96. package/dist/local-runtime-5O2NN3GA.js.map +1 -0
  97. package/dist/mcp-tools-I7JK3S2M.js +4 -0
  98. package/dist/{mcp-tools-I6PME3AV.js.map → mcp-tools-I7JK3S2M.js.map} +1 -1
  99. package/dist/model-execution-profile-TFBKCNJG.js +4 -0
  100. package/dist/model-execution-profile-TFBKCNJG.js.map +1 -0
  101. package/dist/model-orchestrator-TVONAUPS.js +9 -0
  102. package/dist/model-orchestrator-TVONAUPS.js.map +1 -0
  103. package/dist/parse-worker.d.ts +1 -1
  104. package/dist/parse-worker.js +2 -1
  105. package/dist/parse-worker.js.map +1 -1
  106. package/dist/paths-2RAH3GME.js +6 -0
  107. package/dist/paths-2RAH3GME.js.map +1 -0
  108. package/dist/resolve-gguf-VPL4YQ6J.js +8 -0
  109. package/dist/resolve-gguf-VPL4YQ6J.js.map +1 -0
  110. package/dist/runtime-session-JRKCQUHY.js +18 -0
  111. package/dist/runtime-session-JRKCQUHY.js.map +1 -0
  112. package/dist/session-ITPYQ5XI.js +11 -0
  113. package/dist/{session-POJUDFPH.js.map → session-ITPYQ5XI.js.map} +1 -1
  114. package/dist/session-store-WFBX7HXL.js +4 -0
  115. package/dist/{session-store-W5RZZHA3.js.map → session-store-WFBX7HXL.js.map} +1 -1
  116. package/dist/{stream-json-Q2JGWPHB.js → stream-json-SLMDKLXP.js} +17 -6
  117. package/dist/stream-json-SLMDKLXP.js.map +1 -0
  118. package/dist/{types-Jl6RJ175.d.ts → types-7AHBUbFm.d.ts} +8 -2
  119. package/dist/ui-Q6DJ3GCU.js +5 -0
  120. package/dist/ui-Q6DJ3GCU.js.map +1 -0
  121. package/dist/vgd-7JV5KCNJ.js +17 -0
  122. package/dist/vgd-7JV5KCNJ.js.map +1 -0
  123. package/package.json +9 -6
  124. package/dist/baseline-BD3A7EXD.js +0 -7
  125. package/dist/chunk-2O5YZVZV.js.map +0 -1
  126. package/dist/chunk-3MXNNBLI.js.map +0 -1
  127. package/dist/chunk-5BDHQ7DD.js.map +0 -1
  128. package/dist/chunk-6CXTPC74.js.map +0 -1
  129. package/dist/chunk-CS37OBE3.js.map +0 -1
  130. package/dist/chunk-GGJZA3Q6.js +0 -961
  131. package/dist/chunk-GGJZA3Q6.js.map +0 -1
  132. package/dist/chunk-I7GY7MVN.js.map +0 -1
  133. package/dist/chunk-JBXNQCGE.js +0 -484
  134. package/dist/chunk-JBXNQCGE.js.map +0 -1
  135. package/dist/chunk-PY3DNX5H.js.map +0 -1
  136. package/dist/chunk-WNIIKCNF.js.map +0 -1
  137. package/dist/fs-IPY4FJKH.js.map +0 -1
  138. package/dist/fs-KCABDURV.js.map +0 -1
  139. package/dist/interactive-A4DISSIW.js.map +0 -1
  140. package/dist/mcp-tools-I6PME3AV.js +0 -3
  141. package/dist/session-POJUDFPH.js +0 -6
  142. package/dist/session-store-W5RZZHA3.js +0 -3
  143. package/dist/stream-json-Q2JGWPHB.js.map +0 -1
  144. package/dist/ui-HOVOPWMX.js +0 -4
  145. package/dist/ui-HOVOPWMX.js.map +0 -1
@@ -1,4 +1,3 @@
1
- import { indexFor, queryGraph, impactOf } from './chunk-WNIIKCNF.js';
2
1
  import * as fs from 'fs';
3
2
  import * as os from 'os';
4
3
  import * as path from 'path';
@@ -6,856 +5,865 @@ import { spawnSync } from 'child_process';
6
5
  import { createRequire } from 'module';
7
6
  import { pathToFileURL } from 'url';
8
7
 
9
- function runtimeCacheDir() {
10
- const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
11
- return path.join(base, "vibgrate", "runtime");
8
+ // src/code/prompt.ts
9
+ var CODE_SYSTEM_PROMPT = [
10
+ "You are a precise code-editing assistant operating inside the Vibgrate CLI.",
11
+ "You are given repository context derived from a deterministic code graph, then a task.",
12
+ "Propose the smallest correct change. Do not restate unchanged code.",
13
+ "",
14
+ "Reply ONLY with edit blocks in this exact format, one per change:",
15
+ "",
16
+ "<path/to/file>",
17
+ "<<<<<<< SEARCH",
18
+ "<exact current lines to find>",
19
+ "=======",
20
+ "<replacement lines>",
21
+ ">>>>>>> REPLACE",
22
+ "",
23
+ "The SEARCH text must match the current file (whitespace differences are tolerated).",
24
+ "To add a new file use:",
25
+ "CREATE <path/to/file>",
26
+ "<full file body>",
27
+ "END CREATE",
28
+ "To remove a file use: DELETE <path/to/file>",
29
+ "",
30
+ "Respect every hard constraint in the context. Keep edits minimal and self-consistent."
31
+ ].join("\n");
32
+ function buildMessages(context) {
33
+ return [
34
+ { role: "system", content: CODE_SYSTEM_PROMPT },
35
+ { role: "user", content: context.rendered },
36
+ { role: "user", content: `Now produce the edit blocks for: ${context.instruction}` }
37
+ ];
12
38
  }
13
- function isPackageInstalled(spec) {
14
- const name = packageName(spec);
15
- try {
16
- const req = createRequire(path.join(runtimeCacheDir(), "package.json"));
17
- req.resolve(name);
18
- return true;
19
- } catch {
20
- return false;
21
- }
39
+ var AGENT_SYSTEM_PROMPT = [
40
+ "You are VG Code, a precise coding agent working inside a real repository.",
41
+ "You have tools: search_code (searches a deterministic code graph \u2014 prefer it over guessing),",
42
+ "read_file, list_files, graph_impact (blast radius of a change), edit_file, create_file,",
43
+ "delete_file, run_command, and finish.",
44
+ "",
45
+ "Work in small steps: search or read to understand before you edit; check graph_impact before",
46
+ "changing shared code; make the smallest correct edit; run the tests or build to verify when useful.",
47
+ "edit_file replaces an exact snippet \u2014 the search text must match the current file (whitespace is",
48
+ "tolerated). Do not restate unchanged code. When the task is complete, call finish with a short summary.",
49
+ "",
50
+ "Editing, creating, deleting, and running commands require user approval \u2014 expect some to be declined,",
51
+ "and adapt if so. Never invent file contents; read first."
52
+ ].join("\n");
53
+ function buildAgentMessages(context) {
54
+ return [
55
+ { role: "system", content: AGENT_SYSTEM_PROMPT },
56
+ { role: "user", content: context.rendered },
57
+ { role: "user", content: `Task: ${context.instruction}` }
58
+ ];
22
59
  }
23
- function ensureUnavailableMessage(reason, spec) {
24
- const name = packageName(spec);
25
- switch (reason) {
26
- case "offline":
27
- return `${name} is needed for this backend but --local/offline is set \u2014 using a backend that needs no install, or drop --local to allow a one-time install.`;
28
- case "no-consent":
29
- return `${name} is needed for this backend and isn't installed yet. Re-run with --yes to install it once into ${runtimeCacheDir()} (nothing is written into the CLI itself), or pick a backend that needs no install (e.g. --provider ollama).`;
30
- case "install-failed":
31
- return `couldn't install ${name} (offline, or npm unavailable) \u2014 using a backend that needs no install. It will try again next time.`;
32
- case "load-failed":
33
- default:
34
- return `${name} installed but couldn't be loaded here \u2014 using a backend that needs no install.`;
60
+
61
+ // src/code/apply.ts
62
+ function parseEdits(text) {
63
+ const edits = [];
64
+ const lines = text.split("\n");
65
+ let i = 0;
66
+ let pendingFile = "";
67
+ const isFence = (s) => /^\s*```/.test(s);
68
+ while (i < lines.length) {
69
+ const line = lines[i];
70
+ const create = /^\s*CREATE\s+(\S.*?)\s*$/.exec(line);
71
+ if (create) {
72
+ const file = create[1];
73
+ const body = [];
74
+ i++;
75
+ while (i < lines.length && !/^\s*END CREATE\s*$/.test(lines[i])) {
76
+ if (!isFence(lines[i])) body.push(lines[i]);
77
+ i++;
78
+ }
79
+ i++;
80
+ edits.push({ op: "create", file, content: body.join("\n") });
81
+ pendingFile = "";
82
+ continue;
83
+ }
84
+ const del = /^\s*DELETE\s+(\S.*?)\s*$/.exec(line);
85
+ if (del) {
86
+ edits.push({ op: "delete", file: del[1] });
87
+ i++;
88
+ pendingFile = "";
89
+ continue;
90
+ }
91
+ if (/^\s*<{5,}\s*SEARCH/.test(line)) {
92
+ const file = pendingFile;
93
+ const search = [];
94
+ const replace = [];
95
+ i++;
96
+ while (i < lines.length && !/^\s*={5,}\s*$/.test(lines[i])) {
97
+ search.push(lines[i]);
98
+ i++;
99
+ }
100
+ i++;
101
+ while (i < lines.length && !/^\s*>{5,}\s*REPLACE/.test(lines[i])) {
102
+ replace.push(lines[i]);
103
+ i++;
104
+ }
105
+ i++;
106
+ edits.push({
107
+ op: "replace",
108
+ file,
109
+ search: search.join("\n"),
110
+ replace: replace.join("\n"),
111
+ anchorSymbol: void 0
112
+ });
113
+ continue;
114
+ }
115
+ const trimmed = line.trim();
116
+ if (trimmed && !isFence(line) && looksLikePath(trimmed)) pendingFile = trimmed.replace(/[:`]+$/, "");
117
+ i++;
35
118
  }
119
+ return edits;
36
120
  }
37
- async function ensurePackage(spec, options = {}) {
38
- const dir = runtimeCacheDir();
39
- const fail = (reason) => {
40
- options.onUnavailable?.(reason, spec);
41
- return { module: null, dir, reason };
42
- };
43
- if (isPackageInstalled(spec)) {
44
- const mod2 = await load(spec, dir);
45
- return mod2 ? { module: mod2, dir } : fail("load-failed");
121
+ function looksLikePath(s) {
122
+ if (/\s/.test(s.replace(/:$/, ""))) return false;
123
+ return /\//.test(s) || /\.[A-Za-z0-9]{1,8}:?$/.test(s);
124
+ }
125
+ function applyEdit(content, edit, spans = []) {
126
+ if (edit.op === "create") {
127
+ if (content !== null && content !== edit.content) {
128
+ return { content, outcome: { edit, status: "conflict", reason: `${edit.file} already exists \u2014 refusing to overwrite it with a create; use a replace edit instead` } };
129
+ }
130
+ return { content: edit.content, outcome: { edit, status: content === edit.content ? "no-op" : "applied", matchedBy: "exact" } };
46
131
  }
47
- if (options.local) return fail("offline");
48
- const interactive = options.interactive ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
49
- if (!options.consent && !interactive) return fail("no-consent");
50
- if (!options.consent && interactive) return fail("no-consent");
51
- try {
52
- fs.mkdirSync(dir, { recursive: true });
53
- ensureRuntimeManifest(dir);
54
- } catch {
55
- return fail("install-failed");
132
+ if (edit.op === "delete") {
133
+ if (content === null) return { content: null, outcome: { edit, status: "no-op", reason: `${edit.file} does not exist \u2014 nothing to delete` } };
134
+ return { content: null, outcome: { edit, status: "applied", matchedBy: "exact" } };
56
135
  }
57
- const installer = options.install ?? defaultInstaller;
58
- const ok = installer(spec, dir);
59
- if (!ok || !isPackageInstalled(spec)) return fail("install-failed");
60
- const mod = await load(spec, dir);
61
- return mod ? { module: mod, dir } : fail("load-failed");
62
- }
63
- function ensureRuntimeManifest(dir) {
64
- const manifest = path.join(dir, "package.json");
65
- if (!fs.existsSync(manifest)) {
66
- fs.writeFileSync(
67
- manifest,
68
- JSON.stringify({ name: "vibgrate-runtime-deps", private: true, description: "On-demand vg runtime packages" }, null, 2)
69
- );
136
+ if (content === null) {
137
+ return { content: null, outcome: { edit, status: "not-found", reason: `${edit.file} does not exist \u2014 a replace needs an existing file (did you mean CREATE ${edit.file}?)` } };
70
138
  }
71
- }
72
- async function load(spec, dir) {
73
- const name = packageName(spec);
74
- try {
75
- const req = createRequire(path.join(dir, "package.json"));
76
- const resolved = req.resolve(name);
77
- const mod = await import(pathToFileURL(resolved).href);
78
- return mod?.default ?? mod;
79
- } catch {
80
- return null;
139
+ if (edit.search === "") {
140
+ if (content === "") return { content: edit.replace, outcome: { edit, status: "applied", matchedBy: "exact" } };
141
+ return { content, outcome: { edit, status: "invalid", reason: "empty SEARCH on a non-empty file is ambiguous \u2014 quote the exact lines to replace" } };
81
142
  }
82
- }
83
- function defaultInstaller(spec, dir) {
84
- const res = spawnSync("npm", ["install", "--prefix", dir, "--no-audit", "--no-fund", "--ignore-scripts", spec], {
85
- stdio: "ignore",
86
- timeout: 18e4
87
- });
88
- return res.status === 0;
89
- }
90
- function packageName(spec) {
91
- if (spec.startsWith("@")) {
92
- const at2 = spec.indexOf("@", 1);
93
- return at2 === -1 ? spec : spec.slice(0, at2);
143
+ if (edit.search === edit.replace) {
144
+ return { content, outcome: { edit, status: "no-op", reason: "SEARCH and REPLACE are identical \u2014 no change" } };
94
145
  }
95
- const at = spec.indexOf("@");
96
- return at === -1 ? spec : spec.slice(0, at);
146
+ const located = locate(content, edit.search, edit.anchorSymbol, spans, edit.file);
147
+ if (located.kind === "none") {
148
+ return { content, outcome: { edit, status: "not-found", reason: `the SEARCH text was not found in ${edit.file} \u2014 it must match the current file exactly (whitespace-flexible)` } };
149
+ }
150
+ if (located.kind === "ambiguous") {
151
+ return {
152
+ content,
153
+ outcome: {
154
+ edit,
155
+ status: "ambiguous",
156
+ reason: `the SEARCH text matches ${located.count} places in ${edit.file} \u2014 add more surrounding lines, or name the symbol so the graph can disambiguate`
157
+ }
158
+ };
159
+ }
160
+ const next = content.slice(0, located.from) + edit.replace + content.slice(located.to);
161
+ return { content: next, outcome: { edit, status: "applied", matchedBy: located.matchedBy } };
97
162
  }
98
-
99
- // src/code/providers.ts
100
- function openAiMessages(messages) {
101
- return messages.map((m) => {
102
- if (m.role === "tool") return { role: "tool", tool_call_id: m.toolCallId, name: m.name, content: m.content };
103
- if (m.role === "assistant" && m.toolCalls?.length) {
104
- return {
105
- role: "assistant",
106
- content: m.content || null,
107
- tool_calls: m.toolCalls.map((t) => ({ id: t.id, type: "function", function: { name: t.name, arguments: JSON.stringify(t.arguments) } }))
108
- };
163
+ function applyEdits(edits, read, spansByFile = () => []) {
164
+ const state = /* @__PURE__ */ new Map();
165
+ for (const edit of edits) {
166
+ let entry = state.get(edit.file);
167
+ if (!entry) {
168
+ const before = read(edit.file);
169
+ entry = { before, after: before, outcomes: [] };
170
+ state.set(edit.file, entry);
109
171
  }
110
- return { role: m.role, content: m.content };
111
- });
112
- }
113
- function openAiTools(tools) {
114
- if (!tools?.length) return void 0;
115
- return tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
172
+ const { content, outcome } = applyEdit(entry.after, edit, spansByFile(edit.file));
173
+ entry.after = content;
174
+ entry.outcomes.push(outcome);
175
+ }
176
+ return state;
116
177
  }
117
- function parseToolCalls(raw, synthesizePrefix = "call") {
118
- const calls = raw;
119
- if (!Array.isArray(calls)) return [];
120
- return calls.map((c, i) => ({
121
- id: typeof c.id === "string" && c.id ? c.id : `${synthesizePrefix}_${i}`,
122
- name: c.function?.name ?? "unknown",
123
- arguments: coerceArgs(c.function?.arguments)
124
- }));
125
- }
126
- function coerceArgs(raw) {
127
- if (raw && typeof raw === "object") return raw;
128
- if (typeof raw === "string") {
129
- try {
130
- const parsed = JSON.parse(raw);
131
- return parsed && typeof parsed === "object" ? parsed : {};
132
- } catch {
133
- return {};
134
- }
178
+ function locate(content, search, anchor, spans, file) {
179
+ const exact = allIndexes(content, search);
180
+ if (exact.length === 1) return { kind: "unique", from: exact[0], to: exact[0] + search.length, matchedBy: "exact" };
181
+ if (exact.length > 1) {
182
+ const scoped = scopeToAnchor(content, exact.map((from) => ({ from, to: from + search.length })), anchor, spans, file);
183
+ if (scoped) return { ...scoped, matchedBy: "graph-span" };
184
+ return { kind: "ambiguous", count: exact.length };
135
185
  }
136
- return {};
137
- }
138
- function redactSecrets(s) {
139
- return s.replace(/\b(sk|xoxb|ghp|gho|glpat|AKIA)[-_][A-Za-z0-9-_]{6,}/g, "$1-***redacted***").replace(/Bearer\s+[A-Za-z0-9._-]{8,}/gi, "Bearer ***redacted***");
140
- }
141
- function httpError(label, status, hint) {
142
- return new Error(`${label} returned HTTP ${status} \u2014 ${hint}`);
186
+ const flex = flexIndexes(content, search);
187
+ if (flex.length === 1) return { kind: "unique", from: flex[0].from, to: flex[0].to, matchedBy: "whitespace" };
188
+ if (flex.length > 1) {
189
+ const scoped = scopeToAnchor(content, flex, anchor, spans, file);
190
+ if (scoped) return { ...scoped, matchedBy: "graph-span" };
191
+ return { kind: "ambiguous", count: flex.length };
192
+ }
193
+ return { kind: "none" };
143
194
  }
144
- async function* streamLines(res) {
145
- const reader = res.body?.getReader();
146
- if (!reader) return;
147
- const decoder = new TextDecoder();
148
- let buf = "";
195
+ function allIndexes(hay, needle) {
196
+ const out = [];
197
+ if (needle === "") return out;
198
+ let from = 0;
149
199
  for (; ; ) {
150
- const { done, value } = await reader.read();
151
- if (done) break;
152
- buf += decoder.decode(value, { stream: true });
153
- let idx;
154
- while ((idx = buf.indexOf("\n")) >= 0) {
155
- const line = buf.slice(0, idx).trim();
156
- buf = buf.slice(idx + 1);
157
- if (line) yield line;
158
- }
200
+ const idx = hay.indexOf(needle, from);
201
+ if (idx === -1) break;
202
+ out.push(idx);
203
+ from = idx + needle.length;
159
204
  }
160
- if (buf.trim()) yield buf.trim();
205
+ return out;
161
206
  }
162
- function accumulateOpenAiDelta(acc, chunk, onToken) {
163
- const c = chunk;
164
- const delta = c.choices?.[0]?.delta;
165
- if (delta?.content) {
166
- acc.text += delta.content;
167
- onToken?.(delta.content);
168
- }
169
- for (const tc of delta?.tool_calls ?? []) {
170
- const idx = tc.index ?? 0;
171
- const e = acc.tools.get(idx) ?? { args: "" };
172
- if (tc.id) e.id = tc.id;
173
- if (tc.function?.name) e.name = tc.function.name;
174
- if (tc.function?.arguments) e.args += tc.function.arguments;
175
- acc.tools.set(idx, e);
207
+ function flexIndexes(content, search) {
208
+ const cLines = content.split("\n");
209
+ const sLines = search.split("\n");
210
+ while (sLines.length && sLines[0].trim() === "") sLines.shift();
211
+ while (sLines.length && sLines[sLines.length - 1].trim() === "") sLines.pop();
212
+ if (sLines.length === 0) return [];
213
+ const sNorm = sLines.map((l) => l.trim());
214
+ const lineStart = [];
215
+ let acc = 0;
216
+ for (const l of cLines) {
217
+ lineStart.push(acc);
218
+ acc += l.length + 1;
176
219
  }
177
- if (c.usage) {
178
- acc.usage.promptTokens = c.usage.prompt_tokens;
179
- acc.usage.completionTokens = c.usage.completion_tokens;
220
+ const out = [];
221
+ for (let i = 0; i + sNorm.length <= cLines.length; i++) {
222
+ let ok = true;
223
+ for (let j = 0; j < sNorm.length; j++) {
224
+ if (cLines[i + j].trim() !== sNorm[j]) {
225
+ ok = false;
226
+ break;
227
+ }
228
+ }
229
+ if (!ok) continue;
230
+ const from = lineStart[i];
231
+ const lastLine = i + sNorm.length - 1;
232
+ const to = lineStart[lastLine] + cLines[lastLine].length;
233
+ out.push({ from, to });
180
234
  }
235
+ return out;
181
236
  }
182
- function finalizeTools(tools) {
183
- return [...tools.entries()].sort((a, b) => a[0] - b[0]).map(([i, e]) => ({ id: e.id ?? `call_${i}`, name: e.name ?? "unknown", arguments: coerceArgs(e.args) }));
237
+ function scopeToAnchor(content, candidates, anchor, spans, file) {
238
+ if (!anchor) return null;
239
+ const span = spans.find((s) => s.file === file && s.qualifiedName === anchor);
240
+ if (!span) return null;
241
+ const lineOf = lineIndexer(content);
242
+ const inside = candidates.filter((c) => {
243
+ const line = lineOf(c.from);
244
+ return line >= span.start && line <= span.end;
245
+ });
246
+ if (inside.length === 1) return { kind: "unique", from: inside[0].from, to: inside[0].to };
247
+ return null;
184
248
  }
185
- async function postJson(url, body, headers, timeoutMs) {
186
- const controller = new AbortController();
187
- const timer = setTimeout(() => controller.abort(), timeoutMs);
188
- try {
189
- return await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), signal: controller.signal });
190
- } finally {
191
- clearTimeout(timer);
192
- }
249
+ function lineIndexer(content) {
250
+ const starts = [0];
251
+ for (let i = 0; i < content.length; i++) if (content[i] === "\n") starts.push(i + 1);
252
+ return (offset) => {
253
+ let lo = 0;
254
+ let hi = starts.length - 1;
255
+ while (lo < hi) {
256
+ const mid = lo + hi + 1 >> 1;
257
+ if (starts[mid] <= offset) lo = mid;
258
+ else hi = mid - 1;
259
+ }
260
+ return lo + 1;
261
+ };
193
262
  }
194
- var MockProvider = class {
195
- constructor(model, reply) {
196
- this.model = model;
197
- this.reply = reply;
263
+
264
+ // src/code/diff.ts
265
+ function unifiedDiff(before, after, file, context = 3) {
266
+ if (before === after) return "";
267
+ if (before === null) return newFileDiff(after ?? "", file);
268
+ if (after === null) return deletedFileDiff(before, file);
269
+ const a = splitLines(before);
270
+ const b = splitLines(after);
271
+ const ops = diffLines(a, b);
272
+ const hunks = groupHunks(ops, context);
273
+ if (hunks.length === 0) return "";
274
+ const out = [`--- a/${file}`, `+++ b/${file}`];
275
+ for (const h of hunks) {
276
+ out.push(`@@ -${h.aStart},${h.aLen} +${h.bStart},${h.bLen} @@`);
277
+ for (const line of h.lines) out.push(line);
198
278
  }
199
- model;
200
- reply;
201
- id = "mock";
202
- label = "Mock (deterministic)";
203
- local = true;
204
- async chat(_messages, _opts) {
205
- return { text: this.reply, model: this.model, provider: this.id };
279
+ return out.join("\n");
280
+ }
281
+ function newFileDiff(after, file) {
282
+ const lines = splitLines(after);
283
+ const out = [`--- /dev/null`, `+++ b/${file}`, `@@ -0,0 +1,${lines.length} @@`];
284
+ for (const l of lines) out.push(`+${l}`);
285
+ return out.join("\n");
286
+ }
287
+ function deletedFileDiff(before, file) {
288
+ const lines = splitLines(before);
289
+ const out = [`--- a/${file}`, `+++ /dev/null`, `@@ -1,${lines.length} +0,0 @@`];
290
+ for (const l of lines) out.push(`-${l}`);
291
+ return out.join("\n");
292
+ }
293
+ function splitLines(s) {
294
+ if (s === "") return [];
295
+ const lines = s.split("\n");
296
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
297
+ return lines;
298
+ }
299
+ function diffLines(a, b) {
300
+ const n = a.length;
301
+ const m = b.length;
302
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
303
+ for (let i2 = n - 1; i2 >= 0; i2--) {
304
+ for (let j2 = m - 1; j2 >= 0; j2--) {
305
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
306
+ }
206
307
  }
207
- };
208
- var OllamaProvider = class {
209
- constructor(model, host = process.env.OLLAMA_HOST || "http://127.0.0.1:11434") {
210
- this.model = model;
211
- this.host = host;
308
+ const ops = [];
309
+ let i = 0;
310
+ let j = 0;
311
+ while (i < n && j < m) {
312
+ if (a[i] === b[j]) {
313
+ ops.push({ tag: "eq", line: a[i] });
314
+ i++;
315
+ j++;
316
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
317
+ ops.push({ tag: "del", line: a[i] });
318
+ i++;
319
+ } else {
320
+ ops.push({ tag: "add", line: b[j] });
321
+ j++;
322
+ }
212
323
  }
213
- model;
214
- host;
215
- id = "ollama";
216
- label = "Ollama (local)";
217
- local = true;
218
- async chat(messages, opts = {}) {
219
- const streaming = !!(opts.stream && opts.onToken);
220
- let res;
221
- try {
222
- res = await postJson(
223
- `${this.host.replace(/\/$/, "")}/api/chat`,
224
- { model: this.model, messages: openAiMessages(messages), stream: streaming, tools: openAiTools(opts.tools), options: { temperature: opts.temperature ?? 0 } },
225
- {},
226
- opts.timeoutMs ?? 12e4
227
- );
228
- } catch {
229
- throw new Error(`couldn't reach Ollama at ${this.host} \u2014 is \`ollama serve\` running? (start it, or pass --provider to use a different backend)`);
324
+ while (i < n) ops.push({ tag: "del", line: a[i++] });
325
+ while (j < m) ops.push({ tag: "add", line: b[j++] });
326
+ return ops;
327
+ }
328
+ function groupHunks(ops, context) {
329
+ const changeIdx = ops.map((o, i) => o.tag === "eq" ? -1 : i).filter((i) => i >= 0);
330
+ if (changeIdx.length === 0) return [];
331
+ const windows = [];
332
+ let start = Math.max(0, changeIdx[0] - context);
333
+ let end = Math.min(ops.length - 1, changeIdx[0] + context);
334
+ for (let k = 1; k < changeIdx.length; k++) {
335
+ const cs = Math.max(0, changeIdx[k] - context);
336
+ const ce = Math.min(ops.length - 1, changeIdx[k] + context);
337
+ if (cs <= end + 1) {
338
+ end = ce;
339
+ } else {
340
+ windows.push([start, end]);
341
+ start = cs;
342
+ end = ce;
230
343
  }
231
- if (!res.ok) throw httpError("Ollama", res.status, res.status === 404 ? `model "${this.model}" isn't pulled \u2014 run \`ollama pull ${this.model}\` or pick another with --model` : "check `ollama serve` and the model name");
232
- if (streaming) {
233
- let text = "";
234
- let toolCallsRaw;
235
- const usage = {};
236
- for await (const line of streamLines(res)) {
237
- try {
238
- const obj = JSON.parse(line);
239
- const delta = obj.message?.content;
240
- if (delta) {
241
- text += delta;
242
- opts.onToken?.(delta);
243
- }
244
- if (obj.message?.tool_calls) toolCallsRaw = obj.message.tool_calls;
245
- if (typeof obj.prompt_eval_count === "number") usage.promptTokens = obj.prompt_eval_count;
246
- if (typeof obj.eval_count === "number") usage.completionTokens = obj.eval_count;
247
- } catch {
248
- }
249
- }
250
- return { text, model: this.model, provider: this.id, toolCalls: parseToolCalls(toolCallsRaw), usage };
251
- }
252
- const data = await res.json();
253
- return {
254
- text: data.message?.content ?? "",
255
- model: this.model,
256
- provider: this.id,
257
- toolCalls: parseToolCalls(data.message?.tool_calls),
258
- usage: { promptTokens: data.prompt_eval_count, completionTokens: data.eval_count }
259
- };
260
- }
261
- };
262
- var OpenAiCompatibleProvider = class {
263
- constructor(model, config) {
264
- this.model = model;
265
- this.config = config;
266
- this.id = config.id;
267
- this.label = config.label;
268
- this.local = config.local;
269
344
  }
270
- model;
271
- config;
272
- id;
273
- label;
274
- local;
275
- async chat(messages, opts = {}) {
276
- const headers = {};
277
- if (this.config.apiKeyEnv) {
278
- const key = process.env[this.config.apiKeyEnv];
279
- if (!key) {
280
- throw new Error(`${this.label} needs an API key in ${this.config.apiKeyEnv} \u2014 set it in your environment (never pass keys as flags), or use --local for an on-device backend.`);
281
- }
282
- headers.authorization = `Bearer ${key}`;
283
- }
284
- const streaming = !!(opts.stream && opts.onToken);
285
- const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
286
- const body = {
287
- model: this.model,
288
- messages: openAiMessages(messages),
289
- tools: openAiTools(opts.tools),
290
- temperature: opts.temperature ?? 0,
291
- max_tokens: opts.maxTokens,
292
- stream: streaming,
293
- ...streaming ? { stream_options: { include_usage: true } } : {}
294
- };
295
- let res;
296
- try {
297
- res = await postJson(url, body, headers, opts.timeoutMs ?? 12e4);
298
- } catch {
299
- throw new Error(`couldn't reach ${this.label} at ${this.config.baseUrl} \u2014 check the endpoint is up (or use --local).`);
300
- }
301
- if (!res.ok) {
302
- const hint = res.status === 401 || res.status === 403 ? `the key in ${this.config.apiKeyEnv ?? "(none)"} was rejected \u2014 check it has access to "${this.model}"` : `check the model id "${this.model}" and the endpoint`;
303
- throw httpError(this.label, res.status, hint);
345
+ windows.push([start, end]);
346
+ const hunks = [];
347
+ let aLine = 1;
348
+ let bLine = 1;
349
+ let idx = 0;
350
+ for (const [ws, we] of windows) {
351
+ while (idx < ws) {
352
+ const o = ops[idx];
353
+ if (o.tag !== "add") aLine++;
354
+ if (o.tag !== "del") bLine++;
355
+ idx++;
304
356
  }
305
- if (streaming) {
306
- const acc = { text: "", tools: /* @__PURE__ */ new Map(), usage: {} };
307
- for await (const line of streamLines(res)) {
308
- if (!line.startsWith("data:")) continue;
309
- const payload = line.slice(5).trim();
310
- if (payload === "[DONE]") break;
311
- try {
312
- accumulateOpenAiDelta(acc, JSON.parse(payload), opts.onToken);
313
- } catch {
314
- }
357
+ const hunk = { aStart: aLine, aLen: 0, bStart: bLine, bLen: 0, lines: [] };
358
+ for (let k = ws; k <= we; k++) {
359
+ const o = ops[k];
360
+ if (o.tag === "eq") {
361
+ hunk.lines.push(` ${o.line}`);
362
+ hunk.aLen++;
363
+ hunk.bLen++;
364
+ aLine++;
365
+ bLine++;
366
+ } else if (o.tag === "del") {
367
+ hunk.lines.push(`-${o.line}`);
368
+ hunk.aLen++;
369
+ aLine++;
370
+ } else {
371
+ hunk.lines.push(`+${o.line}`);
372
+ hunk.bLen++;
373
+ bLine++;
315
374
  }
316
- const tools = finalizeTools(acc.tools);
317
- return { text: acc.text, model: this.model, provider: this.id, toolCalls: tools.length ? tools : void 0, usage: acc.usage };
375
+ idx = k + 1;
318
376
  }
319
- const data = await res.json();
320
- const message = data.choices?.[0]?.message;
321
- return {
322
- text: message?.content ?? "",
323
- model: this.model,
324
- provider: this.id,
325
- toolCalls: parseToolCalls(message?.tool_calls),
326
- usage: { promptTokens: data.usage?.prompt_tokens, completionTokens: data.usage?.completion_tokens }
327
- };
328
- }
329
- };
330
- var OPENAI_COMPATIBLE = {
331
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKeyEnv: "OPENROUTER_API_KEY", local: false, label: "OpenRouter" },
332
- litellm: { baseUrl: process.env.LITELLM_BASE_URL || "http://127.0.0.1:4000", apiKeyEnv: "LITELLM_API_KEY", local: false, label: "LiteLLM" },
333
- openai: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY", local: false, label: "OpenAI" },
334
- together: { baseUrl: "https://api.together.xyz/v1", apiKeyEnv: "TOGETHER_API_KEY", local: false, label: "Together" },
335
- lmstudio: { baseUrl: process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234/v1", local: true, label: "LM Studio (local)" }
336
- };
337
- var LocalLlamaProvider = class {
338
- constructor(model, modelPath, ensure = ensurePackage, consent = false) {
339
- this.model = model;
340
- this.modelPath = modelPath;
341
- this.ensure = ensure;
342
- this.consent = consent;
377
+ if (hunk.aLen === 0) hunk.aStart = Math.max(0, hunk.aStart - 1);
378
+ if (hunk.bLen === 0) hunk.bStart = Math.max(0, hunk.bStart - 1);
379
+ hunks.push(hunk);
343
380
  }
344
- model;
345
- modelPath;
346
- ensure;
347
- consent;
348
- id = "llama-cpp";
349
- label = "llama.cpp (local)";
350
- local = true;
351
- session = null;
352
- async chat(messages, opts = {}) {
353
- const res = await this.ensure("node-llama-cpp@^3", { consent: this.consent, onUnavailable: () => {
354
- } });
355
- if (!res.module) {
356
- throw new Error(ensureUnavailableMessage(res.reason ?? "no-consent", "node-llama-cpp"));
357
- }
358
- const lib = res.module;
359
- try {
360
- const llama = await lib.getLlama();
361
- const model = await llama.loadModel({ modelPath: this.modelPath });
362
- const ctx = await model.createContext();
363
- const { LlamaChatSession } = lib;
364
- const chat = new LlamaChatSession({ contextSequence: ctx.getSequence() });
365
- const prompt = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n");
366
- const text = await chat.prompt(prompt, { temperature: opts.temperature ?? 0, maxTokens: opts.maxTokens });
367
- return { text, model: this.model, provider: this.id };
368
- } catch (e) {
369
- throw new Error(`local llama.cpp inference failed for ${this.modelPath} \u2014 ${redactSecrets(e.message)}`);
381
+ return hunks;
382
+ }
383
+ function summarizeDiffs(diffs) {
384
+ let adds = 0;
385
+ let dels = 0;
386
+ let touched = 0;
387
+ for (const { diff } of diffs) {
388
+ if (!diff) continue;
389
+ touched++;
390
+ for (const line of diff.split("\n")) {
391
+ if (line.startsWith("+") && !line.startsWith("+++")) adds++;
392
+ else if (line.startsWith("-") && !line.startsWith("---")) dels++;
370
393
  }
371
394
  }
372
- };
373
-
374
- // src/code/context.ts
375
- function buildCodeContext(graph, instruction, options = {}) {
376
- const budget = options.budget ?? 3e3;
377
- const seedLimit = options.seeds ?? 8;
378
- const impactDepth = options.impactDepth ?? 2;
379
- const index = indexFor(graph);
380
- const q = queryGraph(graph, instruction, { budget: Math.floor(budget * 0.6), limit: seedLimit * 2 });
381
- let seeds = q.matches.map((m) => ({ node: m.node, why: m.why }));
382
- if (options.files && options.files.length) {
383
- const set = new Set(options.files.map(normalize));
384
- const inScope = seeds.filter((s) => set.has(normalize(s.node.file)));
385
- if (inScope.length) seeds = inScope;
386
- }
387
- seeds = seeds.slice(0, seedLimit);
388
- const impacted = /* @__PURE__ */ new Map();
389
- for (const s of seeds) {
390
- const impact = impactOf(graph, s.node.id, { depth: impactDepth });
391
- for (const item of impact.affected.slice(0, 6)) {
392
- const node = index.node(item.id);
393
- if (node && !impacted.has(node.id)) impacted.set(node.id, { node, via: s.node.qualifiedName });
394
- }
395
+ return `+${adds} -${dels} across ${touched} file(s)`;
396
+ }
397
+ function runtimeCacheDir() {
398
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
399
+ return path.join(base, "vibgrate", "runtime");
400
+ }
401
+ function isPackageInstalled(spec) {
402
+ const name = packageName(spec);
403
+ try {
404
+ const req = createRequire(path.join(runtimeCacheDir(), "package.json"));
405
+ req.resolve(name);
406
+ return true;
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+ function ensureUnavailableMessage(reason, spec) {
412
+ const name = packageName(spec);
413
+ switch (reason) {
414
+ case "offline":
415
+ return `${name} is needed for this backend but --local/offline is set \u2014 using a backend that needs no install, or drop --local to allow a one-time install.`;
416
+ case "no-consent":
417
+ return `${name} is needed for this backend and isn't installed yet. Run \`vg models install\` (or \`vg models pull\`) to install runtime deps for the provider into ${runtimeCacheDir()} (nothing is written into the CLI itself), or pick a backend that needs no install (e.g. --provider ollama).`;
418
+ case "install-failed":
419
+ return `couldn't install ${name} (offline, or npm unavailable) \u2014 using a backend that needs no install. It will try again next time.`;
420
+ case "load-failed":
421
+ default:
422
+ return `${name} installed but couldn't be loaded here \u2014 using a backend that needs no install.`;
395
423
  }
396
- const targetFiles = options.files?.length ? [...new Set(options.files.map(normalize))].sort() : [...new Set(seeds.map((s) => s.node.file))].sort();
397
- const seedIds = new Set(seeds.map((s) => s.node.id));
398
- const pinnedFacts = pinnedFactsFor(graph, seedIds);
399
- const rendered = render(instruction, seeds, [...impacted.values()], targetFiles, pinnedFacts, index, budget);
400
- return {
401
- instruction,
402
- seeds,
403
- targetFiles,
404
- impacted: [...impacted.values()],
405
- pinnedFacts,
406
- rendered,
407
- tokensEstimate: estimateTokens(rendered)
424
+ }
425
+ async function ensurePackage(spec, options = {}) {
426
+ const dir = runtimeCacheDir();
427
+ const fail = (reason) => {
428
+ options.onUnavailable?.(reason, spec);
429
+ return { module: null, dir, reason };
408
430
  };
431
+ if (isPackageInstalled(spec)) {
432
+ const mod2 = await load(spec, dir);
433
+ return mod2 ? { module: mod2, dir } : fail("load-failed");
434
+ }
435
+ if (options.local) return fail("offline");
436
+ const interactive = options.interactive ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
437
+ if (!options.consent && !interactive) return fail("no-consent");
438
+ if (!options.consent && interactive) return fail("no-consent");
439
+ try {
440
+ fs.mkdirSync(dir, { recursive: true });
441
+ ensureRuntimeManifest(dir);
442
+ } catch {
443
+ return fail("install-failed");
444
+ }
445
+ const installer = options.install ?? defaultInstaller;
446
+ const ok = installer(spec, dir);
447
+ if (!ok || !isPackageInstalled(spec)) return fail("install-failed");
448
+ const mod = await load(spec, dir);
449
+ return mod ? { module: mod, dir } : fail("load-failed");
409
450
  }
410
- function pinnedFactsFor(graph, seedIds) {
411
- if (!graph.facts?.length) return [];
412
- const out = [];
413
- for (const f of graph.facts) {
414
- if (f.kind === "characterization") continue;
415
- if (!f.subjectIds.some((id) => seedIds.has(id))) continue;
416
- const subjects = f.subjectIds.map((id) => graph.nodes.find((n) => n.id === id)?.qualifiedName ?? id).join(", ");
417
- out.push(`${f.kind}: ${subjects} \u2014 ${JSON.stringify(f.predicate)}`);
418
- }
419
- return out.sort().slice(0, 12);
420
- }
421
- function render(instruction, seeds, impacted, targetFiles, pinnedFacts, index, budget) {
422
- const lines = [];
423
- lines.push("# Repository context (from the deterministic code graph)");
424
- lines.push("");
425
- if (pinnedFacts.length) {
426
- lines.push("## Hard constraints (do not violate)");
427
- for (const f of pinnedFacts) lines.push(`- ${f}`);
428
- lines.push("");
429
- }
430
- lines.push("## Relevant symbols");
431
- for (const { node, why } of seeds) {
432
- const block = [];
433
- block.push(`### ${node.qualifiedName} (${node.kind}, ${node.file}:${node.span.start}-${node.span.end})`);
434
- if (node.signature) block.push("`" + node.signature + "`");
435
- const callees = uniqueNames(index.callees(node.id).map((x) => x.node.qualifiedName)).slice(0, 6);
436
- const callers = uniqueNames(index.callers(node.id).map((x) => x.node.qualifiedName)).slice(0, 6);
437
- if (callees.length) block.push(`calls: ${callees.join(", ")}`);
438
- if (callers.length) block.push(`called by: ${callers.join(", ")}`);
439
- block.push(`importance ${node.importance.toFixed(3)}${node.isHub ? " \xB7 hub (high blast radius)" : ""} \xB7 ${why}`);
440
- block.push("");
441
- const candidate = lines.concat(block).join("\n");
442
- if (estimateTokens(candidate) > budget && lines.length > 3) break;
443
- lines.push(...block);
444
- }
445
- if (impacted.length) {
446
- lines.push("## Blast radius (would be affected by a change here)");
447
- for (const { node, via } of impacted.slice(0, 10)) {
448
- lines.push(`- ${node.qualifiedName} (${node.file}:${node.span.start}) \u2014 depends on ${via}`);
449
- }
450
- lines.push("");
451
+ function ensureRuntimeManifest(dir) {
452
+ const manifest = path.join(dir, "package.json");
453
+ if (!fs.existsSync(manifest)) {
454
+ fs.writeFileSync(
455
+ manifest,
456
+ JSON.stringify({ name: "vibgrate-runtime-deps", private: true, description: "On-demand vg runtime packages" }, null, 2)
457
+ );
451
458
  }
452
- lines.push("## Files in scope for this edit");
453
- for (const f of targetFiles) lines.push(`- ${f}`);
454
- lines.push("");
455
- lines.push("## Task");
456
- lines.push(instruction);
457
- return lines.join("\n");
458
459
  }
459
- function uniqueNames(xs) {
460
- return [...new Set(xs)];
460
+ async function load(spec, dir) {
461
+ const name = packageName(spec);
462
+ try {
463
+ const req = createRequire(path.join(dir, "package.json"));
464
+ const resolved = req.resolve(name);
465
+ const mod = await import(pathToFileURL(resolved).href);
466
+ return mod?.default ?? mod;
467
+ } catch {
468
+ return null;
469
+ }
461
470
  }
462
- function normalize(file) {
463
- return file.replace(/\\/g, "/").replace(/^\.\//, "");
471
+ function defaultInstaller(spec, dir) {
472
+ const res = spawnSync("npm", ["install", "--prefix", dir, "--no-audit", "--no-fund", "--ignore-scripts", spec], {
473
+ stdio: "ignore",
474
+ timeout: 18e4
475
+ });
476
+ return res.status === 0;
464
477
  }
465
- function estimateTokens(text) {
466
- return Math.ceil(text.length / 4);
478
+ function packageName(spec) {
479
+ if (spec.startsWith("@")) {
480
+ const at2 = spec.indexOf("@", 1);
481
+ return at2 === -1 ? spec : spec.slice(0, at2);
482
+ }
483
+ const at = spec.indexOf("@");
484
+ return at === -1 ? spec : spec.slice(0, at);
467
485
  }
468
486
 
469
- // src/code/apply.ts
470
- function parseEdits(text) {
471
- const edits = [];
472
- const lines = text.split("\n");
473
- let i = 0;
474
- let pendingFile = "";
475
- const isFence = (s) => /^\s*```/.test(s);
476
- while (i < lines.length) {
477
- const line = lines[i];
478
- const create = /^\s*CREATE\s+(\S.*?)\s*$/.exec(line);
479
- if (create) {
480
- const file = create[1];
481
- const body = [];
482
- i++;
483
- while (i < lines.length && !/^\s*END CREATE\s*$/.test(lines[i])) {
484
- if (!isFence(lines[i])) body.push(lines[i]);
485
- i++;
486
- }
487
- i++;
488
- edits.push({ op: "create", file, content: body.join("\n") });
489
- pendingFile = "";
490
- continue;
491
- }
492
- const del = /^\s*DELETE\s+(\S.*?)\s*$/.exec(line);
493
- if (del) {
494
- edits.push({ op: "delete", file: del[1] });
495
- i++;
496
- pendingFile = "";
497
- continue;
487
+ // src/code/providers.ts
488
+ function openAiMessages(messages) {
489
+ return messages.map((m) => {
490
+ if (m.role === "tool") return { role: "tool", tool_call_id: m.toolCallId, name: m.name, content: m.content };
491
+ if (m.role === "assistant" && m.toolCalls?.length) {
492
+ return {
493
+ role: "assistant",
494
+ content: m.content || null,
495
+ tool_calls: m.toolCalls.map((t) => ({ id: t.id, type: "function", function: { name: t.name, arguments: JSON.stringify(t.arguments) } }))
496
+ };
498
497
  }
499
- if (/^\s*<{5,}\s*SEARCH/.test(line)) {
500
- const file = pendingFile;
501
- const search = [];
502
- const replace = [];
503
- i++;
504
- while (i < lines.length && !/^\s*={5,}\s*$/.test(lines[i])) {
505
- search.push(lines[i]);
506
- i++;
507
- }
508
- i++;
509
- while (i < lines.length && !/^\s*>{5,}\s*REPLACE/.test(lines[i])) {
510
- replace.push(lines[i]);
511
- i++;
512
- }
513
- i++;
514
- edits.push({
515
- op: "replace",
516
- file,
517
- search: search.join("\n"),
518
- replace: replace.join("\n"),
519
- anchorSymbol: void 0
520
- });
521
- continue;
498
+ return { role: m.role, content: m.content };
499
+ });
500
+ }
501
+ function openAiTools(tools) {
502
+ if (!tools?.length) return void 0;
503
+ return tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
504
+ }
505
+ function parseToolCalls(raw, synthesizePrefix = "call") {
506
+ const calls = raw;
507
+ if (!Array.isArray(calls)) return [];
508
+ return calls.map((c, i) => ({
509
+ id: typeof c.id === "string" && c.id ? c.id : `${synthesizePrefix}_${i}`,
510
+ name: c.function?.name ?? "unknown",
511
+ arguments: coerceArgs(c.function?.arguments)
512
+ }));
513
+ }
514
+ function coerceArgs(raw) {
515
+ if (raw && typeof raw === "object") return raw;
516
+ if (typeof raw === "string") {
517
+ try {
518
+ const parsed = JSON.parse(raw);
519
+ return parsed && typeof parsed === "object" ? parsed : {};
520
+ } catch {
521
+ return {};
522
522
  }
523
- const trimmed = line.trim();
524
- if (trimmed && !isFence(line) && looksLikePath(trimmed)) pendingFile = trimmed.replace(/[:`]+$/, "");
525
- i++;
526
523
  }
527
- return edits;
524
+ return {};
528
525
  }
529
- function looksLikePath(s) {
530
- if (/\s/.test(s.replace(/:$/, ""))) return false;
531
- return /\//.test(s) || /\.[A-Za-z0-9]{1,8}:?$/.test(s);
526
+ function redactSecrets(s) {
527
+ return s.replace(/\b(sk|xoxb|ghp|gho|glpat|AKIA)[-_][A-Za-z0-9-_]{6,}/g, "$1-***redacted***").replace(/Bearer\s+[A-Za-z0-9._-]{8,}/gi, "Bearer ***redacted***");
532
528
  }
533
- function applyEdit(content, edit, spans = []) {
534
- if (edit.op === "create") {
535
- if (content !== null && content !== edit.content) {
536
- return { content, outcome: { edit, status: "conflict", reason: `${edit.file} already exists \u2014 refusing to overwrite it with a create; use a replace edit instead` } };
529
+ function httpError(label, status, hint) {
530
+ return new Error(`${label} returned HTTP ${status} \u2014 ${hint}`);
531
+ }
532
+ async function* streamLines(res) {
533
+ const reader = res.body?.getReader();
534
+ if (!reader) return;
535
+ const decoder = new TextDecoder();
536
+ let buf = "";
537
+ for (; ; ) {
538
+ const { done, value } = await reader.read();
539
+ if (done) break;
540
+ buf += decoder.decode(value, { stream: true });
541
+ let idx;
542
+ while ((idx = buf.indexOf("\n")) >= 0) {
543
+ const line = buf.slice(0, idx).trim();
544
+ buf = buf.slice(idx + 1);
545
+ if (line) yield line;
537
546
  }
538
- return { content: edit.content, outcome: { edit, status: content === edit.content ? "no-op" : "applied", matchedBy: "exact" } };
539
- }
540
- if (edit.op === "delete") {
541
- if (content === null) return { content: null, outcome: { edit, status: "no-op", reason: `${edit.file} does not exist \u2014 nothing to delete` } };
542
- return { content: null, outcome: { edit, status: "applied", matchedBy: "exact" } };
543
- }
544
- if (content === null) {
545
- return { content: null, outcome: { edit, status: "not-found", reason: `${edit.file} does not exist \u2014 a replace needs an existing file (did you mean CREATE ${edit.file}?)` } };
546
- }
547
- if (edit.search === "") {
548
- if (content === "") return { content: edit.replace, outcome: { edit, status: "applied", matchedBy: "exact" } };
549
- return { content, outcome: { edit, status: "invalid", reason: "empty SEARCH on a non-empty file is ambiguous \u2014 quote the exact lines to replace" } };
550
547
  }
551
- if (edit.search === edit.replace) {
552
- return { content, outcome: { edit, status: "no-op", reason: "SEARCH and REPLACE are identical \u2014 no change" } };
548
+ if (buf.trim()) yield buf.trim();
549
+ }
550
+ function accumulateOpenAiDelta(acc, chunk, onToken) {
551
+ const c = chunk;
552
+ const delta = c.choices?.[0]?.delta;
553
+ if (delta?.content) {
554
+ acc.text += delta.content;
555
+ onToken?.(delta.content);
553
556
  }
554
- const located = locate(content, edit.search, edit.anchorSymbol, spans, edit.file);
555
- if (located.kind === "none") {
556
- return { content, outcome: { edit, status: "not-found", reason: `the SEARCH text was not found in ${edit.file} \u2014 it must match the current file exactly (whitespace-flexible)` } };
557
+ for (const tc of delta?.tool_calls ?? []) {
558
+ const idx = tc.index ?? 0;
559
+ const e = acc.tools.get(idx) ?? { args: "" };
560
+ if (tc.id) e.id = tc.id;
561
+ if (tc.function?.name) e.name = tc.function.name;
562
+ if (tc.function?.arguments) e.args += tc.function.arguments;
563
+ acc.tools.set(idx, e);
557
564
  }
558
- if (located.kind === "ambiguous") {
559
- return {
560
- content,
561
- outcome: {
562
- edit,
563
- status: "ambiguous",
564
- reason: `the SEARCH text matches ${located.count} places in ${edit.file} \u2014 add more surrounding lines, or name the symbol so the graph can disambiguate`
565
- }
566
- };
565
+ if (c.usage) {
566
+ acc.usage.promptTokens = c.usage.prompt_tokens;
567
+ acc.usage.completionTokens = c.usage.completion_tokens;
567
568
  }
568
- const next = content.slice(0, located.from) + edit.replace + content.slice(located.to);
569
- return { content: next, outcome: { edit, status: "applied", matchedBy: located.matchedBy } };
570
569
  }
571
- function applyEdits(edits, read, spansByFile = () => []) {
572
- const state = /* @__PURE__ */ new Map();
573
- for (const edit of edits) {
574
- let entry = state.get(edit.file);
575
- if (!entry) {
576
- const before = read(edit.file);
577
- entry = { before, after: before, outcomes: [] };
578
- state.set(edit.file, entry);
579
- }
580
- const { content, outcome } = applyEdit(entry.after, edit, spansByFile(edit.file));
581
- entry.after = content;
582
- entry.outcomes.push(outcome);
583
- }
584
- return state;
570
+ function finalizeTools(tools) {
571
+ return [...tools.entries()].sort((a, b) => a[0] - b[0]).map(([i, e]) => ({ id: e.id ?? `call_${i}`, name: e.name ?? "unknown", arguments: coerceArgs(e.args) }));
585
572
  }
586
- function locate(content, search, anchor, spans, file) {
587
- const exact = allIndexes(content, search);
588
- if (exact.length === 1) return { kind: "unique", from: exact[0], to: exact[0] + search.length, matchedBy: "exact" };
589
- if (exact.length > 1) {
590
- const scoped = scopeToAnchor(content, exact.map((from) => ({ from, to: from + search.length })), anchor, spans, file);
591
- if (scoped) return { ...scoped, matchedBy: "graph-span" };
592
- return { kind: "ambiguous", count: exact.length };
593
- }
594
- const flex = flexIndexes(content, search);
595
- if (flex.length === 1) return { kind: "unique", from: flex[0].from, to: flex[0].to, matchedBy: "whitespace" };
596
- if (flex.length > 1) {
597
- const scoped = scopeToAnchor(content, flex, anchor, spans, file);
598
- if (scoped) return { ...scoped, matchedBy: "graph-span" };
599
- return { kind: "ambiguous", count: flex.length };
573
+ async function postJson(url, body, headers, timeoutMs) {
574
+ const controller = new AbortController();
575
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
576
+ try {
577
+ return await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), signal: controller.signal });
578
+ } finally {
579
+ clearTimeout(timer);
600
580
  }
601
- return { kind: "none" };
602
581
  }
603
- function allIndexes(hay, needle) {
604
- const out = [];
605
- if (needle === "") return out;
606
- let from = 0;
607
- for (; ; ) {
608
- const idx = hay.indexOf(needle, from);
609
- if (idx === -1) break;
610
- out.push(idx);
611
- from = idx + needle.length;
582
+ var MockProvider = class {
583
+ constructor(model, reply) {
584
+ this.model = model;
585
+ this.reply = reply;
612
586
  }
613
- return out;
614
- }
615
- function flexIndexes(content, search) {
616
- const cLines = content.split("\n");
617
- const sLines = search.split("\n");
618
- while (sLines.length && sLines[0].trim() === "") sLines.shift();
619
- while (sLines.length && sLines[sLines.length - 1].trim() === "") sLines.pop();
620
- if (sLines.length === 0) return [];
621
- const sNorm = sLines.map((l) => l.trim());
622
- const lineStart = [];
623
- let acc = 0;
624
- for (const l of cLines) {
625
- lineStart.push(acc);
626
- acc += l.length + 1;
587
+ model;
588
+ reply;
589
+ id = "mock";
590
+ label = "Mock (deterministic)";
591
+ local = true;
592
+ async chat(_messages, _opts) {
593
+ return { text: this.reply, model: this.model, provider: this.id };
627
594
  }
628
- const out = [];
629
- for (let i = 0; i + sNorm.length <= cLines.length; i++) {
630
- let ok = true;
631
- for (let j = 0; j < sNorm.length; j++) {
632
- if (cLines[i + j].trim() !== sNorm[j]) {
633
- ok = false;
634
- break;
635
- }
636
- }
637
- if (!ok) continue;
638
- const from = lineStart[i];
639
- const lastLine = i + sNorm.length - 1;
640
- const to = lineStart[lastLine] + cLines[lastLine].length;
641
- out.push({ from, to });
595
+ };
596
+ var OllamaProvider = class {
597
+ constructor(model, host = process.env.OLLAMA_HOST || "http://127.0.0.1:11434") {
598
+ this.model = model;
599
+ this.host = host;
642
600
  }
643
- return out;
644
- }
645
- function scopeToAnchor(content, candidates, anchor, spans, file) {
646
- if (!anchor) return null;
647
- const span = spans.find((s) => s.file === file && s.qualifiedName === anchor);
648
- if (!span) return null;
649
- const lineOf = lineIndexer(content);
650
- const inside = candidates.filter((c) => {
651
- const line = lineOf(c.from);
652
- return line >= span.start && line <= span.end;
653
- });
654
- if (inside.length === 1) return { kind: "unique", from: inside[0].from, to: inside[0].to };
655
- return null;
656
- }
657
- function lineIndexer(content) {
658
- const starts = [0];
659
- for (let i = 0; i < content.length; i++) if (content[i] === "\n") starts.push(i + 1);
660
- return (offset) => {
661
- let lo = 0;
662
- let hi = starts.length - 1;
663
- while (lo < hi) {
664
- const mid = lo + hi + 1 >> 1;
665
- if (starts[mid] <= offset) lo = mid;
666
- else hi = mid - 1;
601
+ model;
602
+ host;
603
+ id = "ollama";
604
+ label = "Ollama (local)";
605
+ local = true;
606
+ async chat(messages, opts = {}) {
607
+ const streaming = !!(opts.stream && opts.onToken);
608
+ let res;
609
+ try {
610
+ res = await postJson(
611
+ `${this.host.replace(/\/$/, "")}/api/chat`,
612
+ { model: this.model, messages: openAiMessages(messages), stream: streaming, tools: openAiTools(opts.tools), options: { temperature: opts.temperature ?? 0 } },
613
+ {},
614
+ opts.timeoutMs ?? 12e4
615
+ );
616
+ } catch {
617
+ throw new Error(`couldn't reach Ollama at ${this.host} \u2014 is \`ollama serve\` running? (start it, or pass --provider to use a different backend)`);
667
618
  }
668
- return lo + 1;
669
- };
670
- }
671
-
672
- // src/code/diff.ts
673
- function unifiedDiff(before, after, file, context = 3) {
674
- if (before === after) return "";
675
- if (before === null) return newFileDiff(after ?? "", file);
676
- if (after === null) return deletedFileDiff(before, file);
677
- const a = splitLines(before);
678
- const b = splitLines(after);
679
- const ops = diffLines(a, b);
680
- const hunks = groupHunks(ops, context);
681
- if (hunks.length === 0) return "";
682
- const out = [`--- a/${file}`, `+++ b/${file}`];
683
- for (const h of hunks) {
684
- out.push(`@@ -${h.aStart},${h.aLen} +${h.bStart},${h.bLen} @@`);
685
- for (const line of h.lines) out.push(line);
686
- }
687
- return out.join("\n");
688
- }
689
- function newFileDiff(after, file) {
690
- const lines = splitLines(after);
691
- const out = [`--- /dev/null`, `+++ b/${file}`, `@@ -0,0 +1,${lines.length} @@`];
692
- for (const l of lines) out.push(`+${l}`);
693
- return out.join("\n");
694
- }
695
- function deletedFileDiff(before, file) {
696
- const lines = splitLines(before);
697
- const out = [`--- a/${file}`, `+++ /dev/null`, `@@ -1,${lines.length} +0,0 @@`];
698
- for (const l of lines) out.push(`-${l}`);
699
- return out.join("\n");
700
- }
701
- function splitLines(s) {
702
- if (s === "") return [];
703
- const lines = s.split("\n");
704
- if (lines.length && lines[lines.length - 1] === "") lines.pop();
705
- return lines;
706
- }
707
- function diffLines(a, b) {
708
- const n = a.length;
709
- const m = b.length;
710
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
711
- for (let i2 = n - 1; i2 >= 0; i2--) {
712
- for (let j2 = m - 1; j2 >= 0; j2--) {
713
- dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
619
+ if (!res.ok) throw httpError("Ollama", res.status, res.status === 404 ? `model "${this.model}" isn't pulled \u2014 run \`ollama pull ${this.model}\` or pick another with --model` : "check `ollama serve` and the model name");
620
+ if (streaming) {
621
+ let text = "";
622
+ let toolCallsRaw;
623
+ const usage = {};
624
+ for await (const line of streamLines(res)) {
625
+ try {
626
+ const obj = JSON.parse(line);
627
+ const delta = obj.message?.content;
628
+ if (delta) {
629
+ text += delta;
630
+ opts.onToken?.(delta);
631
+ }
632
+ if (obj.message?.tool_calls) toolCallsRaw = obj.message.tool_calls;
633
+ if (typeof obj.prompt_eval_count === "number") usage.promptTokens = obj.prompt_eval_count;
634
+ if (typeof obj.eval_count === "number") usage.completionTokens = obj.eval_count;
635
+ } catch {
636
+ }
637
+ }
638
+ return { text, model: this.model, provider: this.id, toolCalls: parseToolCalls(toolCallsRaw), usage };
714
639
  }
640
+ const data = await res.json();
641
+ return {
642
+ text: data.message?.content ?? "",
643
+ model: this.model,
644
+ provider: this.id,
645
+ toolCalls: parseToolCalls(data.message?.tool_calls),
646
+ usage: { promptTokens: data.prompt_eval_count, completionTokens: data.eval_count }
647
+ };
715
648
  }
716
- const ops = [];
717
- let i = 0;
718
- let j = 0;
719
- while (i < n && j < m) {
720
- if (a[i] === b[j]) {
721
- ops.push({ tag: "eq", line: a[i] });
722
- i++;
723
- j++;
724
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
725
- ops.push({ tag: "del", line: a[i] });
726
- i++;
727
- } else {
728
- ops.push({ tag: "add", line: b[j] });
729
- j++;
730
- }
649
+ };
650
+ var OpenAiCompatibleProvider = class {
651
+ constructor(model, config) {
652
+ this.model = model;
653
+ this.config = config;
654
+ this.id = config.id;
655
+ this.label = config.label;
656
+ this.local = config.local;
731
657
  }
732
- while (i < n) ops.push({ tag: "del", line: a[i++] });
733
- while (j < m) ops.push({ tag: "add", line: b[j++] });
734
- return ops;
735
- }
736
- function groupHunks(ops, context) {
737
- const changeIdx = ops.map((o, i) => o.tag === "eq" ? -1 : i).filter((i) => i >= 0);
738
- if (changeIdx.length === 0) return [];
739
- const windows = [];
740
- let start = Math.max(0, changeIdx[0] - context);
741
- let end = Math.min(ops.length - 1, changeIdx[0] + context);
742
- for (let k = 1; k < changeIdx.length; k++) {
743
- const cs = Math.max(0, changeIdx[k] - context);
744
- const ce = Math.min(ops.length - 1, changeIdx[k] + context);
745
- if (cs <= end + 1) {
746
- end = ce;
747
- } else {
748
- windows.push([start, end]);
749
- start = cs;
750
- end = ce;
658
+ model;
659
+ config;
660
+ id;
661
+ label;
662
+ local;
663
+ async chat(messages, opts = {}) {
664
+ const headers = {};
665
+ if (this.config.apiKeyEnv) {
666
+ const key = process.env[this.config.apiKeyEnv];
667
+ if (!key) {
668
+ throw new Error(`${this.label} needs an API key in ${this.config.apiKeyEnv} \u2014 set it in your environment (never pass keys as flags), or use --local for an on-device backend.`);
669
+ }
670
+ headers.authorization = `Bearer ${key}`;
751
671
  }
752
- }
753
- windows.push([start, end]);
754
- const hunks = [];
755
- let aLine = 1;
756
- let bLine = 1;
757
- let idx = 0;
758
- for (const [ws, we] of windows) {
759
- while (idx < ws) {
760
- const o = ops[idx];
761
- if (o.tag !== "add") aLine++;
762
- if (o.tag !== "del") bLine++;
763
- idx++;
672
+ const streaming = !!(opts.stream && opts.onToken);
673
+ const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
674
+ const body = {
675
+ model: this.model,
676
+ messages: openAiMessages(messages),
677
+ tools: openAiTools(opts.tools),
678
+ temperature: opts.temperature ?? 0,
679
+ max_tokens: opts.maxTokens,
680
+ stream: streaming,
681
+ ...streaming ? { stream_options: { include_usage: true } } : {}
682
+ };
683
+ let res;
684
+ try {
685
+ res = await postJson(url, body, headers, opts.timeoutMs ?? 12e4);
686
+ } catch {
687
+ throw new Error(`couldn't reach ${this.label} at ${this.config.baseUrl} \u2014 check the endpoint is up (or use --local).`);
764
688
  }
765
- const hunk = { aStart: aLine, aLen: 0, bStart: bLine, bLen: 0, lines: [] };
766
- for (let k = ws; k <= we; k++) {
767
- const o = ops[k];
768
- if (o.tag === "eq") {
769
- hunk.lines.push(` ${o.line}`);
770
- hunk.aLen++;
771
- hunk.bLen++;
772
- aLine++;
773
- bLine++;
774
- } else if (o.tag === "del") {
775
- hunk.lines.push(`-${o.line}`);
776
- hunk.aLen++;
777
- aLine++;
778
- } else {
779
- hunk.lines.push(`+${o.line}`);
780
- hunk.bLen++;
781
- bLine++;
689
+ if (!res.ok) {
690
+ const hint = res.status === 401 || res.status === 403 ? `the key in ${this.config.apiKeyEnv ?? "(none)"} was rejected \u2014 check it has access to "${this.model}"` : `check the model id "${this.model}" and the endpoint`;
691
+ throw httpError(this.label, res.status, hint);
692
+ }
693
+ if (streaming) {
694
+ const acc = { text: "", tools: /* @__PURE__ */ new Map(), usage: {} };
695
+ for await (const line of streamLines(res)) {
696
+ if (!line.startsWith("data:")) continue;
697
+ const payload = line.slice(5).trim();
698
+ if (payload === "[DONE]") break;
699
+ try {
700
+ accumulateOpenAiDelta(acc, JSON.parse(payload), opts.onToken);
701
+ } catch {
702
+ }
782
703
  }
783
- idx = k + 1;
704
+ const tools = finalizeTools(acc.tools);
705
+ return { text: acc.text, model: this.model, provider: this.id, toolCalls: tools.length ? tools : void 0, usage: acc.usage };
784
706
  }
785
- if (hunk.aLen === 0) hunk.aStart = Math.max(0, hunk.aStart - 1);
786
- if (hunk.bLen === 0) hunk.bStart = Math.max(0, hunk.bStart - 1);
787
- hunks.push(hunk);
707
+ const data = await res.json();
708
+ const message = data.choices?.[0]?.message;
709
+ return {
710
+ text: message?.content ?? "",
711
+ model: this.model,
712
+ provider: this.id,
713
+ toolCalls: parseToolCalls(message?.tool_calls),
714
+ usage: { promptTokens: data.usage?.prompt_tokens, completionTokens: data.usage?.completion_tokens }
715
+ };
788
716
  }
789
- return hunks;
790
- }
791
- function summarizeDiffs(diffs) {
792
- let adds = 0;
793
- let dels = 0;
794
- let touched = 0;
795
- for (const { diff } of diffs) {
796
- if (!diff) continue;
797
- touched++;
798
- for (const line of diff.split("\n")) {
799
- if (line.startsWith("+") && !line.startsWith("+++")) adds++;
800
- else if (line.startsWith("-") && !line.startsWith("---")) dels++;
717
+ };
718
+ var ScriptedProvider = class {
719
+ constructor(model, steps) {
720
+ this.model = model;
721
+ this.steps = steps;
722
+ }
723
+ model;
724
+ steps;
725
+ id = "scripted";
726
+ label = "Scripted (deterministic)";
727
+ local = true;
728
+ turn = 0;
729
+ // Match Provider.chat so tests can wrap/spy with the full signature.
730
+ async chat(_messages, _opts) {
731
+ const step = this.steps[Math.min(this.turn, this.steps.length - 1)];
732
+ this.turn++;
733
+ return { text: step.text ?? "", model: this.model, provider: this.id, toolCalls: step.toolCalls };
734
+ }
735
+ };
736
+ var OPENAI_COMPATIBLE = {
737
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKeyEnv: "OPENROUTER_API_KEY", local: false, label: "OpenRouter" },
738
+ litellm: { baseUrl: process.env.LITELLM_BASE_URL || "http://127.0.0.1:4000", apiKeyEnv: "LITELLM_API_KEY", local: false, label: "LiteLLM" },
739
+ openai: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY", local: false, label: "OpenAI" },
740
+ together: { baseUrl: "https://api.together.xyz/v1", apiKeyEnv: "TOGETHER_API_KEY", local: false, label: "Together" },
741
+ lmstudio: { baseUrl: process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234/v1", local: true, label: "LM Studio (local)" },
742
+ /**
743
+ * Microsoft Foundry Local — OpenAI-compatible local endpoint (Windows NPU/GPU).
744
+ * Default port matches current Foundry Local docs; override with FOUNDRY_LOCAL_BASE_URL.
745
+ */
746
+ "foundry-local": {
747
+ baseUrl: process.env.FOUNDRY_LOCAL_BASE_URL || "http://127.0.0.1:5272/v1",
748
+ local: true,
749
+ label: "Foundry Local"
750
+ }
751
+ };
752
+ var LocalLlamaProvider = class _LocalLlamaProvider {
753
+ constructor(model, modelPath, ensure = ensurePackage, consent = false, isolation = "embedded", processEndpoint) {
754
+ this.model = model;
755
+ this.modelPath = modelPath;
756
+ this.ensure = ensure;
757
+ this.consent = consent;
758
+ this.isolation = isolation;
759
+ this.processEndpoint = processEndpoint;
760
+ }
761
+ model;
762
+ modelPath;
763
+ ensure;
764
+ consent;
765
+ isolation;
766
+ processEndpoint;
767
+ id = "llama-cpp";
768
+ label = "llama.cpp (local)";
769
+ local = true;
770
+ /** Process-wide warm binding + path (best-in-breed: no reload per turn). */
771
+ static warmBinding = null;
772
+ static warmHost = null;
773
+ static warmPath = null;
774
+ async chat(messages, opts = {}) {
775
+ const {
776
+ createLlmHost,
777
+ EmbeddedLlmHost,
778
+ resolveInferenceIsolation,
779
+ acquireHostSession,
780
+ generateOnSession,
781
+ evictIdleSessions
782
+ } = await import('./llm-host-7GWDO6IX.js');
783
+ const isolation = resolveInferenceIsolation(this.isolation);
784
+ try {
785
+ const chatMessages = messages.map((m) => ({
786
+ role: m.role === "tool" ? "user" : m.role,
787
+ content: m.content
788
+ }));
789
+ const genOpts = {
790
+ temperature: opts.temperature ?? 0,
791
+ maxTokens: opts.maxTokens,
792
+ grammar: opts.grammar,
793
+ requireGrammar: opts.requireGrammar,
794
+ identifierTrie: opts.identifierTrie,
795
+ annotateUnknownIdentifiers: !!opts.identifierTrie,
796
+ draftCandidates: opts.draftCandidates,
797
+ promptSegments: opts.promptSegments
798
+ };
799
+ if (isolation === "process") {
800
+ const host = createLlmHost({ isolation: "process", endpoint: this.processEndpoint });
801
+ await host.load(this.modelPath);
802
+ const out = await host.generate(chatMessages, genOpts);
803
+ return { text: out.text, model: this.model, provider: this.id };
804
+ }
805
+ if (!_LocalLlamaProvider.warmBinding) {
806
+ const res = await this.ensure("node-llama-cpp@^3", { consent: this.consent, onUnavailable: () => {
807
+ } });
808
+ if (!res.module) {
809
+ throw new Error(ensureUnavailableMessage(res.reason ?? "no-consent", "node-llama-cpp"));
810
+ }
811
+ _LocalLlamaProvider.warmBinding = res.module;
812
+ }
813
+ evictIdleSessions(2);
814
+ const session = await acquireHostSession(_LocalLlamaProvider.warmBinding, this.modelPath);
815
+ const report = await generateOnSession(session, chatMessages, genOpts);
816
+ return {
817
+ text: report.text,
818
+ model: this.model,
819
+ provider: this.id,
820
+ usage: {
821
+ // Surface host metrics in usage extension fields via completionTokens when useful.
822
+ completionTokens: report.draftAcceptedChars || void 0
823
+ }
824
+ };
825
+ } catch (e) {
826
+ try {
827
+ if (_LocalLlamaProvider.warmBinding) {
828
+ if (!_LocalLlamaProvider.warmHost || _LocalLlamaProvider.warmPath !== this.modelPath) {
829
+ const host = createLlmHost({
830
+ isolation: "embedded",
831
+ binding: _LocalLlamaProvider.warmBinding,
832
+ preferGrammar: !!opts.grammar
833
+ });
834
+ if (host instanceof EmbeddedLlmHost) {
835
+ _LocalLlamaProvider.warmHost = host;
836
+ _LocalLlamaProvider.warmPath = this.modelPath;
837
+ await host.load(this.modelPath);
838
+ }
839
+ }
840
+ if (_LocalLlamaProvider.warmHost) {
841
+ const out = await _LocalLlamaProvider.warmHost.generate(
842
+ messages.map((m) => ({
843
+ role: m.role === "tool" ? "user" : m.role,
844
+ content: m.content
845
+ })),
846
+ {
847
+ temperature: opts.temperature ?? 0,
848
+ maxTokens: opts.maxTokens,
849
+ grammar: opts.grammar,
850
+ requireGrammar: opts.requireGrammar,
851
+ identifierTrie: opts.identifierTrie,
852
+ annotateUnknownIdentifiers: !!opts.identifierTrie,
853
+ draftCandidates: opts.draftCandidates,
854
+ promptSegments: opts.promptSegments
855
+ }
856
+ );
857
+ return { text: out.text, model: this.model, provider: this.id };
858
+ }
859
+ }
860
+ } catch {
861
+ }
862
+ throw new Error(`local llama.cpp inference failed for ${this.modelPath} \u2014 ${redactSecrets(e.message)}`);
801
863
  }
802
864
  }
803
- return `+${adds} -${dels} across ${touched} file(s)`;
804
- }
805
-
806
- // src/code/prompt.ts
807
- var CODE_SYSTEM_PROMPT = [
808
- "You are a precise code-editing assistant operating inside the Vibgrate CLI.",
809
- "You are given repository context derived from a deterministic code graph, then a task.",
810
- "Propose the smallest correct change. Do not restate unchanged code.",
811
- "",
812
- "Reply ONLY with edit blocks in this exact format, one per change:",
813
- "",
814
- "<path/to/file>",
815
- "<<<<<<< SEARCH",
816
- "<exact current lines to find>",
817
- "=======",
818
- "<replacement lines>",
819
- ">>>>>>> REPLACE",
820
- "",
821
- "The SEARCH text must match the current file (whitespace differences are tolerated).",
822
- "To add a new file use:",
823
- "CREATE <path/to/file>",
824
- "<full file body>",
825
- "END CREATE",
826
- "To remove a file use: DELETE <path/to/file>",
827
- "",
828
- "Respect every hard constraint in the context. Keep edits minimal and self-consistent."
829
- ].join("\n");
830
- function buildMessages(context) {
831
- return [
832
- { role: "system", content: CODE_SYSTEM_PROMPT },
833
- { role: "user", content: context.rendered },
834
- { role: "user", content: `Now produce the edit blocks for: ${context.instruction}` }
835
- ];
836
- }
837
- var AGENT_SYSTEM_PROMPT = [
838
- "You are VG Code, a precise coding agent working inside a real repository.",
839
- "You have tools: search_code (searches a deterministic code graph \u2014 prefer it over guessing),",
840
- "read_file, list_files, graph_impact (blast radius of a change), edit_file, create_file,",
841
- "delete_file, run_command, and finish.",
842
- "",
843
- "Work in small steps: search or read to understand before you edit; check graph_impact before",
844
- "changing shared code; make the smallest correct edit; run the tests or build to verify when useful.",
845
- "edit_file replaces an exact snippet \u2014 the search text must match the current file (whitespace is",
846
- "tolerated). Do not restate unchanged code. When the task is complete, call finish with a short summary.",
847
- "",
848
- "Editing, creating, deleting, and running commands require user approval \u2014 expect some to be declined,",
849
- "and adapt if so. Never invent file contents; read first."
850
- ].join("\n");
851
- function buildAgentMessages(context) {
852
- return [
853
- { role: "system", content: AGENT_SYSTEM_PROMPT },
854
- { role: "user", content: context.rendered },
855
- { role: "user", content: `Task: ${context.instruction}` }
856
- ];
857
- }
865
+ };
858
866
 
859
- export { LocalLlamaProvider, MockProvider, OPENAI_COMPATIBLE, OllamaProvider, OpenAiCompatibleProvider, applyEdit, applyEdits, buildAgentMessages, buildCodeContext, buildMessages, parseEdits, redactSecrets, summarizeDiffs, unifiedDiff };
860
- //# sourceMappingURL=chunk-CS37OBE3.js.map
861
- //# sourceMappingURL=chunk-CS37OBE3.js.map
867
+ export { LocalLlamaProvider, MockProvider, OPENAI_COMPATIBLE, OllamaProvider, OpenAiCompatibleProvider, ScriptedProvider, applyEdit, applyEdits, buildAgentMessages, buildMessages, ensurePackage, ensureUnavailableMessage, isPackageInstalled, parseEdits, redactSecrets, summarizeDiffs, unifiedDiff };
868
+ //# sourceMappingURL=chunk-OFUC6RYN.js.map
869
+ //# sourceMappingURL=chunk-OFUC6RYN.js.map