vectorforce 0.1.0 → 0.2.0

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.
@@ -1,13 +1,25 @@
1
1
  import { join, resolve } from 'node:path';
2
- import { homedir } from 'node:os';
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { homedir, tmpdir } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from 'node:fs';
5
+
6
+ function isTempWorkspace(workspacePath) {
7
+ if (process.env.VF_NO_GLOBAL_CONFIG === '1' || process.env.NODE_TEST_CONTEXT) return true;
8
+ try {
9
+ const realWs = realpathSync(workspacePath);
10
+ const realTmp = realpathSync(tmpdir());
11
+ return realWs.startsWith(realTmp);
12
+ } catch {
13
+ return workspacePath.startsWith(tmpdir()) || workspacePath.includes('vf-test-') || workspacePath.includes('vf-practical-');
14
+ }
15
+ }
4
16
 
5
17
  export class HarnessConfig {
6
18
  /**
7
- * Get the absolute path to bin/vf.js
19
+ * Get the absolute path to bin/vf.js (cross-platform: macOS, Linux, Windows)
8
20
  */
9
21
  static getBinPath() {
10
- return resolve(new URL('../../bin/vf.js', import.meta.url).pathname);
22
+ return fileURLToPath(new URL('../../bin/vf.js', import.meta.url));
11
23
  }
12
24
 
13
25
  /**
@@ -66,6 +78,36 @@ export class HarnessConfig {
66
78
  mkdirSync(dir, { recursive: true, mode: 0o755 });
67
79
  }
68
80
  writeFileSync(filePath, JSON.stringify(mergedContent, null, 2) + '\n', 'utf8');
81
+
82
+ if (!isTempWorkspace(workspacePath)) {
83
+ try {
84
+ const globalAgyDir = join(homedir(), '.gemini', 'antigravity');
85
+ if (existsSync(globalAgyDir)) {
86
+ const globalMcpPath = join(globalAgyDir, 'mcp_config.json');
87
+ let globalConfig = {};
88
+ if (existsSync(globalMcpPath)) {
89
+ try {
90
+ globalConfig = JSON.parse(readFileSync(globalMcpPath, 'utf8'));
91
+ } catch {
92
+ globalConfig = {};
93
+ }
94
+ }
95
+ globalConfig.mcpServers = {
96
+ ...(globalConfig.mcpServers || {}),
97
+ vectorforce: {
98
+ command: 'node',
99
+ args: [HarnessConfig.getBinPath(), 'mcp', '--workspace', workspacePath],
100
+ env: {
101
+ VF_WORKSPACE: workspacePath,
102
+ VF_DB_PATH: join(workspacePath, '.vectorforce', 'vectorforce.db')
103
+ }
104
+ }
105
+ };
106
+ writeFileSync(globalMcpPath, JSON.stringify(globalConfig, null, 2) + '\n', 'utf8');
107
+ }
108
+ } catch {}
109
+ }
110
+
69
111
  return filePath;
70
112
  }
71
113
 
@@ -265,28 +307,30 @@ export class HarnessConfig {
265
307
  writeFileSync(filePath, JSON.stringify(mergedContent, null, 2) + '\n', 'utf8');
266
308
  writeFileSync(museJsonPath, JSON.stringify(mergedContent, null, 2) + '\n', 'utf8');
267
309
 
268
- try {
269
- const globalMuseDir = join(homedir(), '.config', 'muse');
270
- const globalSettingsPath = join(globalMuseDir, 'settings.json');
271
- if (existsSync(globalSettingsPath)) {
272
- const settings = JSON.parse(readFileSync(globalSettingsPath, 'utf8'));
273
- settings.mcp_servers = {
274
- ...(settings.mcp_servers || {}),
275
- vectorforce: {
276
- transport: 'stdio',
277
- command: 'node',
278
- args: [HarnessConfig.getBinPath(), 'mcp'],
279
- env: {
280
- VF_HARNESS: 'muse-code',
281
- VF_WORKSPACE: workspacePath
282
- },
283
- mode: 'optional'
284
- }
285
- };
286
- delete settings.mcpServers;
287
- writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
288
- }
289
- } catch {}
310
+ if (!isTempWorkspace(workspacePath)) {
311
+ try {
312
+ const globalMuseDir = join(homedir(), '.config', 'muse');
313
+ const globalSettingsPath = join(globalMuseDir, 'settings.json');
314
+ if (existsSync(globalSettingsPath)) {
315
+ const settings = JSON.parse(readFileSync(globalSettingsPath, 'utf8'));
316
+ settings.mcp_servers = {
317
+ ...(settings.mcp_servers || {}),
318
+ vectorforce: {
319
+ transport: 'stdio',
320
+ command: 'node',
321
+ args: [HarnessConfig.getBinPath(), 'mcp'],
322
+ env: {
323
+ VF_HARNESS: 'muse-code',
324
+ VF_WORKSPACE: workspacePath
325
+ },
326
+ mode: 'optional'
327
+ }
328
+ };
329
+ delete settings.mcpServers;
330
+ writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
331
+ }
332
+ } catch {}
333
+ }
290
334
 
291
335
  return filePath;
292
336
  }
@@ -322,16 +366,18 @@ export class HarnessConfig {
322
366
  } catch {}
323
367
  }
324
368
 
325
- try {
326
- const globalSettingsPath = join(homedir(), '.config', 'muse', 'settings.json');
327
- if (existsSync(globalSettingsPath)) {
328
- const settings = JSON.parse(readFileSync(globalSettingsPath, 'utf8'));
329
- if (settings?.mcp_servers?.vectorforce) {
330
- delete settings.mcp_servers.vectorforce;
331
- writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
369
+ if (!isTempWorkspace(workspacePath)) {
370
+ try {
371
+ const globalSettingsPath = join(homedir(), '.config', 'muse', 'settings.json');
372
+ if (existsSync(globalSettingsPath)) {
373
+ const settings = JSON.parse(readFileSync(globalSettingsPath, 'utf8'));
374
+ if (settings?.mcp_servers?.vectorforce) {
375
+ delete settings.mcp_servers.vectorforce;
376
+ writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
377
+ }
332
378
  }
333
- }
334
- } catch {}
379
+ } catch {}
380
+ }
335
381
 
336
382
  return removed;
337
383
  }
@@ -355,7 +401,7 @@ export class HarnessConfig {
355
401
  * @returns {string} merged TOML
356
402
  */
357
403
  static _mergeCodexToml(existingToml) {
358
- const binPath = HarnessConfig.getBinPath();
404
+ const binPath = HarnessConfig.getBinPath().replace(/\\/g, '\\\\');
359
405
  const serverBlock = `[mcp_servers.vectorforce]\ncommand = "node"\nargs = ["${binPath}", "mcp"]\n[mcp_servers.vectorforce.env]\nVF_HARNESS = "codex"\n`;
360
406
 
361
407
  if (!existingToml || !existingToml.trim()) {
@@ -434,15 +480,17 @@ export class HarnessConfig {
434
480
  writeFileSync(filePath, mergedContent, 'utf8');
435
481
  writeFileSync(codexJsonPath, JSON.stringify(mergedJson, null, 2) + '\n', 'utf8');
436
482
 
437
- try {
438
- const globalCodex = join(homedir(), '.codex');
439
- if (existsSync(globalCodex)) {
440
- const globalTomlPath = join(globalCodex, 'config.toml');
441
- const existingGlobal = existsSync(globalTomlPath) ? readFileSync(globalTomlPath, 'utf8') : '';
442
- const updatedGlobal = HarnessConfig._mergeCodexToml(existingGlobal);
443
- writeFileSync(globalTomlPath, updatedGlobal, 'utf8');
444
- }
445
- } catch {}
483
+ if (!isTempWorkspace(workspacePath)) {
484
+ try {
485
+ const globalCodex = join(homedir(), '.codex');
486
+ if (existsSync(globalCodex)) {
487
+ const globalTomlPath = join(globalCodex, 'config.toml');
488
+ const existingGlobal = existsSync(globalTomlPath) ? readFileSync(globalTomlPath, 'utf8') : '';
489
+ const updatedGlobal = HarnessConfig._mergeCodexToml(existingGlobal);
490
+ writeFileSync(globalTomlPath, updatedGlobal, 'utf8');
491
+ }
492
+ } catch {}
493
+ }
446
494
 
447
495
  return filePath;
448
496
  }
@@ -9,6 +9,8 @@ export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
9
9
  export const DEFAULT_OPENROUTER_MODEL = 'deepseek/deepseek-v4.1-flash-1017';
10
10
  export const DEFAULT_MUSE_BASE_URL = 'https://api.meta.ai/v1';
11
11
  export const DEFAULT_MUSE_MODEL = 'muse-spark-1.3';
12
+ export const DEFAULT_FIREWORKS_BASE_URL = 'https://api.fireworks.ai/inference/v1';
13
+ export const DEFAULT_FIREWORKS_MODEL = 'accounts/fireworks/models/muse-spark-open';
12
14
  export const DEFAULT_TIMEOUT_MS = 60000;
13
15
 
14
16
  function trimTrailingSlash(url) {
@@ -102,10 +104,11 @@ export function resolveLlmConfig(env = process.env, overrides = {}, workspacePat
102
104
  const provider = (
103
105
  overrides.provider ||
104
106
  mergedEnv.VF_LLM_PROVIDER ||
107
+ (mergedEnv.FIREWORKS_API_KEY || mergedEnv.VF_FIREWORKS_API_KEY ? 'fireworks' :
105
108
  (mergedEnv.OPENROUTER_API_KEY || mergedEnv.VF_OPENROUTER_API_KEY ? 'openrouter' :
106
109
  (mergedEnv.VF_CODEX_API_KEY ? 'codex' :
107
110
  (mergedEnv.MODEL_API_KEY || mergedEnv.MUSE_API_KEY || mergedEnv.VF_MUSE_API_KEY ? 'muse' :
108
- (mergedEnv.VF_LLM_API_KEY ? 'openai' : 'ollama'))))
111
+ (mergedEnv.VF_LLM_API_KEY ? 'openai' : 'ollama')))))
109
112
  ).toLowerCase();
110
113
  const timeoutMs = overrides.timeoutMs ?? (mergedEnv.VF_LLM_TIMEOUT_MS ? Number(mergedEnv.VF_LLM_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS);
111
114
 
@@ -120,6 +123,16 @@ export function resolveLlmConfig(env = process.env, overrides = {}, workspacePat
120
123
  };
121
124
  }
122
125
 
126
+ if (provider === 'fireworks') {
127
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_FIREWORKS_BASE_URL || mergedEnv.FIREWORKS_BASE_URL || mergedEnv.VF_LLM_BASE_URL || DEFAULT_FIREWORKS_BASE_URL);
128
+ const apiKey = overrides.apiKey ?? mergedEnv.FIREWORKS_API_KEY ?? mergedEnv.VF_FIREWORKS_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
129
+ const model = overrides.model || mergedEnv.VF_FIREWORKS_MODEL || mergedEnv.FIREWORKS_MODEL || mergedEnv.VF_LLM_MODEL || DEFAULT_FIREWORKS_MODEL;
130
+ if (!apiKey) {
131
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'Fireworks provider requires FIREWORKS_API_KEY or VF_FIREWORKS_API_KEY');
132
+ }
133
+ return { provider: 'fireworks', baseUrl, apiKey, model, timeoutMs };
134
+ }
135
+
123
136
  if (provider === 'openrouter') {
124
137
  const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_OPENROUTER_BASE_URL || mergedEnv.OPENROUTER_BASE_URL || mergedEnv.VF_LLM_BASE_URL || DEFAULT_OPENROUTER_BASE_URL);
