bopodev-api 0.1.31 → 0.1.33

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.
@@ -0,0 +1,109 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import {
4
+ createAssistantThread,
5
+ getAssistantThreadById,
6
+ getOrCreateAssistantThread,
7
+ listAssistantMessages
8
+ } from "bopodev-db";
9
+ import type { AppContext } from "../context";
10
+ import { sendError, sendOk } from "../http";
11
+ import { requireCompanyScope } from "../middleware/company-scope";
12
+ import { ASK_ASSISTANT_BRAIN_IDS, listAskAssistantBrains } from "../services/company-assistant-brain";
13
+ import { getCompanyCeoPersona, runCompanyAssistantTurn } from "../services/company-assistant-service";
14
+
15
+ const brainEnum = z.enum(ASK_ASSISTANT_BRAIN_IDS);
16
+
17
+ const postMessageSchema = z.object({
18
+ message: z.string().trim().min(1).max(16_000),
19
+ /** Adapter / runtime used to answer (same catalog as hiring an agent). */
20
+ brain: brainEnum.optional(),
21
+ /** Active chat thread; omit to use latest-or-create for the company. */
22
+ threadId: z.string().trim().min(1).optional()
23
+ });
24
+
25
+ export function createAssistantRouter(ctx: AppContext) {
26
+ const router = Router();
27
+ router.use(requireCompanyScope);
28
+
29
+ router.get("/brains", (_req, res) => {
30
+ return sendOk(res, { brains: listAskAssistantBrains() });
31
+ });
32
+
33
+ router.get("/messages", async (req, res) => {
34
+ const companyId = req.companyId!;
35
+ const qThread =
36
+ typeof req.query.threadId === "string" && req.query.threadId.trim() ? req.query.threadId.trim() : "";
37
+ let thread;
38
+ if (qThread) {
39
+ const found = await getAssistantThreadById(ctx.db, companyId, qThread);
40
+ if (!found) {
41
+ return sendError(res, "Chat thread not found.", 404);
42
+ }
43
+ thread = found;
44
+ } else {
45
+ thread = await getOrCreateAssistantThread(ctx.db, companyId);
46
+ }
47
+ const rawLimit = Number(req.query.limit ?? 100);
48
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 100;
49
+ const rows = await listAssistantMessages(ctx.db, thread.id, limit);
50
+ const ceoPersona = await getCompanyCeoPersona(ctx.db, companyId);
51
+ return sendOk(res, {
52
+ threadId: thread.id,
53
+ ceoPersona,
54
+ messages: rows.map((m) => ({
55
+ id: m.id,
56
+ role: m.role,
57
+ body: m.body,
58
+ createdAt: m.createdAt instanceof Date ? m.createdAt.toISOString() : String(m.createdAt),
59
+ metadata: m.metadataJson ? safeJsonParse(m.metadataJson) : null
60
+ }))
61
+ });
62
+ });
63
+
64
+ router.post("/messages", async (req, res) => {
65
+ const parsed = postMessageSchema.safeParse(req.body);
66
+ if (!parsed.success) {
67
+ return sendError(res, parsed.error.message, 422);
68
+ }
69
+ const companyId = req.companyId!;
70
+ const actor = req.actor;
71
+ const auditActorType =
72
+ actor?.type === "agent" ? "agent" : actor?.type === "board" || actor?.type === "member" ? "human" : "human";
73
+ const actorId = actor?.id?.trim() || "unknown";
74
+ try {
75
+ const result = await runCompanyAssistantTurn({
76
+ db: ctx.db,
77
+ companyId,
78
+ userMessage: parsed.data.message,
79
+ actorType: auditActorType,
80
+ actorId,
81
+ brain: parsed.data.brain,
82
+ threadId: parsed.data.threadId
83
+ });
84
+ return sendOk(res, result);
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ if (message.includes("Missing API key")) {
88
+ return sendError(res, message, 503);
89
+ }
90
+ return sendError(res, message, 422);
91
+ }
92
+ });
93
+
94
+ router.post("/threads", async (req, res) => {
95
+ const companyId = req.companyId!;
96
+ const thread = await createAssistantThread(ctx.db, companyId);
97
+ return sendOk(res, { threadId: thread.id });
98
+ });
99
+
100
+ return router;
101
+ }
102
+
103
+ function safeJsonParse(raw: string): unknown {
104
+ try {
105
+ return JSON.parse(raw) as unknown;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import type { NextFunction, Request, Response } from "express";
3
3
  import { Router } from "express";
4
+ import multer from "multer";
4
5
  import { z } from "zod";
5
6
  import { CompanySchema } from "bopodev-contracts";
6
7
  import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
@@ -10,11 +11,29 @@ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigTo
10
11
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
11
12
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
12
13
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
13
- import { buildCompanyPortabilityExport } from "../services/company-export-service";
14
14
  import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
15
+ import {
16
+ CompanyFileArchiveError,
17
+ listCompanyExportManifest,
18
+ normalizeExportPath,
19
+ pipeCompanyExportZip,
20
+ readCompanyExportFileText
21
+ } from "../services/company-file-archive-service";
22
+ import { buildCompanyPortabilityExport } from "../services/company-export-service";
23
+ import { CompanyFileImportError, importCompanyFromZipBuffer } from "../services/company-file-import-service";
15
24
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
16
25
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
17
26
 
27
+ const zipUpload = multer({
28
+ storage: multer.memoryStorage(),
29
+ limits: { fileSize: 80 * 1024 * 1024 }
30
+ });
31
+
32
+ const exportZipBodySchema = z.object({
33
+ paths: z.array(z.string()).nullable().optional(),
34
+ includeAgentMemory: z.boolean().optional().default(false)
35
+ });
36
+
18
37
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
19
38
  const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
20
39
 
@@ -54,6 +73,98 @@ export function createCompaniesRouter(ctx: AppContext) {
54
73
  );
55
74
  });
