@vesk/agentic 0.2.11

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.
Files changed (62) hide show
  1. package/README.md +53 -0
  2. package/dist/checkpoints.d.ts +155 -0
  3. package/dist/checkpoints.d.ts.map +1 -0
  4. package/dist/checkpoints.js +394 -0
  5. package/dist/config.d.ts +57 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +399 -0
  8. package/dist/context.d.ts +21 -0
  9. package/dist/context.d.ts.map +1 -0
  10. package/dist/context.js +64 -0
  11. package/dist/dev-api.d.ts +85 -0
  12. package/dist/dev-api.d.ts.map +1 -0
  13. package/dist/dev-api.js +942 -0
  14. package/dist/index.d.ts +25 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +23 -0
  17. package/dist/loop.d.ts +156 -0
  18. package/dist/loop.d.ts.map +1 -0
  19. package/dist/loop.js +178 -0
  20. package/dist/permissions.d.ts +14 -0
  21. package/dist/permissions.d.ts.map +1 -0
  22. package/dist/permissions.js +74 -0
  23. package/dist/plugin.d.ts +38 -0
  24. package/dist/plugin.d.ts.map +1 -0
  25. package/dist/plugin.js +28 -0
  26. package/dist/providers/anthropic.d.ts +13 -0
  27. package/dist/providers/anthropic.d.ts.map +1 -0
  28. package/dist/providers/anthropic.js +100 -0
  29. package/dist/providers/google.d.ts +12 -0
  30. package/dist/providers/google.d.ts.map +1 -0
  31. package/dist/providers/google.js +87 -0
  32. package/dist/providers/ollama.d.ts +11 -0
  33. package/dist/providers/ollama.d.ts.map +1 -0
  34. package/dist/providers/ollama.js +61 -0
  35. package/dist/providers/openai.d.ts +12 -0
  36. package/dist/providers/openai.d.ts.map +1 -0
  37. package/dist/providers/openai.js +193 -0
  38. package/dist/providers/registry.d.ts +7 -0
  39. package/dist/providers/registry.d.ts.map +1 -0
  40. package/dist/providers/registry.js +33 -0
  41. package/dist/providers/types.d.ts +28 -0
  42. package/dist/providers/types.d.ts.map +1 -0
  43. package/dist/providers/types.js +15 -0
  44. package/dist/slash.d.ts +18 -0
  45. package/dist/slash.d.ts.map +1 -0
  46. package/dist/slash.js +77 -0
  47. package/dist/tools/browser.d.ts +3 -0
  48. package/dist/tools/browser.d.ts.map +1 -0
  49. package/dist/tools/browser.js +289 -0
  50. package/dist/tools/command.d.ts +14 -0
  51. package/dist/tools/command.d.ts.map +1 -0
  52. package/dist/tools/command.js +55 -0
  53. package/dist/tools/fs.d.ts +10 -0
  54. package/dist/tools/fs.d.ts.map +1 -0
  55. package/dist/tools/fs.js +142 -0
  56. package/dist/tools/vesk.d.ts +22 -0
  57. package/dist/tools/vesk.d.ts.map +1 -0
  58. package/dist/tools/vesk.js +828 -0
  59. package/dist/tools/web.d.ts +3 -0
  60. package/dist/tools/web.d.ts.map +1 -0
  61. package/dist/tools/web.js +111 -0
  62. package/package.json +47 -0
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Dev API router — `createAgentRouter` (B6-plug of plans/devtools.md).
3
+ *
4
+ * A self-contained, dependency-injectable router for the agentic
5
+ * dev-panel HTTP endpoints under `/__vesk/agent/*`. Mirrors the shape of
6
+ * `createDevApiRouter` from `@vesk/adapter/src/dev-api.ts`:
7
+ *
8
+ * POST /__vesk/agent/run → run the agent (prompt, mode, providerConfig)
9
+ * GET /__vesk/agent/history → list checkpoints (history.json)
10
+ * POST /__vesk/agent/checkpoint → create a checkpoint
11
+ * POST /__vesk/agent/rollback → rollback to a checkpoint
12
+ *
13
+ * ARCHITECTURE: the Dev Server is the ONLY path from browser → project files /
14
+ * build system. Every endpoint is gated by an `AgentCapability` in
15
+ * `AgentCapabilityTable` (server-enforced — the browser cannot bypass it).
16
+ * There is NO raw `child_process` reach: the agent's `command.execute` tool
17
+ * routes through the dev server's allowlisted `runCommand` hook, and file
18
+ * access is containment-checked.
19
+ *
20
+ * Pure (fake injectable inputs, no socket/listener), mirroring
21
+ * `createPluginStateRouter` / `createDevApiRouter`: returns
22
+ * `{ route(method, pathname, body, search) }`, yielding `null` for
23
+ * non-`/ __vesk/agent/*` paths so the dev server can fall through to the
24
+ * next router (e.g. the adapter's `createDevApiRouter`).
25
+ *
26
+ * Zero deps — only local `@vesk/agentic` modules; no npm dependencies
27
+ * beyond `@vesk/types`.
28
+ */
29
+ import { CheckpointManager } from './checkpoints.js';
30
+ import { loadAgenticConfig, saveAgenticConfig, getApiKey, saveApiKey, providerDotenvVar as defaultVkVar, readDotenvValue } from './config.js';
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import { resolve } from 'node:path';
33
+ import { openAiProvider } from './providers/openai.js';
34
+ import { anthropicProvider } from './providers/anthropic.js';
35
+ import { googleProvider } from './providers/google.js';
36
+ import { ollamaProvider } from './providers/ollama.js';
37
+ import { SLASH_COMMANDS } from './slash.js';
38
+ // ---------------------------------------------------------------------------
39
+ // helpers
40
+ // ---------------------------------------------------------------------------
41
+ function jsonStatus(status, data) {
42
+ return {
43
+ status,
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify(data),
46
+ };
47
+ }
48
+ function sseEvent(type, data) {
49
+ return 'event: ' + type + '\n' + 'data: ' + JSON.stringify(data) + '\n\n';
50
+ }
51
+ function badRequest(message) {
52
+ return jsonStatus(400, { error: message });
53
+ }
54
+ function denied(cap) {
55
+ return jsonStatus(403, { error: `capability denied: ${cap}` });
56
+ }
57
+ // per-provider helpers — shared with config.ts but duplicated here to avoid circular
58
+ // Strongly avoid leaking raw keys — only masked previews leave this module.
59
+ const SUPPORTED_PROVIDERS = [
60
+ 'openai', 'anthropic', 'google', 'ollama',
61
+ 'opencode', 'opencode-go', 'openrouter', 'loopers', 'custom',
62
+ ];
63
+ function maskPreview(key) {
64
+ if (!key)
65
+ return null;
66
+ const trimmed = String(key).trim();
67
+ if (!trimmed)
68
+ return null;
69
+ if (trimmed.length <= 8)
70
+ return '***';
71
+ return trimmed.slice(0, 7) + '***' + trimmed.slice(-4);
72
+ }
73
+ function isValidProvider(provider) {
74
+ const normalized = provider.trim().toLowerCase();
75
+ return SUPPORTED_PROVIDERS.includes(normalized);
76
+ }
77
+ function normalizeProvider(provider) {
78
+ return provider.trim().toLowerCase();
79
+ }
80
+ function sanitizeProviderName(provider) {
81
+ const p = provider.trim().toLowerCase();
82
+ if (!p)
83
+ return 'default';
84
+ return p.replace(/[\/\\]+/g, '_').replace(/[^a-z0-9_.-]/g, '_') || 'default';
85
+ }
86
+ // Precise per-provider key lookup from project .env.local VK_{PROVIDER}_KEY.
87
+ // The CLI loads .env.local into process.env at startup, so process.env is the
88
+ // primary read; a direct file read is a defensive fallback for edits made
89
+ // after startup.
90
+ function getPerProviderKeyRaw(projectDir, provider) {
91
+ const prov = normalizeProvider(provider);
92
+ const dotenvName = defaultVkVar(prov);
93
+ // 1. .env.local VK_*_KEY (mirrored in process.env)
94
+ try {
95
+ const v = process.env[dotenvName];
96
+ if (v && v.trim())
97
+ return v.trim();
98
+ }
99
+ catch { }
100
+ try {
101
+ const df = readDotenvValue(projectDir, dotenvName);
102
+ if (df && df.trim())
103
+ return df.trim();
104
+ }
105
+ catch { }
106
+ // 2. legacy per-provider file .vesk/agentic/keys/{provider}.key (compat)
107
+ try {
108
+ const p = resolve(projectDir, '.vesk', 'agentic', 'keys', `${sanitizeProviderName(prov)}.key`);
109
+ if (existsSync(p)) {
110
+ const v = readFileSync(p, 'utf-8').trim();
111
+ if (v)
112
+ return v;
113
+ }
114
+ }
115
+ catch { }
116
+ return null;
117
+ }
118
+ function buildKeysMap(projectDir) {
119
+ const out = {};
120
+ for (const p of SUPPORTED_PROVIDERS) {
121
+ try {
122
+ const k = getPerProviderKeyRaw(projectDir, p);
123
+ out[p] = !!k && String(k).trim().length > 0;
124
+ }
125
+ catch {
126
+ out[p] = false;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ function buildPreviewsMap(projectDir) {
132
+ const out = {};
133
+ for (const p of SUPPORTED_PROVIDERS) {
134
+ try {
135
+ const k = getPerProviderKeyRaw(projectDir, p);
136
+ out[p] = maskPreview(k);
137
+ }
138
+ catch {
139
+ out[p] = null;
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ function allowsAny(table, caps) {
145
+ for (const cap of caps) {
146
+ try {
147
+ if (table.allows(cap))
148
+ return true;
149
+ }
150
+ catch {
151
+ // ignore
152
+ }
153
+ }
154
+ return false;
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // createAgentRouter
158
+ // ---------------------------------------------------------------------------
159
+ export function createAgentRouter(opts) {
160
+ const projectDir = opts.projectDir;
161
+ const veskDir = opts.veskDir;
162
+ const appDir = opts.appDir;
163
+ void veskDir;
164
+ void appDir;
165
+ // Fallback in-memory store when injections are absent. Each router gets
166
+ // its own manager so concurrent routers do not share state; callers that
167
+ // need persistence should inject `listCheckpoints`/`rollback` closures
168
+ // backed by the filesystem (e.g. old `checkpoints.ts` fs helpers) or a
169
+ // shared CheckpointManager instance.
170
+ const fallbackManager = new CheckpointManager();
171
+ async function route(method, pathname, body, _search) {
172
+ // Only handle /__vesk/agent/* — return null so the dev server can
173
+ // fall through to the adapter router or normal page handling.
174
+ if (!pathname.startsWith('/__vesk/agent/') && pathname !== '/__vesk/agent') {
175
+ if (pathname.startsWith('/__vesk/')) {
176
+ return null;
177
+ }
178
+ return null;
179
+ }
180
+ let table = null;
181
+ try {
182
+ const maybe = opts.getPermissions();
183
+ table = maybe instanceof Promise ? await maybe : maybe;
184
+ }
185
+ catch {
186
+ return jsonStatus(403, { error: 'capability denied: unknown' });
187
+ }
188
+ // ── POST /__vesk/agent/run ──────────────────────────────────────────
189
+ if (pathname === '/__vesk/agent/run') {
190
+ if (method !== 'POST')
191
+ return jsonStatus(405, { error: 'method not allowed' });
192
+ if (!table || typeof table.allows !== 'function') {
193
+ return denied('readFiles');
194
+ }
195
+ try {
196
+ if (!table.allows('readFiles')) {
197
+ return denied('readFiles');
198
+ }
199
+ }
200
+ catch {
201
+ return denied('readFiles');
202
+ }
203
+ const b = (body ?? {});
204
+ const promptRaw = b.prompt ?? b.input ?? b.message ?? b.query;
205
+ const prompt = typeof promptRaw === 'string' ? promptRaw : '';
206
+ if (!prompt || !prompt.trim())
207
+ return badRequest('missing "prompt" in body');
208
+ const rawMode = typeof b.mode === 'string' ? b.mode : table.mode;
209
+ const validModes = ['explore', 'debug', 'agent'];
210
+ const mode = validModes.includes(rawMode)
211
+ ? rawMode
212
+ : table.mode;
213
+ const providerConfig = (b.providerConfig ??
214
+ b.provider ??
215
+ b.config ??
216
+ b.provider_config ??
217
+ undefined);
218
+ // Thread the requested step budget into the run path so a per-request
219
+ // setting (e.g. from the dev-panel UI max-steps control) actually applies.
220
+ const pc = (providerConfig && typeof providerConfig === 'object' ? providerConfig : {});
221
+ if (typeof b.maxSteps === 'number' && Number.isFinite(b.maxSteps))
222
+ pc.maxSteps = b.maxSteps;
223
+ if (typeof opts.runAgent !== 'function') {
224
+ return jsonStatus(503, { error: 'agent runner unavailable' });
225
+ }
226
+ if (b.stream === true && typeof opts.runAgentStream === 'function') {
227
+ const headers = {
228
+ 'Content-Type': 'text/event-stream',
229
+ 'Cache-Control': 'no-cache',
230
+ Connection: 'keep-alive',
231
+ 'X-Accel-Buffering': 'no',
232
+ };
233
+ const generator = (async function* () {
234
+ let events;
235
+ try {
236
+ events = await opts.runAgentStream(prompt, mode, pc);
237
+ }
238
+ catch (e) {
239
+ yield sseEvent('error', { message: e instanceof Error ? e.message : String(e) });
240
+ return;
241
+ }
242
+ try {
243
+ for await (const ev of events) {
244
+ yield sseEvent(ev.type, ev);
245
+ }
246
+ }
247
+ catch (e) {
248
+ yield sseEvent('error', { message: e instanceof Error ? e.message : String(e) });
249
+ }
250
+ })();
251
+ return {
252
+ status: 200,
253
+ headers,
254
+ body: '',
255
+ stream: generator,
256
+ };
257
+ }
258
+ try {
259
+ const result = await opts.runAgent(prompt, mode, pc);
260
+ return jsonStatus(200, { ok: true, result });
261
+ }
262
+ catch (e) {
263
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
264
+ }
265
+ }
266
+ // ── GET /__vesk/agent/history ───────────────────────────────────────
267
+ if (pathname === '/__vesk/agent/history' || pathname === '/__vesk/agent/checkpoints') {
268
+ if (method !== 'GET' && method !== 'HEAD')
269
+ return jsonStatus(405, { error: 'method not allowed' });
270
+ if (!table || typeof table.allows !== 'function') {
271
+ return denied('readFiles');
272
+ }
273
+ try {
274
+ if (!table.allows('readFiles')) {
275
+ return denied('readFiles');
276
+ }
277
+ }
278
+ catch {
279
+ return denied('readFiles');
280
+ }
281
+ try {
282
+ let checkpoints = null;
283
+ if (typeof opts.listCheckpoints === 'function') {
284
+ const injected = opts.listCheckpoints;
285
+ let res;
286
+ try {
287
+ if (injected.length === 0) {
288
+ res = await injected();
289
+ }
290
+ else {
291
+ try {
292
+ res = await injected(projectDir);
293
+ if (!Array.isArray(res)) {
294
+ const alt = await injected();
295
+ if (Array.isArray(alt))
296
+ res = alt;
297
+ }
298
+ }
299
+ catch {
300
+ res = await injected();
301
+ }
302
+ }
303
+ }
304
+ catch {
305
+ res = fallbackManager.listNewestFirst();
306
+ }
307
+ if (Array.isArray(res))
308
+ checkpoints = res;
309
+ else if (res == null)
310
+ checkpoints = [];
311
+ else
312
+ checkpoints = fallbackManager.listNewestFirst();
313
+ }
314
+ else {
315
+ checkpoints = fallbackManager.listNewestFirst();
316
+ }
317
+ if (!Array.isArray(checkpoints))
318
+ checkpoints = [];
319
+ return jsonStatus(200, { checkpoints, history: checkpoints });
320
+ }
321
+ catch (e) {
322
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
323
+ }
324
+ }
325
+ // ── POST /__vesk/agent/checkpoint ───────────────────────────────────
326
+ if (pathname === '/__vesk/agent/checkpoint') {
327
+ if (method !== 'POST')
328
+ return jsonStatus(405, { error: 'method not allowed' });
329
+ if (!table || typeof table.allows !== 'function') {
330
+ return denied('createCheckpoint');
331
+ }
332
+ try {
333
+ if (!table.allows('createCheckpoint')) {
334
+ return denied('createCheckpoint');
335
+ }
336
+ }
337
+ catch {
338
+ return denied('createCheckpoint');
339
+ }
340
+ const b = (body ?? {});
341
+ const message = typeof b.message === 'string'
342
+ ? b.message
343
+ : typeof b.msg === 'string'
344
+ ? b.msg
345
+ : typeof b.prompt === 'string'
346
+ ? b.prompt.slice(0, 200)
347
+ : 'checkpoint';
348
+ const filesRaw = Array.isArray(b.files) ? b.files : undefined;
349
+ const commandsRaw = Array.isArray(b.commands) ? b.commands : undefined;
350
+ const depsRaw = b.deps;
351
+ const prompt = typeof b.prompt === 'string' ? b.prompt : undefined;
352
+ // Normalize to CheckpointManager shapes: files = CheckpointFile[], commands = string[][], deps = {installed,removed}
353
+ const files = filesRaw;
354
+ let commands;
355
+ if (commandsRaw) {
356
+ // commands may be string[][] or array of {argv:string[]} objects
357
+ if (commandsRaw.length > 0 && typeof commandsRaw[0] === 'object' && !Array.isArray(commandsRaw[0])) {
358
+ const cmdObjs = commandsRaw;
359
+ commands = cmdObjs.map((c) => (Array.isArray(c.argv) ? c.argv : []));
360
+ }
361
+ else {
362
+ commands = commandsRaw;
363
+ }
364
+ }
365
+ let deps;
366
+ if (Array.isArray(depsRaw)) {
367
+ deps = { installed: depsRaw, removed: [] };
368
+ }
369
+ else if (depsRaw && typeof depsRaw === 'object') {
370
+ const d = depsRaw;
371
+ if (Array.isArray(d.installed) || Array.isArray(d.removed)) {
372
+ deps = {
373
+ installed: Array.isArray(d.installed) ? d.installed : [],
374
+ removed: Array.isArray(d.removed) ? d.removed : [],
375
+ };
376
+ }
377
+ }
378
+ const buildRaw = b.build ?? b.buildResult;
379
+ const testRaw = b.test ?? b.testResult;
380
+ let build = null;
381
+ if (buildRaw && typeof buildRaw === 'object') {
382
+ const br = buildRaw;
383
+ if (typeof br.ok === 'boolean')
384
+ build = { ok: br.ok, error: typeof br.error === 'string' ? br.error : undefined, ms: typeof br.ms === 'number' ? br.ms : undefined };
385
+ else
386
+ build = null;
387
+ }
388
+ let test = null;
389
+ if (testRaw && typeof testRaw === 'object') {
390
+ const tr = testRaw;
391
+ if (typeof tr.ok === 'boolean')
392
+ test = { ok: tr.ok, error: typeof tr.error === 'string' ? tr.error : undefined };
393
+ }
394
+ try {
395
+ let cp = null;
396
+ const injectedCreate = opts.createCheckpoint;
397
+ if (typeof injectedCreate === 'function') {
398
+ const fn = injectedCreate;
399
+ let res;
400
+ try {
401
+ if (fn.length <= 3) {
402
+ // Try (message, changes, buildResult) and (opts) shapes.
403
+ // First try CreateCheckpointOptions shape: single object
404
+ if (fn.length === 1) {
405
+ const createOpts = { label: message, prompt, files, commands, deps, build, test };
406
+ res = await fn(createOpts);
407
+ }
408
+ else {
409
+ const changes = {};
410
+ if (files)
411
+ changes.files = files;
412
+ if (commands)
413
+ changes.commands = commands;
414
+ if (deps)
415
+ changes.deps = deps;
416
+ if (prompt)
417
+ changes.prompt = prompt;
418
+ res = await fn(message, changes, buildRaw ?? testRaw);
419
+ }
420
+ if (res && typeof res === 'object' && 'id' in res) {
421
+ cp = res;
422
+ }
423
+ else {
424
+ // Try projectDir form
425
+ res = await fn(projectDir, message, { files, commands, deps, prompt }, buildRaw);
426
+ if (res && typeof res === 'object' && 'id' in res) {
427
+ cp = res;
428
+ }
429
+ }
430
+ }
431
+ else {
432
+ res = await fn(projectDir, message, { files, commands, deps, prompt }, buildRaw);
433
+ if (res && typeof res === 'object' && 'id' in res) {
434
+ cp = res;
435
+ }
436
+ else {
437
+ const alt = await fn({ label: message, prompt, files, commands, deps, build, test });
438
+ if (alt && typeof alt === 'object' && 'id' in alt)
439
+ cp = alt;
440
+ }
441
+ }
442
+ }
443
+ catch {
444
+ try {
445
+ const alt = await fn(projectDir, message, { files, commands, deps, prompt }, buildRaw);
446
+ if (alt && typeof alt === 'object' && 'id' in alt)
447
+ cp = alt;
448
+ }
449
+ catch {
450
+ cp = null;
451
+ }
452
+ }
453
+ if (!cp || typeof cp.id !== 'string') {
454
+ cp = fallbackManager.create({ label: message, prompt, files: files, commands, deps, build, test });
455
+ }
456
+ }
457
+ else {
458
+ cp = fallbackManager.create({ label: message, prompt, files: files, commands, deps, build, test });
459
+ }
460
+ return jsonStatus(200, { ok: true, checkpoint: cp });
461
+ }
462
+ catch (e) {
463
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
464
+ }
465
+ }
466
+ // ── POST /__vesk/agent/rollback ─────────────────────────────────────
467
+ if (pathname === '/__vesk/agent/rollback') {
468
+ if (method !== 'POST')
469
+ return jsonStatus(405, { error: 'method not allowed' });
470
+ if (!table || typeof table.allows !== 'function') {
471
+ return denied('rollback');
472
+ }
473
+ try {
474
+ if (!table.allows('rollback')) {
475
+ return denied('rollback');
476
+ }
477
+ }
478
+ catch {
479
+ return denied('rollback');
480
+ }
481
+ const b = (body ?? {});
482
+ const id = typeof b.checkpointId === 'string'
483
+ ? b.checkpointId
484
+ : typeof b.id === 'string'
485
+ ? b.id
486
+ : typeof b.checkpoint_id === 'string'
487
+ ? b.checkpoint_id
488
+ : '';
489
+ if (!id || !id.trim())
490
+ return badRequest('missing "checkpointId" in body');
491
+ if (id.includes('/') || id.includes('\\') || id.includes('..')) {
492
+ return badRequest('invalid checkpointId');
493
+ }
494
+ try {
495
+ let cp = null;
496
+ if (typeof opts.rollback === 'function') {
497
+ const fn = opts.rollback;
498
+ let res;
499
+ try {
500
+ if (fn.length <= 1) {
501
+ res = await fn(id);
502
+ if (res && typeof res === 'object' && 'id' in res) {
503
+ cp = res;
504
+ }
505
+ else if (res && typeof res === 'object' && 'checkpoint' in res) {
506
+ // manager.rollback returns {checkpoint, filesToRestore}
507
+ const r = res;
508
+ if (r.checkpoint && typeof r.checkpoint === 'object' && 'id' in r.checkpoint) {
509
+ cp = r.checkpoint;
510
+ }
511
+ }
512
+ else if (res == null) {
513
+ try {
514
+ const alt = await fn(projectDir, id);
515
+ if (alt && typeof alt === 'object' && 'id' in alt)
516
+ cp = alt;
517
+ else if (alt && typeof alt === 'object' && 'checkpoint' in alt) {
518
+ const r = alt;
519
+ if (r.checkpoint && typeof r.checkpoint === 'object')
520
+ cp = r.checkpoint;
521
+ }
522
+ }
523
+ catch {
524
+ cp = null;
525
+ }
526
+ }
527
+ }
528
+ else {
529
+ res = await fn(projectDir, id);
530
+ if (res && typeof res === 'object' && 'id' in res) {
531
+ cp = res;
532
+ }
533
+ else if (res && typeof res === 'object' && 'checkpoint' in res) {
534
+ const r = res;
535
+ if (r.checkpoint && typeof r.checkpoint === 'object')
536
+ cp = r.checkpoint;
537
+ }
538
+ else if (res == null) {
539
+ try {
540
+ const alt = await fn(id);
541
+ if (alt && typeof alt === 'object' && 'id' in alt)
542
+ cp = alt;
543
+ }
544
+ catch { }
545
+ }
546
+ }
547
+ }
548
+ catch {
549
+ try {
550
+ const alt = await fn(projectDir, id);
551
+ if (alt && typeof alt === 'object' && 'id' in alt)
552
+ cp = alt;
553
+ else if (alt && typeof alt === 'object' && 'checkpoint' in alt) {
554
+ const r = alt;
555
+ if (r.checkpoint)
556
+ cp = r.checkpoint;
557
+ }
558
+ }
559
+ catch { }
560
+ }
561
+ if (cp == null && res === undefined) {
562
+ const found = fallbackManager.get(id);
563
+ if (found)
564
+ cp = found;
565
+ else {
566
+ const rb = fallbackManager.rollback(id);
567
+ if (rb)
568
+ cp = rb.checkpoint;
569
+ }
570
+ }
571
+ }
572
+ else {
573
+ const found = fallbackManager.get(id);
574
+ if (found)
575
+ cp = found;
576
+ else {
577
+ const rb = fallbackManager.rollback(id);
578
+ if (rb)
579
+ cp = rb.checkpoint;
580
+ }
581
+ }
582
+ if (cp == null && typeof opts.rollback === 'function') {
583
+ const found = fallbackManager.get(id);
584
+ if (found)
585
+ cp = found;
586
+ else {
587
+ const rb = fallbackManager.rollback(id);
588
+ if (rb)
589
+ cp = rb.checkpoint;
590
+ }
591
+ }
592
+ if (!cp)
593
+ return jsonStatus(404, { error: `checkpoint not found: ${id}` });
594
+ return jsonStatus(200, { ok: true, checkpoint: cp, rolledBack: true });
595
+ }
596
+ catch (e) {
597
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
598
+ }
599
+ }
600
+ // ── GET /__vesk/agent/models?provider= ———— list models for provider
601
+ if (pathname === '/__vesk/agent/models') {
602
+ if (method !== 'GET' && method !== 'HEAD')
603
+ return jsonStatus(405, { error: 'method not allowed' });
604
+ if (!table || typeof table.allows !== 'function')
605
+ return denied('readFiles');
606
+ try {
607
+ if (!table.allows('readFiles'))
608
+ return denied('readFiles');
609
+ }
610
+ catch {
611
+ return denied('readFiles');
612
+ }
613
+ const params = new URLSearchParams(_search ?? '');
614
+ const provider = (params.get('provider') || params.get('p') || loadAgenticConfig(projectDir).provider);
615
+ const cfg = loadAgenticConfig(projectDir);
616
+ // per-provider key: try provider-specific first, fallback generic
617
+ let apiKey = '';
618
+ try {
619
+ apiKey = getApiKey(projectDir, provider) || getApiKey(projectDir) || '';
620
+ }
621
+ catch {
622
+ apiKey = getApiKey(projectDir) || '';
623
+ }
624
+ const baseUrl = params.get('baseUrl') || cfg.baseUrl;
625
+ let models = [];
626
+ try {
627
+ if (provider === 'openai' || provider === 'opencode' || provider === 'opencode-go' || provider === 'openrouter' || provider === 'loopers' || provider === 'custom') {
628
+ let effectiveBase = baseUrl;
629
+ if (!effectiveBase) {
630
+ if (provider === 'opencode')
631
+ effectiveBase = 'https://opencode.ai/zen/v1';
632
+ else if (provider === 'opencode-go')
633
+ effectiveBase = 'https://opencode.ai/zen/go/v1';
634
+ else if (provider === 'openrouter')
635
+ effectiveBase = 'https://openrouter.ai/api/v1';
636
+ else if (provider === 'loopers')
637
+ effectiveBase = 'http://localhost:8080';
638
+ }
639
+ const p = openAiProvider({ apiKey, baseUrl: effectiveBase });
640
+ models = p.listModels ? await p.listModels({ apiKey, baseUrl: effectiveBase }) : [];
641
+ }
642
+ else if (provider === 'anthropic') {
643
+ const p = anthropicProvider({ apiKey, baseUrl });
644
+ models = p.listModels ? await p.listModels({ apiKey, baseUrl }) : [];
645
+ }
646
+ else if (provider === 'google') {
647
+ const p = googleProvider({ apiKey, baseUrl });
648
+ models = p.listModels ? await p.listModels({ apiKey, baseUrl }) : [];
649
+ }
650
+ else if (provider === 'ollama') {
651
+ const p = ollamaProvider({ baseUrl });
652
+ models = p.listModels ? await p.listModels({ baseUrl }) : [];
653
+ }
654
+ else {
655
+ return badRequest(`unknown provider: ${provider}`);
656
+ }
657
+ }
658
+ catch (e) {
659
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
660
+ }
661
+ return jsonStatus(200, { provider, models });
662
+ }
663
+ // ── GET /__vesk/agent/keys + POST /__vesk/agent/keys ───────────────
664
+ if (pathname === '/__vesk/agent/keys') {
665
+ if (method === 'GET' || method === 'HEAD') {
666
+ if (!table || typeof table.allows !== 'function')
667
+ return denied('readFiles');
668
+ try {
669
+ if (!table.allows('readFiles'))
670
+ return denied('readFiles');
671
+ }
672
+ catch {
673
+ return denied('readFiles');
674
+ }
675
+ const keys = buildKeysMap(projectDir);
676
+ const keyPreviews = buildPreviewsMap(projectDir);
677
+ // compatibility aliases: previews, hasKeys, keyPreview alias already handled
678
+ // never echo raw keys — only hasKey booleans + masked previews
679
+ return jsonStatus(200, {
680
+ keys,
681
+ keyPreviews,
682
+ previews: keyPreviews,
683
+ hasKeys: keys,
684
+ keyPreview: keyPreviews,
685
+ // also include top-level provider-agnostic hint matching old config shape
686
+ hasKey: Object.values(keys).some(Boolean),
687
+ });
688
+ }
689
+ if (method === 'POST') {
690
+ // Setting your own API key is allowed in any mode (readFiles) — it's not a project write
691
+ if (!table || typeof table.allows !== 'function')
692
+ return denied('readFiles');
693
+ try {
694
+ if (!table.allows('readFiles'))
695
+ return denied('readFiles');
696
+ }
697
+ catch {
698
+ return denied('readFiles');
699
+ }
700
+ const b = (body ?? {});
701
+ // accept {provider, apiKey} or {provider, key} or {provider, value}
702
+ const providerRaw = typeof b.provider === 'string' ? b.provider : typeof b.p === 'string' ? b.p : typeof b.name === 'string' ? b.name : '';
703
+ const provider = providerRaw ? normalizeProvider(providerRaw) : '';
704
+ if (!provider)
705
+ return badRequest('missing "provider" in body');
706
+ if (!isValidProvider(provider))
707
+ return badRequest(`unknown provider: ${providerRaw}`);
708
+ // apiKey may be under apiKey | key | value | token
709
+ const apiKeyRaw = typeof b.apiKey === 'string' ? b.apiKey : typeof b.key === 'string' ? b.key : typeof b.value === 'string' ? b.value : typeof b.token === 'string' ? b.token : typeof b.api_key === 'string' ? b.api_key : '';
710
+ if (!apiKeyRaw || !String(apiKeyRaw).trim())
711
+ return badRequest('missing "apiKey" in body');
712
+ const apiKey = String(apiKeyRaw).trim();
713
+ // write via saveApiKey — 3-arg per-provider form (projectDir, provider, apiKey)
714
+ try {
715
+ // config's saveApiKey supports (projectDir, provider, apiKey) when 3 args
716
+ // fallback to 2-arg generic if provider is 'openai' for backwards compat? Keep per-provider always.
717
+ // Detect arity: if saveApiKey length >=3, call 3-arg; else call 2-arg generic + per-file
718
+ // Our config now supports 3-arg, so use it.
719
+ const fn = saveApiKey;
720
+ if (fn.length >= 3) {
721
+ fn(projectDir, provider, apiKey);
722
+ }
723
+ else {
724
+ // old signature — still try per-provider file via direct call
725
+ saveApiKey(projectDir, apiKey);
726
+ // also try 3-arg for new config that may be loaded
727
+ try {
728
+ saveApiKey(projectDir, provider, apiKey);
729
+ }
730
+ catch { }
731
+ }
732
+ }
733
+ catch (e) {
734
+ return jsonStatus(500, { error: e instanceof Error ? e.message : String(e) });
735
+ }
736
+ const preview = maskPreview(apiKey);
737
+ // return ok + hasKey + preview — never raw key
738
+ return jsonStatus(200, {
739
+ ok: true,
740
+ provider,
741
+ hasKey: true,
742
+ preview,
743
+ keyPreview: preview,
744
+ masked: preview,
745
+ keys: buildKeysMap(projectDir),
746
+ keyPreviews: buildPreviewsMap(projectDir),
747
+ });
748
+ }
749
+ return jsonStatus(405, { error: 'method not allowed' });
750
+ }
751
+ // ── GET /__vesk/agent/config + POST /__vesk/agent/config ──
752
+ if (pathname === '/__vesk/agent/config') {
753
+ if (method === 'GET' || method === 'HEAD') {
754
+ if (!table || typeof table.allows !== 'function')
755
+ return denied('readFiles');
756
+ try {
757
+ if (!table.allows('readFiles'))
758
+ return denied('readFiles');
759
+ }
760
+ catch {
761
+ return denied('readFiles');
762
+ }
763
+ const cfg = loadAgenticConfig(projectDir);
764
+ // never leak full key — only masked
765
+ const key = getApiKey(projectDir);
766
+ const masked = maskPreview(key);
767
+ const keys = buildKeysMap(projectDir);
768
+ const keyPreviews = buildPreviewsMap(projectDir);
769
+ return jsonStatus(200, {
770
+ provider: cfg.provider,
771
+ model: cfg.model,
772
+ baseUrl: cfg.baseUrl ?? null,
773
+ mode: cfg.mode,
774
+ maxSteps: cfg.maxSteps,
775
+ hasKey: cfg.hasKey,
776
+ keyPreview: masked,
777
+ // per-provider extensions — never raw keys
778
+ keys,
779
+ hasKeys: keys,
780
+ keyPreviews,
781
+ previews: keyPreviews,
782
+ hasKeyMap: keys,
783
+ previewsMap: keyPreviews,
784
+ });
785
+ }
786
+ if (method === 'POST') {
787
+ const b = (body ?? {});
788
+ // If body is only apiKey(s), allow with readFiles (setting your own key shouldn't require modifyConfig)
789
+ const isOnlyKeys = (typeof b.apiKey === 'string' && Object.keys(b).every(k => ['apiKey', 'provider', 'apiKeys', 'keys', 'keyMap'].includes(k))) || (b.apiKeys && typeof b.apiKeys === 'object');
790
+ let neededCap = 'modifyConfig';
791
+ if (isOnlyKeys)
792
+ neededCap = 'readFiles';
793
+ if (!table || typeof table.allows !== 'function')
794
+ return denied(neededCap);
795
+ try {
796
+ if (!table.allows(neededCap))
797
+ return denied(neededCap);
798
+ }
799
+ catch {
800
+ return denied(neededCap);
801
+ }
802
+ const patch = {};
803
+ if (typeof b.provider === 'string')
804
+ patch.provider = b.provider;
805
+ if (typeof b.model === 'string')
806
+ patch.model = b.model;
807
+ if (typeof b.baseUrl === 'string')
808
+ patch.baseUrl = b.baseUrl;
809
+ if (typeof b.mode === 'string' && ['explore', 'debug', 'agent'].includes(b.mode))
810
+ patch.mode = b.mode;
811
+ if (typeof b.maxSteps === 'number')
812
+ patch.maxSteps = b.maxSteps;
813
+ // backwards compat: single apiKey
814
+ if (typeof b.apiKey === 'string' && b.apiKey.trim()) {
815
+ // For backwards compat, save as generic + also as per-current/new provider if known
816
+ const targetProvider = typeof b.provider === 'string' && b.provider.trim() ? normalizeProvider(b.provider) : loadAgenticConfig(projectDir).provider;
817
+ try {
818
+ // save generic legacy
819
+ saveApiKey(projectDir, b.apiKey.trim());
820
+ }
821
+ catch { }
822
+ // also save per-provider for future GET keys consistency
823
+ try {
824
+ const fn = saveApiKey;
825
+ if (fn.length >= 3 && isValidProvider(targetProvider)) {
826
+ fn(projectDir, targetProvider, b.apiKey.trim());
827
+ }
828
+ }
829
+ catch { }
830
+ }
831
+ // per-provider map: {apiKeys:{openai:"sk-...", anthropic:"..."}}
832
+ const apiKeysRaw = b.apiKeys ?? b.keys ?? b.keyMap;
833
+ if (apiKeysRaw && typeof apiKeysRaw === 'object' && !Array.isArray(apiKeysRaw)) {
834
+ const map = apiKeysRaw;
835
+ for (const [provRaw, val] of Object.entries(map)) {
836
+ if (typeof val !== 'string' || !val.trim())
837
+ continue;
838
+ const prov = normalizeProvider(provRaw);
839
+ if (!isValidProvider(prov))
840
+ continue;
841
+ const keyVal = String(val).trim();
842
+ if (!keyVal)
843
+ continue;
844
+ try {
845
+ const fn = saveApiKey;
846
+ if (fn.length >= 3)
847
+ fn(projectDir, prov, keyVal);
848
+ else {
849
+ // old 2-arg — at least save generic if provider is current, otherwise try 3-arg anyway
850
+ try {
851
+ saveApiKey(projectDir, prov, keyVal);
852
+ }
853
+ catch { }
854
+ if (prov === normalizeProvider(loadAgenticConfig(projectDir).provider)) {
855
+ saveApiKey(projectDir, keyVal);
856
+ }
857
+ }
858
+ }
859
+ catch { }
860
+ }
861
+ }
862
+ // also handle alternative shape: {openaiKey, anthropicKey, ...} or {keys:{...}} already handled
863
+ // also handle direct per-provider fields like b["openai_apiKey"]? Not needed, but be lenient
864
+ // Check for apiKey per provider via body[provider] keys? Skip
865
+ const next = saveAgenticConfig(projectDir, patch);
866
+ // build updated per-provider maps for response (never raw keys)
867
+ const keys = buildKeysMap(projectDir);
868
+ const keyPreviews = buildPreviewsMap(projectDir);
869
+ // masked preview for the current/single key for backward compat
870
+ const curKey = getApiKey(projectDir);
871
+ const curMasked = maskPreview(curKey);
872
+ return jsonStatus(200, {
873
+ ok: true,
874
+ provider: next.provider,
875
+ model: next.model,
876
+ mode: next.mode,
877
+ hasKey: Object.values(keys).some(Boolean) || !!curKey,
878
+ keyPreview: curMasked,
879
+ keys,
880
+ hasKeys: keys,
881
+ keyPreviews,
882
+ previews: keyPreviews,
883
+ });
884
+ }
885
+ return jsonStatus(405, { error: 'method not allowed' });
886
+ }
887
+ // ── GET /__vesk/agent/tools ──
888
+ if (pathname === '/__vesk/agent/tools') {
889
+ if (method !== 'GET' && method !== 'HEAD')
890
+ return jsonStatus(405, { error: 'method not allowed' });
891
+ if (!table || typeof table.allows !== 'function')
892
+ return denied('readFiles');
893
+ try {
894
+ if (!table.allows('readFiles'))
895
+ return denied('readFiles');
896
+ }
897
+ catch {
898
+ return denied('readFiles');
899
+ }
900
+ // Filter tools by permissions: expose what current mode allows
901
+ // We don't have full tool list here without deps, so return capability map + known tool names
902
+ const allTools = [
903
+ { name: 'vesk.inspectProject', description: 'inspect project structure', capability: 'readFiles' },
904
+ { name: 'vesk.inspectComponent', description: 'read component source', capability: 'readFiles' },
905
+ { name: 'vesk.readConfig', description: 'read vesk.config.ts', capability: 'readFiles' },
906
+ { name: 'vesk.updateConfig', description: 'update config', capability: 'modifyConfig' },
907
+ { name: 'vesk.getDiagnostics', description: 'get diagnostics', capability: 'readFiles' },
908
+ { name: 'vesk.runBuild', description: 'run build', capability: 'runBuild' },
909
+ { name: 'vesk.runTests', description: 'run tests', capability: 'runTests' },
910
+ { name: 'vesk.installPlugin', description: 'install plugin', capability: 'installPackages' },
911
+ { name: 'vesk.enablePlugin', description: 'enable plugin', capability: 'managePlugins' },
912
+ { name: 'filesystem.read', description: 'read file', capability: 'readFiles' },
913
+ { name: 'filesystem.write', description: 'write file', capability: 'writeFiles' },
914
+ { name: 'filesystem.delete', description: 'delete file', capability: 'deleteFiles' },
915
+ { name: 'command.execute', description: 'execute allowlisted command', capability: 'executeCommands' },
916
+ { name: 'vesk.createCheckpoint', description: 'create checkpoint', capability: 'createCheckpoint' },
917
+ { name: 'vesk.rollback', description: 'rollback', capability: 'rollback' },
918
+ ];
919
+ const allowed = allTools.filter(t => {
920
+ try {
921
+ return table.allows(t.capability);
922
+ }
923
+ catch {
924
+ return false;
925
+ }
926
+ });
927
+ return jsonStatus(200, { tools: allowed, allTools });
928
+ }
929
+ // ── GET /__vesk/agent/commands ──
930
+ if (pathname === '/__vesk/agent/commands') {
931
+ if (method !== 'GET' && method !== 'HEAD')
932
+ return jsonStatus(405, { error: 'method not allowed' });
933
+ return jsonStatus(200, { commands: SLASH_COMMANDS });
934
+ }
935
+ // ── Unknown /__vesk/agent/* subpath → 404 ────────────────────────────
936
+ if (pathname.startsWith('/__vesk/agent/')) {
937
+ return jsonStatus(404, { error: 'Not found' });
938
+ }
939
+ return null;
940
+ }
941
+ return { route };
942
+ }