@yeaft/webchat-agent 0.1.596 → 0.1.598
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/package.json +1 -1
- package/unify/engine.js +83 -10
- package/unify/llm/adapter.js +24 -23
- package/unify/llm/router.js +27 -23
- package/unify/models.js +14 -14
- package/unify/prompts.js +34 -19
- package/unify/templates/common-rules.md +87 -0
- package/unify/templates/identity-yeaft.md +11 -0
- package/unify/threads/engine-instance.js +2 -2
- package/unify/web-bridge.js +6 -2
- package/unify/llm/chat-completions.js +0 -501
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -18,16 +18,18 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
|
-
import { buildSystemPrompt } from './prompts.js';
|
|
21
|
+
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
22
22
|
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
23
|
import { recallR6, formatForInjection } from './memory/recall-r6.js';
|
|
24
24
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
25
25
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
26
26
|
import { buildUserProfile } from './memory/user-memory-store.js';
|
|
27
|
+
import { readSummary as readScopeSummary } from './memory/scope-tree.js';
|
|
27
28
|
import { runStopHooks } from './stop-hooks.js';
|
|
28
29
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
29
30
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
|
30
31
|
import { normalizeEffort } from './models.js';
|
|
32
|
+
import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* task-324 — Turn cap removed.
|
|
@@ -288,16 +290,56 @@ export class Engine {
|
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
/**
|
|
291
|
-
*
|
|
293
|
+
* Load Layer A scope summaries from `<memoryRoot>/<scope>/summary.md`.
|
|
294
|
+
*
|
|
295
|
+
* Scopes:
|
|
296
|
+
* - user → `user/summary.md` (always attempted)
|
|
297
|
+
* - group <gid> → `groups/<gid>/summary.md` (if groupId)
|
|
298
|
+
* - vp <vpId> → `vp/<vpId>/summary.md` (if vpId)
|
|
299
|
+
*
|
|
300
|
+
* Each fetch is best-effort — missing files / read errors return ''. The
|
|
301
|
+
* dream tick (Phase 6) is what populates these; on a fresh install they
|
|
302
|
+
* all return ''.
|
|
303
|
+
*
|
|
304
|
+
* @param {{groupId?: string, vpId?: string}} ctx
|
|
305
|
+
* @returns {Promise<{user:string, group:string, vp:string}>}
|
|
306
|
+
*/
|
|
307
|
+
async #loadLayerASummaries({ groupId, vpId } = {}) {
|
|
308
|
+
if (!this.#yeaftDir) return { user: '', group: '', vp: '' };
|
|
309
|
+
const memoryRoot = `${this.#yeaftDir}/memory`;
|
|
310
|
+
const tasks = [
|
|
311
|
+
readScopeSummary({ kind: 'user' }, { root: memoryRoot }).catch(() => ''),
|
|
312
|
+
groupId
|
|
313
|
+
? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot }).catch(() => '')
|
|
314
|
+
: Promise.resolve(''),
|
|
315
|
+
vpId
|
|
316
|
+
? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot }).catch(() => '')
|
|
317
|
+
: Promise.resolve(''),
|
|
318
|
+
];
|
|
319
|
+
const [user, group, vp] = await Promise.all(tasks);
|
|
320
|
+
return { user: user || '', group: group || '', vp: vp || '' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build the system prompt with memory, compact summary, skill content,
|
|
325
|
+
* and (Phase 8 wire-up) Layer-A scope summaries.
|
|
326
|
+
*
|
|
327
|
+
* Routes through `buildWorkerPrompt`, which:
|
|
328
|
+
* - Lays in the persona-as-identity block (or Yeaft identity fallback)
|
|
329
|
+
* - Concatenates Layer A summaries (`user/group/vp/summary.md`)
|
|
330
|
+
* - Reserves Layer B / C / D placeholders for future wiring (router
|
|
331
|
+
* preselected memory, task scope, turn scope)
|
|
292
332
|
*
|
|
293
333
|
* @param {{ profile?: string, entries?: object[] }} [memory]
|
|
294
334
|
* @param {string} [compactSummary]
|
|
295
335
|
* @param {string} [prompt] — user prompt (for skill relevance matching)
|
|
296
|
-
* @param {string} [memoryInjection] — task-287: prebuilt memory block
|
|
336
|
+
* @param {string} [memoryInjection] — task-287: prebuilt memory block
|
|
297
337
|
* @param {string} [userProfile] — user profile from user-memory shard store
|
|
338
|
+
* @param {object} [vpPersona]
|
|
339
|
+
* @param {{user?:string, group?:string, vp?:string}} [summaries]
|
|
298
340
|
* @returns {string}
|
|
299
341
|
*/
|
|
300
|
-
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile, vpPersona) {
|
|
342
|
+
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries) {
|
|
301
343
|
// Get relevant skill content if SkillManager is wired
|
|
302
344
|
let skillContent = '';
|
|
303
345
|
if (this.#skillManager && prompt) {
|
|
@@ -309,7 +351,7 @@ export class Engine {
|
|
|
309
351
|
? this.#toolRegistry.getToolNames()
|
|
310
352
|
: Array.from(this.#tools.keys());
|
|
311
353
|
|
|
312
|
-
return
|
|
354
|
+
return buildWorkerPrompt({
|
|
313
355
|
language: this.#config.language || 'en',
|
|
314
356
|
toolNames,
|
|
315
357
|
memory,
|
|
@@ -318,6 +360,11 @@ export class Engine {
|
|
|
318
360
|
skillContent,
|
|
319
361
|
userProfile,
|
|
320
362
|
vpPersona,
|
|
363
|
+
summaries,
|
|
364
|
+
// Worker-shape harness is descriptive metadata for human inspection;
|
|
365
|
+
// production prompts skip it to save tokens. Re-enable via env when
|
|
366
|
+
// diagnosing prompt structure issues.
|
|
367
|
+
includeShape: process.env.UNIFY_PROMPT_INCLUDE_SHAPE === '1',
|
|
321
368
|
// task-334f: memory_trace tool is now registered (49 → 51 tools), so
|
|
322
369
|
// unlock the core_memory meta-line behind 334e's feature flag.
|
|
323
370
|
memoryTraceAvailable: true,
|
|
@@ -518,7 +565,7 @@ export class Engine {
|
|
|
518
565
|
* SCENARIO_EFFORT. Unknown values fall through to 'high'.
|
|
519
566
|
* @yields {EngineEvent}
|
|
520
567
|
*/
|
|
521
|
-
async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers } = {}) {
|
|
568
|
+
async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId } = {}) {
|
|
522
569
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
523
570
|
yield {
|
|
524
571
|
type: 'error',
|
|
@@ -569,7 +616,7 @@ export class Engine {
|
|
|
569
616
|
const runSignal = abortCtrl.signal;
|
|
570
617
|
|
|
571
618
|
try {
|
|
572
|
-
yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers });
|
|
619
|
+
yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId });
|
|
573
620
|
} finally {
|
|
574
621
|
if (signal) {
|
|
575
622
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -587,7 +634,7 @@ export class Engine {
|
|
|
587
634
|
* in a try/finally without indenting the whole loop.
|
|
588
635
|
* @private
|
|
589
636
|
*/
|
|
590
|
-
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers }) {
|
|
637
|
+
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId }) {
|
|
591
638
|
|
|
592
639
|
// ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
|
|
593
640
|
// Two-layer recall:
|
|
@@ -626,7 +673,21 @@ export class Engine {
|
|
|
626
673
|
|
|
627
674
|
const compactSummary = this.#getCompactSummary();
|
|
628
675
|
const userProfile = recallResult?.profile || '';
|
|
629
|
-
|
|
676
|
+
|
|
677
|
+
// Phase 8 wire-up — Layer A scope summaries
|
|
678
|
+
// Load `summary.md` for the user / addressed group / addressed VP from
|
|
679
|
+
// the scoped memory tree (DESIGN.md §2). This is the rolling synopsis a
|
|
680
|
+
// dream tick maintains; we surface it to the worker prompt so the LLM
|
|
681
|
+
// has cheap, persistent context without paying the recall cost on every
|
|
682
|
+
// turn. Failures are non-fatal (cold-start / no memory dir).
|
|
683
|
+
const summaries = await this.#loadLayerASummaries({
|
|
684
|
+
groupId,
|
|
685
|
+
vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
|
|
686
|
+
? vpPersona.vpId
|
|
687
|
+
: (typeof senderVpId === 'string' ? senderVpId : undefined),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries);
|
|
630
691
|
|
|
631
692
|
// Build conversation: existing messages + new user message
|
|
632
693
|
const conversationMessages = [
|
|
@@ -688,7 +749,7 @@ export class Engine {
|
|
|
688
749
|
for await (const event of this.#adapter.stream({
|
|
689
750
|
model: currentModel,
|
|
690
751
|
system: systemPrompt,
|
|
691
|
-
messages: [...conversationMessages],
|
|
752
|
+
messages: stripMetaForWire([...conversationMessages]),
|
|
692
753
|
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
693
754
|
maxTokens: this.#config.maxOutputTokens || 16384,
|
|
694
755
|
effort: resolvedEffort,
|
|
@@ -837,6 +898,18 @@ export class Engine {
|
|
|
837
898
|
input: tc.input,
|
|
838
899
|
}));
|
|
839
900
|
}
|
|
901
|
+
// Phase 8 (DESIGN.md §9.15): carry the router plan back on the
|
|
902
|
+
// assistant message that produced it. Stripped at the wire by
|
|
903
|
+
// stripMetaForWire — pure bookkeeping for priorPlan continuity.
|
|
904
|
+
if (vpPersona && vpPersona.vpId) {
|
|
905
|
+
attachRouterPlan(assistantMsg, {
|
|
906
|
+
vpId: vpPersona.vpId,
|
|
907
|
+
forwardQuery: { userOriginal: prompt || '', intent: '' },
|
|
908
|
+
preselect: undefined,
|
|
909
|
+
thinking: null,
|
|
910
|
+
thinkingReason: '',
|
|
911
|
+
});
|
|
912
|
+
}
|
|
840
913
|
conversationMessages.push(assistantMsg);
|
|
841
914
|
fullResponseText += responseText;
|
|
842
915
|
|
package/unify/llm/adapter.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* adapter.js — LLM Adapter base class, unified types, and factory
|
|
3
3
|
*
|
|
4
|
-
* Design decision (2026-04-
|
|
5
|
-
* 1. AnthropicAdapter
|
|
6
|
-
* 2.
|
|
4
|
+
* Design decision (Phase 7, 2026-04-27): Only two adapters remain:
|
|
5
|
+
* 1. AnthropicAdapter — Anthropic Messages API
|
|
6
|
+
* 2. OpenAIResponsesAdapter — OpenAI Responses API (covers GPT, DeepSeek, CopilotProxy, etc.)
|
|
7
|
+
*
|
|
8
|
+
* The legacy ChatCompletionsAdapter (OpenAI Chat Completions API) was deleted in
|
|
9
|
+
* Phase 7. Configurations using protocol "openai" or alias "chat-completions"
|
|
10
|
+
* must migrate to "openai-responses" or "anthropic".
|
|
7
11
|
*
|
|
8
12
|
* The engine sees only unified types — it never knows which API is underneath.
|
|
9
13
|
*/
|
|
@@ -234,8 +238,7 @@ export async function createLLMAdapter(config) {
|
|
|
234
238
|
}
|
|
235
239
|
|
|
236
240
|
// ─── Legacy path: single adapter from env vars ────────
|
|
237
|
-
|
|
238
|
-
const adapter = config.adapter === 'chat-completions' ? 'openai' : config.adapter;
|
|
241
|
+
const adapter = config.adapter;
|
|
239
242
|
|
|
240
243
|
if (adapter === 'anthropic' || (!adapter && config.apiKey)) {
|
|
241
244
|
if (!config.apiKey) {
|
|
@@ -248,26 +251,24 @@ export async function createLLMAdapter(config) {
|
|
|
248
251
|
});
|
|
249
252
|
}
|
|
250
253
|
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const { ChatCompletionsAdapter } = await import('./chat-completions.js');
|
|
264
|
-
return new ChatCompletionsAdapter({
|
|
265
|
-
apiKey: 'proxy', // CopilotProxy handles auth
|
|
266
|
-
baseUrl: `${config.proxyUrl}/v1`,
|
|
267
|
-
});
|
|
254
|
+
if (
|
|
255
|
+
adapter === 'chat-completions' ||
|
|
256
|
+
adapter === 'openai' ||
|
|
257
|
+
adapter === 'proxy' ||
|
|
258
|
+
(!adapter && (config.openaiApiKey || config.proxyUrl))
|
|
259
|
+
) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
'The chat-completions adapter was removed in Phase 7. ' +
|
|
262
|
+
'Configure providers via ~/.yeaft/config.json with protocol: "anthropic" ' +
|
|
263
|
+
'or protocol: "openai-responses" instead of using the legacy ' +
|
|
264
|
+
'"openai"/"proxy"/"chat-completions" adapter env-var path.'
|
|
265
|
+
);
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
throw new Error(
|
|
271
|
-
'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic)
|
|
269
|
+
'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic) or configure ' +
|
|
270
|
+
'providers in ~/.yeaft/config.json. The chat-completions/openai/proxy ' +
|
|
271
|
+
'env-var paths were removed in Phase 7 — use protocol: "openai-responses" ' +
|
|
272
|
+
'in a provider entry instead.',
|
|
272
273
|
);
|
|
273
274
|
}
|
package/unify/llm/router.js
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* [{ name, baseUrl, apiKey, protocol?, models[] }, ...]
|
|
6
6
|
*
|
|
7
7
|
* The router resolves model → provider, lazy-creates the right adapter
|
|
8
|
-
* (AnthropicAdapter or
|
|
8
|
+
* (AnthropicAdapter or OpenAIResponsesAdapter based on protocol), caches it,
|
|
9
9
|
* and forwards stream()/call() to the resolved adapter.
|
|
10
10
|
*
|
|
11
|
-
* protocol
|
|
12
|
-
*
|
|
11
|
+
* protocol must be one of:
|
|
12
|
+
* - "anthropic" — Anthropic Messages API (required for claude-* models)
|
|
13
|
+
* - "openai-responses" — OpenAI Responses API (default for everything else)
|
|
14
|
+
*
|
|
15
|
+
* Phase 7 removed the legacy "openai" (Chat Completions) protocol entirely.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import { LLMAdapter } from './adapter.js';
|
|
@@ -93,23 +96,25 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
93
96
|
/**
|
|
94
97
|
* Resolve the effective wire protocol for a (provider, model) pair.
|
|
95
98
|
*
|
|
96
|
-
*
|
|
97
|
-
* model IDs
|
|
98
|
-
*
|
|
99
|
-
* that mismatch and downgrade to chat-completions, which proxies like
|
|
100
|
-
* GitHub Copilot DO support for Claude models.
|
|
99
|
+
* Phase 7: only "anthropic" and "openai-responses" are supported. Claude
|
|
100
|
+
* model IDs require provider.protocol === "anthropic" — there is no
|
|
101
|
+
* chat-completions fallback any more.
|
|
101
102
|
*
|
|
102
103
|
* @param {object} provider — Provider config
|
|
103
104
|
* @param {string} modelId
|
|
104
|
-
* @returns {'anthropic' | 'openai-responses'
|
|
105
|
+
* @returns {'anthropic' | 'openai-responses'}
|
|
105
106
|
*/
|
|
106
107
|
#effectiveProtocol(provider, modelId) {
|
|
107
|
-
const declared = provider.protocol || 'openai';
|
|
108
|
-
// Anthropic models — only 'anthropic' or 'openai' (chat-completions) make sense.
|
|
109
|
-
// Responses API does not support Claude → fall back to chat-completions.
|
|
108
|
+
const declared = provider.protocol || 'openai-responses';
|
|
110
109
|
if (typeof modelId === 'string' && modelId.startsWith('claude-')) {
|
|
111
|
-
if (declared
|
|
112
|
-
|
|
110
|
+
if (declared !== 'anthropic') {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Claude models require provider.protocol="anthropic"; ` +
|
|
113
|
+
`chat-completions fallback removed in Phase 7. ` +
|
|
114
|
+
`Provider "${provider.name}" declares protocol="${declared}" for model "${modelId}".`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return 'anthropic';
|
|
113
118
|
}
|
|
114
119
|
return declared;
|
|
115
120
|
}
|
|
@@ -131,8 +136,8 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
// Compute the effective protocol per model — a single provider may need
|
|
134
|
-
// two adapters (e.g.
|
|
135
|
-
//
|
|
139
|
+
// two adapters (e.g. mixed config: openai-responses for gpt-5*, anthropic
|
|
140
|
+
// for claude-*). Cache key includes the protocol.
|
|
136
141
|
const protocol = this.#effectiveProtocol(provider, modelId);
|
|
137
142
|
const cacheKey = `${provider.name}::${protocol}`;
|
|
138
143
|
const cached = this.#adapterCache.get(cacheKey);
|
|
@@ -147,19 +152,18 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
147
152
|
baseUrl: provider.baseUrl,
|
|
148
153
|
});
|
|
149
154
|
} else if (protocol === 'openai-responses') {
|
|
150
|
-
// OpenAI Responses API (/v1/responses) —
|
|
155
|
+
// OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
|
|
151
156
|
const { OpenAIResponsesAdapter } = await import('./openai-responses.js');
|
|
152
157
|
adapter = new OpenAIResponsesAdapter({
|
|
153
158
|
apiKey: provider.apiKey,
|
|
154
159
|
baseUrl: provider.baseUrl,
|
|
155
160
|
});
|
|
156
161
|
} else {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Unsupported protocol "${protocol}" for provider "${provider.name}". ` +
|
|
164
|
+
`Use protocol: "anthropic" or "openai-responses". ` +
|
|
165
|
+
`The chat-completions adapter was removed in Phase 7.`
|
|
166
|
+
);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
this.#adapterCache.set(cacheKey, adapter);
|
package/unify/models.js
CHANGED
|
@@ -76,7 +76,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
76
76
|
// ── OpenAI ─────────────────────────────────────────────────────
|
|
77
77
|
['gpt-5', {
|
|
78
78
|
provider: 'openai',
|
|
79
|
-
adapter: '
|
|
79
|
+
adapter: 'openai-responses',
|
|
80
80
|
baseUrl: 'https://api.openai.com/v1',
|
|
81
81
|
contextWindow: 256000,
|
|
82
82
|
maxOutputTokens: 16384,
|
|
@@ -91,25 +91,25 @@ export const MODEL_REGISTRY = new Map([
|
|
|
91
91
|
// should come from provider config (user-supplied) instead of guesses.
|
|
92
92
|
['gpt-5-mini', {
|
|
93
93
|
provider: 'openai',
|
|
94
|
-
adapter: '
|
|
94
|
+
adapter: 'openai-responses',
|
|
95
95
|
baseUrl: 'https://api.openai.com/v1',
|
|
96
96
|
displayName: 'GPT-5 Mini',
|
|
97
97
|
}],
|
|
98
98
|
['gpt-5-nano', {
|
|
99
99
|
provider: 'openai',
|
|
100
|
-
adapter: '
|
|
100
|
+
adapter: 'openai-responses',
|
|
101
101
|
baseUrl: 'https://api.openai.com/v1',
|
|
102
102
|
displayName: 'GPT-5 Nano',
|
|
103
103
|
}],
|
|
104
104
|
['gpt-5-pro', {
|
|
105
105
|
provider: 'openai',
|
|
106
|
-
adapter: '
|
|
106
|
+
adapter: 'openai-responses',
|
|
107
107
|
baseUrl: 'https://api.openai.com/v1',
|
|
108
108
|
displayName: 'GPT-5 Pro',
|
|
109
109
|
}],
|
|
110
110
|
['gpt-5.4', {
|
|
111
111
|
provider: 'openai',
|
|
112
|
-
adapter: '
|
|
112
|
+
adapter: 'openai-responses',
|
|
113
113
|
baseUrl: 'https://api.openai.com/v1',
|
|
114
114
|
contextWindow: 272000,
|
|
115
115
|
maxOutputTokens: 16384,
|
|
@@ -117,7 +117,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
117
117
|
}],
|
|
118
118
|
['gpt-4.1', {
|
|
119
119
|
provider: 'openai',
|
|
120
|
-
adapter: '
|
|
120
|
+
adapter: 'openai-responses',
|
|
121
121
|
baseUrl: 'https://api.openai.com/v1',
|
|
122
122
|
contextWindow: 1047576,
|
|
123
123
|
maxOutputTokens: 32768,
|
|
@@ -125,7 +125,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
125
125
|
}],
|
|
126
126
|
['gpt-4.1-mini', {
|
|
127
127
|
provider: 'openai',
|
|
128
|
-
adapter: '
|
|
128
|
+
adapter: 'openai-responses',
|
|
129
129
|
baseUrl: 'https://api.openai.com/v1',
|
|
130
130
|
contextWindow: 1047576,
|
|
131
131
|
maxOutputTokens: 16384,
|
|
@@ -133,7 +133,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
133
133
|
}],
|
|
134
134
|
['gpt-4.1-nano', {
|
|
135
135
|
provider: 'openai',
|
|
136
|
-
adapter: '
|
|
136
|
+
adapter: 'openai-responses',
|
|
137
137
|
baseUrl: 'https://api.openai.com/v1',
|
|
138
138
|
contextWindow: 1047576,
|
|
139
139
|
maxOutputTokens: 16384,
|
|
@@ -141,7 +141,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
141
141
|
}],
|
|
142
142
|
['o3', {
|
|
143
143
|
provider: 'openai',
|
|
144
|
-
adapter: '
|
|
144
|
+
adapter: 'openai-responses',
|
|
145
145
|
baseUrl: 'https://api.openai.com/v1',
|
|
146
146
|
contextWindow: 200000,
|
|
147
147
|
maxOutputTokens: 100000,
|
|
@@ -153,7 +153,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
153
153
|
}],
|
|
154
154
|
['o4-mini', {
|
|
155
155
|
provider: 'openai',
|
|
156
|
-
adapter: '
|
|
156
|
+
adapter: 'openai-responses',
|
|
157
157
|
baseUrl: 'https://api.openai.com/v1',
|
|
158
158
|
contextWindow: 200000,
|
|
159
159
|
maxOutputTokens: 100000,
|
|
@@ -166,7 +166,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
166
166
|
// ── DeepSeek ───────────────────────────────────────────────────
|
|
167
167
|
['deepseek-chat', {
|
|
168
168
|
provider: 'deepseek',
|
|
169
|
-
adapter: '
|
|
169
|
+
adapter: 'openai-responses',
|
|
170
170
|
baseUrl: 'https://api.deepseek.com',
|
|
171
171
|
contextWindow: 131072,
|
|
172
172
|
maxOutputTokens: 8192,
|
|
@@ -174,7 +174,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
174
174
|
}],
|
|
175
175
|
['deepseek-reasoner', {
|
|
176
176
|
provider: 'deepseek',
|
|
177
|
-
adapter: '
|
|
177
|
+
adapter: 'openai-responses',
|
|
178
178
|
baseUrl: 'https://api.deepseek.com',
|
|
179
179
|
contextWindow: 131072,
|
|
180
180
|
maxOutputTokens: 8192,
|
|
@@ -184,7 +184,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
184
184
|
// ── Google (via OpenAI-compatible API) ─────────────────────────
|
|
185
185
|
['gemini-2.5-pro', {
|
|
186
186
|
provider: 'google',
|
|
187
|
-
adapter: '
|
|
187
|
+
adapter: 'openai-responses',
|
|
188
188
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
189
189
|
contextWindow: 1048576,
|
|
190
190
|
maxOutputTokens: 65536,
|
|
@@ -192,7 +192,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
192
192
|
}],
|
|
193
193
|
['gemini-2.5-flash', {
|
|
194
194
|
provider: 'google',
|
|
195
|
-
adapter: '
|
|
195
|
+
adapter: 'openai-responses',
|
|
196
196
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
197
197
|
contextWindow: 1048576,
|
|
198
198
|
maxOutputTokens: 65536,
|
package/unify/prompts.js
CHANGED
|
@@ -155,6 +155,14 @@ function extractLangSection(content, language) {
|
|
|
155
155
|
/** Loaded templates — read once at module load time. */
|
|
156
156
|
const RAW_TEMPLATES = {
|
|
157
157
|
base: readTemplate('base.md'),
|
|
158
|
+
// Phase 8 wire-up: split-out fragments for the persona-as-identity path.
|
|
159
|
+
// `identityYeaft` ships only when NO VP persona is active. `commonRules`
|
|
160
|
+
// ships every turn (with persona OR with Yeaft identity) — it carries
|
|
161
|
+
// output-format, code-editing, search, and frontend rules that are
|
|
162
|
+
// identity-independent. base.md remains as a back-compat bundle so any
|
|
163
|
+
// external snapshotter / test that reads the file directly keeps working.
|
|
164
|
+
identityYeaft: readTemplate('identity-yeaft.md', { required: false }),
|
|
165
|
+
commonRules: readTemplate('common-rules.md', { required: false }),
|
|
158
166
|
modeUnified: readTemplate('mode-unified.md'),
|
|
159
167
|
modeDream: readTemplate('mode-dream.md'),
|
|
160
168
|
toolGuidance: readTemplate('tool-guidance.md'),
|
|
@@ -201,7 +209,7 @@ const PROMPTS = {
|
|
|
201
209
|
coreMemoryMeta: 'To open the original message behind any entry above, call `memory_trace`.',
|
|
202
210
|
vpPersonaHeader: '## active_persona',
|
|
203
211
|
vpPersonaIntro: (name, role) =>
|
|
204
|
-
`
|
|
212
|
+
`You ARE **${name}**${role ? ` (${role})` : ''}. Speak in the first person as ${name}; do not refer to yourself as "Yeaft" or as a generic AI assistant. The text below is your identity, expertise, and decision style.`,
|
|
205
213
|
},
|
|
206
214
|
zh: {
|
|
207
215
|
identity: '你是 Yeaft,一个有用的 AI 助手。',
|
|
@@ -222,7 +230,7 @@ const PROMPTS = {
|
|
|
222
230
|
coreMemoryMeta: '如需原始 message,调 `memory_trace`。',
|
|
223
231
|
vpPersonaHeader: '## active_persona',
|
|
224
232
|
vpPersonaIntro: (name, role) =>
|
|
225
|
-
|
|
233
|
+
`你就是 **${name}**${role ? `(${role})` : ''}。请以 ${name} 的第一人称发言;不要自称 "Yeaft" 或泛指的 AI 助手。下面的文字是你的身份、专业方向与判断风格。`,
|
|
226
234
|
},
|
|
227
235
|
};
|
|
228
236
|
|
|
@@ -311,25 +319,28 @@ export function buildSystemPrompt({
|
|
|
311
319
|
const parts = [];
|
|
312
320
|
|
|
313
321
|
// ─── 1. Core Identity ──────────────────────────────────
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
// Phase 8 wire-up: when a VP persona is active, the persona body REPLACES
|
|
323
|
+
// the Yeaft identity block (the LLM is that VP, not Yeaft pretending). When
|
|
324
|
+
// there is no persona, fall back to the legacy Yeaft identity bundle.
|
|
325
|
+
const personaBlock = renderVpPersona(vpPersona, lang);
|
|
326
|
+
if (personaBlock) {
|
|
327
|
+
parts.push(personaBlock);
|
|
328
|
+
// Common rules (output format, code editing, search, frontend) still
|
|
329
|
+
// apply to every turn, regardless of which VP is speaking.
|
|
330
|
+
const commonRules = getTemplate('commonRules', effectiveLang);
|
|
331
|
+
if (commonRules) parts.push(commonRules);
|
|
318
332
|
} else {
|
|
319
|
-
|
|
333
|
+
const baseTemplate = getTemplate('base', effectiveLang);
|
|
334
|
+
if (baseTemplate) {
|
|
335
|
+
parts.push(baseTemplate);
|
|
336
|
+
} else {
|
|
337
|
+
parts.push(lang.identity);
|
|
338
|
+
}
|
|
320
339
|
}
|
|
321
340
|
|
|
322
341
|
// ─── 2. Date Metadata ──────────────────────────────────
|
|
323
342
|
parts.push(lang.date(new Date().toISOString().split('T')[0]));
|
|
324
343
|
|
|
325
|
-
// ─── 2.5 VP Persona Override (Bug 3 fix) ───────────────
|
|
326
|
-
// When the caller (web-bridge / dispatcher) addressed a specific VP via
|
|
327
|
-
// @-mention, inject that VP's persona body so the LLM stops speaking as
|
|
328
|
-
// generic Yeaft and adopts the VP's voice. Placed AFTER base identity so
|
|
329
|
-
// the persona section's directive ("override generic Yeaft") wins.
|
|
330
|
-
const vpBlock = renderVpPersona(vpPersona, lang);
|
|
331
|
-
if (vpBlock) parts.push(vpBlock);
|
|
332
|
-
|
|
333
344
|
// ─── 3. Mode-Specific Instructions ─────────────────────
|
|
334
345
|
// task-297: single unified mode for all normal operation.
|
|
335
346
|
// `dream` is retained for background memory maintenance.
|
|
@@ -426,10 +437,14 @@ function renderVpPersona(vpPersona, lang) {
|
|
|
426
437
|
if (!name) return '';
|
|
427
438
|
const role = typeof vpPersona.role === 'string' ? vpPersona.role.trim() : '';
|
|
428
439
|
const body = typeof vpPersona.persona === 'string' ? vpPersona.persona.trim() : '';
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
440
|
+
|
|
441
|
+
// Phase 8 wire-up: persona is now the IDENTITY layer (not an overlay).
|
|
442
|
+
// Emit a `# <name> — <role>` H1 so the prompt opens with the VP's name,
|
|
443
|
+
// matching the legacy Yeaft identity shape but speaking as the VP. The
|
|
444
|
+
// first-person imperative ("you ARE X") replaces the old soft overlay
|
|
445
|
+
// language so the LLM does not slip back into Yeaft voice mid-turn.
|
|
446
|
+
const heading = role ? `# ${name} — ${role}` : `# ${name}`;
|
|
447
|
+
const lines = [heading, '', lang.vpPersonaIntro(name, role)];
|
|
433
448
|
if (body) lines.push('', body);
|
|
434
449
|
return lines.join('\n');
|
|
435
450
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
|
|
3
|
+
## Core Principles
|
|
4
|
+
|
|
5
|
+
- You are a thoughtful collaborator, not just a command executor
|
|
6
|
+
- Admit uncertainty honestly — say "I'm not sure" rather than guessing
|
|
7
|
+
- Cite evidence when making claims about code, behavior, or facts
|
|
8
|
+
- Be concise: prefer short, direct answers over verbose explanations
|
|
9
|
+
- Never add emoji unless the user uses them first
|
|
10
|
+
- Never start responses with excessive flattery ("Great question!")
|
|
11
|
+
|
|
12
|
+
## Output Format
|
|
13
|
+
|
|
14
|
+
- Use GitHub-flavored Markdown
|
|
15
|
+
- Code blocks must include language identifiers: ```js, ```python, etc.
|
|
16
|
+
- Reference files with inline code: `src/app.ts:42`
|
|
17
|
+
- Avoid deeply nested bullet lists — prefer flat structure or numbered steps
|
|
18
|
+
- For terminal commands, use single-line code blocks
|
|
19
|
+
- For multi-step instructions, use numbered lists
|
|
20
|
+
|
|
21
|
+
## Code Editing Rules
|
|
22
|
+
|
|
23
|
+
- Always read a file before editing it
|
|
24
|
+
- Never revert changes you did not make
|
|
25
|
+
- Never amend commits unless the user explicitly asks
|
|
26
|
+
- Never use `git reset --hard` or `git clean -f` without user approval
|
|
27
|
+
- Prefer non-interactive git commands (no `git rebase -i`, no `git add -i`)
|
|
28
|
+
- Default to ASCII — avoid Unicode decorations in code
|
|
29
|
+
- Follow existing code style: indentation, naming conventions, patterns
|
|
30
|
+
- When adding code, match the surrounding context
|
|
31
|
+
|
|
32
|
+
## Search and Navigation
|
|
33
|
+
|
|
34
|
+
- Prefer `rg` (ripgrep) over `grep` for speed and regex support
|
|
35
|
+
- Use `glob` patterns for file discovery
|
|
36
|
+
- Read files with offset/limit for large files instead of loading everything
|
|
37
|
+
|
|
38
|
+
## Frontend Design (when applicable)
|
|
39
|
+
|
|
40
|
+
- Avoid "AI slop": no gratuitous purple gradients, no hero sections with vague taglines
|
|
41
|
+
- Do not default to dark theme — follow project conventions
|
|
42
|
+
- Match existing design system; do not introduce new component libraries without asking
|
|
43
|
+
- Prefer semantic HTML and progressive enhancement
|
|
44
|
+
|
|
45
|
+
<!-- lang:zh -->
|
|
46
|
+
|
|
47
|
+
## 核心原则
|
|
48
|
+
|
|
49
|
+
- 你是一个深思熟虑的协作者,而非单纯的命令执行器
|
|
50
|
+
- 诚实地承认不确定性 — 说"我不确定"而不是猜测
|
|
51
|
+
- 在对代码、行为或事实做出断言时引用证据
|
|
52
|
+
- 简洁:优先使用简短直接的回答,而非冗长的解释
|
|
53
|
+
- 除非用户先使用 emoji,否则不要添加
|
|
54
|
+
- 不要以过度的奉承开头("好问题!")
|
|
55
|
+
|
|
56
|
+
## 输出格式
|
|
57
|
+
|
|
58
|
+
- 使用 GitHub 风格的 Markdown
|
|
59
|
+
- 代码块必须包含语言标识:```js、```python 等
|
|
60
|
+
- 使用内联代码引用文件:`src/app.ts:42`
|
|
61
|
+
- 避免深层嵌套的项目列表 — 优先使用扁平结构或编号步骤
|
|
62
|
+
- 终端命令使用单行代码块
|
|
63
|
+
- 多步骤指令使用编号列表
|
|
64
|
+
|
|
65
|
+
## 代码编辑规则
|
|
66
|
+
|
|
67
|
+
- 编辑文件前必须先读取
|
|
68
|
+
- 不要回退你未做的修改
|
|
69
|
+
- 除非用户明确要求,否则不要 amend commit
|
|
70
|
+
- 未经用户同意不使用 `git reset --hard` 或 `git clean -f`
|
|
71
|
+
- 优先使用非交互式 git 命令(不用 `git rebase -i`、不用 `git add -i`)
|
|
72
|
+
- 默认使用 ASCII — 避免在代码中使用 Unicode 装饰
|
|
73
|
+
- 遵循已有的代码风格:缩进、命名约定、模式
|
|
74
|
+
- 添加代码时匹配周围的上下文
|
|
75
|
+
|
|
76
|
+
## 搜索与导航
|
|
77
|
+
|
|
78
|
+
- 优先使用 `rg`(ripgrep)而非 `grep`,速度更快且支持正则
|
|
79
|
+
- 使用 `glob` 模式发现文件
|
|
80
|
+
- 对大文件使用 offset/limit 读取,而非加载全部内容
|
|
81
|
+
|
|
82
|
+
## 前端设计(适用时)
|
|
83
|
+
|
|
84
|
+
- 避免 "AI 泛滥风格":不要无端使用紫色渐变、不要带模糊标语的 hero 区域
|
|
85
|
+
- 不要默认使用暗色主题 — 遵循项目约定
|
|
86
|
+
- 匹配现有设计系统;不要在未询问的情况下引入新的组件库
|
|
87
|
+
- 优先使用语义化 HTML 和渐进增强
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
|
|
3
|
+
# Yeaft — AI Companion
|
|
4
|
+
|
|
5
|
+
You are Yeaft, an AI companion that maintains a single continuous conversation with the user. You remember context across sessions through your memory system. Every interaction builds on what came before.
|
|
6
|
+
|
|
7
|
+
<!-- lang:zh -->
|
|
8
|
+
|
|
9
|
+
# Yeaft — AI 伙伴
|
|
10
|
+
|
|
11
|
+
你是 Yeaft,一个与用户保持单一持续对话的 AI 伙伴。你通过记忆系统在会话间记住上下文。每次交互都建立在之前的基础上。
|
|
@@ -97,7 +97,7 @@ export class EngineInstance {
|
|
|
97
97
|
* @param {AbortSignal} [params.signal]
|
|
98
98
|
* @yields {object} EngineEvent with { ...event, threadId }
|
|
99
99
|
*/
|
|
100
|
-
async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers } = {}) {
|
|
100
|
+
async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId } = {}) {
|
|
101
101
|
if (this.#terminated) {
|
|
102
102
|
yield {
|
|
103
103
|
type: 'error',
|
|
@@ -173,7 +173,7 @@ export class EngineInstance {
|
|
|
173
173
|
curToolResults = [];
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers })) {
|
|
176
|
+
for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId })) {
|
|
177
177
|
// Re-tag every event with the bound threadId. Non-object events
|
|
178
178
|
// (shouldn't happen — all engine events are objects) are passed
|
|
179
179
|
// through untouched.
|
package/unify/web-bridge.js
CHANGED
|
@@ -1256,13 +1256,17 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1256
1256
|
* Returns `undefined` when we have nothing to inject — keeps the legacy
|
|
1257
1257
|
* single-agent path identical to before.
|
|
1258
1258
|
*/
|
|
1259
|
-
function buildVpQueryOpts({ vpId, groupCoordinator }) {
|
|
1259
|
+
function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
1260
1260
|
if (!vpId) return undefined;
|
|
1261
1261
|
const out = { senderVpId: vpId };
|
|
1262
|
+
if (typeof groupId === 'string' && groupId.trim()) {
|
|
1263
|
+
out.groupId = groupId.trim();
|
|
1264
|
+
}
|
|
1262
1265
|
try {
|
|
1263
1266
|
const vp = readVp(vpId);
|
|
1264
1267
|
if (vp) {
|
|
1265
1268
|
out.vpPersona = {
|
|
1269
|
+
vpId,
|
|
1266
1270
|
displayName: vp.displayName || vpId,
|
|
1267
1271
|
role: vp.role || '',
|
|
1268
1272
|
persona: vp.persona || '',
|
|
@@ -1411,7 +1415,7 @@ export async function handleUnifyChat(msg) {
|
|
|
1411
1415
|
const { entry } = session.dispatcher.submit(cleanedPrompt, {
|
|
1412
1416
|
messageId: msg.messageId,
|
|
1413
1417
|
override: override || undefined,
|
|
1414
|
-
queryOpts: buildVpQueryOpts({ vpId, groupCoordinator }),
|
|
1418
|
+
queryOpts: buildVpQueryOpts({ vpId, groupCoordinator, groupId }),
|
|
1415
1419
|
});
|
|
1416
1420
|
sendUnifyEvent({
|
|
1417
1421
|
type: 'input_queue_updated',
|
|
@@ -1,501 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* chat-completions.js — OpenAI Chat Completions API adapter
|
|
3
|
-
*
|
|
4
|
-
* Covers ALL OpenAI-compatible backends via baseUrl:
|
|
5
|
-
* - https://api.openai.com/v1 → OpenAI direct
|
|
6
|
-
* - https://api.deepseek.com → DeepSeek
|
|
7
|
-
* - http://localhost:6628/v1 → CopilotProxy
|
|
8
|
-
* - Azure, Ollama, LMStudio, etc.
|
|
9
|
-
*
|
|
10
|
-
* Key translation responsibilities:
|
|
11
|
-
* Request: UnifiedToolDef → { type: "function", function: { name, description, parameters } }
|
|
12
|
-
* Response: delta.tool_calls[i].function.arguments (JSON string) → accumulate → JSON.parse → UnifiedToolCall
|
|
13
|
-
* Result: UnifiedToolResult → { role: "tool", tool_call_id, content }
|
|
14
|
-
* Finish: "tool_calls" → "tool_use", "stop" → "end_turn", "length" → "max_tokens"
|
|
15
|
-
*
|
|
16
|
-
* max_tokens strategy (based on model ID):
|
|
17
|
-
* OpenAI models (gpt-*, o1*, o3*, o4*) use "max_completion_tokens" (new standard).
|
|
18
|
-
* All other models (DeepSeek, Gemini, etc.) use "max_tokens" (legacy/compat).
|
|
19
|
-
* CopilotProxy transparently forwards whatever the client sends.
|
|
20
|
-
* Callers can override via extraBody to pass any parameter directly.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
LLMAdapter,
|
|
25
|
-
LLMRateLimitError,
|
|
26
|
-
LLMAuthError,
|
|
27
|
-
LLMContextError,
|
|
28
|
-
LLMServerError,
|
|
29
|
-
LLMAbortError,
|
|
30
|
-
redactRawRequest,
|
|
31
|
-
capRawRequest,
|
|
32
|
-
capRawString,
|
|
33
|
-
RAW_PAYLOAD_CAP_BYTES,
|
|
34
|
-
} from './adapter.js';
|
|
35
|
-
import {
|
|
36
|
-
normalizeEffort,
|
|
37
|
-
mapEffortToOpenAIReasoning,
|
|
38
|
-
getThinkingCapability,
|
|
39
|
-
} from '../models.js';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* task-327a: feature-flag accessor. Read lazily so tests can flip.
|
|
43
|
-
*/
|
|
44
|
-
function thinkingV1Enabled() {
|
|
45
|
-
return process.env.UNIFY_THINKING_V1 === '1';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* task-DESIGN-v4: Chat Completions adapter is deprecated in favour of
|
|
50
|
-
* `openai-responses.js` (Responses API) for OpenAI-protocol providers and
|
|
51
|
-
* `anthropic.js` for Anthropic. This warning fires once per process the
|
|
52
|
-
* first time the adapter is instantiated, unless UNIFY_SUPPRESS_DEPRECATION=1.
|
|
53
|
-
* Removal is scheduled for Phase 7 of the multi-VP redesign — see
|
|
54
|
-
* `agent/unify/DESIGN.md` § "Migration Plan".
|
|
55
|
-
*/
|
|
56
|
-
let _chatCompletionsDeprecationWarned = false;
|
|
57
|
-
function warnChatCompletionsDeprecated() {
|
|
58
|
-
if (_chatCompletionsDeprecationWarned) return;
|
|
59
|
-
if (process.env.UNIFY_SUPPRESS_DEPRECATION === '1') {
|
|
60
|
-
_chatCompletionsDeprecationWarned = true;
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
_chatCompletionsDeprecationWarned = true;
|
|
64
|
-
// eslint-disable-next-line no-console
|
|
65
|
-
console.warn(
|
|
66
|
-
'[unify] ChatCompletionsAdapter is deprecated. Migrate OpenAI-protocol '
|
|
67
|
-
+ 'providers to the Responses API (set provider.protocol="openai-responses"). '
|
|
68
|
-
+ 'This adapter will be removed in a future release. Set '
|
|
69
|
-
+ 'UNIFY_SUPPRESS_DEPRECATION=1 to silence this warning.'
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Check if a model ID is an OpenAI model that supports max_completion_tokens.
|
|
75
|
-
* OpenAI introduced max_completion_tokens with o1 and made it standard for
|
|
76
|
-
* GPT-4.1+, o-series, and GPT-5+. Other OpenAI-compatible APIs (DeepSeek,
|
|
77
|
-
* Gemini, Ollama) still only understand max_tokens.
|
|
78
|
-
*
|
|
79
|
-
* @param {string} model — The model ID (e.g. "gpt-5", "deepseek-chat", "o3")
|
|
80
|
-
* @returns {boolean} true = use max_completion_tokens
|
|
81
|
-
*/
|
|
82
|
-
export function useNewMaxTokensParam(model) {
|
|
83
|
-
if (!model) return false;
|
|
84
|
-
const m = model.toLowerCase();
|
|
85
|
-
// GPT-4.1+ and GPT-5+
|
|
86
|
-
if (m.startsWith('gpt-')) return true;
|
|
87
|
-
// o-series reasoning models (o1, o3, o4-mini, etc.)
|
|
88
|
-
if (/^o\d/.test(m)) return true;
|
|
89
|
-
// Everything else (deepseek-*, gemini-*, claude-*, custom models): legacy
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* ChatCompletionsAdapter — Talks to OpenAI Chat Completions API and compatibles.
|
|
95
|
-
*/
|
|
96
|
-
export class ChatCompletionsAdapter extends LLMAdapter {
|
|
97
|
-
#apiKey;
|
|
98
|
-
#baseUrl;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* @param {{ apiKey: string, baseUrl: string }} config
|
|
102
|
-
*/
|
|
103
|
-
constructor({ apiKey, baseUrl }) {
|
|
104
|
-
super({ apiKey, baseUrl });
|
|
105
|
-
this.#apiKey = apiKey;
|
|
106
|
-
this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
|
|
107
|
-
warnChatCompletionsDeprecated();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Expose baseUrl for testing. */
|
|
111
|
-
get baseUrl() { return this.#baseUrl; }
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Build the max-tokens portion of the request body.
|
|
115
|
-
* Uses max_completion_tokens for OpenAI models, max_tokens for others.
|
|
116
|
-
*
|
|
117
|
-
* @param {string} model
|
|
118
|
-
* @param {number} maxTokens
|
|
119
|
-
* @returns {object}
|
|
120
|
-
*/
|
|
121
|
-
#maxTokensBody(model, maxTokens) {
|
|
122
|
-
if (useNewMaxTokensParam(model)) {
|
|
123
|
-
return { max_completion_tokens: maxTokens };
|
|
124
|
-
}
|
|
125
|
-
return { max_tokens: maxTokens };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Translate UnifiedToolDef[] → Chat Completions tool format.
|
|
130
|
-
* @param {import('./adapter.js').UnifiedToolDef[]} tools
|
|
131
|
-
* @returns {object[]|undefined}
|
|
132
|
-
*/
|
|
133
|
-
#translateTools(tools) {
|
|
134
|
-
if (!tools || tools.length === 0) return undefined;
|
|
135
|
-
return tools.map(t => ({
|
|
136
|
-
type: 'function',
|
|
137
|
-
function: {
|
|
138
|
-
name: t.name,
|
|
139
|
-
description: t.description,
|
|
140
|
-
parameters: t.parameters,
|
|
141
|
-
},
|
|
142
|
-
}));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Translate UnifiedMessage[] → Chat Completions message format.
|
|
147
|
-
* @param {string} system — System prompt
|
|
148
|
-
* @param {import('./adapter.js').UnifiedMessage[]} messages
|
|
149
|
-
* @returns {object[]}
|
|
150
|
-
*/
|
|
151
|
-
#translateMessages(system, messages) {
|
|
152
|
-
const result = [];
|
|
153
|
-
|
|
154
|
-
// System message first
|
|
155
|
-
if (system) {
|
|
156
|
-
result.push({ role: 'system', content: system });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
for (const msg of messages) {
|
|
160
|
-
if (msg.role === 'system') {
|
|
161
|
-
result.push({ role: 'system', content: msg.content });
|
|
162
|
-
} else if (msg.role === 'user') {
|
|
163
|
-
result.push({ role: 'user', content: msg.content });
|
|
164
|
-
} else if (msg.role === 'assistant') {
|
|
165
|
-
const entry = { role: 'assistant' };
|
|
166
|
-
// Some OpenAI-compatible APIs require `content: null` when tool_calls are present
|
|
167
|
-
entry.content = msg.content || null;
|
|
168
|
-
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
169
|
-
entry.tool_calls = msg.toolCalls.map(tc => ({
|
|
170
|
-
id: tc.id,
|
|
171
|
-
type: 'function',
|
|
172
|
-
function: {
|
|
173
|
-
name: tc.name,
|
|
174
|
-
arguments: JSON.stringify(tc.input),
|
|
175
|
-
},
|
|
176
|
-
}));
|
|
177
|
-
}
|
|
178
|
-
result.push(entry);
|
|
179
|
-
} else if (msg.role === 'tool') {
|
|
180
|
-
result.push({
|
|
181
|
-
role: 'tool',
|
|
182
|
-
tool_call_id: msg.toolCallId,
|
|
183
|
-
content: msg.content,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return result;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Classify HTTP errors.
|
|
192
|
-
* @param {number} status
|
|
193
|
-
* @param {string} body
|
|
194
|
-
*/
|
|
195
|
-
#classifyError(status, body) {
|
|
196
|
-
if (status === 401 || status === 403) {
|
|
197
|
-
return new LLMAuthError(`Auth error: ${body}`, status);
|
|
198
|
-
}
|
|
199
|
-
if (status === 429) {
|
|
200
|
-
return new LLMRateLimitError(`Rate limit: ${body}`, status);
|
|
201
|
-
}
|
|
202
|
-
if (status === 529) {
|
|
203
|
-
return new LLMRateLimitError(`Overloaded: ${body}`, status);
|
|
204
|
-
}
|
|
205
|
-
if (status === 413 || body.includes('context_length_exceeded') || body.includes('maximum context length')) {
|
|
206
|
-
return new LLMContextError(`Context too long: ${body}`);
|
|
207
|
-
}
|
|
208
|
-
if (status >= 500) {
|
|
209
|
-
return new LLMServerError(`Server error: ${body}`, status);
|
|
210
|
-
}
|
|
211
|
-
return new Error(`API error ${status}: ${body}`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Map Chat Completions finish_reason → unified stop reason.
|
|
216
|
-
* @param {string|null} reason
|
|
217
|
-
* @returns {'end_turn' | 'tool_use' | 'max_tokens'}
|
|
218
|
-
*/
|
|
219
|
-
#mapFinishReason(reason) {
|
|
220
|
-
switch (reason) {
|
|
221
|
-
case 'tool_calls': return 'tool_use';
|
|
222
|
-
case 'stop': return 'end_turn';
|
|
223
|
-
case 'length': return 'max_tokens';
|
|
224
|
-
default: return 'end_turn';
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
230
|
-
* @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
|
|
231
|
-
*/
|
|
232
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
|
|
233
|
-
if (signal?.aborted) throw new LLMAbortError();
|
|
234
|
-
|
|
235
|
-
const body = {
|
|
236
|
-
model,
|
|
237
|
-
messages: this.#translateMessages(system, messages),
|
|
238
|
-
...this.#maxTokensBody(model, maxTokens),
|
|
239
|
-
stream: true,
|
|
240
|
-
stream_options: { include_usage: true },
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// task-327a: inject OpenAI reasoning.effort when feature flag on, effort is
|
|
244
|
-
// valid, and model's registry entry flags openai-reasoning protocol.
|
|
245
|
-
// 'max' downgrades to 'high' (OpenAI has no 'max' enum). Unknown / unsupported
|
|
246
|
-
// models silently drop the parameter.
|
|
247
|
-
const normEffort = normalizeEffort(effort);
|
|
248
|
-
if (thinkingV1Enabled() && normEffort) {
|
|
249
|
-
const cap = getThinkingCapability(model);
|
|
250
|
-
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
251
|
-
const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
|
|
252
|
-
if (reasoningEffort) {
|
|
253
|
-
body.reasoning = { effort: reasoningEffort };
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const translatedTools = this.#translateTools(tools);
|
|
259
|
-
if (translatedTools) body.tools = translatedTools;
|
|
260
|
-
|
|
261
|
-
// extraBody allows callers to pass through any additional/override parameters
|
|
262
|
-
if (extraBody) Object.assign(body, extraBody);
|
|
263
|
-
|
|
264
|
-
const url = `${this.#baseUrl}/chat/completions`;
|
|
265
|
-
const headers = {
|
|
266
|
-
'Content-Type': 'application/json',
|
|
267
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// task-344: expose raw request (redacted) for debug panel.
|
|
271
|
-
// task-344 follow-up (N2): cap body size.
|
|
272
|
-
const rawRequest = capRawRequest(redactRawRequest({ url, method: 'POST', headers, body }));
|
|
273
|
-
|
|
274
|
-
const response = await fetch(url, {
|
|
275
|
-
method: 'POST',
|
|
276
|
-
headers,
|
|
277
|
-
body: JSON.stringify(body),
|
|
278
|
-
signal,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
if (!response.ok) {
|
|
282
|
-
const errorBody = await response.text();
|
|
283
|
-
if (onRawExchange) {
|
|
284
|
-
try {
|
|
285
|
-
onRawExchange({
|
|
286
|
-
rawRequest,
|
|
287
|
-
rawResponse: {
|
|
288
|
-
status: response.status,
|
|
289
|
-
headers: response.headers && typeof response.headers.entries === 'function'
|
|
290
|
-
? Object.fromEntries(response.headers.entries())
|
|
291
|
-
: {},
|
|
292
|
-
// task-344 follow-up (N2): cap error body.
|
|
293
|
-
body: capRawString(errorBody),
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
} catch { /* ignore */ }
|
|
297
|
-
}
|
|
298
|
-
throw this.#classifyError(response.status, errorBody);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Parse SSE stream
|
|
302
|
-
const reader = response.body.getReader();
|
|
303
|
-
const decoder = new TextDecoder();
|
|
304
|
-
let buffer = '';
|
|
305
|
-
// task-344: accumulate raw SSE body + headers/status for debug exposure.
|
|
306
|
-
// task-344 follow-up (N2): cap growth at RAW_PAYLOAD_CAP_BYTES.
|
|
307
|
-
let rawSseBody = '';
|
|
308
|
-
let rawSseTotalBytes = 0;
|
|
309
|
-
let rawSseCapped = false;
|
|
310
|
-
const responseHeaders = response.headers && typeof response.headers.entries === 'function'
|
|
311
|
-
? Object.fromEntries(response.headers.entries())
|
|
312
|
-
: {};
|
|
313
|
-
const responseStatus = response.status;
|
|
314
|
-
|
|
315
|
-
// Tool call accumulation — Chat Completions sends tool args as fragments
|
|
316
|
-
// keyed by index within the delta.tool_calls array
|
|
317
|
-
/** @type {Map<number, { id: string, name: string, arguments: string }>} */
|
|
318
|
-
const toolCallAccum = new Map();
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
while (true) {
|
|
322
|
-
const { done, value } = await reader.read();
|
|
323
|
-
if (done) break;
|
|
324
|
-
|
|
325
|
-
const chunkText = decoder.decode(value, { stream: true });
|
|
326
|
-
buffer += chunkText;
|
|
327
|
-
// task-344 follow-up (N2): size-capped capture.
|
|
328
|
-
rawSseTotalBytes += value.byteLength;
|
|
329
|
-
if (!rawSseCapped) {
|
|
330
|
-
if (rawSseTotalBytes <= RAW_PAYLOAD_CAP_BYTES) {
|
|
331
|
-
rawSseBody += chunkText;
|
|
332
|
-
} else {
|
|
333
|
-
const remaining = RAW_PAYLOAD_CAP_BYTES - (rawSseTotalBytes - value.byteLength);
|
|
334
|
-
if (remaining > 0) {
|
|
335
|
-
rawSseBody += chunkText.slice(0, remaining);
|
|
336
|
-
}
|
|
337
|
-
rawSseCapped = true;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const lines = buffer.split('\n');
|
|
341
|
-
buffer = lines.pop() || '';
|
|
342
|
-
|
|
343
|
-
for (const line of lines) {
|
|
344
|
-
if (!line.startsWith('data: ')) continue;
|
|
345
|
-
const data = line.slice(6).trim();
|
|
346
|
-
if (data === '[DONE]') continue;
|
|
347
|
-
|
|
348
|
-
let chunk;
|
|
349
|
-
try {
|
|
350
|
-
chunk = JSON.parse(data);
|
|
351
|
-
} catch {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Usage (from stream_options: include_usage)
|
|
356
|
-
if (chunk.usage) {
|
|
357
|
-
yield {
|
|
358
|
-
type: 'usage',
|
|
359
|
-
inputTokens: chunk.usage.prompt_tokens || 0,
|
|
360
|
-
outputTokens: chunk.usage.completion_tokens || 0,
|
|
361
|
-
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
|
362
|
-
cacheWriteTokens: 0,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const choice = chunk.choices?.[0];
|
|
367
|
-
if (!choice) continue;
|
|
368
|
-
|
|
369
|
-
const delta = choice.delta;
|
|
370
|
-
if (!delta) continue;
|
|
371
|
-
|
|
372
|
-
// Text content
|
|
373
|
-
if (delta.content) {
|
|
374
|
-
yield { type: 'text_delta', text: delta.content };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Tool calls (streamed as fragments)
|
|
378
|
-
if (delta.tool_calls) {
|
|
379
|
-
for (const tc of delta.tool_calls) {
|
|
380
|
-
const idx = tc.index;
|
|
381
|
-
if (!toolCallAccum.has(idx)) {
|
|
382
|
-
toolCallAccum.set(idx, {
|
|
383
|
-
id: tc.id || '',
|
|
384
|
-
name: tc.function?.name || '',
|
|
385
|
-
arguments: '',
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
const accum = toolCallAccum.get(idx);
|
|
389
|
-
if (tc.id) accum.id = tc.id;
|
|
390
|
-
if (tc.function?.name) accum.name = tc.function.name;
|
|
391
|
-
if (tc.function?.arguments) accum.arguments += tc.function.arguments;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Finish reason
|
|
396
|
-
if (choice.finish_reason) {
|
|
397
|
-
// Emit accumulated tool calls before stop
|
|
398
|
-
for (const [, accum] of toolCallAccum) {
|
|
399
|
-
let parsedInput = {};
|
|
400
|
-
try {
|
|
401
|
-
parsedInput = accum.arguments ? JSON.parse(accum.arguments) : {};
|
|
402
|
-
} catch {
|
|
403
|
-
parsedInput = {};
|
|
404
|
-
}
|
|
405
|
-
yield {
|
|
406
|
-
type: 'tool_call',
|
|
407
|
-
id: accum.id,
|
|
408
|
-
name: accum.name,
|
|
409
|
-
input: parsedInput,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
toolCallAccum.clear();
|
|
413
|
-
|
|
414
|
-
yield {
|
|
415
|
-
type: 'stop',
|
|
416
|
-
stopReason: this.#mapFinishReason(choice.finish_reason),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
} finally {
|
|
422
|
-
reader.releaseLock();
|
|
423
|
-
// task-344: emit raw exchange after stream completes.
|
|
424
|
-
if (onRawExchange) {
|
|
425
|
-
try {
|
|
426
|
-
// task-344 follow-up (N2): append truncation marker when capped.
|
|
427
|
-
const finalBody = rawSseCapped
|
|
428
|
-
? `${rawSseBody}…[truncated, original ${rawSseTotalBytes} bytes]`
|
|
429
|
-
: rawSseBody;
|
|
430
|
-
onRawExchange({
|
|
431
|
-
rawRequest,
|
|
432
|
-
rawResponse: {
|
|
433
|
-
status: responseStatus,
|
|
434
|
-
headers: responseHeaders,
|
|
435
|
-
body: finalBody,
|
|
436
|
-
format: 'sse',
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
} catch { /* ignore */ }
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Non-streaming call for side queries.
|
|
446
|
-
*
|
|
447
|
-
* task-327c: accepts `effort` for internal scenario-tagged calls
|
|
448
|
-
* (consolidate/dream/recall/light). Feature-flag + capability guards
|
|
449
|
-
* mirror stream() exactly; unsupported models silently drop the param.
|
|
450
|
-
*/
|
|
451
|
-
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
452
|
-
if (signal?.aborted) throw new LLMAbortError();
|
|
453
|
-
|
|
454
|
-
const body = {
|
|
455
|
-
model,
|
|
456
|
-
messages: this.#translateMessages(system, messages),
|
|
457
|
-
...this.#maxTokensBody(model, maxTokens),
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
// task-327c: mirror stream()'s reasoning.effort injection for side queries.
|
|
461
|
-
const normEffort = normalizeEffort(effort);
|
|
462
|
-
if (thinkingV1Enabled() && normEffort) {
|
|
463
|
-
const cap = getThinkingCapability(model);
|
|
464
|
-
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
465
|
-
const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
|
|
466
|
-
if (reasoningEffort) {
|
|
467
|
-
body.reasoning = { effort: reasoningEffort };
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// extraBody allows callers to pass through any additional/override parameters
|
|
473
|
-
if (extraBody) Object.assign(body, extraBody);
|
|
474
|
-
|
|
475
|
-
const response = await fetch(`${this.#baseUrl}/chat/completions`, {
|
|
476
|
-
method: 'POST',
|
|
477
|
-
headers: {
|
|
478
|
-
'Content-Type': 'application/json',
|
|
479
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
480
|
-
},
|
|
481
|
-
body: JSON.stringify(body),
|
|
482
|
-
signal,
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
if (!response.ok) {
|
|
486
|
-
const errorBody = await response.text();
|
|
487
|
-
throw this.#classifyError(response.status, errorBody);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const result = await response.json();
|
|
491
|
-
const text = result.choices?.[0]?.message?.content || '';
|
|
492
|
-
|
|
493
|
-
return {
|
|
494
|
-
text,
|
|
495
|
-
usage: {
|
|
496
|
-
inputTokens: result.usage?.prompt_tokens || 0,
|
|
497
|
-
outputTokens: result.usage?.completion_tokens || 0,
|
|
498
|
-
},
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
}
|