56
75
 
76
+ router.post("/import/files", requireBoardRole, zipUpload.single("archive"), async (req, res) => {
77
+ const file = req.file;
78
+ if (!file?.buffer) {
79
+ return sendError(res, 'Upload a .zip file in field "archive".', 422);
80
+ }
81
+ try {
82
+ const result = await importCompanyFromZipBuffer(ctx.db, file.buffer);
83
+ return sendOk(res, result);
84
+ } catch (err) {
85
+ const message = err instanceof CompanyFileImportError ? err.message : String(err);
86
+ return sendError(res, message, 422);
87
+ }
88
+ });
89
+
90
+ router.get("/:companyId/export/files/manifest", async (req, res) => {
91
+ const companyId = readCompanyIdParam(req);
92
+ if (!companyId) {
93
+ return sendError(res, "Missing company id.", 422);
94
+ }
95
+ if (!canAccessCompany(req, companyId)) {
96
+ return sendError(res, "Actor does not have access to this company.", 403);
97
+ }
98
+ const includeAgentMemory = req.query.includeAgentMemory === "1" || req.query.includeAgentMemory === "true";
99
+ try {
100
+ const files = await listCompanyExportManifest(ctx.db, companyId, { includeAgentMemory });
101
+ return sendOk(res, { files, includeAgentMemory });
102
+ } catch (err) {
103
+ const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
104
+ return sendError(res, message, 422);
105
+ }
106
+ });
107
+
108
+ router.get("/:companyId/export/files/preview", async (req, res) => {
109
+ const companyId = readCompanyIdParam(req);
110
+ if (!companyId) {
111
+ return sendError(res, "Missing company id.", 422);
112
+ }
113
+ if (!canAccessCompany(req, companyId)) {
114
+ return sendError(res, "Actor does not have access to this company.", 403);
115
+ }
116
+ const pathRaw = typeof req.query.path === "string" ? req.query.path : "";
117
+ const includeAgentMemory = req.query.includeAgentMemory === "1" || req.query.includeAgentMemory === "true";
118
+ const normalizedPath = normalizeExportPath(pathRaw);
119
+ if (!normalizedPath) {
120
+ return sendError(res, "Invalid or missing path query parameter.", 422);
121
+ }
122
+ try {
123
+ const preview = await readCompanyExportFileText(ctx.db, companyId, normalizedPath, { includeAgentMemory });
124
+ if (!preview) {
125
+ return sendError(res, "File not found in export manifest.", 404);
126
+ }
127
+ res.setHeader("content-type", "text/plain; charset=utf-8");
128
+ return res.status(200).send(preview.content);
129
+ } catch (err) {
130
+ const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
131
+ return sendError(res, message, 422);
132
+ }
133
+ });
134
+
135
+ router.post("/:companyId/export/files/zip", async (req, res) => {
136
+ const companyId = readCompanyIdParam(req);
137
+ if (!companyId) {
138
+ return sendError(res, "Missing company id.", 422);
139
+ }
140
+ if (!canAccessCompany(req, companyId)) {
141
+ return sendError(res, "Actor does not have access to this company.", 403);
142
+ }
143
+ const parsed = exportZipBodySchema.safeParse(req.body ?? {});
144
+ if (!parsed.success) {
145
+ return sendError(res, parsed.error.message, 422);
146
+ }
147
+ try {
148
+ const stream = await pipeCompanyExportZip(ctx.db, companyId, {
149
+ paths: parsed.data.paths ?? null,
150
+ includeAgentMemory: parsed.data.includeAgentMemory
151
+ });
152
+ res.setHeader("Content-Type", "application/zip");
153
+ res.setHeader("Content-Disposition", `attachment; filename="company-${companyId}-export.zip"`);
154
+ stream.on("error", () => {
155
+ if (!res.headersSent) {
156
+ sendError(res, "Zip stream failed.", 500);
157
+ } else {
158
+ res.end();
159
+ }
160
+ });
161
+ stream.pipe(res);
162
+ } catch (err) {
163
+ const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
164
+ return sendError(res, message, 422);
165
+ }
166
+ });
167
+
57
168
  router.get("/:companyId/export", async (req, res) => {
58
169
  const companyId = readCompanyIdParam(req);
59
170
  if (!companyId) {
@@ -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,7 +11,8 @@ 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";
@@ -22,10 +24,25 @@ import {
22
24
  readAgentOperatingFile,
23
25
  writeAgentOperatingFile
24
26
  } from "../services/agent-operating-file-service";
27
+ import { BUILTIN_BOPO_SKILLS } from "../lib/builtin-bopo-skills";
28
+ import {
29
+ createCompanySkillPackage,
30
+ deleteCompanySkillPackage,
31
+ linkCompanySkillFromUrl,
32
+ listCompanySkillFiles,
33
+ listCompanySkillPackages,
34
+ refreshCompanySkillFromUrl,
35
+ readCompanySkillFile,
36
+ writeCompanySkillFile
37
+ } from "../services/company-skill-file-service";
25
38
  import {
26
39
  listAgentMemoryFiles,
40
+ listCompanyMemoryFiles,
41
+ listProjectMemoryFiles,
27
42
  loadAgentMemoryContext,
28
43
  readAgentMemoryFile,
44
+ readCompanyMemoryFile,
45
+ readProjectMemoryFile,
29
46
  writeAgentMemoryFile
30
47
  } from "../services/memory-file-service";
31
48
 
@@ -55,6 +72,53 @@ export function createObservabilityRouter(ctx: AppContext) {
55
72
  );
56
73
  });
57
74
 
75
+ /**
76
+ * Owner-assistant threads with message counts in `[from, toExclusive)` on message `created_at`.
77
+ * Prefer `from` + `toExclusive` (ISO 8601) so the window matches the browser local month used for cost charts;
78
+ * otherwise `monthKey=YYYY-MM` selects that month in UTC.
79
+ */
80
+ router.get("/assistant-chat-threads", async (req, res) => {
81
+ const companyId = req.companyId!;
82
+ const fromRaw = typeof req.query.from === "string" ? req.query.from.trim() : "";
83
+ const toRaw = typeof req.query.toExclusive === "string" ? req.query.toExclusive.trim() : "";
84
+ let startInclusive: Date;
85
+ let endExclusive: Date;
86
+ if (fromRaw.length > 0 && toRaw.length > 0) {
87
+ startInclusive = new Date(fromRaw);
88
+ endExclusive = new Date(toRaw);
89
+ if (Number.isNaN(startInclusive.getTime()) || Number.isNaN(endExclusive.getTime())) {
90
+ return sendError(res, "from and toExclusive must be valid ISO 8601 datetimes", 422);
91
+ }
92
+ if (endExclusive.getTime() <= startInclusive.getTime()) {
93
+ return sendError(res, "toExclusive must be after from", 422);
94
+ }
95
+ const maxSpanMs = 120 * 86400000;
96
+ if (endExclusive.getTime() - startInclusive.getTime() > maxSpanMs) {
97
+ return sendError(res, "Date range too large (max 120 days)", 422);
98
+ }
99
+ } else {
100
+ const monthKey = typeof req.query.monthKey === "string" ? req.query.monthKey.trim() : "";
101
+ const match = monthKey.match(/^(\d{4})-(\d{2})$/);
102
+ if (!match) {
103
+ return sendError(res, "Provide from+toExclusive (ISO) or monthKey (YYYY-MM)", 422);
104
+ }
105
+ const year = Number(match[1]);
106
+ const month = Number(match[2]);
107
+ if (month < 1 || month > 12) {
108
+ return sendError(res, "Invalid month in monthKey", 422);
109
+ }
110
+ startInclusive = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0, 0));
111
+ endExclusive = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
112
+ }
113
+ const threads = await listAssistantChatThreadStatsInCreatedAtRange(
114
+ ctx.db,
115
+ companyId,
116
+ startInclusive,
117
+ endExclusive
118
+ );
119
+ return sendOk(res, { threads });
120
+ });
121
+
58
122
  router.get("/heartbeats", async (req, res) => {
59
123
  const companyId = req.companyId!;
60
124
  const rawLimit = Number(req.query.limit ?? 100);
@@ -251,6 +315,84 @@ export function createObservabilityRouter(ctx: AppContext) {
251
315
  });
252
316
  });
