@krmxd/onegpt 2.0.0-beta.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.
- package/bin/ogpt.js +63 -0
- package/package.json +35 -0
- package/src/agent.js +763 -0
- package/src/catalog.js +108 -0
- package/src/cli.js +1111 -0
- package/src/config.js +157 -0
- package/src/glob.js +58 -0
- package/src/index.js +11 -0
- package/src/ollama.js +427 -0
- package/src/platform.js +37 -0
- package/src/preview.js +71 -0
- package/src/static.js +2 -0
- package/src/tools.js +1119 -0
- package/src/web.js +270 -0
package/src/agent.js
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Agent loop with small-model guardrails, in full parity with the Python
|
|
4
|
+
// build: tool-call canonicalization, JSON-in-text extraction, repeat-loop
|
|
5
|
+
// breaking, self-correction nudges and automatic "Done - ..." summaries.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { getConfig } = require("./config");
|
|
11
|
+
const { getSharedProvider } = require("./ollama");
|
|
12
|
+
const { ToolRegistry } = require("./tools");
|
|
13
|
+
|
|
14
|
+
const TOOL_RE = /\x00TOOL:([^:]*):([^:]*):(.*?)\x00/g;
|
|
15
|
+
const USAGE_RE = /\x00USAGE:(.*?)\x00/;
|
|
16
|
+
|
|
17
|
+
// Identity rules appended to every system prompt so the assistant always
|
|
18
|
+
// presents itself as the OGPT product/model - never as an underlying engine.
|
|
19
|
+
const IDENTITY_BLOCK = [
|
|
20
|
+
"",
|
|
21
|
+
"",
|
|
22
|
+
"Identity (follow strictly):",
|
|
23
|
+
"- You are OGPT, an AI coding assistant created by KareemXD, and you are currently running as {name}.",
|
|
24
|
+
"- When asked who you are, what model you are, what you run on, or anything similar, answer briefly, naturally and confidently in one sentence, e.g. \"I'm using {name}.\"",
|
|
25
|
+
"- Never reveal, confirm, or discuss any underlying engine, provider, base model, framework, or company behind you. If pressed, simply and politely repeat that you are {name}.",
|
|
26
|
+
"- Never claim to be a different product and never deny being OGPT.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
|
|
29
|
+
// Deterministic answers for identity questions. Small local models often
|
|
30
|
+
// imitate stale (wrong) answers from restored history instead of following the
|
|
31
|
+
// system prompt, so these questions are answered directly - instantly and
|
|
32
|
+
// correctly - without a round-trip through the model.
|
|
33
|
+
const IDENTITY_REPLIES = [
|
|
34
|
+
[/\bwho\s+(?:made|created|built|developed|trained)\s+you\b/i, "creator"],
|
|
35
|
+
[/\bare\s+you\s+(?:chatgpt|gpt[\s-]?\d*|openai'?s?|qwen|claude|gemini|llama|deepseek|mistral|grok|kimi|copilot)\b/i, "other"],
|
|
36
|
+
[/\b(?:what|which)\s+model\s+(?:are\s+you|do\s+you\s+(?:use|run)|are\s+you\s+using|runs?\s+you|powers?\s+you|is\s+(?:behind|under)\s+you)\b/i, "model"],
|
|
37
|
+
[/\bwhat\s+(?:are|is)\s+you\s+(?:running|using|built\s+on|trained\s+on|based\s+on)\b/i, "model"],
|
|
38
|
+
[/\b(?:what|which)(?:'s| is|'re| are)\s+your\s+(?:model|engine|llm|ai)\b/i, "model"],
|
|
39
|
+
[/\byour\s+(?:model|engine|llm)\b/i, "model"],
|
|
40
|
+
[/\bwho\s+are\s+you\b|\bwhat\s+are\s+you\b(?!\s*(?:doing|up\s+to|waiting|talking|typing))/i, "self"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Requests like "write a poem about who created you" are NOT identity questions.
|
|
44
|
+
const CREATIVE_RE = /\b(?:poem|poems|story|stories|song|songs|essay|joke|jokes|lyrics|haiku|rap|fiction|character|script)\b/i;
|
|
45
|
+
|
|
46
|
+
// Pure greetings get a deterministic friendly reply - tiny local models
|
|
47
|
+
// otherwise over-apply the identity rules and answer plain "hi" with
|
|
48
|
+
// "I'm using <model>".
|
|
49
|
+
const GREETING_RE = /^\s*(?:hey+|hi+|hello+|yo|sup|howdy|hola|good\s+(?:morning|afternoon|evening)|what'?s?\s+up)\s*[!,.?~]*\s*$/i;
|
|
50
|
+
|
|
51
|
+
function identityReply(userInput, name) {
|
|
52
|
+
if (GREETING_RE.test(userInput)) {
|
|
53
|
+
return `Hey! I'm ${name} - ready when you are. What are we working on?`;
|
|
54
|
+
}
|
|
55
|
+
if (CREATIVE_RE.test(userInput)) return null;
|
|
56
|
+
let kind = null;
|
|
57
|
+
for (const [rx, k] of IDENTITY_REPLIES) {
|
|
58
|
+
if (rx.test(userInput)) { kind = k; break; }
|
|
59
|
+
}
|
|
60
|
+
if (!kind) return null;
|
|
61
|
+
if (kind === "creator") return `I was created by KareemXD. I'm ${name}, OGPT's built-in coding assistant.`;
|
|
62
|
+
if (kind === "other") return `No - I'm ${name}, OGPT's own coding assistant, built by KareemXD.`;
|
|
63
|
+
if (kind === "self") return `I'm ${name}, the OGPT AI coding assistant. How can I help you today?`;
|
|
64
|
+
return `I'm using ${name}.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tool-use rules appended to every system prompt. Without them small local
|
|
69
|
+
// models tend to describe or print code instead of actually creating files.
|
|
70
|
+
const TOOL_GUIDANCE = (
|
|
71
|
+
"\n\nWorking style (follow strictly):\n" +
|
|
72
|
+
"- Your working directory is {cwd}. Create files THERE using RELATIVE paths " +
|
|
73
|
+
"(e.g. 'app.py' or 'src/app.py'). NEVER invent absolute locations like " +
|
|
74
|
+
"'/home/user/...' or '/Users/...' - they do not exist on this machine.\n" +
|
|
75
|
+
"- Tool arguments are REAL values: 'path': 'my_folder/app.py'. Never send the " +
|
|
76
|
+
"parameter schema ({'type': 'string'}) as a value, and never leave arguments empty.\n" +
|
|
77
|
+
"- To act, output plain JSON tool calls (never shell commands in code fences):\n" +
|
|
78
|
+
'{"name": "write_file", "arguments": {"path": "widgets/gadget.py", ' +
|
|
79
|
+
'"content": "print(\'hi\')"}}\n' +
|
|
80
|
+
"- When the user asks you to create, build, write, save or fix anything that lives in " +
|
|
81
|
+
"files, DO IT with your tools instead of only printing code.\n" +
|
|
82
|
+
"- Build EXACTLY what the user asked for: filenames and file types must match the " +
|
|
83
|
+
"request (an 'index.html' request means a file named index.html containing real HTML), " +
|
|
84
|
+
"and every file must be complete and working - never placeholders, never unrelated " +
|
|
85
|
+
"boilerplate, never code dumped only in chat.\n" +
|
|
86
|
+
"- write_file creates a file AND all missing parent folders automatically, so a separate " +
|
|
87
|
+
"make_dir call is usually unnecessary - just call write_file with the full path.\n" +
|
|
88
|
+
"- Complete EVERY part of the request before replying. If asked for a folder and files " +
|
|
89
|
+
"inside it, make all the tool calls in one turn: one write_file per file.\n" +
|
|
90
|
+
"- Never stop halfway: after each tool result, continue with the next step until the " +
|
|
91
|
+
"whole task is done. Never rewrite a file that already has the exact right content - " +
|
|
92
|
+
"when everything is written, reply with a short summary instead of calling tools again.\n" +
|
|
93
|
+
"- NEVER give the user instructions like 'you can use write_file', 'save this as...' or " +
|
|
94
|
+
"'run this command'. You are the one holding the tools - act immediately and silently " +
|
|
95
|
+
"execute the work yourself.\n" +
|
|
96
|
+
"- Use edit_file with the exact old text snippet when changing existing files.\n" +
|
|
97
|
+
"- Afterwards reply briefly with what you created or changed."
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Self-correction patterns: requests like "create a folder and a file" MUST end
|
|
101
|
+
// in real file operations. Small local models love to answer with instructions
|
|
102
|
+
// instead of acting; these detect both sides so the agent can give itself one
|
|
103
|
+
// firm follow-up round and actually execute the task.
|
|
104
|
+
const TASK_RE = new RegExp(
|
|
105
|
+
"\\b(create|make|build|write|add|generate|scaffold|implement|fix|update)\\b" +
|
|
106
|
+
"[^\\n]{0,120}?\\b(file|files|folder|folders|directory|dir|script|module|" +
|
|
107
|
+
"app|component|config|readme|tests?)\\b", "i");
|
|
108
|
+
const QUESTION_RE = new RegExp(
|
|
109
|
+
"^\\s*(how|what|why|when|where|which|who|whom|can you (?:tell|explain)|" +
|
|
110
|
+
"explain|describe|list|is|are|does|do)\\b", "i");
|
|
111
|
+
const SLOTH_RE = new RegExp(
|
|
112
|
+
"(you (?:can|could|may|might want to|might wanna|should) (?:\\w+ )?" +
|
|
113
|
+
"(?:use|create|make|run|write|save|place|put|execute)" +
|
|
114
|
+
"|here(?:'s| is) how|to (?:create|make|write)[^\\n]{0,60}(?:use|run|type)" +
|
|
115
|
+
"|use (?:the )?(?:provided )?\\w* ?tool" +
|
|
116
|
+
"|i (?:cannot|can't|won't) (?:create|write|make)" +
|
|
117
|
+
"|follow these steps|step \\d)", "i");
|
|
118
|
+
|
|
119
|
+
const EXECUTE_NUDGE =
|
|
120
|
+
"[auto-correction] Your previous reply gave instructions instead of acting. " +
|
|
121
|
+
"You hold the tools and have permission - EXECUTE the original request yourself NOW: " +
|
|
122
|
+
"output bare JSON tool calls (NOT shell commands, NOT explanations). Exact shape:\n" +
|
|
123
|
+
'{"name": "write_file", "arguments": {"path": "<folder>/<file>.py", ' +
|
|
124
|
+
'"content": "<complete file text>"}}\n' +
|
|
125
|
+
"Do not tell the user to do anything.";
|
|
126
|
+
|
|
127
|
+
function wantsAction(userInput) {
|
|
128
|
+
// True when the user asked for something to be built (not explained).
|
|
129
|
+
return TASK_RE.test(userInput) && !QUESTION_RE.test(userInput.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function redirectMsg(task) {
|
|
133
|
+
// Loop-break redirect: push the model to the NEXT steps with a concrete
|
|
134
|
+
// call example (tiny models need shapes, not descriptions).
|
|
135
|
+
const example = '{"name": "write_file", "arguments": ' +
|
|
136
|
+
'{"path": "<folder>/<file>.py", "content": "<complete file text>"}}';
|
|
137
|
+
return "[auto-correction] That exact call already succeeded - do NOT repeat it. " +
|
|
138
|
+
`Continue the ORIGINAL request ("${task}") with the NEXT steps now: ` +
|
|
139
|
+
"write every still-missing file via write_file, one call per file, " +
|
|
140
|
+
"with real relative paths and the FULL content. Example of the exact shape:\n" +
|
|
141
|
+
`${example}\n` +
|
|
142
|
+
"When everything exists, reply briefly.";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function repliedWithoutActing(reply) {
|
|
146
|
+
// True when a reply looks like instructions/code instead of done work.
|
|
147
|
+
return SLOTH_RE.test(reply) || reply.includes("```");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// "Created the folder." style announcements. Harmless on their own, but a
|
|
151
|
+
// red flag when the request named files that were never actually created
|
|
152
|
+
// (e.g. 'a folder AND an index.html' - folder done, file skipped).
|
|
153
|
+
const ANNOUNCE_RE = /^\s*(?:ok(?:ay)?|done|created|made|added|all\s+set|i'?ve?\s+(?:created|made|added))\b/i;
|
|
154
|
+
|
|
155
|
+
const ARTIFACT_FILE_RE = /\b[\w./\\~-]*[\w.-]+\.(?:html?|css|jsx?|tsx?|py|json|md|txt|sh|ya?ml|csv)\b/gi;
|
|
156
|
+
|
|
157
|
+
function namedFiles(userInput) {
|
|
158
|
+
const seen = new Map();
|
|
159
|
+
for (const m of userInput.matchAll(ARTIFACT_FILE_RE)) {
|
|
160
|
+
const name = m[0].replace(/[`"']/g, "").replace(/\\/g, "/");
|
|
161
|
+
if (name && !seen.has(name.toLowerCase())) seen.set(name.toLowerCase(), name);
|
|
162
|
+
}
|
|
163
|
+
return [...seen.values()];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function artifactExists(name) {
|
|
167
|
+
// Does the requested file exist (cwd-relative, case-insensitive fallback)?
|
|
168
|
+
try {
|
|
169
|
+
const p = expandHome(name);
|
|
170
|
+
if (fs.existsSync(p)) return true;
|
|
171
|
+
const dir = path.dirname(p);
|
|
172
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
|
173
|
+
return fs.readdirSync(dir).some((f) => f.toLowerCase() === path.basename(p).toLowerCase());
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stalledHalfway(reply, userInput, doneOutputs) {
|
|
180
|
+
// A named file counts as done when the exact path exists OR the completed
|
|
181
|
+
// tool outputs already produced that file name (covers requests like
|
|
182
|
+
// 'a folder and an index.html inside it' where write_file made the full
|
|
183
|
+
// path in one step).
|
|
184
|
+
const t = reply.trim();
|
|
185
|
+
if (!t || t.length > 160 || t.includes("```")) return false;
|
|
186
|
+
if (!ANNOUNCE_RE.test(t)) return false;
|
|
187
|
+
const bits = (doneOutputs || []).join(" ").toLowerCase();
|
|
188
|
+
return namedFiles(userInput).some((n) => {
|
|
189
|
+
const base = path.basename(n).toLowerCase();
|
|
190
|
+
if (base && bits.includes(base)) return false;
|
|
191
|
+
return !artifactExists(n);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function expandHome(p) {
|
|
196
|
+
return String(p).replace(/^~(?=$|\/)/, os.homedir());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Intent-level signature of a tool-call batch (repeat-loop detection).
|
|
200
|
+
// Paths are resolved to absolute form so 'make_dir widgets' and
|
|
201
|
+
// 'make_dir /cwd/widgets' count as the SAME action - tiny models switch
|
|
202
|
+
// path styles instead of moving on to the next step.
|
|
203
|
+
function callSig(calls) {
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const tc of calls) {
|
|
206
|
+
const args = tc.arguments || {};
|
|
207
|
+
const norm = Object.keys(args).sort().map((k) => {
|
|
208
|
+
let v = args[k];
|
|
209
|
+
if (k === "path" && typeof v === "string" && !v.includes("\n")) {
|
|
210
|
+
// Python's os.path.realpath resolves nonexistent paths lexically;
|
|
211
|
+
// fs.realpathSync throws - so resolve lexically first, then refine
|
|
212
|
+
// symlinks via the (almost always existing) parent directory.
|
|
213
|
+
const abs = path.resolve(expandHome(v.trim()));
|
|
214
|
+
try {
|
|
215
|
+
v = fs.realpathSync(path.dirname(abs)) + path.sep + path.basename(abs);
|
|
216
|
+
} catch {
|
|
217
|
+
v = abs;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [k, String(v)];
|
|
221
|
+
});
|
|
222
|
+
out.push(`${tc.name}(${norm.map((kv) => kv.join("=")).join(",")})`);
|
|
223
|
+
}
|
|
224
|
+
return out.sort().join(";;");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Catch tool calls the model printed as bare JSON text instead of using the
|
|
228
|
+
// native tool_calls channel (very common with tiny models).
|
|
229
|
+
function extractJsonTools(text) {
|
|
230
|
+
const calls = [];
|
|
231
|
+
const spans = [];
|
|
232
|
+
if (!text || text.indexOf('"arguments"') === -1) return { clean: text || "", calls };
|
|
233
|
+
let i = 0;
|
|
234
|
+
while ((i = text.indexOf("{", i)) !== -1 && calls.length < 8) {
|
|
235
|
+
let depth = 0, inStr = false, esc = false;
|
|
236
|
+
for (let j = i; j < text.length && j < i + 20000; j++) {
|
|
237
|
+
const c = text[j];
|
|
238
|
+
if (inStr) {
|
|
239
|
+
if (esc) esc = false;
|
|
240
|
+
else if (c === "\\") esc = true;
|
|
241
|
+
else if (c === '"') inStr = false;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (c === '"') inStr = true;
|
|
245
|
+
else if (c === "{") depth++;
|
|
246
|
+
else if (c === "}") {
|
|
247
|
+
depth--;
|
|
248
|
+
if (depth === 0) {
|
|
249
|
+
try {
|
|
250
|
+
const obj = JSON.parse(text.slice(i, j + 1));
|
|
251
|
+
if (obj && typeof obj.name === "string"
|
|
252
|
+
&& obj.arguments && typeof obj.arguments === "object"
|
|
253
|
+
&& !Array.isArray(obj.arguments)) {
|
|
254
|
+
spans.push([i, j + 1]);
|
|
255
|
+
calls.push({
|
|
256
|
+
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
257
|
+
name: obj.name,
|
|
258
|
+
arguments: obj.arguments,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} catch {}
|
|
262
|
+
i = j;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
i++;
|
|
268
|
+
}
|
|
269
|
+
let clean = "";
|
|
270
|
+
let pos = 0;
|
|
271
|
+
for (const [s, e] of spans) {
|
|
272
|
+
clean += text.slice(pos, s);
|
|
273
|
+
pos = e;
|
|
274
|
+
}
|
|
275
|
+
clean += text.slice(pos);
|
|
276
|
+
return { clean, calls };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Code blocks in chat: after every successful write/edit, surface the written
|
|
281
|
+
// file as a fenced code block right inside the conversation.
|
|
282
|
+
const MAX_PREVIEW_CHARS = 3000;
|
|
283
|
+
|
|
284
|
+
const LANG_BY_EXT = {
|
|
285
|
+
py: "python", js: "javascript", mjs: "javascript", cjs: "javascript",
|
|
286
|
+
ts: "typescript", tsx: "typescript", jsx: "javascript", html: "html",
|
|
287
|
+
htm: "html", css: "css", json: "json", md: "markdown", sh: "bash",
|
|
288
|
+
bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml", c: "c", cpp: "cpp",
|
|
289
|
+
h: "cpp", java: "java", go: "go", rs: "rust", rb: "ruby", php: "php",
|
|
290
|
+
sql: "sql", xml: "xml", toml: "toml",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function detectLang(filePath) {
|
|
294
|
+
const ext = path.extname(String(filePath)).slice(1).toLowerCase();
|
|
295
|
+
return LANG_BY_EXT[ext] || "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function snapshot(tc) {
|
|
299
|
+
// Read the target file before a write/edit ("" = did not exist).
|
|
300
|
+
const p = String(tc.arguments?.path || "").trim();
|
|
301
|
+
if (!p || !["write_file", "edit_file"].includes(tc.name)) return null;
|
|
302
|
+
try {
|
|
303
|
+
const fp = expandHome(p);
|
|
304
|
+
return fs.existsSync(fp) ? fs.readFileSync(fp, "utf-8") : "";
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function codeEvent(tc, pre) {
|
|
311
|
+
// Build an opencode-style code event after a successful write/edit.
|
|
312
|
+
if (pre === null) return null;
|
|
313
|
+
const p = String(tc.arguments?.path || "").trim();
|
|
314
|
+
if (!p) return null;
|
|
315
|
+
if (tc.name === "write_file") {
|
|
316
|
+
const content = String(tc.arguments?.content ?? "");
|
|
317
|
+
if (content.length > MAX_PREVIEW_CHARS || pre.length > MAX_PREVIEW_CHARS) return null;
|
|
318
|
+
return { path: p, content, action: pre ? "UPDATED" : "CREATED" };
|
|
319
|
+
}
|
|
320
|
+
if (tc.name === "edit_file") {
|
|
321
|
+
try {
|
|
322
|
+
const fp = expandHome(p);
|
|
323
|
+
const post = fs.existsSync(fp) ? fs.readFileSync(fp, "utf-8") : "";
|
|
324
|
+
if (post.length > MAX_PREVIEW_CHARS || pre.length > MAX_PREVIEW_CHARS) return null;
|
|
325
|
+
return { path: p, content: post, old_content: pre, action: "EDITED" };
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Minimal unified diff (-/+ lines) good enough for chat previews.
|
|
334
|
+
function diffLines(oldStr, newStr) {
|
|
335
|
+
const a = oldStr.split("\n"), b = newStr.split("\n");
|
|
336
|
+
let start = 0;
|
|
337
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
338
|
+
let endA = a.length, endB = b.length;
|
|
339
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
340
|
+
const out = ["@@ -" + (start + 1) + " +" + (start + 1) + " @@"];
|
|
341
|
+
for (let i = start; i < endA; i++) out.push("-" + a[i]);
|
|
342
|
+
for (let i = start; i < endB; i++) out.push("+" + b[i]);
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function codeMarkdown(ev) {
|
|
347
|
+
if (ev.action === "EDITED" && ev.old_content !== undefined) {
|
|
348
|
+
return "```diff\n" + diffLines(ev.old_content, ev.content).join("\n") + "\n```";
|
|
349
|
+
}
|
|
350
|
+
const lang = detectLang(ev.path);
|
|
351
|
+
return "```" + lang + "\n" + ev.content + "\n```";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
class Agent {
|
|
355
|
+
constructor(options = {}) {
|
|
356
|
+
this.cfg = options.config || getConfig();
|
|
357
|
+
this.tools = options.tools || new ToolRegistry();
|
|
358
|
+
this.host = options.host || this.cfg.get("ollama.host", "http://localhost:11434");
|
|
359
|
+
this.history = [];
|
|
360
|
+
this.maxRounds = this.cfg.get("agent.max_rounds", 25);
|
|
361
|
+
this.temperature = this.cfg.get("agent.temperature", 0.7);
|
|
362
|
+
this.maxTokens = this.cfg.get("agent.max_tokens", 4096);
|
|
363
|
+
this.usage = { prompt: 0, completion: 0, total: 0 };
|
|
364
|
+
this._cancelled = false;
|
|
365
|
+
this.lastRun = {};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_systemPrompt() {
|
|
369
|
+
// The base prompt may contain an {ogpt_model} placeholder that is replaced
|
|
370
|
+
// with the active model's oGPT display name (e.g. oGPT-1a). The identity
|
|
371
|
+
// block is always appended so the assistant never exposes the underlying
|
|
372
|
+
// engine, even if an older prompt is set in the user's config.
|
|
373
|
+
const base =
|
|
374
|
+
this.cfg.get("agent.system_prompt", "") ||
|
|
375
|
+
"You are OGPT, an expert AI coding assistant running as {ogpt_model}.";
|
|
376
|
+
let name = "OGPT";
|
|
377
|
+
try {
|
|
378
|
+
name = this.cfg.displayName() || "OGPT";
|
|
379
|
+
} catch {}
|
|
380
|
+
const cwd = process.cwd();
|
|
381
|
+
return (
|
|
382
|
+
base.split("{ogpt_model}").join(name)
|
|
383
|
+
+ IDENTITY_BLOCK.split("{name}").join(name)
|
|
384
|
+
+ TOOL_GUIDANCE.split("{cwd}").join(cwd)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
get provider() {
|
|
389
|
+
if (this._provider) return this._provider;
|
|
390
|
+
const { getSharedProvider } = require("./ollama");
|
|
391
|
+
return getSharedProvider(this.host);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
set provider(p) {
|
|
395
|
+
this._provider = p;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_autoCompact() {
|
|
399
|
+
if (!this.cfg.get("agent.auto_compact", true)) return;
|
|
400
|
+
const keep = parseInt(this.cfg.get("agent.auto_compact_keep", 8), 10);
|
|
401
|
+
const threshold = parseInt(this.cfg.get("agent.auto_compact_after", 24), 10);
|
|
402
|
+
if (this.history.length < threshold) return;
|
|
403
|
+
const old = this.history.slice(0, -keep);
|
|
404
|
+
const recent = this.history.slice(-keep);
|
|
405
|
+
const summary = old
|
|
406
|
+
.filter((m) => m.role !== "tool" && m.content)
|
|
407
|
+
.map((m) => `${m.role}: ${m.content.slice(0, 200)}`)
|
|
408
|
+
.join("\n");
|
|
409
|
+
if (!summary) return;
|
|
410
|
+
this.history = [{ role: "user", content: `[Conversation summary]\n${summary}` }, ...recent];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
_identityName() {
|
|
414
|
+
try {
|
|
415
|
+
return this.cfg.displayName() || "OGPT";
|
|
416
|
+
} catch {
|
|
417
|
+
return "OGPT";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_maybeIdentity(userInput) {
|
|
422
|
+
return identityReply(userInput, this._identityName());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async run(userInput) {
|
|
426
|
+
this._cancelled = false;
|
|
427
|
+
this._autoCompact();
|
|
428
|
+
const canned = this._maybeIdentity(userInput);
|
|
429
|
+
if (canned !== null) {
|
|
430
|
+
this.history.push({ role: "user", content: userInput, ts: Date.now() / 1000 });
|
|
431
|
+
this.history.push({ role: "assistant", content: canned, ts: Date.now() / 1000 });
|
|
432
|
+
return canned;
|
|
433
|
+
}
|
|
434
|
+
this.history.push({ role: "user", content: userInput, ts: Date.now() / 1000 });
|
|
435
|
+
let full = "";
|
|
436
|
+
let rounds = 0;
|
|
437
|
+
let nudges = 0;
|
|
438
|
+
let lastSig = null;
|
|
439
|
+
let loopBreaks = 0;
|
|
440
|
+
const doneBits = [];
|
|
441
|
+
|
|
442
|
+
while (rounds < this.maxRounds && !this._cancelled) {
|
|
443
|
+
rounds++;
|
|
444
|
+
const model = this.cfg.activeModel();
|
|
445
|
+
const resp = await this.provider.chat(this.history, model, {
|
|
446
|
+
tools: this.tools.definitions(),
|
|
447
|
+
system: this._systemPrompt(),
|
|
448
|
+
temperature: this.temperature,
|
|
449
|
+
maxTokens: this.maxTokens,
|
|
450
|
+
});
|
|
451
|
+
this.usage.prompt += resp.usage.prompt;
|
|
452
|
+
this.usage.completion += resp.usage.completion;
|
|
453
|
+
this.usage.total += resp.usage.total;
|
|
454
|
+
|
|
455
|
+
// catch tool calls the model printed as JSON text
|
|
456
|
+
const extracted = extractJsonTools(resp.content);
|
|
457
|
+
const cleanContent = extracted.clean;
|
|
458
|
+
const allCalls = [...(resp.toolCalls || []), ...extracted.calls];
|
|
459
|
+
for (const tc of allCalls) this.tools.prepareCall(tc);
|
|
460
|
+
|
|
461
|
+
if (allCalls.length) {
|
|
462
|
+
const sig = callSig(allCalls);
|
|
463
|
+
if (lastSig !== null && sig === lastSig) {
|
|
464
|
+
// Degenerate repeat-loop: first time, redirect it forward;
|
|
465
|
+
// second time, close the run out.
|
|
466
|
+
this.history.push({ role: "assistant", content: cleanContent.trim(), ts: Date.now() / 1000 });
|
|
467
|
+
if (loopBreaks < 1) {
|
|
468
|
+
loopBreaks++;
|
|
469
|
+
full += "\n· continuing the task after a repeated step…\n";
|
|
470
|
+
this.history.push({ role: "user", content: redirectMsg(userInput) });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
full += "\n· same action repeated - finishing up\n";
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
lastSig = sig;
|
|
477
|
+
this.history.push({ role: "assistant", content: cleanContent, toolCalls: allCalls, ts: Date.now() / 1000 });
|
|
478
|
+
if (cleanContent.trim()) full += cleanContent;
|
|
479
|
+
const seenRound = new Set();
|
|
480
|
+
for (const tc of allCalls) {
|
|
481
|
+
const s = callSig([tc]);
|
|
482
|
+
if (seenRound.has(s)) {
|
|
483
|
+
// identical call twice in ONE turn - execute once only
|
|
484
|
+
this.history.push({
|
|
485
|
+
role: "tool",
|
|
486
|
+
content: "Skipped - the exact same call was already made in this turn.",
|
|
487
|
+
toolCallId: tc.id,
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
seenRound.add(s);
|
|
492
|
+
const pre = snapshot(tc);
|
|
493
|
+
const result = await this.tools.execute(tc);
|
|
494
|
+
this.history.push({ role: "tool", content: result.content, toolCallId: tc.id });
|
|
495
|
+
if (!result.success) {
|
|
496
|
+
full += `\n[Error: ${result.error}]\n`;
|
|
497
|
+
} else {
|
|
498
|
+
doneBits.push(result.output.split("\n")[0].slice(0, 90));
|
|
499
|
+
const ev = codeEvent(tc, pre);
|
|
500
|
+
if (ev) full += (full.trim() ? "\n\n" : "") + codeMarkdown(ev);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
lastSig = null;
|
|
505
|
+
full += resp.content;
|
|
506
|
+
this.history.push({ role: "assistant", content: resp.content, ts: Date.now() / 1000 });
|
|
507
|
+
// Self-correction: instructions/code instead of done work gets one
|
|
508
|
+
// firm push to act - on ANY round, not just the first (tiny models
|
|
509
|
+
// relapse into how-to mode mid-task). An EMPTY reply after partial
|
|
510
|
+
// work means it stalled - push it too.
|
|
511
|
+
const replyLazy = repliedWithoutActing(resp.content);
|
|
512
|
+
const wentSilent = !resp.content.trim() && (rounds > 1 || doneBits.length > 0);
|
|
513
|
+
if (nudges < 3 && !this._cancelled
|
|
514
|
+
&& wantsAction(userInput) && (replyLazy || wentSilent || stalledHalfway(resp.content, userInput, doneBits))) {
|
|
515
|
+
nudges++;
|
|
516
|
+
this.history.push({ role: "user", content: redirectMsg(userInput) });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Models that only emit tool JSON never write a closing line - make sure
|
|
524
|
+
// the user still sees what happened.
|
|
525
|
+
if (doneBits.length && !full.trim()) {
|
|
526
|
+
const summary = "Done - " + [...new Set(doneBits)].join(" · ");
|
|
527
|
+
full += summary;
|
|
528
|
+
this.history.push({ role: "assistant", content: summary, ts: Date.now() / 1000 });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return full;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async *runStream(userInput) {
|
|
535
|
+
this._cancelled = false;
|
|
536
|
+
this._autoCompact();
|
|
537
|
+
const canned = this._maybeIdentity(userInput);
|
|
538
|
+
if (canned !== null) {
|
|
539
|
+
this.history.push({ role: "user", content: userInput, ts: Date.now() / 1000 });
|
|
540
|
+
this.history.push({ role: "assistant", content: canned, ts: Date.now() / 1000 });
|
|
541
|
+
yield canned;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
this.history.push({ role: "user", content: userInput, ts: Date.now() / 1000 });
|
|
545
|
+
let rounds = 0;
|
|
546
|
+
let nudges = 0;
|
|
547
|
+
const tStart = Date.now();
|
|
548
|
+
let ttft = 0;
|
|
549
|
+
let nTokens = 0;
|
|
550
|
+
this.lastRun = {};
|
|
551
|
+
let lastSig = null;
|
|
552
|
+
let loopBreaks = 0;
|
|
553
|
+
const doneBits = [];
|
|
554
|
+
let sawText = false;
|
|
555
|
+
let lastUsage = { prompt: 0, completion: 0, total: 0 };
|
|
556
|
+
|
|
557
|
+
while (rounds < this.maxRounds && !this._cancelled) {
|
|
558
|
+
rounds++;
|
|
559
|
+
const model = this.cfg.activeModel();
|
|
560
|
+
const contentParts = [];
|
|
561
|
+
const toolCalls = [];
|
|
562
|
+
let usage = { prompt: 0, completion: 0, total: 0 };
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
for await (const chunk of this.provider.stream(this.history, model, {
|
|
566
|
+
tools: this.tools.definitions(),
|
|
567
|
+
system: this._systemPrompt(),
|
|
568
|
+
temperature: this.temperature,
|
|
569
|
+
maxTokens: this.maxTokens,
|
|
570
|
+
})) {
|
|
571
|
+
if (chunk.type === "text") {
|
|
572
|
+
contentParts.push(chunk.text);
|
|
573
|
+
if (!ttft) ttft = Date.now() - tStart;
|
|
574
|
+
nTokens++;
|
|
575
|
+
sawText = true;
|
|
576
|
+
yield chunk.text;
|
|
577
|
+
} else if (chunk.type === "tool") {
|
|
578
|
+
toolCalls.push(chunk);
|
|
579
|
+
} else if (chunk.type === "usage") {
|
|
580
|
+
usage = chunk.usage;
|
|
581
|
+
} else if (chunk.type === "error") {
|
|
582
|
+
yield `\n[Error: ${chunk.error}]\n`;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch (e) {
|
|
587
|
+
yield `\n[Error: ${e.message}]\n`;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const full = contentParts.join("");
|
|
592
|
+
this.usage.prompt += usage.prompt;
|
|
593
|
+
this.usage.completion += usage.completion;
|
|
594
|
+
this.usage.total += usage.total;
|
|
595
|
+
lastUsage = usage;
|
|
596
|
+
|
|
597
|
+
// catch tool calls the model printed as JSON text
|
|
598
|
+
const extracted = extractJsonTools(full);
|
|
599
|
+
const allCalls = [...toolCalls, ...extracted.calls];
|
|
600
|
+
for (const tc of allCalls) this.tools.prepareCall(tc);
|
|
601
|
+
|
|
602
|
+
if (allCalls.length) {
|
|
603
|
+
const sig = callSig(allCalls);
|
|
604
|
+
if (lastSig !== null && sig === lastSig) {
|
|
605
|
+
// Degenerate repeat-loop: first time, redirect it forward;
|
|
606
|
+
// second time, close the run out.
|
|
607
|
+
this.history.push({ role: "assistant", content: extracted.clean.trim(), toolCalls: allCalls });
|
|
608
|
+
if (loopBreaks < 1) {
|
|
609
|
+
loopBreaks++;
|
|
610
|
+
yield "\n· continuing the task after a repeated step…\n";
|
|
611
|
+
this.history.push({ role: "user", content: redirectMsg(userInput) });
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
yield "\n· same action repeated - finishing up\n";
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
lastSig = sig;
|
|
618
|
+
this.history.push({ role: "assistant", content: extracted.clean, toolCalls: allCalls });
|
|
619
|
+
const seenRound = new Set();
|
|
620
|
+
for (const tc of allCalls) {
|
|
621
|
+
const s = callSig([tc]);
|
|
622
|
+
if (seenRound.has(s)) {
|
|
623
|
+
// identical call twice in ONE turn - execute once only
|
|
624
|
+
this.history.push({
|
|
625
|
+
role: "tool",
|
|
626
|
+
content: "Skipped - the exact same call was already made in this turn.",
|
|
627
|
+
toolCallId: tc.id,
|
|
628
|
+
});
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
seenRound.add(s);
|
|
632
|
+
yield `\n[Tool: ${tc.name}]\n`;
|
|
633
|
+
const pre = snapshot(tc);
|
|
634
|
+
const result = await this.tools.execute(tc);
|
|
635
|
+
this.history.push({ role: "tool", content: result.content, toolCallId: tc.id });
|
|
636
|
+
if (!result.success) {
|
|
637
|
+
yield `[Error: ${result.error}]\n`;
|
|
638
|
+
} else {
|
|
639
|
+
const firstLine = result.output.split("\n")[0];
|
|
640
|
+
doneBits.push(firstLine.slice(0, 90));
|
|
641
|
+
// File writes surface their result line (path + lines + size).
|
|
642
|
+
if (tc.name === "write_file" || tc.name === "edit_file") {
|
|
643
|
+
yield `\n\u2713 ${firstLine}\n`;
|
|
644
|
+
}
|
|
645
|
+
const ev = codeEvent(tc, pre);
|
|
646
|
+
if (ev) yield "\n" + codeMarkdown(ev) + "\n";
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
lastSig = null;
|
|
651
|
+
this.history.push({ role: "assistant", content: full, ts: Date.now() / 1000 });
|
|
652
|
+
// Self-correction: instructions/code instead of done work gets one
|
|
653
|
+
// firm push to act - on ANY round, not just the first (tiny models
|
|
654
|
+
// relapse into how-to mode mid-task). An EMPTY reply after partial
|
|
655
|
+
// work means it stalled - push it too.
|
|
656
|
+
const replyLazy = repliedWithoutActing(full);
|
|
657
|
+
const wentSilent = !full.trim() && (rounds > 1 || doneBits.length > 0);
|
|
658
|
+
if (nudges < 3 && !this._cancelled
|
|
659
|
+
&& wantsAction(userInput) && (replyLazy || wentSilent || stalledHalfway(full, userInput, doneBits))) {
|
|
660
|
+
nudges++;
|
|
661
|
+
yield "\n";
|
|
662
|
+
this.history.push({ role: "user", content: redirectMsg(userInput) });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Models that only emit tool JSON never write a closing line - make sure
|
|
670
|
+
// the user still sees what happened.
|
|
671
|
+
if (doneBits.length && !sawText) {
|
|
672
|
+
const summary = "Done - " + [...new Set(doneBits)].join(" · ");
|
|
673
|
+
yield summary;
|
|
674
|
+
this.history.push({ role: "assistant", content: summary, ts: Date.now() / 1000 });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const duration = (Date.now() - tStart) / 1000;
|
|
678
|
+
const tokens = lastUsage && lastUsage.completion ? lastUsage.completion : nTokens;
|
|
679
|
+
this.lastRun = {
|
|
680
|
+
rounds,
|
|
681
|
+
ttft: Math.round(ttft / 100) / 10,
|
|
682
|
+
duration: Math.round(duration * 100) / 100,
|
|
683
|
+
tokens,
|
|
684
|
+
tps: duration > 0 && tokens ? Math.round((tokens / duration) * 10) / 10 : 0,
|
|
685
|
+
promptTokens: lastUsage ? lastUsage.prompt : 0,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
finalizeInterrupted(partial) {
|
|
690
|
+
partial = (partial || "").trim();
|
|
691
|
+
if (partial) this.history.push({ role: "assistant", content: partial, ts: Date.now() / 1000 });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
undo() {
|
|
695
|
+
if (!this.history.length) return false;
|
|
696
|
+
while (this.history.length && this.history[this.history.length - 1].role === "tool") {
|
|
697
|
+
this.history.pop();
|
|
698
|
+
}
|
|
699
|
+
if (this.history.length && this.history[this.history.length - 1].role === "assistant") {
|
|
700
|
+
this.history.pop();
|
|
701
|
+
}
|
|
702
|
+
if (this.history.length && this.history[this.history.length - 1].role === "user") {
|
|
703
|
+
this.history.pop();
|
|
704
|
+
}
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
retry() {
|
|
709
|
+
if (!this.history.length) return null;
|
|
710
|
+
while (this.history.length && this.history[this.history.length - 1].role === "tool") {
|
|
711
|
+
this.history.pop();
|
|
712
|
+
}
|
|
713
|
+
if (this.history.length && this.history[this.history.length - 1].role === "assistant") {
|
|
714
|
+
this.history.pop();
|
|
715
|
+
}
|
|
716
|
+
let lastUser = null;
|
|
717
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
718
|
+
const m = this.history[i];
|
|
719
|
+
if (m.role === "user" && !m.content.startsWith("[Conversation summary]") && !m.content.startsWith("[auto-correction]")) {
|
|
720
|
+
lastUser = m.content;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (lastUser === null) return null;
|
|
725
|
+
while (this.history.length) {
|
|
726
|
+
const m = this.history.pop();
|
|
727
|
+
if (m.role === "user" && m.content === lastUser) break;
|
|
728
|
+
}
|
|
729
|
+
return lastUser;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
cancel() {
|
|
733
|
+
this._cancelled = true;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
clear() {
|
|
737
|
+
this.history = [];
|
|
738
|
+
this.usage = { prompt: 0, completion: 0, total: 0 };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
compact(keep = 4) {
|
|
742
|
+
if (this.history.length <= keep) return 0;
|
|
743
|
+
const old = this.history.slice(0, -keep);
|
|
744
|
+
const recent = this.history.slice(-keep);
|
|
745
|
+
const summary = old
|
|
746
|
+
.filter((m) => m.role !== "tool" && m.content)
|
|
747
|
+
.map((m) => `${m.role}: ${m.content.slice(0, 200)}`)
|
|
748
|
+
.join("\n");
|
|
749
|
+
this.history = [{ role: "user", content: `[Conversation summary]\n${summary}` }, ...recent];
|
|
750
|
+
return old.length;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
stats() {
|
|
754
|
+
return {
|
|
755
|
+
messages: this.history.length,
|
|
756
|
+
totalTokens: this.usage.total,
|
|
757
|
+
promptTokens: this.usage.prompt,
|
|
758
|
+
completionTokens: this.usage.completion,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
module.exports = { Agent };
|