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.
@@ -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 { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
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
+ }