253
317
 
318
+ router.get("/memory/company/files", async (req, res) => {
319
+ const companyId = req.companyId!;
320
+ const rawLimit = Number(req.query.limit ?? 100);
321
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
322
+ try {
323
+ const files = await listCompanyMemoryFiles({ companyId, maxFiles: limit });
324
+ return sendOk(res, {
325
+ items: files.map((file) => ({
326
+ relativePath: file.relativePath,
327
+ path: file.path
328
+ }))
329
+ });
330
+ } catch (error) {
331
+ return sendError(res, String(error), 422);
332
+ }
333
+ });
334
+
335
+ router.get("/memory/company/file", async (req, res) => {
336
+ const companyId = req.companyId!;
337
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
338
+ if (!relativePath) {
339
+ return sendError(res, "Query parameter 'path' is required.", 422);
340
+ }
341
+ try {
342
+ const file = await readCompanyMemoryFile({ companyId, relativePath });
343
+ return sendOk(res, file);
344
+ } catch (error) {
345
+ return sendError(res, String(error), 422);
346
+ }
347
+ });
348
+
349
+ router.get("/memory/project/:projectId/files", async (req, res) => {
350
+ const companyId = req.companyId!;
351
+ const projectId = req.params.projectId?.trim() ?? "";
352
+ if (!projectId) {
353
+ return sendError(res, "Missing project id.", 422);
354
+ }
355
+ const projects = await listProjects(ctx.db, companyId);
356
+ if (!projects.some((p) => p.id === projectId)) {
357
+ return sendError(res, "Project not found.", 404);
358
+ }
359
+ const rawLimit = Number(req.query.limit ?? 100);
360
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
361
+ try {
362
+ const files = await listProjectMemoryFiles({ companyId, projectId, maxFiles: limit });
363
+ return sendOk(res, {
364
+ items: files.map((file) => ({
365
+ relativePath: file.relativePath,
366
+ path: file.path
367
+ }))
368
+ });
369
+ } catch (error) {
370
+ return sendError(res, String(error), 422);
371
+ }
372
+ });
373
+
374
+ router.get("/memory/project/:projectId/file", async (req, res) => {
375
+ const companyId = req.companyId!;
376
+ const projectId = req.params.projectId?.trim() ?? "";
377
+ if (!projectId) {
378
+ return sendError(res, "Missing project id.", 422);
379
+ }
380
+ const projects = await listProjects(ctx.db, companyId);
381
+ if (!projects.some((p) => p.id === projectId)) {
382
+ return sendError(res, "Project not found.", 404);
383
+ }
384
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
385
+ if (!relativePath) {
386
+ return sendError(res, "Query parameter 'path' is required.", 422);
387
+ }
388
+ try {
389
+ const file = await readProjectMemoryFile({ companyId, projectId, relativePath });
390
+ return sendOk(res, file);
391
+ } catch (error) {
392
+ return sendError(res, String(error), 422);
393
+ }
394
+ });
395
+
254
396
  router.get("/memory/:agentId/file", async (req, res) => {
255
397
  const companyId = req.companyId!;
256
398
  const agentId = req.params.agentId;
@@ -381,6 +523,162 @@ export function createObservabilityRouter(ctx: AppContext) {
381
523
  }
382
524
  });