125
138
  const apiKey = overrides.apiKey ?? mergedEnv.OPENROUTER_API_KEY ?? mergedEnv.VF_OPENROUTER_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
@@ -140,10 +153,12 @@ export function resolveLlmConfig(env = process.env, overrides = {}, workspacePat
140
153
  return { provider: 'muse', baseUrl, apiKey, model, timeoutMs };
141
154
  }
142
155
 
143
- if (provider === 'openai' || provider === 'codex') {
144
- const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_CODEX_BASE_URL || mergedEnv.OPENAI_BASE_URL || mergedEnv.VF_LLM_BASE_URL || '');
145
- const apiKey = overrides.apiKey ?? mergedEnv.VF_CODEX_API_KEY ?? mergedEnv.OPENAI_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
146
- const model = overrides.model || mergedEnv.VF_CODEX_MODEL || mergedEnv.VF_LLM_MODEL || DEFAULT_CLOUD_MODEL;
156
+ if (provider === 'openai' || provider === 'codex' || provider === 'anthropic' || provider === 'byok') {
157
+ const defaultBase = provider === 'anthropic' ? 'https://api.anthropic.com/v1' : '';
158
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_CODEX_BASE_URL || mergedEnv.OPENAI_BASE_URL || mergedEnv.ANTHROPIC_BASE_URL || mergedEnv.VF_LLM_BASE_URL || defaultBase);
159
+ const apiKey = overrides.apiKey ?? mergedEnv.VF_CODEX_API_KEY ?? mergedEnv.OPENAI_API_KEY ?? mergedEnv.ANTHROPIC_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
160
+ const defaultModel = provider === 'anthropic' ? 'claude-sonnet-4-20250514' : DEFAULT_CLOUD_MODEL;
161
+ const model = overrides.model || mergedEnv.VF_CODEX_MODEL || mergedEnv.VF_LLM_MODEL || defaultModel;
147
162
  if (!baseUrl) {
148
163
  throw new VectorForceError(
149
164
  ErrorCode.LLM_CONFIG_INVALID,
@@ -158,7 +173,7 @@ export function resolveLlmConfig(env = process.env, overrides = {}, workspacePat
158
173
 
159
174
  throw new VectorForceError(
160
175
  ErrorCode.LLM_CONFIG_INVALID,
161
- `Unknown LLM provider '${provider}'. Use 'openrouter', 'muse', 'codex', 'openai' (cloud) or 'ollama' (local).`
176
+ `Unknown LLM provider '${provider}'. Use 'openrouter', 'muse', 'codex', 'openai', 'anthropic', 'byok' (cloud) or 'ollama' (local).`
162
177
  );
163
178
  }
164
179
 
@@ -1,6 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { join, extname } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { SqliteStore } from '../storage/sqlite-store.js';
6
8
  import { ProjectService } from '../operations/project-service.js';
@@ -11,6 +13,7 @@ import { ReasoningService } from '../operations/reasoning-service.js';
11
13
  import { CodeIndexProvider } from '../providers/code-index-provider.js';
12
14
  import { LlmProvider, resolveLlmConfig } from '../providers/llm-provider.js';
13
15
  import { GitProvider } from '../providers/git-provider.js';
16
+ import { HarnessConfig } from '../mcp/harness-config.js';
14
17
 
15
18
  export function createStudioServer({
16
19
  dbPath,
@@ -319,13 +322,75 @@ export function createStudioServer({
319
322
  req.on('end', async () => {
320
323
  try {
321
324
  const data = JSON.parse(body || '{}');
325
+ const project = projectService.getProjectForWorkspace(workspacePath);
326
+ const allTasks = store.listTasks(project.project_id);
327
+
328
+ let resolvedTaskId = data.taskId;
329
+ if (!resolvedTaskId || !allTasks.some(t => t.task_id === resolvedTaskId)) {
330
+ const activeTask = allTasks.find(t => t.status === 'active') || allTasks[0];
331
+ if (activeTask) {
332
+ resolvedTaskId = activeTask.task_id;
333
+ }
334
+ }
335
+
336
+ let resolvedCheckpointId = data.checkpointId || undefined;
337
+ if (!resolvedCheckpointId && resolvedTaskId) {
338
+ const leaves = store.getLeafCheckpoints(project.project_id, resolvedTaskId);
339
+ if (leaves.length > 1) {
340
+ const sorted = [...leaves].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
341
+ resolvedCheckpointId = sorted[0].checkpoint_id;
342
+ }
343
+ }
344
+
322
345
  const result = handoffService.exportHandoff({
323
346
  workspacePath,
324
- taskId: data.taskId || 'phase-5-pilot',
325
- checkpointId: data.checkpointId || undefined,
347
+ taskId: resolvedTaskId || 'phase-5-pilot',
348
+ checkpointId: resolvedCheckpointId,
326
349
  recipient: data.recipient || 'Collaborator',
327
350
  destinationHarness: data.destinationHarness || 'any'
328
351
  });
352
+
353
+ const bundle = {
354
+ schema_version: 'vectorforce/handoff-bundle/v1',
355
+ package_id: result.package_id,
356
+ created_at: result.handoff?.created_at || new Date().toISOString(),
357
+ manifest: result.manifest,
358
+ handoff: result.handoff
359
+ };
360
+
361
+ res.writeHead(200, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({ ...result, bundle }));
363
+ } catch (err) {
364
+ res.writeHead(500, { 'Content-Type': 'application/json' });
365
+ res.end(JSON.stringify({ error: err.message }));
366
+ }
367
+ });
368
+ return;
369
+ }
370
+
371
+ if (pathname === '/api/handoff/import' && req.method === 'POST') {
372
+ let body = '';
373
+ req.on('data', chunk => { body += chunk; });
374
+ req.on('end', async () => {
375
+ try {
376
+ const data = JSON.parse(body || '{}');
377
+ let targetBundlePath = data.bundlePath;
378
+
379
+ if (!targetBundlePath && data.bundleContent) {
380
+ const handoffsDir = join(homedir(), '.vectorforce', 'handoffs');
381
+ mkdirSync(handoffsDir, { recursive: true, mode: 0o700 });
382
+ const rawStr = typeof data.bundleContent === 'string'
383
+ ? data.bundleContent
384
+ : JSON.stringify(data.bundleContent, null, 2);
385
+ targetBundlePath = join(handoffsDir, `import-${randomUUID().slice(0, 8)}.vfhandoff.json`);
386
+ writeFileSync(targetBundlePath, rawStr, 'utf8');
387
+ }
388
+
389
+ const result = handoffService.importHandoff({
390
+ workspacePath,
391
+ bundlePath: targetBundlePath,
392
+ targetTaskId: data.targetTaskId || undefined
393
+ });
329
394
  res.writeHead(200, { 'Content-Type': 'application/json' });
330
395
  res.end(JSON.stringify(result));
331
396
  } catch (err) {
@@ -380,7 +445,7 @@ export function createStudioServer({
380
445
  requestedDocuments: data.requestedDocuments || [],
381
446
  includeGit: data.includeGit !== false,
382
447
  authorName: data.authorName || 'developer',
383
- authorHarness: 'studio'
448
+ authorHarness: data.authorHarness || 'studio'
384
449
  });
385
450
  res.writeHead(200, { 'Content-Type': 'application/json' });
386
451
  res.end(JSON.stringify(result));
@@ -392,6 +457,249 @@ export function createStudioServer({
392
457
  return;
393
458
  }
394
459
 
460
+ // --- Interactive Studio Configuration & Task/To-Do Endpoints ---
461
+
462
+ if (pathname === '/api/config/llm' && req.method === 'GET') {
463
+ let activeCfg = null;
464
+ let error = null;
465
+ try {
466
+ activeCfg = resolveLlmConfig(process.env, {}, workspacePath);
467
+ } catch (err) {
468
+ error = err.message;
469
+ }
470
+ res.writeHead(200, { 'Content-Type': 'application/json' });
471
+ res.end(JSON.stringify({
472
+ provider: activeCfg?.provider || 'ollama',
473
+ model: activeCfg?.model || 'llama3.3',
474
+ baseUrl: activeCfg?.baseUrl || 'http://127.0.0.1:11434',
475
+ hasApiKey: Boolean(activeCfg?.apiKey),
476
+ error
477
+ }));
478
+ return;
479
+ }
480
+
481
+ if (pathname === '/api/config/llm' && req.method === 'POST') {
482
+ let body = '';
483
+ req.on('data', chunk => { body += chunk; });
484
+ req.on('end', () => {
485
+ try {
486
+ const data = JSON.parse(body || '{}');
487
+ const provider = String(data.provider || 'ollama').toLowerCase().trim();
488
+ const model = String(data.model || '').trim();
489
+ const baseUrl = String(data.baseUrl || '').trim();
490
+ const apiKey = String(data.apiKey || '').trim();
491
+
492
+ const vfDir = join(workspacePath, '.vectorforce');
493
+ mkdirSync(vfDir, { recursive: true, mode: 0o700 });
494
+ const cfgPath = join(vfDir, 'config.json');
495
+ let existing = {};
496
+ if (existsSync(cfgPath)) {
497
+ try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch {}
498
+ }
499
+
500
+ existing.VF_LLM_PROVIDER = provider;
501
+ if (provider === 'fireworks') {
502
+ if (apiKey) existing.FIREWORKS_API_KEY = apiKey;
503
+ existing.VF_FIREWORKS_MODEL = model || 'accounts/fireworks/models/muse-spark-open';
504
+ if (baseUrl) existing.VF_FIREWORKS_BASE_URL = baseUrl;
505
+ } else if (provider === 'muse') {
506
+ if (apiKey) existing.MODEL_API_KEY = apiKey;
507
+ existing.VF_MUSE_MODEL = model || 'muse-spark-1.3';
508
+ if (baseUrl) existing.VF_MUSE_BASE_URL = baseUrl;
509
+ } else if (provider === 'openrouter') {
510
+ if (apiKey) existing.OPENROUTER_API_KEY = apiKey;
511
+ existing.VF_OPENROUTER_MODEL = model || 'meta/muse-spark-open';
512
+ } else if (provider === 'ollama') {
513
+ existing.VF_OLLAMA_MODEL = model || 'llama3.3';
514
+ existing.VF_OLLAMA_BASE_URL = baseUrl || 'http://127.0.0.1:11434';
515
+ } else if (provider === 'openai' || provider === 'codex') {
516
+ if (apiKey) existing.VF_LLM_API_KEY = apiKey;
517
+ existing.VF_LLM_MODEL = model || 'gpt-4.1';
518
+ existing.VF_LLM_BASE_URL = baseUrl || 'https://api.openai.com/v1';
519
+ } else if (provider === 'anthropic') {
520
+ if (apiKey) existing.ANTHROPIC_API_KEY = apiKey;
521
+ existing.VF_ANTHROPIC_MODEL = model || 'claude-sonnet-4-5';
522
+ if (baseUrl) existing.VF_ANTHROPIC_BASE_URL = baseUrl;
523
+ } else if (provider === 'byok') {
524
+ if (apiKey) existing.VF_LLM_API_KEY = apiKey;
525
+ if (model) existing.VF_LLM_MODEL = model;
526
+ if (baseUrl) existing.VF_LLM_BASE_URL = baseUrl;
527
+ }
528
+
529
+ writeFileSync(cfgPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
530
+
531
+ // Hot-reload reasoningService LLM provider
532
+ let resolved = null;
533
+ let resolveErr = null;
534
+ try {
535
+ resolved = resolveLlmConfig(process.env, {}, workspacePath);
536
+ reasoningService.llmProvider = new LlmProvider(resolved);
537
+ } catch (err) {
538
+ resolveErr = err.message;
539
+ }
540
+
541
+ res.writeHead(200, { 'Content-Type': 'application/json' });
542
+ res.end(JSON.stringify({
543
+ ok: true,
544
+ provider: resolved?.provider || provider,
545
+ model: resolved?.model || model,
546
+ baseUrl: resolved?.baseUrl || baseUrl,
547
+ hasApiKey: Boolean(resolved?.apiKey || apiKey),
548
+ error: resolveErr
549
+ }));
550
+ } catch (err) {
551
+ res.writeHead(400, { 'Content-Type': 'application/json' });
552
+ res.end(JSON.stringify({ error: err.message }));
553
+ }
554
+ });
555
+ return;
556
+ }
557
+
558
+ if (pathname === '/api/policy' && req.method === 'POST') {
559
+ let body = '';
560
+ req.on('data', chunk => { body += chunk; });
561
+ req.on('end', () => {
562
+ try {
563
+ const data = JSON.parse(body || '{}');
564
+ const project = projectService.initProject(workspacePath);
565
+ const current = store.getDocumentPolicy(project.project_id);
566
+ let nextList = [...current];
567
+ if (Array.isArray(data.allowedDocuments)) {
568
+ nextList = data.allowedDocuments;
569
+ } else if (data.action === 'remove' && data.path) {
570
+ nextList = current.filter(p => p !== data.path);
571
+ } else if (data.path) {
572
+ if (!nextList.includes(data.path)) nextList.push(data.path);
573
+ }
574
+ const updated = store.setDocumentPolicy(project.project_id, nextList);
575
+ res.writeHead(200, { 'Content-Type': 'application/json' });
576
+ res.end(JSON.stringify({ ok: true, allowedDocuments: updated }));
577
+ } catch (err) {
578
+ res.writeHead(400, { 'Content-Type': 'application/json' });
579
+ res.end(JSON.stringify({ error: err.message }));
580
+ }
581
+ });
582
+ return;
583
+ }
584
+
585
+ if (pathname === '/api/config/harnesses' && req.method === 'POST') {
586
+ try {
587
+ const agy = HarnessConfig.installAntigravity(workspacePath);
588
+ const cmd = HarnessConfig.installCommandCode(workspacePath);
589
+ const muse = HarnessConfig.installMuseCode(workspacePath);
590
+ const codex = HarnessConfig.installCodex(workspacePath);
591
+ res.writeHead(200, { 'Content-Type': 'application/json' });
592
+ res.end(JSON.stringify({
593
+ ok: true,
594
+ installed: [
595
+ { harness: 'Google Antigravity', path: agy },
596
+ { harness: 'Claude Code & Command Code', path: cmd },
597
+ { harness: 'Meta Muse', path: muse },
598
+ { harness: 'OpenAI Codex CLI', path: codex }
599
+ ]
600
+ }));
601
+ } catch (err) {
602
+ res.writeHead(500, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify({ error: err.message }));
604
+ }
605
+ return;
606
+ }
607
+
608
+ if (pathname === '/api/tasks' && req.method === 'POST') {
609
+ let body = '';
610
+ req.on('data', chunk => { body += chunk; });
611
+ req.on('end', () => {
612
+ try {
613
+ const data = JSON.parse(body || '{}');
614
+ if (data.action === 'status' && data.taskId && data.status) {
615
+ const updated = projectService.updateTaskStatus(workspacePath, data.taskId, data.status);
616
+ res.writeHead(200, { 'Content-Type': 'application/json' });
617
+ res.end(JSON.stringify({ ok: true, task: updated }));
618
+ return;
619
+ }
620
+ const taskId = String(data.taskId || '').trim();
621
+ const title = String(data.title || taskId).trim();
622
+ if (!taskId) throw new Error('taskId is required');
623
+ const created = projectService.createTask(workspacePath, taskId, title);
624
+ res.writeHead(201, { 'Content-Type': 'application/json' });
625
+ res.end(JSON.stringify({ ok: true, task: created }));
626
+ } catch (err) {
627
+ res.writeHead(400, { 'Content-Type': 'application/json' });
628
+ res.end(JSON.stringify({ error: err.message }));
629
+ }
630
+ });
631
+ return;
632
+ }
633
+
634
+ if (pathname === '/api/todos' && (req.method === 'GET' || req.method === 'POST')) {
635
+ const vfDir = join(workspacePath, '.vectorforce');
636
+ mkdirSync(vfDir, { recursive: true, mode: 0o700 });
637
+ const todosPath = join(vfDir, 'todos.json');
638
+
639
+ const loadTodos = () => {
640
+ if (existsSync(todosPath)) {
641
+ try { return JSON.parse(readFileSync(todosPath, 'utf8')); } catch {}
642
+ }
643
+ // Auto-seed from latest checkpoint next_steps & goals
644
+ const project = projectService.initProject(workspacePath);
645
+ const cps = store.getCheckpoints(project.project_id, { limit: 10 });
646
+ const latest = cps[cps.length - 1];
647
+ const seeded = [];
648
+ if (latest?.claims?.next_steps) {
649
+ for (const step of latest.claims.next_steps) {
650
+ seeded.push({
651
+ id: `todo-${Math.random().toString(36).slice(2, 9)}`,
652
+ text: step,
653
+ taskId: latest.task_id,
654
+ done: false,
655
+ createdAt: latest.timestamp
656
+ });
657
+ }
658
+ }
659
+ if (seeded.length > 0) {
660
+ writeFileSync(todosPath, JSON.stringify(seeded, null, 2) + '\n', 'utf8');
661
+ }
662
+ return seeded;
663
+ };
664
+
665
+ if (req.method === 'GET') {
666
+ res.writeHead(200, { 'Content-Type': 'application/json' });
667
+ res.end(JSON.stringify({ todos: loadTodos() }));
668
+ return;
669
+ }
670
+
671
+ let body = '';
672
+ req.on('data', chunk => { body += chunk; });
673
+ req.on('end', () => {
674
+ try {
675
+ const data = JSON.parse(body || '{}');
676
+ let todos = loadTodos();
677
+ if (data.action === 'add' && data.text) {
678
+ todos.unshift({
679
+ id: `todo-${Date.now().toString(36)}`,
680
+ text: String(data.text).trim(),
681
+ taskId: data.taskId || 'main',
682
+ done: false,
683
+ createdAt: new Date().toISOString()
684
+ });
685
+ } else if (data.action === 'toggle' && data.id) {
686
+ todos = todos.map(t => t.id === data.id ? { ...t, done: !t.done } : t);
687
+ } else if (data.action === 'delete' && data.id) {
688
+ todos = todos.filter(t => t.id !== data.id);
689
+ } else if (Array.isArray(data.todos)) {
690
+ todos = data.todos;
691
+ }
692
+ writeFileSync(todosPath, JSON.stringify(todos, null, 2) + '\n', 'utf8');
693
+ res.writeHead(200, { 'Content-Type': 'application/json' });
694
+ res.end(JSON.stringify({ ok: true, todos }));
695
+ } catch (err) {
696
+ res.writeHead(400, { 'Content-Type': 'application/json' });
697
+ res.end(JSON.stringify({ error: err.message }));
698
+ }
699
+ });
700
+ return;
701
+ }
702
+
395
703
  // Serve static assets from studio/dist for standalone Studio UI
396
704
  const bundledDist = fileURLToPath(new URL('../../studio/dist', import.meta.url));
397
705
  const workspaceDist = join(workspacePath, 'studio', 'dist');