bopodev-api 0.1.30 → 0.1.32
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 +8 -4
- package/src/app.ts +4 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +255 -2
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/company-assistant-brain.ts +50 -0
- package/src/services/company-assistant-cli.ts +388 -0
- package/src/services/company-assistant-context-snapshot.ts +287 -0
- package/src/services/company-assistant-llm.ts +375 -0
- package/src/services/company-assistant-service.ts +1012 -0
- package/src/services/company-file-archive-service.ts +444 -0
- package/src/services/company-file-import-service.ts +279 -0
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +105 -1
- package/src/services/template-apply-service.ts +33 -0
- package/src/services/template-catalog.ts +19 -6
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/worker/scheduler.ts +26 -1
|
@@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
getHeartbeatRun,
|
|
6
|
+
listAssistantChatThreadStatsInCreatedAtRange,
|
|
6
7
|
listCompanies,
|
|
7
8
|
listAgents,
|
|
8
9
|
listAuditEvents,
|
|
@@ -10,13 +11,29 @@ import {
|
|
|
10
11
|
listGoals,
|
|
11
12
|
listHeartbeatRunMessages,
|
|
12
13
|
listHeartbeatRuns,
|
|
13
|
-
listPluginRuns
|
|
14
|
+
listPluginRuns,
|
|
15
|
+
listProjects
|
|
14
16
|
} from "bopodev-db";
|
|
15
17
|
import type { AppContext } from "../context";
|
|
16
18
|
import { sendError, sendOk } from "../http";
|
|
17
19
|
import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
|
|
18
20
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
19
|
-
import {
|
|
21
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
22
|
+
import {
|
|
23
|
+
listAgentOperatingMarkdownFiles,
|
|
24
|
+
readAgentOperatingFile,
|
|
25
|
+
writeAgentOperatingFile
|
|
26
|
+
} from "../services/agent-operating-file-service";
|
|
27
|
+
import {
|
|
28
|
+
listAgentMemoryFiles,
|
|
29
|
+
listCompanyMemoryFiles,
|
|
30
|
+
listProjectMemoryFiles,
|
|
31
|
+
loadAgentMemoryContext,
|
|
32
|
+
readAgentMemoryFile,
|
|
33
|
+
readCompanyMemoryFile,
|
|
34
|
+
readProjectMemoryFile,
|
|
35
|
+
writeAgentMemoryFile
|
|
36
|
+
} from "../services/memory-file-service";
|
|
20
37
|
|
|
21
38
|
export function createObservabilityRouter(ctx: AppContext) {
|
|
22
39
|
const router = Router();
|
|
@@ -44,6 +61,53 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
44
61
|
);
|
|
45
62
|
});
|
|
46
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Owner-assistant threads with message counts in `[from, toExclusive)` on message `created_at`.
|
|
66
|
+
* Prefer `from` + `toExclusive` (ISO 8601) so the window matches the browser local month used for cost charts;
|
|
67
|
+
* otherwise `monthKey=YYYY-MM` selects that month in UTC.
|
|
68
|
+
*/
|
|
69
|
+
router.get("/assistant-chat-threads", async (req, res) => {
|
|
70
|
+
const companyId = req.companyId!;
|
|
71
|
+
const fromRaw = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
|
72
|
+
const toRaw = typeof req.query.toExclusive === "string" ? req.query.toExclusive.trim() : "";
|
|
73
|
+
let startInclusive: Date;
|
|
74
|
+
let endExclusive: Date;
|
|
75
|
+
if (fromRaw.length > 0 && toRaw.length > 0) {
|
|
76
|
+
startInclusive = new Date(fromRaw);
|
|
77
|
+
endExclusive = new Date(toRaw);
|
|
78
|
+
if (Number.isNaN(startInclusive.getTime()) || Number.isNaN(endExclusive.getTime())) {
|
|
79
|
+
return sendError(res, "from and toExclusive must be valid ISO 8601 datetimes", 422);
|
|
80
|
+
}
|
|
81
|
+
if (endExclusive.getTime() <= startInclusive.getTime()) {
|
|
82
|
+
return sendError(res, "toExclusive must be after from", 422);
|
|
83
|
+
}
|
|
84
|
+
const maxSpanMs = 120 * 86400000;
|
|
85
|
+
if (endExclusive.getTime() - startInclusive.getTime() > maxSpanMs) {
|
|
86
|
+
return sendError(res, "Date range too large (max 120 days)", 422);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const monthKey = typeof req.query.monthKey === "string" ? req.query.monthKey.trim() : "";
|
|
90
|
+
const match = monthKey.match(/^(\d{4})-(\d{2})$/);
|
|
91
|
+
if (!match) {
|
|
92
|
+
return sendError(res, "Provide from+toExclusive (ISO) or monthKey (YYYY-MM)", 422);
|
|
93
|
+
}
|
|
94
|
+
const year = Number(match[1]);
|
|
95
|
+
const month = Number(match[2]);
|
|
96
|
+
if (month < 1 || month > 12) {
|
|
97
|
+
return sendError(res, "Invalid month in monthKey", 422);
|
|
98
|
+
}
|
|
99
|
+
startInclusive = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0, 0));
|
|
100
|
+
endExclusive = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
|
|
101
|
+
}
|
|
102
|
+
const threads = await listAssistantChatThreadStatsInCreatedAtRange(
|
|
103
|
+
ctx.db,
|
|
104
|
+
companyId,
|
|
105
|
+
startInclusive,
|
|
106
|
+
endExclusive
|
|
107
|
+
);
|
|
108
|
+
return sendOk(res, { threads });
|
|
109
|
+
});
|
|
110
|
+
|
|
47
111
|
router.get("/heartbeats", async (req, res) => {
|
|
48
112
|
const companyId = req.companyId!;
|
|
49
113
|
const rawLimit = Number(req.query.limit ?? 100);
|
|
@@ -240,6 +304,84 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
240
304
|
});
|
|
241
305
|
});
|
|
242
306
|
|
|
307
|
+
router.get("/memory/company/files", async (req, res) => {
|
|
308
|
+
const companyId = req.companyId!;
|
|
309
|
+
const rawLimit = Number(req.query.limit ?? 100);
|
|
310
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
|
|
311
|
+
try {
|
|
312
|
+
const files = await listCompanyMemoryFiles({ companyId, maxFiles: limit });
|
|
313
|
+
return sendOk(res, {
|
|
314
|
+
items: files.map((file) => ({
|
|
315
|
+
relativePath: file.relativePath,
|
|
316
|
+
path: file.path
|
|
317
|
+
}))
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return sendError(res, String(error), 422);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
router.get("/memory/company/file", async (req, res) => {
|
|
325
|
+
const companyId = req.companyId!;
|
|
326
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
327
|
+
if (!relativePath) {
|
|
328
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const file = await readCompanyMemoryFile({ companyId, relativePath });
|
|
332
|
+
return sendOk(res, file);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return sendError(res, String(error), 422);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
router.get("/memory/project/:projectId/files", async (req, res) => {
|
|
339
|
+
const companyId = req.companyId!;
|
|
340
|
+
const projectId = req.params.projectId?.trim() ?? "";
|
|
341
|
+
if (!projectId) {
|
|
342
|
+
return sendError(res, "Missing project id.", 422);
|
|
343
|
+
}
|
|
344
|
+
const projects = await listProjects(ctx.db, companyId);
|
|
345
|
+
if (!projects.some((p) => p.id === projectId)) {
|
|
346
|
+
return sendError(res, "Project not found.", 404);
|
|
347
|
+
}
|
|
348
|
+
const rawLimit = Number(req.query.limit ?? 100);
|
|
349
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
|
|
350
|
+
try {
|
|
351
|
+
const files = await listProjectMemoryFiles({ companyId, projectId, maxFiles: limit });
|
|
352
|
+
return sendOk(res, {
|
|
353
|
+
items: files.map((file) => ({
|
|
354
|
+
relativePath: file.relativePath,
|
|
355
|
+
path: file.path
|
|
356
|
+
}))
|
|
357
|
+
});
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return sendError(res, String(error), 422);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
router.get("/memory/project/:projectId/file", async (req, res) => {
|
|
364
|
+
const companyId = req.companyId!;
|
|
365
|
+
const projectId = req.params.projectId?.trim() ?? "";
|
|
366
|
+
if (!projectId) {
|
|
367
|
+
return sendError(res, "Missing project id.", 422);
|
|
368
|
+
}
|
|
369
|
+
const projects = await listProjects(ctx.db, companyId);
|
|
370
|
+
if (!projects.some((p) => p.id === projectId)) {
|
|
371
|
+
return sendError(res, "Project not found.", 404);
|
|
372
|
+
}
|
|
373
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
374
|
+
if (!relativePath) {
|
|
375
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const file = await readProjectMemoryFile({ companyId, projectId, relativePath });
|
|
379
|
+
return sendOk(res, file);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return sendError(res, String(error), 422);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
243
385
|
router.get("/memory/:agentId/file", async (req, res) => {
|
|
244
386
|
const companyId = req.companyId!;
|
|
245
387
|
const agentId = req.params.agentId;
|
|
@@ -259,6 +401,117 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
259
401
|
}
|
|
260
402
|
});
|
|
261
403
|
|
|
404
|
+
router.put("/memory/:agentId/file", async (req, res) => {
|
|
405
|
+
if (!enforcePermission(req, res, "agents:write")) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const companyId = req.companyId!;
|
|
409
|
+
const agentId = req.params.agentId;
|
|
410
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
411
|
+
if (!relativePath) {
|
|
412
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
413
|
+
}
|
|
414
|
+
const body = req.body as { content?: unknown };
|
|
415
|
+
if (typeof body?.content !== "string") {
|
|
416
|
+
return sendError(res, "Expected JSON body with string 'content'.", 422);
|
|
417
|
+
}
|
|
418
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
419
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
420
|
+
return sendError(res, "Agent not found", 404);
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const result = await writeAgentMemoryFile({
|
|
424
|
+
companyId,
|
|
425
|
+
agentId,
|
|
426
|
+
relativePath,
|
|
427
|
+
content: body.content
|
|
428
|
+
});
|
|
429
|
+
return sendOk(res, result);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return sendError(res, String(error), 422);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
router.get("/agent-operating/:agentId/files", async (req, res) => {
|
|
436
|
+
const companyId = req.companyId!;
|
|
437
|
+
const agentId = req.params.agentId;
|
|
438
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
439
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
440
|
+
return sendError(res, "Agent not found", 404);
|
|
441
|
+
}
|
|
442
|
+
const rawLimit = Number(req.query.limit ?? 100);
|
|
443
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
|
|
444
|
+
try {
|
|
445
|
+
const files = await listAgentOperatingMarkdownFiles({
|
|
446
|
+
companyId,
|
|
447
|
+
agentId,
|
|
448
|
+
maxFiles: limit
|
|
449
|
+
});
|
|
450
|
+
return sendOk(res, {
|
|
451
|
+
items: files.map((file) => ({
|
|
452
|
+
relativePath: file.relativePath,
|
|
453
|
+
path: file.path
|
|
454
|
+
}))
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return sendError(res, String(error), 422);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
router.get("/agent-operating/:agentId/file", async (req, res) => {
|
|
462
|
+
const companyId = req.companyId!;
|
|
463
|
+
const agentId = req.params.agentId;
|
|
464
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
465
|
+
if (!relativePath) {
|
|
466
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
467
|
+
}
|
|
468
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
469
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
470
|
+
return sendError(res, "Agent not found", 404);
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const file = await readAgentOperatingFile({
|
|
474
|
+
companyId,
|
|
475
|
+
agentId,
|
|
476
|
+
relativePath
|
|
477
|
+
});
|
|
478
|
+
return sendOk(res, file);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return sendError(res, String(error), 422);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
router.put("/agent-operating/:agentId/file", async (req, res) => {
|
|
485
|
+
if (!enforcePermission(req, res, "agents:write")) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const companyId = req.companyId!;
|
|
489
|
+
const agentId = req.params.agentId;
|
|
490
|
+
const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
|
|
491
|
+
if (!relativePath) {
|
|
492
|
+
return sendError(res, "Query parameter 'path' is required.", 422);
|
|
493
|
+
}
|
|
494
|
+
const body = req.body as { content?: unknown };
|
|
495
|
+
if (typeof body?.content !== "string") {
|
|
496
|
+
return sendError(res, "Expected JSON body with string 'content'.", 422);
|
|
497
|
+
}
|
|
498
|
+
const agents = await listAgents(ctx.db, companyId);
|
|
499
|
+
if (!agents.some((entry) => entry.id === agentId)) {
|
|
500
|
+
return sendError(res, "Agent not found", 404);
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const result = await writeAgentOperatingFile({
|
|
504
|
+
companyId,
|
|
505
|
+
agentId,
|
|
506
|
+
relativePath,
|
|
507
|
+
content: body.content
|
|
508
|
+
});
|
|
509
|
+
return sendOk(res, result);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
return sendError(res, String(error), 422);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
262
515
|
router.get("/memory/:agentId/context-preview", async (req, res) => {
|
|
263
516
|
const companyId = req.companyId!;
|
|
264
517
|
const agentId = req.params.agentId;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isInsidePath, resolveAgentOperatingPath } from "../lib/instance-paths";
|
|
4
|
+
|
|
5
|
+
const MAX_OBSERVABILITY_FILES = 200;
|
|
6
|
+
const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
|
|
7
|
+
|
|
8
|
+
function isMarkdownFileName(name: string) {
|
|
9
|
+
return name.toLowerCase().endsWith(".md");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function walkMarkdownFiles(root: string, maxFiles: number) {
|
|
13
|
+
const collected: string[] = [];
|
|
14
|
+
const queue = [root];
|
|
15
|
+
while (queue.length > 0 && collected.length < maxFiles) {
|
|
16
|
+
const current = queue.shift();
|
|
17
|
+
if (!current) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const absolutePath = join(current, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
queue.push(absolutePath);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry.isFile() && isMarkdownFileName(entry.name)) {
|
|
28
|
+
collected.push(absolutePath);
|
|
29
|
+
if (collected.length >= maxFiles) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return collected.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function listAgentOperatingMarkdownFiles(input: {
|
|
39
|
+
companyId: string;
|
|
40
|
+
agentId: string;
|
|
41
|
+
maxFiles?: number;
|
|
42
|
+
}) {
|
|
43
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
44
|
+
await mkdir(root, { recursive: true });
|
|
45
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
|
|
46
|
+
const files = await walkMarkdownFiles(root, maxFiles);
|
|
47
|
+
return files.map((filePath) => ({
|
|
48
|
+
path: filePath,
|
|
49
|
+
relativePath: relative(root, filePath),
|
|
50
|
+
operatingRoot: root
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readAgentOperatingFile(input: {
|
|
55
|
+
companyId: string;
|
|
56
|
+
agentId: string;
|
|
57
|
+
relativePath: string;
|
|
58
|
+
}) {
|
|
59
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
60
|
+
await mkdir(root, { recursive: true });
|
|
61
|
+
const candidate = resolve(root, input.relativePath);
|
|
62
|
+
if (!isInsidePath(root, candidate)) {
|
|
63
|
+
throw new Error("Requested operating path is outside of operating root.");
|
|
64
|
+
}
|
|
65
|
+
const info = await stat(candidate);
|
|
66
|
+
if (!info.isFile()) {
|
|
67
|
+
throw new Error("Requested operating path is not a file.");
|
|
68
|
+
}
|
|
69
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
70
|
+
throw new Error("Requested operating file exceeds size limit.");
|
|
71
|
+
}
|
|
72
|
+
const content = await readFile(candidate, "utf8");
|
|
73
|
+
return {
|
|
74
|
+
path: candidate,
|
|
75
|
+
relativePath: relative(root, candidate),
|
|
76
|
+
content,
|
|
77
|
+
sizeBytes: info.size
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function writeAgentOperatingFile(input: {
|
|
82
|
+
companyId: string;
|
|
83
|
+
agentId: string;
|
|
84
|
+
relativePath: string;
|
|
85
|
+
content: string;
|
|
86
|
+
}) {
|
|
87
|
+
const root = resolveAgentOperatingPath(input.companyId, input.agentId);
|
|
88
|
+
await mkdir(root, { recursive: true });
|
|
89
|
+
const normalizedRel = input.relativePath.trim();
|
|
90
|
+
if (!normalizedRel || normalizedRel.includes("..")) {
|
|
91
|
+
throw new Error("Invalid relative path.");
|
|
92
|
+
}
|
|
93
|
+
if (!isMarkdownFileName(normalizedRel)) {
|
|
94
|
+
throw new Error("Only .md files can be written under the operating directory.");
|
|
95
|
+
}
|
|
96
|
+
const candidate = resolve(root, normalizedRel);
|
|
97
|
+
if (!isInsidePath(root, candidate)) {
|
|
98
|
+
throw new Error("Requested operating path is outside of operating root.");
|
|
99
|
+
}
|
|
100
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
101
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
102
|
+
throw new Error("Content exceeds size limit.");
|
|
103
|
+
}
|
|
104
|
+
const parent = dirname(candidate);
|
|
105
|
+
if (!isInsidePath(root, parent)) {
|
|
106
|
+
throw new Error("Invalid parent directory.");
|
|
107
|
+
}
|
|
108
|
+
await mkdir(parent, { recursive: true });
|
|
109
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
110
|
+
const info = await stat(candidate);
|
|
111
|
+
return {
|
|
112
|
+
path: candidate,
|
|
113
|
+
relativePath: relative(root, candidate),
|
|
114
|
+
sizeBytes: info.size
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getAdapterMetadata } from "bopodev-agent-sdk";
|
|
2
|
+
|
|
3
|
+
/** CLI/local runtimes only (no direct API keys in Chat). */
|
|
4
|
+
export const ASK_ASSISTANT_BRAIN_IDS = [
|
|
5
|
+
"claude_code",
|
|
6
|
+
"codex",
|
|
7
|
+
"cursor",
|
|
8
|
+
"opencode",
|
|
9
|
+
"gemini_cli"
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type AskAssistantBrainId = (typeof ASK_ASSISTANT_BRAIN_IDS)[number];
|
|
13
|
+
|
|
14
|
+
export type AskCliBrainId = AskAssistantBrainId;
|
|
15
|
+
|
|
16
|
+
const ASK_BRAIN_SET = new Set<string>(ASK_ASSISTANT_BRAIN_IDS);
|
|
17
|
+
|
|
18
|
+
/** Default when the client omits `brain` (env `BOPO_CHAT_DEFAULT_BRAIN` if set and valid, else codex). */
|
|
19
|
+
export const DEFAULT_ASK_ASSISTANT_BRAIN: AskAssistantBrainId = "codex";
|
|
20
|
+
|
|
21
|
+
const CLI_BRAINS = ASK_BRAIN_SET;
|
|
22
|
+
|
|
23
|
+
export function listAskAssistantBrains() {
|
|
24
|
+
return getAdapterMetadata()
|
|
25
|
+
.filter((m) => ASK_BRAIN_SET.has(m.providerType))
|
|
26
|
+
.map((m) => ({
|
|
27
|
+
providerType: m.providerType,
|
|
28
|
+
label: m.label,
|
|
29
|
+
requiresRuntimeCwd: m.requiresRuntimeCwd
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseAskBrain(raw?: string | null): string {
|
|
34
|
+
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
|
35
|
+
if (!trimmed) {
|
|
36
|
+
const env = process.env.BOPO_CHAT_DEFAULT_BRAIN?.trim();
|
|
37
|
+
if (env && ASK_BRAIN_SET.has(env)) {
|
|
38
|
+
return env;
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_ASK_ASSISTANT_BRAIN;
|
|
41
|
+
}
|
|
42
|
+
if (!ASK_BRAIN_SET.has(trimmed)) {
|
|
43
|
+
throw new Error(`Unsupported assistant brain "${trimmed}".`);
|
|
44
|
+
}
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isAskCliBrain(brain: string): boolean {
|
|
49
|
+
return CLI_BRAINS.has(brain);
|
|
50
|
+
}
|