@yeaft/webchat-agent 0.1.398 → 0.1.408
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/crew/role-query.js +10 -6
- package/package.json +3 -1
- package/sdk/query.js +3 -1
- package/unify/cli.js +537 -0
- package/unify/config.js +256 -0
- package/unify/debug-trace.js +398 -0
- package/unify/engine.js +319 -0
- package/unify/index.js +21 -0
- package/unify/init.js +147 -0
- package/unify/llm/adapter.js +186 -0
- package/unify/llm/anthropic.js +322 -0
- package/unify/llm/chat-completions.js +315 -0
- package/unify/models.js +167 -0
- package/unify/prompts.js +61 -0
package/unify/engine.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.js — Yeaft query loop
|
|
3
|
+
*
|
|
4
|
+
* The engine is the core orchestrator:
|
|
5
|
+
* 1. Build messages array
|
|
6
|
+
* 2. Call adapter.stream()
|
|
7
|
+
* 3. Collect text + tool_calls from stream events
|
|
8
|
+
* 4. If tool_calls → execute tools → append results → goto 2
|
|
9
|
+
* 5. If end_turn → done
|
|
10
|
+
* 6. If max_tokens → done (Phase 2: auto-continue)
|
|
11
|
+
*
|
|
12
|
+
* Pattern derived from Claude Code's query loop (src/query.ts).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { buildSystemPrompt } from './prompts.js';
|
|
17
|
+
|
|
18
|
+
/** Maximum number of turns before the engine stops to prevent infinite loops. */
|
|
19
|
+
const MAX_TURNS = 25;
|
|
20
|
+
|
|
21
|
+
// ─── Engine Events (superset of adapter events) ──────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ type: 'turn_start', turnNumber: number }} TurnStartEvent
|
|
25
|
+
* @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string }} TurnEndEvent
|
|
26
|
+
* @typedef {{ type: 'tool_start', id: string, name: string, input: object }} ToolStartEvent
|
|
27
|
+
* @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean }} ToolEndEvent
|
|
28
|
+
*
|
|
29
|
+
* @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent} EngineEvent
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// ─── Engine ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export class Engine {
|
|
35
|
+
/** @type {import('./llm/adapter.js').LLMAdapter} */
|
|
36
|
+
#adapter;
|
|
37
|
+
|
|
38
|
+
/** @type {import('./debug-trace.js').DebugTrace | import('./debug-trace.js').NullTrace} */
|
|
39
|
+
#trace;
|
|
40
|
+
|
|
41
|
+
/** @type {object} */
|
|
42
|
+
#config;
|
|
43
|
+
|
|
44
|
+
/** @type {Map<string, { name: string, description: string, parameters: object, execute: function }>} */
|
|
45
|
+
#tools;
|
|
46
|
+
|
|
47
|
+
/** @type {string} */
|
|
48
|
+
#traceId;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ adapter: import('./llm/adapter.js').LLMAdapter, trace: object, config: object }} params
|
|
52
|
+
*/
|
|
53
|
+
constructor({ adapter, trace, config }) {
|
|
54
|
+
this.#adapter = adapter;
|
|
55
|
+
this.#trace = trace;
|
|
56
|
+
this.#config = config;
|
|
57
|
+
this.#tools = new Map();
|
|
58
|
+
this.#traceId = randomUUID();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register a tool that the LLM can call.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ name: string, description: string, parameters: object, execute: (input: object, ctx?: { signal?: AbortSignal }) => Promise<string> }} tool
|
|
65
|
+
*/
|
|
66
|
+
registerTool(tool) {
|
|
67
|
+
this.#tools.set(tool.name, tool);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Unregister a tool.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} name
|
|
74
|
+
*/
|
|
75
|
+
unregisterTool(name) {
|
|
76
|
+
this.#tools.delete(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the list of registered tool definitions (for passing to the adapter).
|
|
81
|
+
*
|
|
82
|
+
* @returns {import('./llm/adapter.js').UnifiedToolDef[]}
|
|
83
|
+
*/
|
|
84
|
+
#getToolDefs() {
|
|
85
|
+
const defs = [];
|
|
86
|
+
for (const [, tool] of this.#tools) {
|
|
87
|
+
defs.push({
|
|
88
|
+
name: tool.name,
|
|
89
|
+
description: tool.description,
|
|
90
|
+
parameters: tool.parameters,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return defs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the system prompt.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} mode — 'chat' | 'work' | 'dream'
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
#buildSystemPrompt(mode) {
|
|
103
|
+
return buildSystemPrompt({
|
|
104
|
+
language: this.#config.language || 'en',
|
|
105
|
+
mode,
|
|
106
|
+
toolNames: Array.from(this.#tools.keys()),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run a query — the main loop.
|
|
112
|
+
*
|
|
113
|
+
* Yields EngineEvent objects that the caller (CLI, web) can consume
|
|
114
|
+
* to render output in real-time.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
|
|
117
|
+
* @yields {EngineEvent}
|
|
118
|
+
*/
|
|
119
|
+
async *query({ prompt, mode = 'chat', messages = [], signal }) {
|
|
120
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
121
|
+
yield {
|
|
122
|
+
type: 'error',
|
|
123
|
+
error: new Error('prompt is required and must be a non-empty string'),
|
|
124
|
+
retryable: false,
|
|
125
|
+
};
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const systemPrompt = this.#buildSystemPrompt(mode);
|
|
130
|
+
|
|
131
|
+
// Build conversation: existing messages + new user message
|
|
132
|
+
const conversationMessages = [
|
|
133
|
+
...messages,
|
|
134
|
+
{ role: 'user', content: prompt },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const toolDefs = this.#getToolDefs();
|
|
138
|
+
let turnNumber = 0;
|
|
139
|
+
|
|
140
|
+
while (true) {
|
|
141
|
+
turnNumber++;
|
|
142
|
+
|
|
143
|
+
// Safety: prevent infinite loops
|
|
144
|
+
if (turnNumber > MAX_TURNS) {
|
|
145
|
+
yield {
|
|
146
|
+
type: 'error',
|
|
147
|
+
error: new Error(`Max turns (${MAX_TURNS}) reached — stopping to prevent infinite loop`),
|
|
148
|
+
retryable: false,
|
|
149
|
+
};
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const turnId = this.#trace.startTurn({
|
|
154
|
+
traceId: this.#traceId,
|
|
155
|
+
mode,
|
|
156
|
+
turnNumber,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const startTime = Date.now();
|
|
160
|
+
let responseText = '';
|
|
161
|
+
const toolCalls = [];
|
|
162
|
+
let stopReason = 'end_turn';
|
|
163
|
+
const totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
164
|
+
|
|
165
|
+
yield { type: 'turn_start', turnNumber };
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Stream from adapter
|
|
169
|
+
// Note: pass a snapshot of messages so later mutations don't affect the adapter
|
|
170
|
+
for await (const event of this.#adapter.stream({
|
|
171
|
+
model: this.#config.model,
|
|
172
|
+
system: systemPrompt,
|
|
173
|
+
messages: [...conversationMessages],
|
|
174
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
175
|
+
maxTokens: this.#config.maxOutputTokens || 16384,
|
|
176
|
+
signal,
|
|
177
|
+
})) {
|
|
178
|
+
switch (event.type) {
|
|
179
|
+
case 'text_delta':
|
|
180
|
+
responseText += event.text;
|
|
181
|
+
yield event;
|
|
182
|
+
break;
|
|
183
|
+
case 'thinking_delta':
|
|
184
|
+
yield event;
|
|
185
|
+
break;
|
|
186
|
+
case 'tool_call':
|
|
187
|
+
toolCalls.push(event);
|
|
188
|
+
yield event;
|
|
189
|
+
break;
|
|
190
|
+
case 'usage':
|
|
191
|
+
totalUsage.inputTokens += event.inputTokens;
|
|
192
|
+
totalUsage.outputTokens += event.outputTokens;
|
|
193
|
+
yield event;
|
|
194
|
+
break;
|
|
195
|
+
case 'stop':
|
|
196
|
+
stopReason = event.stopReason;
|
|
197
|
+
yield event;
|
|
198
|
+
break;
|
|
199
|
+
case 'error':
|
|
200
|
+
yield event;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// Adapter threw an exception (network, auth, etc.)
|
|
206
|
+
const latencyMs = Date.now() - startTime;
|
|
207
|
+
this.#trace.endTurn(turnId, {
|
|
208
|
+
model: this.#config.model,
|
|
209
|
+
inputTokens: totalUsage.inputTokens,
|
|
210
|
+
outputTokens: totalUsage.outputTokens,
|
|
211
|
+
stopReason: 'error',
|
|
212
|
+
latencyMs,
|
|
213
|
+
responseText,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
yield {
|
|
217
|
+
type: 'error',
|
|
218
|
+
error: err,
|
|
219
|
+
retryable: err.name === 'LLMRateLimitError' || err.name === 'LLMServerError',
|
|
220
|
+
};
|
|
221
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'error' };
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const latencyMs = Date.now() - startTime;
|
|
226
|
+
|
|
227
|
+
// Record turn in debug trace
|
|
228
|
+
this.#trace.endTurn(turnId, {
|
|
229
|
+
model: this.#config.model,
|
|
230
|
+
inputTokens: totalUsage.inputTokens,
|
|
231
|
+
outputTokens: totalUsage.outputTokens,
|
|
232
|
+
stopReason,
|
|
233
|
+
latencyMs,
|
|
234
|
+
responseText,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Append assistant message to conversation
|
|
238
|
+
const assistantMsg = { role: 'assistant', content: responseText };
|
|
239
|
+
if (toolCalls.length > 0) {
|
|
240
|
+
assistantMsg.toolCalls = toolCalls.map(tc => ({
|
|
241
|
+
id: tc.id,
|
|
242
|
+
name: tc.name,
|
|
243
|
+
input: tc.input,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
conversationMessages.push(assistantMsg);
|
|
247
|
+
|
|
248
|
+
// If no tool calls, we're done
|
|
249
|
+
if (stopReason !== 'tool_use' || toolCalls.length === 0) {
|
|
250
|
+
yield { type: 'turn_end', turnNumber, stopReason };
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Execute tool calls and feed results back
|
|
255
|
+
for (const tc of toolCalls) {
|
|
256
|
+
const tool = this.#tools.get(tc.name);
|
|
257
|
+
const toolStartTime = Date.now();
|
|
258
|
+
|
|
259
|
+
let output;
|
|
260
|
+
let isError = false;
|
|
261
|
+
|
|
262
|
+
if (!tool) {
|
|
263
|
+
output = `Error: unknown tool "${tc.name}"`;
|
|
264
|
+
isError = true;
|
|
265
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
|
|
266
|
+
} else {
|
|
267
|
+
try {
|
|
268
|
+
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
|
|
269
|
+
output = await tool.execute(tc.input, { signal });
|
|
270
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
output = `Error: ${err.message}`;
|
|
273
|
+
isError = true;
|
|
274
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const toolDurationMs = Date.now() - toolStartTime;
|
|
279
|
+
|
|
280
|
+
// Log tool to debug trace
|
|
281
|
+
this.#trace.logTool(turnId, {
|
|
282
|
+
toolName: tc.name,
|
|
283
|
+
toolInput: JSON.stringify(tc.input),
|
|
284
|
+
toolOutput: output,
|
|
285
|
+
durationMs: toolDurationMs,
|
|
286
|
+
isError,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Append tool result to conversation
|
|
290
|
+
conversationMessages.push({
|
|
291
|
+
role: 'tool',
|
|
292
|
+
toolCallId: tc.id,
|
|
293
|
+
content: output,
|
|
294
|
+
isError,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
|
|
299
|
+
|
|
300
|
+
// Loop back to call adapter again with tool results
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get the trace ID for this engine instance.
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
get traceId() {
|
|
309
|
+
return this.#traceId;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get registered tool names.
|
|
314
|
+
* @returns {string[]}
|
|
315
|
+
*/
|
|
316
|
+
get toolNames() {
|
|
317
|
+
return Array.from(this.#tools.keys());
|
|
318
|
+
}
|
|
319
|
+
}
|
package/unify/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js — Yeaft Unify module entry point
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all public APIs for external consumption.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { initYeaftDir, DEFAULT_YEAFT_DIR } from './init.js';
|
|
8
|
+
export { loadConfig, parseFrontmatter } from './config.js';
|
|
9
|
+
export { DebugTrace, NullTrace, createTrace } from './debug-trace.js';
|
|
10
|
+
export {
|
|
11
|
+
LLMAdapter,
|
|
12
|
+
LLMRateLimitError,
|
|
13
|
+
LLMAuthError,
|
|
14
|
+
LLMContextError,
|
|
15
|
+
LLMServerError,
|
|
16
|
+
LLMAbortError,
|
|
17
|
+
createLLMAdapter,
|
|
18
|
+
} from './llm/adapter.js';
|
|
19
|
+
export { MODEL_REGISTRY, resolveModel, listModels, isKnownModel } from './models.js';
|
|
20
|
+
export { buildSystemPrompt, SUPPORTED_LANGUAGES } from './prompts.js';
|
|
21
|
+
export { Engine } from './engine.js';
|
package/unify/init.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init.js — Yeaft directory structure initialization
|
|
3
|
+
*
|
|
4
|
+
* Ensures ~/.yeaft/ and all required subdirectories exist.
|
|
5
|
+
* Creates default config.md, MEMORY.md, and conversation/index.md if missing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
|
|
12
|
+
/** Default directory for Yeaft data. */
|
|
13
|
+
export const DEFAULT_YEAFT_DIR = join(homedir(), '.yeaft');
|
|
14
|
+
|
|
15
|
+
/** Subdirectories that must exist inside the Yeaft data directory. */
|
|
16
|
+
const SUBDIRS = [
|
|
17
|
+
'conversation/messages',
|
|
18
|
+
'conversation/cold',
|
|
19
|
+
'conversation/blobs',
|
|
20
|
+
'memory/entries',
|
|
21
|
+
'tasks',
|
|
22
|
+
'dream',
|
|
23
|
+
'skills',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Default config.md content (YAML frontmatter + markdown body). */
|
|
27
|
+
const DEFAULT_CONFIG = `---
|
|
28
|
+
model: claude-sonnet-4-20250514
|
|
29
|
+
language: en
|
|
30
|
+
debug: false
|
|
31
|
+
maxContextTokens: 200000
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Yeaft Config
|
|
35
|
+
|
|
36
|
+
Edit the YAML frontmatter above to change settings.
|
|
37
|
+
The \`model\` field is a model ID (e.g. \`gpt-5\`, \`claude-sonnet-4-20250514\`).
|
|
38
|
+
Yeaft auto-detects the correct API adapter and endpoint from the model ID.
|
|
39
|
+
|
|
40
|
+
## Language
|
|
41
|
+
|
|
42
|
+
Set \`language\` in frontmatter or \`YEAFT_LANGUAGE\` env var:
|
|
43
|
+
- \`en\` — English system prompts (default)
|
|
44
|
+
- \`zh\` — Chinese system prompts (中文系统提示)
|
|
45
|
+
|
|
46
|
+
## Model IDs
|
|
47
|
+
|
|
48
|
+
- \`claude-sonnet-4-20250514\` (default)
|
|
49
|
+
- \`claude-opus-4-20250514\`
|
|
50
|
+
- \`gpt-5\`, \`gpt-5.4\`, \`gpt-4.1\`, \`gpt-4.1-mini\`
|
|
51
|
+
- \`o3\`, \`o4-mini\`
|
|
52
|
+
- \`deepseek-chat\`, \`deepseek-reasoner\`
|
|
53
|
+
- \`gemini-2.5-pro\`, \`gemini-2.5-flash\`
|
|
54
|
+
|
|
55
|
+
## API Keys
|
|
56
|
+
|
|
57
|
+
Store API keys in \`~/.yeaft/.env\` (recommended) or export as env vars:
|
|
58
|
+
|
|
59
|
+
\`\`\`bash
|
|
60
|
+
# ~/.yeaft/.env
|
|
61
|
+
YEAFT_API_KEY=sk-ant-... # Anthropic
|
|
62
|
+
YEAFT_OPENAI_API_KEY=sk-... # OpenAI / DeepSeek / Gemini
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
## Environment Variables
|
|
66
|
+
|
|
67
|
+
Shell env vars take precedence over .env and config.md:
|
|
68
|
+
|
|
69
|
+
- \`YEAFT_MODEL\` — override model ID
|
|
70
|
+
- \`YEAFT_LANGUAGE\` — language for system prompts (en/zh)
|
|
71
|
+
- \`YEAFT_API_KEY\` — Anthropic API key
|
|
72
|
+
- \`YEAFT_OPENAI_API_KEY\` — OpenAI-compatible API key
|
|
73
|
+
- \`YEAFT_PROXY_URL\` — CopilotProxy URL (default: http://localhost:6628)
|
|
74
|
+
- \`YEAFT_DEBUG\` — enable debug mode (1/true)
|
|
75
|
+
- \`YEAFT_DIR\` — data directory (default: ~/.yeaft)
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
/** Default MEMORY.md content. */
|
|
79
|
+
const DEFAULT_MEMORY = `# Yeaft Memory
|
|
80
|
+
|
|
81
|
+
This file stores persistent memory entries. The agent will read and update this file.
|
|
82
|
+
|
|
83
|
+
## Facts
|
|
84
|
+
|
|
85
|
+
## Preferences
|
|
86
|
+
|
|
87
|
+
## Project Context
|
|
88
|
+
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/** Default conversation/index.md content. */
|
|
92
|
+
const DEFAULT_CONVERSATION_INDEX = `---
|
|
93
|
+
lastMessageId: null
|
|
94
|
+
totalMessages: 0
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
# Conversation Index
|
|
98
|
+
|
|
99
|
+
This file tracks the conversation state for the "one eternal conversation" model.
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Initialize the Yeaft data directory structure.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
|
|
106
|
+
* @returns {{ dir: string, created: string[] }} — The root dir and list of created paths
|
|
107
|
+
*/
|
|
108
|
+
export function initYeaftDir(dir) {
|
|
109
|
+
const root = dir || DEFAULT_YEAFT_DIR;
|
|
110
|
+
const created = [];
|
|
111
|
+
|
|
112
|
+
// Ensure root exists
|
|
113
|
+
if (!existsSync(root)) {
|
|
114
|
+
mkdirSync(root, { recursive: true });
|
|
115
|
+
created.push(root);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Ensure all subdirectories exist
|
|
119
|
+
for (const sub of SUBDIRS) {
|
|
120
|
+
const fullPath = join(root, sub);
|
|
121
|
+
if (!existsSync(fullPath)) {
|
|
122
|
+
mkdirSync(fullPath, { recursive: true });
|
|
123
|
+
created.push(fullPath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Create default files if they don't exist
|
|
128
|
+
const configPath = join(root, 'config.md');
|
|
129
|
+
if (!existsSync(configPath)) {
|
|
130
|
+
writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
|
|
131
|
+
created.push(configPath);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const memoryPath = join(root, 'memory', 'MEMORY.md');
|
|
135
|
+
if (!existsSync(memoryPath)) {
|
|
136
|
+
writeFileSync(memoryPath, DEFAULT_MEMORY, 'utf8');
|
|
137
|
+
created.push(memoryPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const indexPath = join(root, 'conversation', 'index.md');
|
|
141
|
+
if (!existsSync(indexPath)) {
|
|
142
|
+
writeFileSync(indexPath, DEFAULT_CONVERSATION_INDEX, 'utf8');
|
|
143
|
+
created.push(indexPath);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { dir: root, created };
|
|
147
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapter.js — LLM Adapter base class, unified types, and factory
|
|
3
|
+
*
|
|
4
|
+
* Design decision (2026-04-10): Only two adapters needed:
|
|
5
|
+
* 1. AnthropicAdapter — Anthropic Messages API
|
|
6
|
+
* 2. ChatCompletionsAdapter — OpenAI Chat Completions API (covers GPT, DeepSeek, CopilotProxy, etc.)
|
|
7
|
+
*
|
|
8
|
+
* The engine sees only unified types — it never knows which API is underneath.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Unified Types ─────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} UnifiedToolDef
|
|
15
|
+
* @property {string} name
|
|
16
|
+
* @property {string} description
|
|
17
|
+
* @property {object} parameters — JSON Schema
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} UnifiedToolCall
|
|
22
|
+
* @property {string} id
|
|
23
|
+
* @property {string} name
|
|
24
|
+
* @property {object} input — Parsed object (not JSON string)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} UnifiedToolResult
|
|
29
|
+
* @property {string} toolCallId
|
|
30
|
+
* @property {string} output
|
|
31
|
+
* @property {boolean} [isError]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ─── Unified Event Stream ──────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{ type: 'text_delta', text: string }} TextDeltaEvent
|
|
38
|
+
* @typedef {{ type: 'thinking_delta', text: string }} ThinkingDeltaEvent
|
|
39
|
+
* @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
|
|
40
|
+
* @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number }} UsageEvent
|
|
41
|
+
* @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
|
|
42
|
+
* @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
|
|
43
|
+
*
|
|
44
|
+
* @typedef {TextDeltaEvent | ThinkingDeltaEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// ─── Unified Message Types ─────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {{ role: 'system', content: string }} SystemMessage
|
|
51
|
+
* @typedef {{ role: 'user', content: string }} UserMessage
|
|
52
|
+
* @typedef {{ role: 'assistant', content: string, toolCalls?: UnifiedToolCall[] }} AssistantMessage
|
|
53
|
+
* @typedef {{ role: 'tool', toolCallId: string, content: string, isError?: boolean }} ToolMessage
|
|
54
|
+
*
|
|
55
|
+
* @typedef {SystemMessage | UserMessage | AssistantMessage | ToolMessage} UnifiedMessage
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
// ─── Error Types ───────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Rate limit error (429, 529) — retryable with backoff. */
|
|
61
|
+
export class LLMRateLimitError extends Error {
|
|
62
|
+
constructor(message, statusCode, retryAfterMs = null) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'LLMRateLimitError';
|
|
65
|
+
this.statusCode = statusCode;
|
|
66
|
+
this.retryAfterMs = retryAfterMs;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Authentication error (401, 403) — need to re-authenticate. */
|
|
71
|
+
export class LLMAuthError extends Error {
|
|
72
|
+
constructor(message, statusCode) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = 'LLMAuthError';
|
|
75
|
+
this.statusCode = statusCode;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Context too long error (413 or API-specific) — need compaction. */
|
|
80
|
+
export class LLMContextError extends Error {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'LLMContextError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Server error (500, 502, 503) — retryable. */
|
|
88
|
+
export class LLMServerError extends Error {
|
|
89
|
+
constructor(message, statusCode) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'LLMServerError';
|
|
92
|
+
this.statusCode = statusCode;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Abort error — signal was aborted. */
|
|
97
|
+
export class LLMAbortError extends Error {
|
|
98
|
+
constructor() {
|
|
99
|
+
super('Request aborted');
|
|
100
|
+
this.name = 'LLMAbortError';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Base Class ────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* LLMAdapter — Abstract base class for LLM API adapters.
|
|
108
|
+
*
|
|
109
|
+
* Subclasses implement stream() and call() to talk to a specific API,
|
|
110
|
+
* translating between the unified types and the wire format.
|
|
111
|
+
*/
|
|
112
|
+
export class LLMAdapter {
|
|
113
|
+
/**
|
|
114
|
+
* @param {object} config — Adapter-specific configuration
|
|
115
|
+
*/
|
|
116
|
+
constructor(config = {}) {
|
|
117
|
+
this.config = config;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stream a model response with tool support (the query loop call).
|
|
122
|
+
*
|
|
123
|
+
* @param {{ model: string, system: string, messages: UnifiedMessage[], tools?: UnifiedToolDef[], maxTokens?: number, signal?: AbortSignal }} params
|
|
124
|
+
* @returns {AsyncGenerator<StreamEvent>}
|
|
125
|
+
*/
|
|
126
|
+
async *stream(params) { // eslint-disable-line no-unused-vars
|
|
127
|
+
throw new Error('stream() must be implemented by subclass');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Make a single model call without tools (for side queries like summarization).
|
|
132
|
+
*
|
|
133
|
+
* @param {{ model: string, system: string, messages: UnifiedMessage[], maxTokens?: number, signal?: AbortSignal }} params
|
|
134
|
+
* @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
|
|
135
|
+
*/
|
|
136
|
+
async call(params) { // eslint-disable-line no-unused-vars
|
|
137
|
+
throw new Error('call() must be implemented by subclass');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Factory ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create an LLM adapter based on configuration.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} config — From loadConfig()
|
|
147
|
+
* @returns {Promise<LLMAdapter>}
|
|
148
|
+
*/
|
|
149
|
+
export async function createLLMAdapter(config) {
|
|
150
|
+
// Normalize adapter name — accept 'chat-completions' as alias for 'openai'
|
|
151
|
+
const adapter = config.adapter === 'chat-completions' ? 'openai' : config.adapter;
|
|
152
|
+
|
|
153
|
+
if (adapter === 'anthropic' || (!adapter && config.apiKey)) {
|
|
154
|
+
if (!config.apiKey) {
|
|
155
|
+
throw new Error('Anthropic adapter requires YEAFT_API_KEY');
|
|
156
|
+
}
|
|
157
|
+
const { AnthropicAdapter } = await import('./anthropic.js');
|
|
158
|
+
return new AnthropicAdapter({
|
|
159
|
+
apiKey: config.apiKey,
|
|
160
|
+
baseUrl: config.baseUrl || undefined, // AnthropicAdapter has its own default
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (adapter === 'openai' || (!adapter && config.openaiApiKey)) {
|
|
165
|
+
if (!config.openaiApiKey && !config.apiKey) {
|
|
166
|
+
throw new Error('OpenAI adapter requires YEAFT_OPENAI_API_KEY (or YEAFT_API_KEY as fallback)');
|
|
167
|
+
}
|
|
168
|
+
const { ChatCompletionsAdapter } = await import('./chat-completions.js');
|
|
169
|
+
return new ChatCompletionsAdapter({
|
|
170
|
+
apiKey: config.openaiApiKey || config.apiKey,
|
|
171
|
+
baseUrl: config.baseUrl || 'https://api.openai.com/v1',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (adapter === 'proxy' || (!adapter && config.proxyUrl)) {
|
|
176
|
+
const { ChatCompletionsAdapter } = await import('./chat-completions.js');
|
|
177
|
+
return new ChatCompletionsAdapter({
|
|
178
|
+
apiKey: 'proxy', // CopilotProxy handles auth
|
|
179
|
+
baseUrl: `${config.proxyUrl}/v1`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error(
|
|
184
|
+
'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic), YEAFT_OPENAI_API_KEY (OpenAI), or YEAFT_PROXY_URL (CopilotProxy).',
|
|
185
|
+
);
|
|
186
|
+
}
|