bopodev-api 0.1.31 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -8,20 +8,24 @@
8
8
  "tsconfig.json"
9
9
  ],
10
10
  "dependencies": {
11
+ "archiver": "^7.0.1",
11
12
  "cors": "^2.8.5",
12
13
  "cron-parser": "^5.3.1",
13
14
  "dotenv": "^17.0.1",
14
15
  "drizzle-orm": "^0.44.5",
15
16
  "express": "^5.1.0",
17
+ "fflate": "^0.8.2",
16
18
  "multer": "^2.1.1",
17
19
  "nanoid": "^5.1.5",
18
20
  "ws": "^8.19.0",
21
+ "yaml": "^2.8.3",
19
22
  "zod": "^4.1.5",
20
- "bopodev-db": "0.1.31",
21
- "bopodev-agent-sdk": "0.1.31",
22
- "bopodev-contracts": "0.1.31"
23
+ "bopodev-agent-sdk": "0.1.32",
24
+ "bopodev-contracts": "0.1.32",
25
+ "bopodev-db": "0.1.32"
23
26
  },
24
27
  "devDependencies": {
28
+ "@types/archiver": "^7.0.0",
25
29
  "@types/cors": "^2.8.19",
26
30
  "@types/express": "^5.0.3",
27
31
  "@types/multer": "^2.1.0",
package/src/app.ts CHANGED
@@ -2,6 +2,7 @@ import express from "express";
2
2
  import type { NextFunction, Request, Response } from "express";
3
3
  import { pingDatabase, RepositoryValidationError } from "bopodev-db";
4
4
  import type { AppContext } from "./context";
5
+ import { createAssistantRouter } from "./routes/assistant";
5
6
  import { createAgentsRouter } from "./routes/agents";
6
7
  import { createAuthRouter } from "./routes/auth";
7
8
  import { createAttentionRouter } from "./routes/attention";
@@ -62,6 +63,7 @@ export function createApp(ctx: AppContext) {
62
63
 
63
64
  app.use("/auth", createAuthRouter(ctx));
64
65
  app.use("/attention", createAttentionRouter(ctx));
66
+ app.use("/assistant", createAssistantRouter(ctx));
65
67
  app.use("/companies", createCompaniesRouter(ctx));
66
68
  app.use("/projects", createProjectsRouter(ctx));
67
69
  app.use("/issues", createIssuesRouter(ctx));
@@ -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";
@@ -24,8 +26,12 @@ import {
24
26
  } from "../services/agent-operating-file-service";
25
27
  import {
26
28
  listAgentMemoryFiles,
29
+ listCompanyMemoryFiles,
30
+ listProjectMemoryFiles,
27
31
  loadAgentMemoryContext,
28
32
  readAgentMemoryFile,
33
+ readCompanyMemoryFile,
34
+ readProjectMemoryFile,
29
35
  writeAgentMemoryFile
30
36
  } from "../services/memory-file-service";
31
37
 
@@ -55,6 +61,53 @@ export function createObservabilityRouter(ctx: AppContext) {
55
61
  );
56
62
  });
57
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
+
58
111
  router.get("/heartbeats", async (req, res) => {
59
112
  const companyId = req.companyId!;
60
113
  const rawLimit = Number(req.query.limit ?? 100);
@@ -251,6 +304,84 @@ export function createObservabilityRouter(ctx: AppContext) {
251
304
  });
252
305
  });
253
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
+
254
385
  router.get("/memory/:agentId/file", async (req, res) => {
255
386
  const companyId = req.companyId!;
256
387
  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
+ }