dsh-tdai-memory 0.3.1 → 0.3.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.
- package/client.js +4 -3
- package/index.js +436 -436
- package/package.json +1 -1
- package/vendor/tdai/adapters/standalone/llm-runner.js +274 -274
- package/vendor/tdai/config.js +366 -366
- package/vendor/tdai/core/hooks/auto-recall.js +13 -13
- package/vendor/tdai/core/prompts/l1-dedup.js +64 -64
- package/vendor/tdai/core/prompts/l1-extraction.js +98 -98
- package/vendor/tdai/core/prompts/persona-generation.js +125 -125
- package/vendor/tdai/core/prompts/scene-extraction.js +224 -224
- package/vendor/tdai/core/scene/scene-navigation.js +3 -3
- package/vendor/tdai/core/seed/seed-runtime.js +318 -318
- package/vendor/tdai/core/store/sqlite.js +190 -190
- package/vendor/tdai/core/tdai-core.js +450 -450
- package/vendor/tdai/utils/session-header-fetch.js +88 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tdai-memory",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "DeepSeek Harness 的 TencentDB Agent Memory 移植:L0 对话捕获 → L1 结构化记忆提取 → L2 场景/L3 画像,自动召回注入 + 记忆/对话搜索工具;复用现有 ~/.memory-tencentdb/memory-tdai 数据;附 Web UI 设置栏。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* StandaloneLLMRunner — powered by Vercel AI SDK (`ai` + `@ai-sdk/openai`).
|
|
3
|
-
*
|
|
4
|
-
* This runner does NOT depend on OpenClaw's `runEmbeddedPiAgent`. It is designed
|
|
5
|
-
* for the Hermes Gateway scenario where TDAI runs as an independent Node.js sidecar
|
|
6
|
-
* without the OpenClaw host.
|
|
7
|
-
*
|
|
8
|
-
* Capabilities:
|
|
9
|
-
* - `enableTools: false`: pure text output (L1 extraction, L1 dedup)
|
|
10
|
-
* - `enableTools: true`: automatic tool-call loop with local file operations
|
|
11
|
-
* (L2 scene, L3 persona) via AI SDK's `maxSteps`
|
|
12
|
-
*
|
|
13
|
-
* Tool sandbox:
|
|
14
|
-
* When tools are enabled, three basic file operations are exposed:
|
|
15
|
-
* `read_file`, `write_to_file`, `replace_in_file`.
|
|
16
|
-
* All file paths are resolved relative to `workspaceDir`, enforcing sandbox boundaries.
|
|
17
|
-
*/
|
|
18
|
-
import fsPromises from "node:fs/promises";
|
|
19
|
-
import path from "node:path";
|
|
20
|
-
import { generateText, tool, stepCountIs, jsonSchema } from "ai";
|
|
21
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
22
|
-
import { report } from "../../core/report/reporter.js";
|
|
23
|
-
import { createNoThinkFetch } from "../../utils/no-think-fetch.js";
|
|
24
|
-
import { resolvePersistentSessionId, sessionHeaderOf } from "../../utils/session-header-fetch.js";
|
|
25
|
-
const TAG = "[memory-tdai] [standalone-runner]";
|
|
26
|
-
// Max iterations in the tool-call loop to prevent infinite loops
|
|
27
|
-
const MAX_TOOL_ITERATIONS = 20;
|
|
28
|
-
// ============================
|
|
29
|
-
// Sandboxed tool execution helpers
|
|
30
|
-
// ============================
|
|
31
|
-
function resolveSandboxedPath(workspaceDir, relativePath) {
|
|
32
|
-
const resolved = path.resolve(workspaceDir, relativePath);
|
|
33
|
-
if (!resolved.startsWith(path.resolve(workspaceDir))) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
return resolved;
|
|
37
|
-
}
|
|
38
|
-
// ============================
|
|
39
|
-
// Tool definitions (Vercel AI SDK `tool()` format)
|
|
40
|
-
// ============================
|
|
41
|
-
function createSandboxedTools(workspaceDir, logger) {
|
|
42
|
-
return {
|
|
43
|
-
read_file: tool({
|
|
44
|
-
description: "Read the contents of a file at the given relative path.",
|
|
45
|
-
inputSchema: jsonSchema({
|
|
46
|
-
type: "object",
|
|
47
|
-
properties: {
|
|
48
|
-
path: { type: "string", description: "Relative file path to read." },
|
|
49
|
-
},
|
|
50
|
-
required: ["path"],
|
|
51
|
-
}),
|
|
52
|
-
execute: (async (args) => {
|
|
53
|
-
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
54
|
-
if (!resolved)
|
|
55
|
-
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
56
|
-
try {
|
|
57
|
-
return await fsPromises.readFile(resolved, "utf-8");
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
-
logger?.warn?.(`${TAG} read_file failed: ${msg}`);
|
|
62
|
-
return JSON.stringify({ error: msg });
|
|
63
|
-
}
|
|
64
|
-
}),
|
|
65
|
-
}),
|
|
66
|
-
write_to_file: tool({
|
|
67
|
-
description: "Write content to a file at the given relative path. Creates or overwrites.",
|
|
68
|
-
inputSchema: jsonSchema({
|
|
69
|
-
type: "object",
|
|
70
|
-
properties: {
|
|
71
|
-
path: { type: "string", description: "Relative file path to write." },
|
|
72
|
-
content: { type: "string", description: "Content to write." },
|
|
73
|
-
},
|
|
74
|
-
required: ["path", "content"],
|
|
75
|
-
}),
|
|
76
|
-
execute: (async (args) => {
|
|
77
|
-
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
78
|
-
if (!resolved)
|
|
79
|
-
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
80
|
-
try {
|
|
81
|
-
await fsPromises.mkdir(path.dirname(resolved), { recursive: true });
|
|
82
|
-
await fsPromises.writeFile(resolved, args.content, "utf-8");
|
|
83
|
-
return JSON.stringify({ success: true });
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
-
logger?.warn?.(`${TAG} write_to_file failed: ${msg}`);
|
|
88
|
-
return JSON.stringify({ error: msg });
|
|
89
|
-
}
|
|
90
|
-
}),
|
|
91
|
-
}),
|
|
92
|
-
replace_in_file: tool({
|
|
93
|
-
description: "Replace an exact substring in a file with new content.",
|
|
94
|
-
inputSchema: jsonSchema({
|
|
95
|
-
type: "object",
|
|
96
|
-
properties: {
|
|
97
|
-
path: { type: "string", description: "Relative file path." },
|
|
98
|
-
old_str: { type: "string", description: "Exact string to find and replace." },
|
|
99
|
-
new_str: { type: "string", description: "Replacement string." },
|
|
100
|
-
},
|
|
101
|
-
required: ["path", "old_str", "new_str"],
|
|
102
|
-
}),
|
|
103
|
-
execute: (async (args) => {
|
|
104
|
-
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
105
|
-
if (!resolved)
|
|
106
|
-
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
107
|
-
if (!args.old_str)
|
|
108
|
-
return JSON.stringify({ error: "old_str cannot be empty." });
|
|
109
|
-
try {
|
|
110
|
-
const existing = await fsPromises.readFile(resolved, "utf-8");
|
|
111
|
-
if (!existing.includes(args.old_str)) {
|
|
112
|
-
return JSON.stringify({ error: `old_str not found in file "${args.path}".` });
|
|
113
|
-
}
|
|
114
|
-
const updated = existing.replace(args.old_str, args.new_str);
|
|
115
|
-
await fsPromises.writeFile(resolved, updated, "utf-8");
|
|
116
|
-
return JSON.stringify({ success: true });
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
-
logger?.warn?.(`${TAG} replace_in_file failed: ${msg}`);
|
|
121
|
-
return JSON.stringify({ error: msg });
|
|
122
|
-
}
|
|
123
|
-
}),
|
|
124
|
-
}),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
// ============================
|
|
128
|
-
// StandaloneLLMRunner
|
|
129
|
-
// ============================
|
|
130
|
-
export class StandaloneLLMRunner {
|
|
131
|
-
config;
|
|
132
|
-
model;
|
|
133
|
-
enableTools;
|
|
134
|
-
logger;
|
|
135
|
-
customFetch;
|
|
136
|
-
sessionIdPromise;
|
|
137
|
-
constructor(opts) {
|
|
138
|
-
this.config = opts.config;
|
|
139
|
-
this.model = opts.model ?? opts.config.model;
|
|
140
|
-
this.enableTools = opts.enableTools ?? false;
|
|
141
|
-
this.logger = opts.logger;
|
|
142
|
-
this.customFetch = opts.config.disableThinking
|
|
143
|
-
? createNoThinkFetch(opts.config.disableThinking)
|
|
144
|
-
: undefined;
|
|
145
|
-
// Resolve the persistent session id lazily (async, cached). Only needed
|
|
146
|
-
// when the session header is enabled and no fixed sessionId is set.
|
|
147
|
-
const { sendSessionHeader, sessionId, dataDir } = opts.config;
|
|
148
|
-
if (sendSessionHeader === false || (sessionId && String(sessionId).trim())) {
|
|
149
|
-
this.sessionIdPromise = Promise.resolve(String(sessionId ?? "").trim());
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
this.sessionIdPromise = resolvePersistentSessionId(dataDir);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
async buildProvider() {
|
|
156
|
-
const providerOptions = {
|
|
157
|
-
baseURL: this.config.baseUrl,
|
|
158
|
-
apiKey: this.config.apiKey,
|
|
159
|
-
compatibility: "compatible",
|
|
160
|
-
...(this.customFetch ? { fetch: this.customFetch } : {}),
|
|
161
|
-
};
|
|
162
|
-
// x-opencode-session: stable per-conversation id header.
|
|
163
|
-
if (this.config.sendSessionHeader !== false) {
|
|
164
|
-
const persistentId = await this.sessionIdPromise;
|
|
165
|
-
const header = sessionHeaderOf({
|
|
166
|
-
send: this.config.sendSessionHeader,
|
|
167
|
-
headerName: this.config.sessionHeaderName,
|
|
168
|
-
sessionId: this.config.sessionId,
|
|
169
|
-
persistentId,
|
|
170
|
-
});
|
|
171
|
-
if (header) {
|
|
172
|
-
providerOptions.headers = { ...(providerOptions.headers ?? {}), ...header };
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return createOpenAI(providerOptions);
|
|
176
|
-
}
|
|
177
|
-
async run(params) {
|
|
178
|
-
const runStartMs = Date.now();
|
|
179
|
-
const timeoutMs = params.timeoutMs ?? this.config.timeoutMs ?? 120_000;
|
|
180
|
-
const maxTokens = params.maxTokens ?? this.config.maxTokens ?? 4096;
|
|
181
|
-
const workspaceDir = params.workspaceDir ?? process.cwd();
|
|
182
|
-
this.logger?.debug?.(`${TAG} run() start: taskId=${params.taskId}, model=${this.model}, ` +
|
|
183
|
-
`tools=${this.enableTools}, timeout=${timeoutMs}ms`);
|
|
184
|
-
// Create OpenAI-compatible provider via AI SDK
|
|
185
|
-
// Use "compatible" mode to call /chat/completions (not Responses API),
|
|
186
|
-
// which works with all OpenAI-compatible backends (DeepSeek, Qwen, etc.)
|
|
187
|
-
const provider = await this.buildProvider();
|
|
188
|
-
// For pure text tasks like L1 extraction, avoid exposing any tools.
|
|
189
|
-
const tools = this.enableTools
|
|
190
|
-
? createSandboxedTools(workspaceDir, this.logger)
|
|
191
|
-
: undefined;
|
|
192
|
-
try {
|
|
193
|
-
const result = await generateText({
|
|
194
|
-
model: provider.chat(this.model),
|
|
195
|
-
system: params.systemPrompt,
|
|
196
|
-
prompt: params.prompt,
|
|
197
|
-
...(tools ? { tools } : {}),
|
|
198
|
-
stopWhen: stepCountIs(this.enableTools ? MAX_TOOL_ITERATIONS : 1),
|
|
199
|
-
maxOutputTokens: maxTokens,
|
|
200
|
-
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
201
|
-
});
|
|
202
|
-
const text = result.text.trim();
|
|
203
|
-
const totalMs = Date.now() - runStartMs;
|
|
204
|
-
this.logger?.debug?.(`${TAG} run() completed: ${totalMs}ms, steps=${result.steps.length}, output=${text.length} chars`);
|
|
205
|
-
// Log tool usage if any
|
|
206
|
-
if (result.steps.length > 1) {
|
|
207
|
-
const toolCalls = result.steps.flatMap((s) => s.toolCalls ?? []);
|
|
208
|
-
this.logger?.debug?.(`${TAG} Tool calls: ${toolCalls.map((tc) => tc.toolName).join(", ")}`);
|
|
209
|
-
}
|
|
210
|
-
// Metric
|
|
211
|
-
if (params.instanceId) {
|
|
212
|
-
report("llm_call", {
|
|
213
|
-
taskId: params.taskId,
|
|
214
|
-
provider: "standalone",
|
|
215
|
-
model: this.model,
|
|
216
|
-
inputLength: params.prompt.length,
|
|
217
|
-
outputLength: text.length,
|
|
218
|
-
totalDurationMs: totalMs,
|
|
219
|
-
success: true,
|
|
220
|
-
error: null,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
return text;
|
|
224
|
-
}
|
|
225
|
-
catch (err) {
|
|
226
|
-
const totalMs = Date.now() - runStartMs;
|
|
227
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
228
|
-
this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${errMsg}`);
|
|
229
|
-
if (params.instanceId) {
|
|
230
|
-
report("llm_call", {
|
|
231
|
-
taskId: params.taskId,
|
|
232
|
-
provider: "standalone",
|
|
233
|
-
model: this.model,
|
|
234
|
-
inputLength: params.prompt.length,
|
|
235
|
-
outputLength: 0,
|
|
236
|
-
totalDurationMs: totalMs,
|
|
237
|
-
success: false,
|
|
238
|
-
error: errMsg,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
throw err;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Factory that creates StandaloneLLMRunner instances.
|
|
247
|
-
*
|
|
248
|
-
* Used by the Gateway and Hermes host adapters.
|
|
249
|
-
*/
|
|
250
|
-
export class StandaloneLLMRunnerFactory {
|
|
251
|
-
config;
|
|
252
|
-
logger;
|
|
253
|
-
constructor(opts) {
|
|
254
|
-
this.config = opts.config;
|
|
255
|
-
this.logger = opts.logger;
|
|
256
|
-
}
|
|
257
|
-
createRunner(opts) {
|
|
258
|
-
const enableTools = opts?.enableTools ?? false;
|
|
259
|
-
const modelRef = opts?.modelRef;
|
|
260
|
-
// Parse "provider/model" → just use the model part for OpenAI-compatible API
|
|
261
|
-
let model = this.config.model;
|
|
262
|
-
if (modelRef) {
|
|
263
|
-
const slashIdx = modelRef.indexOf("/");
|
|
264
|
-
model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
265
|
-
}
|
|
266
|
-
this.logger?.debug?.(`${TAG} Creating StandaloneLLMRunner: model=${model}, tools=${enableTools}`);
|
|
267
|
-
return new StandaloneLLMRunner({
|
|
268
|
-
config: this.config,
|
|
269
|
-
model,
|
|
270
|
-
enableTools,
|
|
271
|
-
logger: this.logger,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* StandaloneLLMRunner — powered by Vercel AI SDK (`ai` + `@ai-sdk/openai`).
|
|
3
|
+
*
|
|
4
|
+
* This runner does NOT depend on OpenClaw's `runEmbeddedPiAgent`. It is designed
|
|
5
|
+
* for the Hermes Gateway scenario where TDAI runs as an independent Node.js sidecar
|
|
6
|
+
* without the OpenClaw host.
|
|
7
|
+
*
|
|
8
|
+
* Capabilities:
|
|
9
|
+
* - `enableTools: false`: pure text output (L1 extraction, L1 dedup)
|
|
10
|
+
* - `enableTools: true`: automatic tool-call loop with local file operations
|
|
11
|
+
* (L2 scene, L3 persona) via AI SDK's `maxSteps`
|
|
12
|
+
*
|
|
13
|
+
* Tool sandbox:
|
|
14
|
+
* When tools are enabled, three basic file operations are exposed:
|
|
15
|
+
* `read_file`, `write_to_file`, `replace_in_file`.
|
|
16
|
+
* All file paths are resolved relative to `workspaceDir`, enforcing sandbox boundaries.
|
|
17
|
+
*/
|
|
18
|
+
import fsPromises from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { generateText, tool, stepCountIs, jsonSchema } from "ai";
|
|
21
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
22
|
+
import { report } from "../../core/report/reporter.js";
|
|
23
|
+
import { createNoThinkFetch } from "../../utils/no-think-fetch.js";
|
|
24
|
+
import { resolvePersistentSessionId, sessionHeaderOf } from "../../utils/session-header-fetch.js";
|
|
25
|
+
const TAG = "[memory-tdai] [standalone-runner]";
|
|
26
|
+
// Max iterations in the tool-call loop to prevent infinite loops
|
|
27
|
+
const MAX_TOOL_ITERATIONS = 20;
|
|
28
|
+
// ============================
|
|
29
|
+
// Sandboxed tool execution helpers
|
|
30
|
+
// ============================
|
|
31
|
+
function resolveSandboxedPath(workspaceDir, relativePath) {
|
|
32
|
+
const resolved = path.resolve(workspaceDir, relativePath);
|
|
33
|
+
if (!resolved.startsWith(path.resolve(workspaceDir))) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
// ============================
|
|
39
|
+
// Tool definitions (Vercel AI SDK `tool()` format)
|
|
40
|
+
// ============================
|
|
41
|
+
function createSandboxedTools(workspaceDir, logger) {
|
|
42
|
+
return {
|
|
43
|
+
read_file: tool({
|
|
44
|
+
description: "Read the contents of a file at the given relative path.",
|
|
45
|
+
inputSchema: jsonSchema({
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
path: { type: "string", description: "Relative file path to read." },
|
|
49
|
+
},
|
|
50
|
+
required: ["path"],
|
|
51
|
+
}),
|
|
52
|
+
execute: (async (args) => {
|
|
53
|
+
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
54
|
+
if (!resolved)
|
|
55
|
+
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
56
|
+
try {
|
|
57
|
+
return await fsPromises.readFile(resolved, "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
logger?.warn?.(`${TAG} read_file failed: ${msg}`);
|
|
62
|
+
return JSON.stringify({ error: msg });
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
}),
|
|
66
|
+
write_to_file: tool({
|
|
67
|
+
description: "Write content to a file at the given relative path. Creates or overwrites.",
|
|
68
|
+
inputSchema: jsonSchema({
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
path: { type: "string", description: "Relative file path to write." },
|
|
72
|
+
content: { type: "string", description: "Content to write." },
|
|
73
|
+
},
|
|
74
|
+
required: ["path", "content"],
|
|
75
|
+
}),
|
|
76
|
+
execute: (async (args) => {
|
|
77
|
+
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
78
|
+
if (!resolved)
|
|
79
|
+
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
80
|
+
try {
|
|
81
|
+
await fsPromises.mkdir(path.dirname(resolved), { recursive: true });
|
|
82
|
+
await fsPromises.writeFile(resolved, args.content, "utf-8");
|
|
83
|
+
return JSON.stringify({ success: true });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
logger?.warn?.(`${TAG} write_to_file failed: ${msg}`);
|
|
88
|
+
return JSON.stringify({ error: msg });
|
|
89
|
+
}
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
replace_in_file: tool({
|
|
93
|
+
description: "Replace an exact substring in a file with new content.",
|
|
94
|
+
inputSchema: jsonSchema({
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
path: { type: "string", description: "Relative file path." },
|
|
98
|
+
old_str: { type: "string", description: "Exact string to find and replace." },
|
|
99
|
+
new_str: { type: "string", description: "Replacement string." },
|
|
100
|
+
},
|
|
101
|
+
required: ["path", "old_str", "new_str"],
|
|
102
|
+
}),
|
|
103
|
+
execute: (async (args) => {
|
|
104
|
+
const resolved = resolveSandboxedPath(workspaceDir, args.path);
|
|
105
|
+
if (!resolved)
|
|
106
|
+
return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
|
|
107
|
+
if (!args.old_str)
|
|
108
|
+
return JSON.stringify({ error: "old_str cannot be empty." });
|
|
109
|
+
try {
|
|
110
|
+
const existing = await fsPromises.readFile(resolved, "utf-8");
|
|
111
|
+
if (!existing.includes(args.old_str)) {
|
|
112
|
+
return JSON.stringify({ error: `old_str not found in file "${args.path}".` });
|
|
113
|
+
}
|
|
114
|
+
const updated = existing.replace(args.old_str, args.new_str);
|
|
115
|
+
await fsPromises.writeFile(resolved, updated, "utf-8");
|
|
116
|
+
return JSON.stringify({ success: true });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
logger?.warn?.(`${TAG} replace_in_file failed: ${msg}`);
|
|
121
|
+
return JSON.stringify({ error: msg });
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// ============================
|
|
128
|
+
// StandaloneLLMRunner
|
|
129
|
+
// ============================
|
|
130
|
+
export class StandaloneLLMRunner {
|
|
131
|
+
config;
|
|
132
|
+
model;
|
|
133
|
+
enableTools;
|
|
134
|
+
logger;
|
|
135
|
+
customFetch;
|
|
136
|
+
sessionIdPromise;
|
|
137
|
+
constructor(opts) {
|
|
138
|
+
this.config = opts.config;
|
|
139
|
+
this.model = opts.model ?? opts.config.model;
|
|
140
|
+
this.enableTools = opts.enableTools ?? false;
|
|
141
|
+
this.logger = opts.logger;
|
|
142
|
+
this.customFetch = opts.config.disableThinking
|
|
143
|
+
? createNoThinkFetch(opts.config.disableThinking)
|
|
144
|
+
: undefined;
|
|
145
|
+
// Resolve the persistent session id lazily (async, cached). Only needed
|
|
146
|
+
// when the session header is enabled and no fixed sessionId is set.
|
|
147
|
+
const { sendSessionHeader, sessionId, dataDir } = opts.config;
|
|
148
|
+
if (sendSessionHeader === false || (sessionId && String(sessionId).trim())) {
|
|
149
|
+
this.sessionIdPromise = Promise.resolve(String(sessionId ?? "").trim());
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.sessionIdPromise = resolvePersistentSessionId(dataDir);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async buildProvider() {
|
|
156
|
+
const providerOptions = {
|
|
157
|
+
baseURL: this.config.baseUrl,
|
|
158
|
+
apiKey: this.config.apiKey,
|
|
159
|
+
compatibility: "compatible",
|
|
160
|
+
...(this.customFetch ? { fetch: this.customFetch } : {}),
|
|
161
|
+
};
|
|
162
|
+
// x-opencode-session: stable per-conversation id header.
|
|
163
|
+
if (this.config.sendSessionHeader !== false) {
|
|
164
|
+
const persistentId = await this.sessionIdPromise;
|
|
165
|
+
const header = sessionHeaderOf({
|
|
166
|
+
send: this.config.sendSessionHeader,
|
|
167
|
+
headerName: this.config.sessionHeaderName,
|
|
168
|
+
sessionId: this.config.sessionId,
|
|
169
|
+
persistentId,
|
|
170
|
+
});
|
|
171
|
+
if (header) {
|
|
172
|
+
providerOptions.headers = { ...(providerOptions.headers ?? {}), ...header };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return createOpenAI(providerOptions);
|
|
176
|
+
}
|
|
177
|
+
async run(params) {
|
|
178
|
+
const runStartMs = Date.now();
|
|
179
|
+
const timeoutMs = params.timeoutMs ?? this.config.timeoutMs ?? 120_000;
|
|
180
|
+
const maxTokens = params.maxTokens ?? this.config.maxTokens ?? 4096;
|
|
181
|
+
const workspaceDir = params.workspaceDir ?? process.cwd();
|
|
182
|
+
this.logger?.debug?.(`${TAG} run() start: taskId=${params.taskId}, model=${this.model}, ` +
|
|
183
|
+
`tools=${this.enableTools}, timeout=${timeoutMs}ms`);
|
|
184
|
+
// Create OpenAI-compatible provider via AI SDK
|
|
185
|
+
// Use "compatible" mode to call /chat/completions (not Responses API),
|
|
186
|
+
// which works with all OpenAI-compatible backends (DeepSeek, Qwen, etc.)
|
|
187
|
+
const provider = await this.buildProvider();
|
|
188
|
+
// For pure text tasks like L1 extraction, avoid exposing any tools.
|
|
189
|
+
const tools = this.enableTools
|
|
190
|
+
? createSandboxedTools(workspaceDir, this.logger)
|
|
191
|
+
: undefined;
|
|
192
|
+
try {
|
|
193
|
+
const result = await generateText({
|
|
194
|
+
model: provider.chat(this.model),
|
|
195
|
+
system: params.systemPrompt,
|
|
196
|
+
prompt: params.prompt,
|
|
197
|
+
...(tools ? { tools } : {}),
|
|
198
|
+
stopWhen: stepCountIs(this.enableTools ? MAX_TOOL_ITERATIONS : 1),
|
|
199
|
+
maxOutputTokens: maxTokens,
|
|
200
|
+
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
201
|
+
});
|
|
202
|
+
const text = result.text.trim();
|
|
203
|
+
const totalMs = Date.now() - runStartMs;
|
|
204
|
+
this.logger?.debug?.(`${TAG} run() completed: ${totalMs}ms, steps=${result.steps.length}, output=${text.length} chars`);
|
|
205
|
+
// Log tool usage if any
|
|
206
|
+
if (result.steps.length > 1) {
|
|
207
|
+
const toolCalls = result.steps.flatMap((s) => s.toolCalls ?? []);
|
|
208
|
+
this.logger?.debug?.(`${TAG} Tool calls: ${toolCalls.map((tc) => tc.toolName).join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
// Metric
|
|
211
|
+
if (params.instanceId) {
|
|
212
|
+
report("llm_call", {
|
|
213
|
+
taskId: params.taskId,
|
|
214
|
+
provider: "standalone",
|
|
215
|
+
model: this.model,
|
|
216
|
+
inputLength: params.prompt.length,
|
|
217
|
+
outputLength: text.length,
|
|
218
|
+
totalDurationMs: totalMs,
|
|
219
|
+
success: true,
|
|
220
|
+
error: null,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const totalMs = Date.now() - runStartMs;
|
|
227
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
228
|
+
this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${errMsg}`);
|
|
229
|
+
if (params.instanceId) {
|
|
230
|
+
report("llm_call", {
|
|
231
|
+
taskId: params.taskId,
|
|
232
|
+
provider: "standalone",
|
|
233
|
+
model: this.model,
|
|
234
|
+
inputLength: params.prompt.length,
|
|
235
|
+
outputLength: 0,
|
|
236
|
+
totalDurationMs: totalMs,
|
|
237
|
+
success: false,
|
|
238
|
+
error: errMsg,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Factory that creates StandaloneLLMRunner instances.
|
|
247
|
+
*
|
|
248
|
+
* Used by the Gateway and Hermes host adapters.
|
|
249
|
+
*/
|
|
250
|
+
export class StandaloneLLMRunnerFactory {
|
|
251
|
+
config;
|
|
252
|
+
logger;
|
|
253
|
+
constructor(opts) {
|
|
254
|
+
this.config = opts.config;
|
|
255
|
+
this.logger = opts.logger;
|
|
256
|
+
}
|
|
257
|
+
createRunner(opts) {
|
|
258
|
+
const enableTools = opts?.enableTools ?? false;
|
|
259
|
+
const modelRef = opts?.modelRef;
|
|
260
|
+
// Parse "provider/model" → just use the model part for OpenAI-compatible API
|
|
261
|
+
let model = this.config.model;
|
|
262
|
+
if (modelRef) {
|
|
263
|
+
const slashIdx = modelRef.indexOf("/");
|
|
264
|
+
model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
265
|
+
}
|
|
266
|
+
this.logger?.debug?.(`${TAG} Creating StandaloneLLMRunner: model=${model}, tools=${enableTools}`);
|
|
267
|
+
return new StandaloneLLMRunner({
|
|
268
|
+
config: this.config,
|
|
269
|
+
model,
|
|
270
|
+
enableTools,
|
|
271
|
+
logger: this.logger,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|