@yeaft/webchat-agent 0.1.872 → 0.1.874
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/providers/copilot-models.js +105 -89
- package/providers/copilot.js +3 -1
- package/yeaft/init.js +19 -3
- package/yeaft/migrate/sessions-v1.js +27 -8
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Copilot model list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Source of truth (in order): another module can prime the in-process cache
|
|
5
|
+
* by calling `cacheCopilotModelsFromAcp(availableModels)` after a successful
|
|
6
|
+
* `session/new` — that response carries the full model list the CLI itself
|
|
7
|
+
* would show in its `/model` picker, including pricing/usage metadata. If
|
|
8
|
+
* the cache is cold when `listCopilotModels()` is called (e.g. the model
|
|
9
|
+
* picker is opened before any Copilot conversation has started), we spawn
|
|
10
|
+
* a one-shot `copilot --acp` child, do the `initialize` + `session/new`
|
|
11
|
+
* handshake to get the same list, then close. Falls back to a static curated
|
|
12
|
+
* list if even that fails.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* Why ACP and not the HTTP `/models` endpoint: the HTTP endpoint is gated
|
|
15
|
+
* by org policy on enterprise accounts and frequently returns only legacy
|
|
16
|
+
* models. ACP `session/new` always returns the real per-account picker list.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { homedir } from 'os';
|
|
18
|
-
import { join } from 'path';
|
|
19
|
+
import { spawn } from 'child_process';
|
|
20
|
+
import { AcpClient } from './acp-client.js';
|
|
19
21
|
|
|
20
|
-
// Curated fallback —
|
|
21
|
-
//
|
|
22
|
-
// or when org policy hides the picker list at the API (some enterprise orgs
|
|
23
|
-
// gate it). `--model <id>` still accepts these IDs at chat time.
|
|
22
|
+
// Curated fallback — last resort if no ACP cache and no live probe works.
|
|
23
|
+
// Vendor inferred from id prefix when missing.
|
|
24
24
|
export const FALLBACK_COPILOT_MODELS = Object.freeze([
|
|
25
25
|
{ id: 'claude-sonnet-4.6', label: 'Claude Sonnet 4.6', vendor: 'Anthropic' },
|
|
26
26
|
{ id: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5', vendor: 'Anthropic' },
|
|
@@ -32,7 +32,6 @@ export const FALLBACK_COPILOT_MODELS = Object.freeze([
|
|
|
32
32
|
{ id: 'gpt-5.5', label: 'GPT-5.5', vendor: 'OpenAI' },
|
|
33
33
|
{ id: 'gpt-5.4', label: 'GPT-5.4', vendor: 'OpenAI' },
|
|
34
34
|
{ id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', vendor: 'OpenAI' },
|
|
35
|
-
{ id: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', vendor: 'OpenAI' },
|
|
36
35
|
{ id: 'gpt-5.2', label: 'GPT-5.2', vendor: 'OpenAI' },
|
|
37
36
|
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', vendor: 'OpenAI' },
|
|
38
37
|
{ id: 'gpt-5-mini', label: 'GPT-5 Mini', vendor: 'OpenAI' },
|
|
@@ -41,58 +40,93 @@ export const FALLBACK_COPILOT_MODELS = Object.freeze([
|
|
|
41
40
|
|
|
42
41
|
export const DEFAULT_COPILOT_MODEL = 'claude-sonnet-4.5';
|
|
43
42
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
43
|
+
const CACHE_TTL_MS = 30 * 60 * 1000;
|
|
44
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
45
|
+
|
|
46
|
+
let _cache = null; // { models, fetchedAt }
|
|
47
|
+
let _inflight = null;
|
|
48
|
+
|
|
49
|
+
function _vendorFromId(id) {
|
|
50
|
+
const s = String(id || '').toLowerCase();
|
|
51
|
+
if (s.startsWith('claude')) return 'Anthropic';
|
|
52
|
+
if (s.startsWith('gpt') || s.startsWith('o1') || s.startsWith('o3') || s.startsWith('chatgpt')) return 'OpenAI';
|
|
53
|
+
if (s.startsWith('gemini')) return 'Google';
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _normalizeAcpModel(m) {
|
|
58
|
+
if (!m || !m.modelId) return null;
|
|
59
|
+
// Skip "auto" sentinel — not a real model, just a router placeholder.
|
|
60
|
+
if (m.modelId === 'auto') return null;
|
|
61
|
+
const meta = m._meta || {};
|
|
62
|
+
return {
|
|
63
|
+
id: m.modelId,
|
|
64
|
+
label: m.name || m.modelId,
|
|
65
|
+
vendor: _vendorFromId(m.modelId),
|
|
66
|
+
usage: meta.copilotUsage || '', // "1x" / "0.33x" / "15x"
|
|
67
|
+
priceCategory: meta.copilotPriceCategory || '', // "low" / "medium" / "high"
|
|
68
|
+
enablement: meta.copilotEnablement || '', // "enabled" / "disabled"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
47
71
|
|
|
48
72
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* credential pipeline (env / gh CLI / persisted device flow) didn't surface
|
|
52
|
-
* a token, we re-use the CLI's. Same auth surface — no extra perm needed.
|
|
73
|
+
* Prime the in-process cache from an ACP `session/new` response (preferred).
|
|
74
|
+
* Called from copilot.js after a successful handshake.
|
|
53
75
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const cleaned = raw.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
|
|
60
|
-
const cfg = JSON.parse(cleaned);
|
|
61
|
-
const tokens = cfg?.copilotTokens && typeof cfg.copilotTokens === 'object' ? cfg.copilotTokens : null;
|
|
62
|
-
if (!tokens) return null;
|
|
63
|
-
for (const tok of Object.values(tokens)) {
|
|
64
|
-
if (typeof tok === 'string' && validateRawToken(tok).valid) return tok;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
} catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
76
|
+
export function cacheCopilotModelsFromAcp(availableModels) {
|
|
77
|
+
if (!Array.isArray(availableModels)) return;
|
|
78
|
+
const models = availableModels.map(_normalizeAcpModel).filter(Boolean);
|
|
79
|
+
if (!models.length) return;
|
|
80
|
+
_cache = { models, fetchedAt: Date.now() };
|
|
70
81
|
}
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
83
|
+
/**
|
|
84
|
+
* Spawn a short-lived `copilot --acp` child, do initialize + session/new,
|
|
85
|
+
* pull `models.availableModels` out of the response, then close. Times out
|
|
86
|
+
* after PROBE_TIMEOUT_MS. Resolves to an array (possibly empty) or rejects.
|
|
87
|
+
*/
|
|
88
|
+
function _probeAcpModels() {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let child;
|
|
91
|
+
let client;
|
|
92
|
+
let done = false;
|
|
93
|
+
const finish = (err, models) => {
|
|
94
|
+
if (done) return;
|
|
95
|
+
done = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
try { client?.close(); } catch {}
|
|
98
|
+
try { child?.kill('SIGTERM'); } catch {}
|
|
99
|
+
if (err) reject(err); else resolve(models || []);
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
child = spawn('copilot', ['--acp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
103
|
+
} catch (e) { return reject(e); }
|
|
104
|
+
const timer = setTimeout(() => finish(new Error('ACP probe timeout')), PROBE_TIMEOUT_MS);
|
|
105
|
+
child.on('error', (e) => finish(e));
|
|
106
|
+
child.on('exit', () => finish(new Error('ACP child exited')));
|
|
107
|
+
child.stderr.on('data', () => { /* swallow */ });
|
|
88
108
|
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
client = new AcpClient({
|
|
110
|
+
stdin: child.stdin,
|
|
111
|
+
stdout: child.stdout,
|
|
112
|
+
onError: (e) => finish(e),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
(async () => {
|
|
116
|
+
try {
|
|
117
|
+
await client.request('initialize', { protocolVersion: 1, clientCapabilities: {} });
|
|
118
|
+
const r = await client.request('session/new', { cwd: process.cwd(), mcpServers: [] });
|
|
119
|
+
finish(null, r?.models?.availableModels || []);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
finish(e);
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
91
126
|
|
|
92
127
|
/**
|
|
93
|
-
* Returns
|
|
94
|
-
*
|
|
95
|
-
* falls back to the static list on any error (including network timeout).
|
|
128
|
+
* Returns picker-enabled chat models for the signed-in Copilot account.
|
|
129
|
+
* Cache for 30 min. Never throws — falls back to the curated static list.
|
|
96
130
|
*/
|
|
97
131
|
export async function listCopilotModels({ force = false } = {}) {
|
|
98
132
|
if (!force && _cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) {
|
|
@@ -100,35 +134,15 @@ export async function listCopilotModels({ force = false } = {}) {
|
|
|
100
134
|
}
|
|
101
135
|
if (_inflight) return (await _inflight).slice();
|
|
102
136
|
_inflight = (async () => {
|
|
103
|
-
const ac = new AbortController();
|
|
104
|
-
const timer = setTimeout(() => ac.abort(), 5000);
|
|
105
137
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const data = Array.isArray(body?.data) ? body.data : [];
|
|
115
|
-
const models = data
|
|
116
|
-
.filter(m => m && m.model_picker_enabled && m.capabilities?.type === 'chat')
|
|
117
|
-
.map(m => ({
|
|
118
|
-
id: m.id,
|
|
119
|
-
label: m.name || m.id,
|
|
120
|
-
vendor: m.vendor || '',
|
|
121
|
-
preview: !!m.preview,
|
|
122
|
-
family: m.capabilities?.family || '',
|
|
123
|
-
}));
|
|
124
|
-
if (!models.length) return FALLBACK_COPILOT_MODELS.slice();
|
|
125
|
-
_cache = { models, fetchedAt: Date.now() };
|
|
126
|
-
return models.slice();
|
|
127
|
-
} catch {
|
|
128
|
-
return FALLBACK_COPILOT_MODELS.slice();
|
|
129
|
-
} finally {
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
}
|
|
138
|
+
const acpModels = await _probeAcpModels();
|
|
139
|
+
const models = acpModels.map(_normalizeAcpModel).filter(Boolean);
|
|
140
|
+
if (models.length) {
|
|
141
|
+
_cache = { models, fetchedAt: Date.now() };
|
|
142
|
+
return models;
|
|
143
|
+
}
|
|
144
|
+
} catch { /* fall through to static */ }
|
|
145
|
+
return FALLBACK_COPILOT_MODELS.slice();
|
|
132
146
|
})();
|
|
133
147
|
try {
|
|
134
148
|
return (await _inflight).slice();
|
|
@@ -139,3 +153,5 @@ export async function listCopilotModels({ force = false } = {}) {
|
|
|
139
153
|
|
|
140
154
|
/** Tests only. */
|
|
141
155
|
export function _resetCopilotModelsCacheForTests() { _cache = null; _inflight = null; }
|
|
156
|
+
/** Tests only. */
|
|
157
|
+
export function _normalizeAcpModelForTests(m) { return _normalizeAcpModel(m); }
|
package/providers/copilot.js
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'path';
|
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
import ctx from '../context.js';
|
|
8
8
|
import { AcpClient } from './acp-client.js';
|
|
9
|
-
import { listCopilotModels, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
|
|
9
|
+
import { listCopilotModels, DEFAULT_COPILOT_MODEL, cacheCopilotModelsFromAcp } from './copilot-models.js';
|
|
10
10
|
|
|
11
11
|
export const name = 'copilot';
|
|
12
12
|
|
|
@@ -179,6 +179,7 @@ async function _bootAcp(state, resumeSessionId, model) {
|
|
|
179
179
|
state.sessionId = resumeSessionId;
|
|
180
180
|
state.claudeSessionId = resumeSessionId;
|
|
181
181
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
182
|
+
if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
|
|
182
183
|
} else {
|
|
183
184
|
if (resumeSessionId && !state.acpCapabilities.loadSession) {
|
|
184
185
|
// Surface the downgrade — silently handing back a fresh session would
|
|
@@ -196,6 +197,7 @@ async function _bootAcp(state, resumeSessionId, model) {
|
|
|
196
197
|
state.sessionId = r?.sessionId || randomUUID();
|
|
197
198
|
state.claudeSessionId = state.sessionId;
|
|
198
199
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
200
|
+
if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
// 3) Emit a system_init envelope so the UI populates tools / model panels.
|
package/yeaft/init.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
-
// NOTE: migrateSessionsV1 is
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// NOTE: migrateSessionsV1 is called at the end of initYeaftDir() to collapse
|
|
12
|
+
// any legacy groups/ + chats/ + memory/{group,chat}/ data into the unified
|
|
13
|
+
// sessions/ layout. Idempotent via sentinel file.
|
|
14
|
+
import { migrateSessionsV1 } from './migrate/sessions-v1.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Check if an error is a permission error (EACCES or EPERM).
|
|
@@ -221,5 +222,20 @@ export function initYeaftDir(dir) {
|
|
|
221
222
|
// from under the live group/chat code paths. Phase 2 flips the runtime
|
|
222
223
|
// and then hooks `migrateSessionsV1(root)` here.
|
|
223
224
|
|
|
225
|
+
// NOTE: sessions-v1 migration runs at end. Fire-and-log: keep
|
|
226
|
+
// initYeaftDir() sync so existing callers don't break. The migration is
|
|
227
|
+
// idempotent (sentinel file) so a partial run on crash is safe.
|
|
228
|
+
Promise.resolve()
|
|
229
|
+
.then(() => migrateSessionsV1(root))
|
|
230
|
+
.then((res) => {
|
|
231
|
+
if (res && res.migrated) {
|
|
232
|
+
console.log(`[yeaft] session migration complete (${res.moved} dirs moved${res.warnings?.length ? `, ${res.warnings.length} warnings` : ''})`);
|
|
233
|
+
if (res.warnings?.length) for (const w of res.warnings) console.warn(`[yeaft] migration: ${w}`);
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.catch((err) => {
|
|
237
|
+
console.warn(`[yeaft] session migration failed (continuing): ${err?.message || err}`);
|
|
238
|
+
});
|
|
239
|
+
|
|
224
240
|
return { dir: root, created, writable, warnings };
|
|
225
241
|
}
|
|
@@ -43,7 +43,7 @@ const SENTINEL = '.session-migration-v1.done';
|
|
|
43
43
|
* @param {string} yeaftDir
|
|
44
44
|
* @returns {{ migrated: boolean, moved: number, warnings: string[] }}
|
|
45
45
|
*/
|
|
46
|
-
export function migrateSessionsV1(yeaftDir) {
|
|
46
|
+
export async function migrateSessionsV1(yeaftDir) {
|
|
47
47
|
const warnings = [];
|
|
48
48
|
if (!yeaftDir || !existsSync(yeaftDir)) {
|
|
49
49
|
return { migrated: false, moved: 0, warnings: ['yeaftDir missing'] };
|
|
@@ -172,13 +172,32 @@ export function migrateSessionsV1(yeaftDir) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// 6.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
// 6. Rewrite SQLite FTS index scope strings via the shared index-db
|
|
176
|
+
// module (which already handles ABI loading). Idempotent: WHERE clause
|
|
177
|
+
// skips already-rewritten rows.
|
|
178
|
+
try {
|
|
179
|
+
const dbPath = join(memoryRoot, 'index.db');
|
|
180
|
+
if (existsSync(dbPath)) {
|
|
181
|
+
const { openSegmentIndex } = await import('../memory/index-db.js');
|
|
182
|
+
const idx = openSegmentIndex(dbPath);
|
|
183
|
+
try {
|
|
184
|
+
const db = idx._db;
|
|
185
|
+
db.exec("BEGIN");
|
|
186
|
+
db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'group/', 'session/') WHERE scope LIKE 'group/%'");
|
|
187
|
+
db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'chat/', 'session/') WHERE scope LIKE 'chat/%'");
|
|
188
|
+
db.exec("COMMIT");
|
|
189
|
+
} catch (err) {
|
|
190
|
+
try { idx._db.exec("ROLLBACK"); } catch { /* ignore */ }
|
|
191
|
+
warnings.push(`FTS scope rewrite failed: ${err.message}`);
|
|
192
|
+
} finally {
|
|
193
|
+
try { idx.close(); } catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
warnings.push(`FTS rewrite skipped: ${err.message}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 7. sentinel
|
|
182
201
|
writeFileSync(sentinel, JSON.stringify({
|
|
183
202
|
version: 1,
|
|
184
203
|
migratedAt: new Date().toISOString(),
|