383
525
 
526
+ router.get("/builtin-skills", async (_req, res) => {
527
+ return sendOk(
528
+ res,
529
+ BUILTIN_BOPO_SKILLS.map((row) => ({
530
+ id: row.id,
531
+ title: row.title,
532
+ content: row.content
533
+ }))
534
+ );
535
+ });
536
+
537
+ router.get("/company-skills", async (req, res) => {
538
+ const companyId = req.companyId!;
539
+ try {
540
+ const { items: packages } = await listCompanySkillPackages({ companyId, maxSkills: 80 });
541
+ const items = await Promise.all(
542
+ packages.map(async (pack) => {
543
+ const { relativePaths, hasLocalSkillMd } = await listCompanySkillFiles({
544
+ companyId,
545
+ skillId: pack.skillId,
546
+ maxFiles: 200
547
+ });
548
+ return {
549
+ skillId: pack.skillId,
550
+ linkedUrl: pack.linkedUrl,
551
+ linkLastFetchedAt: pack.linkLastFetchedAt,
552
+ hasLocalSkillMd,
553
+ files: relativePaths.map((relativePath) => ({ relativePath }))
554
+ };
555
+ })
556
+ );
557
+ return sendOk(res, { items });
558
+ } catch (error) {
559
+ return sendError(res, String(error), 422);
560
+ }
561
+ });
562
+
563
+ router.get("/company-skills/file", async (req, res) => {
564
+ const companyId = req.companyId!;
565
+ const skillId = typeof req.query.skillId === "string" ? req.query.skillId.trim() : "";
566
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
567
+ if (!skillId || !relativePath) {
568
+ return sendError(res, "Query parameters 'skillId' and 'path' are required.", 422);
569
+ }
570
+ try {
571
+ const file = await readCompanySkillFile({ companyId, skillId, relativePath });
572
+ return sendOk(res, { content: file.content });
573
+ } catch (error) {
574
+ return sendError(res, String(error), 422);
575
+ }
576
+ });
577
+
578
+ router.put("/company-skills/file", async (req, res) => {
579
+ if (!enforcePermission(req, res, "agents:write")) {
580
+ return;
581
+ }
582
+ const companyId = req.companyId!;
583
+ const skillId = typeof req.query.skillId === "string" ? req.query.skillId.trim() : "";
584
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
585
+ if (!skillId || !relativePath) {
586
+ return sendError(res, "Query parameters 'skillId' and 'path' are required.", 422);
587
+ }
588
+ const body = req.body as { content?: unknown };
589
+ if (typeof body?.content !== "string") {
590
+ return sendError(res, "Expected JSON body with string 'content'.", 422);
591
+ }
592
+ try {
593
+ const result = await writeCompanySkillFile({
594
+ companyId,
595
+ skillId,
596
+ relativePath,
597
+ content: body.content
598
+ });
599
+ return sendOk(res, result);
600
+ } catch (error) {
601
+ return sendError(res, String(error), 422);
602
+ }
603
+ });
604
+
605
+ router.post("/company-skills/create", async (req, res) => {
606
+ if (!enforcePermission(req, res, "agents:write")) {
607
+ return;
608
+ }
609
+ const companyId = req.companyId!;
610
+ const body = req.body as { skillId?: unknown };
611
+ if (typeof body?.skillId !== "string" || !body.skillId.trim()) {
612
+ return sendError(res, "Expected JSON body with string 'skillId'.", 422);
613
+ }
614
+ try {
615
+ const result = await createCompanySkillPackage({ companyId, skillId: body.skillId });
616
+ return sendOk(res, result);
617
+ } catch (error) {
618
+ return sendError(res, String(error), 422);
619
+ }
620
+ });
621
+
622
+ router.post("/company-skills/link-url", async (req, res) => {
623
+ if (!enforcePermission(req, res, "agents:write")) {
624
+ return;
625
+ }
626
+ const companyId = req.companyId!;
627
+ const body = req.body as { url?: unknown; skillId?: unknown };
628
+ if (typeof body?.url !== "string" || !body.url.trim()) {
629
+ return sendError(res, "Expected JSON body with string 'url'.", 422);
630
+ }
631
+ const optionalSkillId =
632
+ typeof body.skillId === "string" && body.skillId.trim() ? body.skillId.trim() : undefined;
633
+ try {
634
+ const result = await linkCompanySkillFromUrl({
635
+ companyId,
636
+ url: body.url,
637
+ ...(optionalSkillId ? { skillId: optionalSkillId } : {})
638
+ });
639
+ return sendOk(res, result);
640
+ } catch (error) {
641
+ return sendError(res, String(error), 422);
642
+ }
643
+ });
644
+
645
+ router.post("/company-skills/refresh-from-url", async (req, res) => {
646
+ if (!enforcePermission(req, res, "agents:write")) {
647
+ return;
648
+ }
649
+ const companyId = req.companyId!;
650
+ const body = req.body as { skillId?: unknown };
651
+ if (typeof body?.skillId !== "string" || !body.skillId.trim()) {
652
+ return sendError(res, "Expected JSON body with string 'skillId'.", 422);
653
+ }
654
+ try {
655
+ const result = await refreshCompanySkillFromUrl({
656
+ companyId,
657
+ skillId: body.skillId.trim()
658
+ });
659
+ return sendOk(res, result);
660
+ } catch (error) {
661
+ return sendError(res, String(error), 422);
662
+ }
663
+ });
664
+
665
+ router.delete("/company-skills", async (req, res) => {
666
+ if (!enforcePermission(req, res, "agents:write")) {
667
+ return;
668
+ }
669
+ const companyId = req.companyId!;
670
+ const skillId = typeof req.query.skillId === "string" ? req.query.skillId.trim() : "";
671
+ if (!skillId) {
672
+ return sendError(res, "Query parameter 'skillId' is required.", 422);
673
+ }
674
+ try {
675
+ const result = await deleteCompanySkillPackage({ companyId, skillId });
676
+ return sendOk(res, result);
677
+ } catch (error) {
678
+ return sendError(res, String(error), 422);
679
+ }
680
+ });
681
+
384
682
  router.get("/memory/:agentId/context-preview", async (req, res) => {
385
683
  const companyId = req.companyId!;
386
684
  const agentId = req.params.agentId;
@@ -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
+ }