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.
@@ -0,0 +1,1012 @@
1
+ import type { BopoDb } from "bopodev-db";
2
+ import {
3
+ aggregateCompanyCostLedgerAllTime,
4
+ aggregateCompanyCostLedgerInRange,
5
+ appendAuditEvent,
6
+ appendCost,
7
+ getCompany,
8
+ getIssue,
9
+ listAgents,
10
+ listApprovalRequests,
11
+ listAuditEvents,
12
+ listCostEntries,
13
+ listGoals,
14
+ listHeartbeatRuns,
15
+ listIssueComments,
16
+ listIssueGoalIdsBatch,
17
+ listIssues,
18
+ listProjectWorkspaces,
19
+ listProjects,
20
+ getAssistantThreadById,
21
+ getOrCreateAssistantThread,
22
+ insertAssistantMessage,
23
+ listAssistantMessages
24
+ } from "bopodev-db";
25
+ import {
26
+ listAgentOperatingMarkdownFiles,
27
+ readAgentOperatingFile
28
+ } from "./agent-operating-file-service";
29
+ import {
30
+ listAgentMemoryFiles,
31
+ listCompanyMemoryFiles,
32
+ listProjectMemoryFiles,
33
+ loadAgentMemoryContext,
34
+ readAgentMemoryFile,
35
+ readCompanyMemoryFile,
36
+ readProjectMemoryFile
37
+ } from "./memory-file-service";
38
+ import { getWorkLoop, listWorkLoops } from "./work-loop-service/work-loop-service";
39
+ import {
40
+ runAssistantWithTools,
41
+ type AssistantToolDefinition,
42
+ type AssistantChatMessage
43
+ } from "./company-assistant-llm";
44
+ import { calculateModelPricedUsdCost } from "./model-pricing";
45
+ import { type AskCliBrainId, isAskCliBrain, parseAskBrain } from "./company-assistant-brain";
46
+ import { runCompanyAssistantBrainCliTurn } from "./company-assistant-cli";
47
+ import type { DirectApiProvider } from "bopodev-agent-sdk";
48
+
49
+ const MAX_TOOL_JSON_CHARS = 48_000;
50
+ const DEFAULT_MAX_TOOL_ROUNDS = 8;
51
+ const DEFAULT_TIMEOUT_MS = 120_000;
52
+
53
+ /** `cost_ledger.cost_category` for owner-assistant API turns */
54
+ const COMPANY_ASSISTANT_COST_CATEGORY = "company_assistant";
55
+
56
+ /**
57
+ * One cost_ledger row per assistant reply: API rows include metered tokens/USD when the provider
58
+ * reports usage; CLI rows and zero-usage API rows still append so Costs / By chats can attribute
59
+ * turns to threads (USD may be $0).
60
+ */
61
+ async function recordCompanyAssistantTurnLedger(input: {
62
+ db: BopoDb;
63
+ companyId: string;
64
+ threadId: string;
65
+ assistantMessageId: string;
66
+ mode: "api" | "cli";
67
+ /** `anthropic_api` / `openai_api` for API; codex / cursor / … for CLI */
68
+ brain: string;
69
+ runtimeModelId: string | null;
70
+ tokenInput: number;
71
+ tokenOutput: number;
72
+ /** CLI: parsed from runtime `parsedUsage` when the adapter reports it */
73
+ runtimeUsdCost?: number;
74
+ }) {
75
+ const base = {
76
+ companyId: input.companyId,
77
+ runId: null as string | null,
78
+ costCategory: COMPANY_ASSISTANT_COST_CATEGORY,
79
+ assistantThreadId: input.threadId,
80
+ assistantMessageId: input.assistantMessageId,
81
+ providerType: input.brain
82
+ };
83
+
84
+ if (input.mode === "cli") {
85
+ const ti = Math.max(0, Math.floor(input.tokenInput));
86
+ const to = Math.max(0, Math.floor(input.tokenOutput));
87
+ const runtimeUsd = Math.max(0, Number(input.runtimeUsdCost ?? 0) || 0);
88
+ const hasMetered = ti > 0 || to > 0 || runtimeUsd > 0;
89
+ if (!hasMetered) {
90
+ await appendCost(input.db, {
91
+ ...base,
92
+ runtimeModelId: null,
93
+ pricingProviderType: null,
94
+ pricingModelId: null,
95
+ pricingSource: null,
96
+ usdCostStatus: "unknown" as const,
97
+ tokenInput: 0,
98
+ tokenOutput: 0,
99
+ usdCost: "0.000000"
100
+ });
101
+ return;
102
+ }
103
+ const usdCostStatus: "exact" | "estimated" | "unknown" =
104
+ runtimeUsd > 0 ? "exact" : ti > 0 || to > 0 ? "unknown" : "unknown";
105
+ await appendCost(input.db, {
106
+ ...base,
107
+ runtimeModelId: null,
108
+ pricingProviderType: null,
109
+ pricingModelId: null,
110
+ pricingSource: runtimeUsd > 0 ? ("exact" as const) : null,
111
+ usdCostStatus,
112
+ tokenInput: ti,
113
+ tokenOutput: to,
114
+ usdCost: runtimeUsd > 0 ? runtimeUsd.toFixed(6) : "0.000000"
115
+ });
116
+ return;
117
+ }
118
+
119
+ const modelId = input.runtimeModelId?.trim() || null;
120
+ const isDirectApi = input.brain === "anthropic_api" || input.brain === "openai_api";
121
+ const hasTokenUsage = input.tokenInput > 0 || input.tokenOutput > 0;
122
+
123
+ if (isDirectApi && hasTokenUsage) {
124
+ const pricingDecision = await calculateModelPricedUsdCost({
125
+ db: input.db,
126
+ companyId: input.companyId,
127
+ providerType: input.brain,
128
+ pricingProviderType: input.brain,
129
+ modelId,
130
+ tokenInput: input.tokenInput,
131
+ tokenOutput: input.tokenOutput
132
+ });
133
+ const pricedUsdCost = Math.max(0, pricingDecision.usdCost);
134
+ const usdCostStatus: "exact" | "estimated" | "unknown" =
135
+ pricedUsdCost > 0 ? "estimated" : "unknown";
136
+ const effectiveUsdCost = usdCostStatus === "estimated" ? pricedUsdCost : 0;
137
+ await appendCost(input.db, {
138
+ ...base,
139
+ runtimeModelId: modelId,
140
+ pricingProviderType: pricingDecision.pricingProviderType,
141
+ pricingModelId: pricingDecision.pricingModelId,
142
+ pricingSource: pricingDecision.pricingSource,
143
+ usdCostStatus,
144
+ tokenInput: input.tokenInput,
145
+ tokenOutput: input.tokenOutput,
146
+ usdCost: effectiveUsdCost.toFixed(6)
147
+ });
148
+ return;
149
+ }
150
+
151
+ await appendCost(input.db, {
152
+ ...base,
153
+ runtimeModelId: modelId,
154
+ pricingProviderType: null,
155
+ pricingModelId: null,
156
+ pricingSource: null,
157
+ usdCostStatus: "unknown" as const,
158
+ tokenInput: input.tokenInput,
159
+ tokenOutput: input.tokenOutput,
160
+ usdCost: "0.000000"
161
+ });
162
+ }
163
+
164
+ function capToolOutput(value: unknown): string {
165
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
166
+ if (raw.length <= MAX_TOOL_JSON_CHARS) {
167
+ return raw;
168
+ }
169
+ return `${raw.slice(0, MAX_TOOL_JSON_CHARS)}\n…(truncated)`;
170
+ }
171
+
172
+ function parseJsonArray(raw: string | null | undefined): string[] {
173
+ if (!raw) {
174
+ return [];
175
+ }
176
+ try {
177
+ const v = JSON.parse(raw) as unknown;
178
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ function serializeIssue(row: Record<string, unknown>, goalIds: string[]) {
185
+ return {
186
+ id: row.id,
187
+ projectId: row.projectId,
188
+ parentIssueId: row.parentIssueId ?? null,
189
+ loopId: row.loopId ?? null,
190
+ title: row.title,
191
+ body: row.body ?? null,
192
+ status: row.status,
193
+ priority: row.priority,
194
+ assigneeAgentId: row.assigneeAgentId ?? null,
195
+ labels: parseJsonArray(row.labelsJson as string),
196
+ tags: parseJsonArray(row.tagsJson as string),
197
+ goalIds,
198
+ updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
199
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
200
+ };
201
+ }
202
+
203
+ function serializeWorkLoopRow(row: Record<string, unknown>) {
204
+ return {
205
+ id: row.id,
206
+ projectId: row.projectId,
207
+ title: row.title,
208
+ description: row.description ?? null,
209
+ assigneeAgentId: row.assigneeAgentId,
210
+ status: row.status,
211
+ priority: row.priority,
212
+ goalIds: parseJsonArray(row.goalIdsJson as string),
213
+ updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt)
214
+ };
215
+ }
216
+
217
+ function sanitizeAgentRow(row: Record<string, unknown>) {
218
+ return {
219
+ id: row.id,
220
+ name: row.name,
221
+ role: row.role,
222
+ roleKey: row.roleKey ?? null,
223
+ title: row.title ?? null,
224
+ capabilities: row.capabilities ?? null,
225
+ status: row.status,
226
+ managerAgentId: row.managerAgentId ?? null,
227
+ providerType: row.providerType,
228
+ heartbeatCron: row.heartbeatCron,
229
+ canHireAgents: row.canHireAgents ?? null
230
+ };
231
+ }
232
+
233
+ export const ASSISTANT_TOOLS: AssistantToolDefinition[] = [
234
+ {
235
+ name: "get_company",
236
+ description: "Load the active company name and mission statement.",
237
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
238
+ },
239
+ {
240
+ name: "list_projects",
241
+ description: "List all projects for the company with status and descriptions.",
242
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
243
+ },
244
+ {
245
+ name: "get_project",
246
+ description: "Get one project by id, including workspace metadata.",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: { project_id: { type: "string", description: "Project id" } },
250
+ required: ["project_id"],
251
+ additionalProperties: false
252
+ }
253
+ },
254
+ {
255
+ name: "list_issues",
256
+ description: "List issues with optional filters. Prefer small limits. Sorted by most recently updated.",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ status: { type: "string", description: "Optional: todo|in_progress|blocked|in_review|done|canceled" },
261
+ project_id: { type: "string" },
262
+ limit: { type: "number", description: "Max rows, default 30, max 100" }
263
+ },
264
+ additionalProperties: false
265
+ }
266
+ },
267
+ {
268
+ name: "get_issue",
269
+ description: "Load full issue detail including description and linked goal ids.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: { issue_id: { type: "string" } },
273
+ required: ["issue_id"],
274
+ additionalProperties: false
275
+ }
276
+ },
277
+ {
278
+ name: "list_issue_comments",
279
+ description: "List recent comments on an issue (newest last, capped).",
280
+ inputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ issue_id: { type: "string" },
284
+ limit: { type: "number", description: "Default 25, max 50" }
285
+ },
286
+ required: ["issue_id"],
287
+ additionalProperties: false
288
+ }
289
+ },
290
+ {
291
+ name: "list_goals",
292
+ description: "List goals for the company.",
293
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
294
+ },
295
+ {
296
+ name: "get_goal",
297
+ description: "Get a single goal by id.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: { goal_id: { type: "string" } },
301
+ required: ["goal_id"],
302
+ additionalProperties: false
303
+ }
304
+ },
305
+ {
306
+ name: "list_work_loops",
307
+ description: "List recurring work loops.",
308
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
309
+ },
310
+ {
311
+ name: "get_work_loop",
312
+ description: "Get one work loop by id.",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: { loop_id: { type: "string" } },
316
+ required: ["loop_id"],
317
+ additionalProperties: false
318
+ }
319
+ },
320
+ {
321
+ name: "list_agents",
322
+ description: "List agents (directory fields only, no secrets).",
323
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
324
+ },
325
+ {
326
+ name: "get_agent",
327
+ description: "Get one agent directory profile (no runtime secrets).",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: { agent_id: { type: "string" } },
331
+ required: ["agent_id"],
332
+ additionalProperties: false
333
+ }
334
+ },
335
+ {
336
+ name: "list_pending_approvals",
337
+ description: "List pending governance approval requests.",
338
+ inputSchema: {
339
+ type: "object",
340
+ properties: { limit: { type: "number" } },
341
+ additionalProperties: false
342
+ }
343
+ },
344
+ {
345
+ name: "list_recent_heartbeat_runs",
346
+ description: "Recent heartbeat runs with status.",
347
+ inputSchema: {
348
+ type: "object",
349
+ properties: { limit: { type: "number" } },
350
+ additionalProperties: false
351
+ }
352
+ },
353
+ {
354
+ name: "list_cost_entries",
355
+ description:
356
+ "Recent cost ledger rows: token_input, token_output, usd_cost, provider, agent, run/issue links. For **monthly or all-time totals**, prefer **get_cost_usage_summary**.",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: { limit: { type: "number" } },
360
+ additionalProperties: false
361
+ }
362
+ },
363
+ {
364
+ name: "get_cost_usage_summary",
365
+ description:
366
+ "Exact aggregates from cost_ledger: ledger row count, total input/output tokens, total USD (string, full precision). Use current_month_utc for 'this month' questions (UTC calendar month); all_time for lifetime totals.",
367
+ inputSchema: {
368
+ type: "object",
369
+ properties: {
370
+ period: {
371
+ type: "string",
372
+ enum: ["current_month_utc", "all_time"],
373
+ description: "current_month_utc = entire UTC calendar month containing now; all_time = every row for the company"
374
+ }
375
+ },
376
+ required: ["period"],
377
+ additionalProperties: false
378
+ }
379
+ },
380
+ {
381
+ name: "list_audit_events",
382
+ description: "Recent coarse audit events for the company.",
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: { limit: { type: "number" } },
386
+ additionalProperties: false
387
+ }
388
+ },
389
+ {
390
+ name: "memory_context_preview",
391
+ description:
392
+ "Merged memory context (company + optional projects + agent) as used by heartbeats: tacit notes, durable facts, daily notes. Pass agent_id and optional project_ids.",
393
+ inputSchema: {
394
+ type: "object",
395
+ properties: {
396
+ agent_id: { type: "string" },
397
+ project_ids: {
398
+ type: "array",
399
+ items: { type: "string" },
400
+ description: "Optional project ids to include project memory roots"
401
+ },
402
+ query: { type: "string", description: "Optional keyword hint for fact/note ranking" }
403
+ },
404
+ required: ["agent_id"],
405
+ additionalProperties: false
406
+ }
407
+ },
408
+ {
409
+ name: "list_company_memory_files",
410
+ description: "List files under the company-wide memory directory.",
411
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
412
+ },
413
+ {
414
+ name: "read_company_memory_file",
415
+ description: "Read a file from company memory by relative path.",
416
+ inputSchema: {
417
+ type: "object",
418
+ properties: { path: { type: "string" } },
419
+ required: ["path"],
420
+ additionalProperties: false
421
+ }
422
+ },
423
+ {
424
+ name: "list_project_memory_files",
425
+ description: "List memory files for a project.",
426
+ inputSchema: {
427
+ type: "object",
428
+ properties: { project_id: { type: "string" } },
429
+ required: ["project_id"],
430
+ additionalProperties: false
431
+ }
432
+ },
433
+ {
434
+ name: "read_project_memory_file",
435
+ description: "Read a project memory file by relative path.",
436
+ inputSchema: {
437
+ type: "object",
438
+ properties: {
439
+ project_id: { type: "string" },
440
+ path: { type: "string" }
441
+ },
442
+ required: ["project_id", "path"],
443
+ additionalProperties: false
444
+ }
445
+ },
446
+ {
447
+ name: "list_agent_memory_files",
448
+ description: "List memory files for an agent.",
449
+ inputSchema: {
450
+ type: "object",
451
+ properties: { agent_id: { type: "string" } },
452
+ required: ["agent_id"],
453
+ additionalProperties: false
454
+ }
455
+ },
456
+ {
457
+ name: "read_agent_memory_file",
458
+ description: "Read an agent memory file by relative path.",
459
+ inputSchema: {
460
+ type: "object",
461
+ properties: {
462
+ agent_id: { type: "string" },
463
+ path: { type: "string" }
464
+ },
465
+ required: ["agent_id", "path"],
466
+ additionalProperties: false
467
+ }
468
+ },
469
+ {
470
+ name: "list_agent_operating_files",
471
+ description: "List markdown operating docs for an agent.",
472
+ inputSchema: {
473
+ type: "object",
474
+ properties: { agent_id: { type: "string" } },
475
+ required: ["agent_id"],
476
+ additionalProperties: false
477
+ }
478
+ },
479
+ {
480
+ name: "read_agent_operating_file",
481
+ description: "Read an agent operating markdown file by relative path.",
482
+ inputSchema: {
483
+ type: "object",
484
+ properties: {
485
+ agent_id: { type: "string" },
486
+ path: { type: "string" }
487
+ },
488
+ required: ["agent_id", "path"],
489
+ additionalProperties: false
490
+ }
491
+ }
492
+ ];
493
+
494
+ export async function executeAssistantTool(
495
+ db: BopoDb,
496
+ companyId: string,
497
+ name: string,
498
+ args: Record<string, unknown>
499
+ ): Promise<string> {
500
+ switch (name) {
501
+ case "get_company": {
502
+ const row = await getCompany(db, companyId);
503
+ return capToolOutput(row ? { id: row.id, name: row.name, mission: row.mission ?? null } : { error: "not_found" });
504
+ }
505
+ case "list_projects": {
506
+ const rows = await listProjects(db, companyId);
507
+ return capToolOutput(
508
+ rows.map((p) => ({
509
+ id: p.id,
510
+ name: p.name,
511
+ status: p.status,
512
+ description: p.description ?? null
513
+ }))
514
+ );
515
+ }
516
+ case "get_project": {
517
+ const projectId = String(args.project_id ?? "").trim();
518
+ const rows = await listProjects(db, companyId);
519
+ const p = rows.find((r) => r.id === projectId);
520
+ if (!p) {
521
+ return capToolOutput({ error: "project_not_found" });
522
+ }
523
+ const workspaces = await listProjectWorkspaces(db, companyId, projectId);
524
+ return capToolOutput({
525
+ ...p,
526
+ workspaces: workspaces.map((w) => ({
527
+ id: w.id,
528
+ name: w.name,
529
+ cwd: w.cwd ?? null,
530
+ repoUrl: w.repoUrl ?? null,
531
+ isPrimary: w.isPrimary
532
+ }))
533
+ });
534
+ }
535
+ case "list_issues": {
536
+ const status = typeof args.status === "string" ? args.status.trim() : "";
537
+ const projectId = typeof args.project_id === "string" ? args.project_id.trim() : "";
538
+ let limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.floor(args.limit) : 30;
539
+ limit = Math.min(100, Math.max(1, limit));
540
+ const rows = await listIssues(db, companyId, projectId || undefined);
541
+ const filtered = rows
542
+ .filter((r) => !status || String(r.status) === status)
543
+ .slice(0, limit);
544
+ const goalMap = await listIssueGoalIdsBatch(
545
+ db,
546
+ companyId,
547
+ filtered.map((r) => r.id)
548
+ );
549
+ return capToolOutput(
550
+ filtered.map((r) => serializeIssue(r as unknown as Record<string, unknown>, goalMap.get(r.id) ?? []))
551
+ );
552
+ }
553
+ case "get_issue": {
554
+ const issueId = String(args.issue_id ?? "").trim();
555
+ const row = await getIssue(db, companyId, issueId);
556
+ if (!row) {
557
+ return capToolOutput({ error: "issue_not_found" });
558
+ }
559
+ const goalMap = await listIssueGoalIdsBatch(db, companyId, [issueId]);
560
+ return capToolOutput(serializeIssue(row as unknown as Record<string, unknown>, goalMap.get(issueId) ?? []));
561
+ }
562
+ case "list_issue_comments": {
563
+ const issueId = String(args.issue_id ?? "").trim();
564
+ let lim = typeof args.limit === "number" ? Math.floor(args.limit) : 25;
565
+ lim = Math.min(50, Math.max(1, lim));
566
+ const comments = await listIssueComments(db, companyId, issueId);
567
+ const slice = comments.slice(-lim).map((c) => ({
568
+ id: c.id,
569
+ authorType: c.authorType,
570
+ authorId: c.authorId,
571
+ body: c.body,
572
+ createdAt:
573
+ c.createdAt && typeof (c.createdAt as { toISOString?: () => string }).toISOString === "function"
574
+ ? (c.createdAt as Date).toISOString()
575
+ : String(c.createdAt)
576
+ }));
577
+ return capToolOutput(slice);
578
+ }
579
+ case "list_goals": {
580
+ const goals = await listGoals(db, companyId);
581
+ return capToolOutput(
582
+ goals.map((g) => ({
583
+ id: g.id,
584
+ title: g.title,
585
+ status: g.status,
586
+ level: g.level,
587
+ projectId: g.projectId ?? null,
588
+ description: g.description ?? null
589
+ }))
590
+ );
591
+ }
592
+ case "get_goal": {
593
+ const goalId = String(args.goal_id ?? "").trim();
594
+ const goals = await listGoals(db, companyId);
595
+ const g = goals.find((x) => x.id === goalId);
596
+ return capToolOutput(g ?? { error: "goal_not_found" });
597
+ }
598
+ case "list_work_loops": {
599
+ const loops = await listWorkLoops(db, companyId);
600
+ return capToolOutput(loops.map((l) => serializeWorkLoopRow(l as unknown as Record<string, unknown>)));
601
+ }
602
+ case "get_work_loop": {
603
+ const loopId = String(args.loop_id ?? "").trim();
604
+ const row = await getWorkLoop(db, companyId, loopId);
605
+ return capToolOutput(row ? serializeWorkLoopRow(row as unknown as Record<string, unknown>) : { error: "loop_not_found" });
606
+ }
607
+ case "list_agents": {
608
+ const agents = await listAgents(db, companyId);
609
+ return capToolOutput(agents.map((a) => sanitizeAgentRow(a as unknown as Record<string, unknown>)));
610
+ }
611
+ case "get_agent": {
612
+ const agentId = String(args.agent_id ?? "").trim();
613
+ const agents = await listAgents(db, companyId);
614
+ const a = agents.find((x) => x.id === agentId);
615
+ return capToolOutput(a ? sanitizeAgentRow(a as unknown as Record<string, unknown>) : { error: "agent_not_found" });
616
+ }
617
+ case "list_pending_approvals": {
618
+ let lim = typeof args.limit === "number" ? Math.floor(args.limit) : 30;
619
+ lim = Math.min(50, Math.max(1, lim));
620
+ const rows = await listApprovalRequests(db, companyId);
621
+ const pending = rows.filter((r) => r.status === "pending").slice(0, lim);
622
+ return capToolOutput(
623
+ pending.map((r) => ({
624
+ id: r.id,
625
+ action: r.action,
626
+ status: r.status,
627
+ createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt)
628
+ }))
629
+ );
630
+ }
631
+ case "list_recent_heartbeat_runs": {
632
+ let lim = typeof args.limit === "number" ? Math.floor(args.limit) : 20;
633
+ lim = Math.min(50, Math.max(1, lim));
634
+ const runs = await listHeartbeatRuns(db, companyId, lim);
635
+ return capToolOutput(
636
+ runs.map((r) => ({
637
+ id: r.id,
638
+ agentId: r.agentId,
639
+ status: r.status,
640
+ startedAt: r.startedAt instanceof Date ? r.startedAt.toISOString() : String(r.startedAt),
641
+ finishedAt: r.finishedAt ? (r.finishedAt instanceof Date ? r.finishedAt.toISOString() : String(r.finishedAt)) : null
642
+ }))
643
+ );
644
+ }
645
+ case "list_cost_entries": {
646
+ let lim = typeof args.limit === "number" ? Math.floor(args.limit) : 30;
647
+ lim = Math.min(100, Math.max(1, lim));
648
+ const rows = await listCostEntries(db, companyId, lim);
649
+ return capToolOutput(rows);
650
+ }
651
+ case "get_cost_usage_summary": {
652
+ const period = String(args.period ?? "").trim();
653
+ if (period === "current_month_utc") {
654
+ const ref = new Date();
655
+ const y = ref.getUTCFullYear();
656
+ const m0 = ref.getUTCMonth();
657
+ const start = new Date(Date.UTC(y, m0, 1, 0, 0, 0, 0));
658
+ const endExclusive = new Date(Date.UTC(y, m0 + 1, 1, 0, 0, 0, 0));
659
+ const agg = await aggregateCompanyCostLedgerInRange(db, companyId, start, endExclusive);
660
+ return capToolOutput({
661
+ period,
662
+ calendarMonthUtc: `${y}-${String(m0 + 1).padStart(2, "0")}`,
663
+ rangeStartUtcInclusive: start.toISOString(),
664
+ rangeEndUtcExclusive: endExclusive.toISOString(),
665
+ ...agg
666
+ });
667
+ }
668
+ if (period === "all_time") {
669
+ const agg = await aggregateCompanyCostLedgerAllTime(db, companyId);
670
+ return capToolOutput({ period, ...agg });
671
+ }
672
+ return capToolOutput({
673
+ error: "invalid_period",
674
+ allowed: ["current_month_utc", "all_time"]
675
+ });
676
+ }
677
+ case "list_audit_events": {
678
+ let lim = typeof args.limit === "number" ? Math.floor(args.limit) : 25;
679
+ lim = Math.min(100, Math.max(1, lim));
680
+ const rows = await listAuditEvents(db, companyId, lim);
681
+ return capToolOutput(rows);
682
+ }
683
+ case "memory_context_preview": {
684
+ const agentId = String(args.agent_id ?? "").trim();
685
+ const projectIds = Array.isArray(args.project_ids)
686
+ ? (args.project_ids as unknown[]).filter((x): x is string => typeof x === "string")
687
+ : [];
688
+ const queryText = typeof args.query === "string" ? args.query.trim() : "";
689
+ const agents = await listAgents(db, companyId);
690
+ if (!agents.some((a) => a.id === agentId)) {
691
+ return capToolOutput({ error: "agent_not_found" });
692
+ }
693
+ const ctx = await loadAgentMemoryContext({
694
+ companyId,
695
+ agentId,
696
+ projectIds,
697
+ queryText: queryText || undefined
698
+ });
699
+ return capToolOutput({
700
+ memoryRoot: ctx.memoryRoot,
701
+ tacitNotes: ctx.tacitNotes ?? null,
702
+ durableFacts: ctx.durableFacts ?? [],
703
+ dailyNotes: ctx.dailyNotes ?? []
704
+ });
705
+ }
706
+ case "list_company_memory_files": {
707
+ const files = await listCompanyMemoryFiles({ companyId, maxFiles: 200 });
708
+ return capToolOutput(files.map((f) => ({ path: f.relativePath })));
709
+ }
710
+ case "read_company_memory_file": {
711
+ const path = String(args.path ?? "").trim();
712
+ try {
713
+ const file = await readCompanyMemoryFile({ companyId, relativePath: path });
714
+ return capToolOutput({ path: file.relativePath, content: file.content });
715
+ } catch (e) {
716
+ return capToolOutput({ error: String(e) });
717
+ }
718
+ }
719
+ case "list_project_memory_files": {
720
+ const projectId = String(args.project_id ?? "").trim();
721
+ const files = await listProjectMemoryFiles({ companyId, projectId, maxFiles: 200 });
722
+ return capToolOutput(files.map((f) => ({ path: f.relativePath })));
723
+ }
724
+ case "read_project_memory_file": {
725
+ const projectId = String(args.project_id ?? "").trim();
726
+ const path = String(args.path ?? "").trim();
727
+ try {
728
+ const file = await readProjectMemoryFile({ companyId, projectId, relativePath: path });
729
+ return capToolOutput({ path: file.relativePath, content: file.content });
730
+ } catch (e) {
731
+ return capToolOutput({ error: String(e) });
732
+ }
733
+ }
734
+ case "list_agent_memory_files": {
735
+ const agentId = String(args.agent_id ?? "").trim();
736
+ const files = await listAgentMemoryFiles({ companyId, agentId, maxFiles: 200 });
737
+ return capToolOutput(files.map((f) => ({ path: f.relativePath })));
738
+ }
739
+ case "read_agent_memory_file": {
740
+ const agentId = String(args.agent_id ?? "").trim();
741
+ const path = String(args.path ?? "").trim();
742
+ try {
743
+ const file = await readAgentMemoryFile({ companyId, agentId, relativePath: path });
744
+ return capToolOutput({ path: file.relativePath, content: file.content });
745
+ } catch (e) {
746
+ return capToolOutput({ error: String(e) });
747
+ }
748
+ }
749
+ case "list_agent_operating_files": {
750
+ const agentId = String(args.agent_id ?? "").trim();
751
+ const files = await listAgentOperatingMarkdownFiles({ companyId, agentId, maxFiles: 200 });
752
+ return capToolOutput(files.map((f) => ({ path: f.relativePath })));
753
+ }
754
+ case "read_agent_operating_file": {
755
+ const agentId = String(args.agent_id ?? "").trim();
756
+ const path = String(args.path ?? "").trim();
757
+ try {
758
+ const file = await readAgentOperatingFile({ companyId, agentId, relativePath: path });
759
+ return capToolOutput({ path: file.relativePath, content: file.content });
760
+ } catch (e) {
761
+ return capToolOutput({ error: String(e) });
762
+ }
763
+ }
764
+ default:
765
+ return capToolOutput({ error: "unknown_tool", name });
766
+ }
767
+ }
768
+
769
+ export type AssistantCeoPersona = {
770
+ agentId: string | null;
771
+ name: string;
772
+ title: string | null;
773
+ avatarSeed: string;
774
+ };
775
+
776
+ export async function getCompanyCeoPersona(db: BopoDb, companyId: string): Promise<AssistantCeoPersona> {
777
+ const agents = await listAgents(db, companyId);
778
+ const ceo =
779
+ agents.find((a) => String(a.roleKey ?? "").toLowerCase() === "ceo") ??
780
+ agents.find((a) => String(a.role ?? "").toUpperCase() === "CEO");
781
+ if (!ceo) {
782
+ return { agentId: null, name: "CEO", title: null, avatarSeed: "" };
783
+ }
784
+ return {
785
+ agentId: ceo.id,
786
+ name: (ceo.name && String(ceo.name).trim()) || "CEO",
787
+ title: ceo.title ? String(ceo.title).trim() || null : null,
788
+ avatarSeed: (ceo.avatarSeed && String(ceo.avatarSeed).trim()) || ""
789
+ };
790
+ }
791
+
792
+ function ceoPromptDisplayName(persona: AssistantCeoPersona): string {
793
+ if (persona.title?.trim()) {
794
+ return `${persona.name} (${persona.title})`;
795
+ }
796
+ return persona.name;
797
+ }
798
+
799
+ function buildSystemPrompt(companyId: string, companyName: string, persona: AssistantCeoPersona) {
800
+ const who = ceoPromptDisplayName(persona);
801
+ return [
802
+ `You are ${who}, the CEO of ${companyName}. The owner/operator is talking with you in Chat: sound human—warm, direct, plain language, short paragraphs. Use bullet lists only when comparing several items; otherwise prefer flowing prose.`,
803
+ `Scope: this session is fixed to one company (${companyId}). Never claim access to other companies.`,
804
+ "**Answer only what they asked.** Do not volunteer status briefings, metrics, or “here’s what’s going on” summaries—agent activity, approvals, heartbeats, runs, costs, spend, tokens, project/issue inventories, etc.—unless the user clearly asked for that information or a specific fact. If they only greet you (“hi”, “hello”) or make small talk, reply in one or two friendly sentences and offer help; **do not call tools** and do not mention internal numbers or operational state.",
805
+ "**Tools:** Call tools only when the user’s message requires company data you cannot infer from the chat. Use the **narrowest** calls that answer the question (e.g. for **tokens / USD this month or all-time**, **get_cost_usage_summary**; for recent line-level costs, **list_cost_entries**). Use memory/operating file tools only when relevant to the question. If memory conflicts with structured data, prefer structured data and mention the mismatch briefly.",
806
+ "Never paste raw JSON, NDJSON, or internal event logs. When you do cite numbers from tools, keep them proportional to the question—no extra dashboards.",
807
+ "Be concise. If data is missing, say what you could not find.",
808
+ `Active company: ${companyName} (${companyId}).`
809
+ ].join("\n");
810
+ }
811
+
812
+ async function resolveAssistantThreadForTurn(db: BopoDb, companyId: string, threadId: string | null | undefined) {
813
+ const trimmed = threadId?.trim();
814
+ if (trimmed) {
815
+ const row = await getAssistantThreadById(db, companyId, trimmed);
816
+ if (!row) {
817
+ throw new Error("Chat thread not found.");
818
+ }
819
+ return row;
820
+ }
821
+ return getOrCreateAssistantThread(db, companyId);
822
+ }
823
+
824
+ export async function runCompanyAssistantTurn(input: {
825
+ db: BopoDb;
826
+ companyId: string;
827
+ userMessage: string;
828
+ actorType: "human" | "agent" | "system";
829
+ actorId: string;
830
+ /** Adapter id (e.g. anthropic_api, codex). Defaults from BOPO_ASSISTANT_PROVIDER for API. */
831
+ brain?: string | null;
832
+ /** When set, append to this thread; otherwise use latest-or-create for the company. */
833
+ threadId?: string | null;
834
+ }): Promise<{
835
+ userMessageId: string;
836
+ assistantMessageId: string;
837
+ assistantBody: string;
838
+ toolRoundCount: number;
839
+ mode: "api" | "cli";
840
+ brain: string;
841
+ threadId: string;
842
+ cliElapsedMs?: number;
843
+ }> {
844
+ const company = await getCompany(input.db, input.companyId);
845
+ if (!company) {
846
+ throw new Error("Company not found.");
847
+ }
848
+
849
+ const ceoPersona = await getCompanyCeoPersona(input.db, input.companyId);
850
+
851
+ const thread = await resolveAssistantThreadForTurn(input.db, input.companyId, input.threadId);
852
+ const userRow = await insertAssistantMessage(input.db, {
853
+ threadId: thread.id,
854
+ companyId: input.companyId,
855
+ role: "user",
856
+ body: input.userMessage
857
+ });
858
+
859
+ const prior = await listAssistantMessages(input.db, thread.id, 80);
860
+ const chatHistory: AssistantChatMessage[] = prior
861
+ .filter((m) => m.role === "user" || m.role === "assistant")
862
+ .map((m) => ({ role: m.role as "user" | "assistant", content: m.body }));
863
+
864
+ const brain = parseAskBrain(input.brain);
865
+ let text: string;
866
+ let toolRoundCount = 0;
867
+ let mode: "api" | "cli" = "api";
868
+ let cliElapsedMs: number | undefined;
869
+ let cliMetered = { tokenInput: 0, tokenOutput: 0, usdCost: 0 };
870
+
871
+ if (isAskCliBrain(brain)) {
872
+ const cli = await runCompanyAssistantBrainCliTurn({
873
+ db: input.db,
874
+ companyId: input.companyId,
875
+ providerType: brain as AskCliBrainId,
876
+ userMessage: input.userMessage,
877
+ ceoDisplayName: ceoPromptDisplayName(ceoPersona)
878
+ });
879
+ text = cli.assistantBody;
880
+ mode = "cli";
881
+ cliElapsedMs = cli.elapsedMs;
882
+ cliMetered = { tokenInput: cli.tokenInput, tokenOutput: cli.tokenOutput, usdCost: cli.usdCost };
883
+ } else {
884
+ const maxRounds = Math.min(
885
+ 20,
886
+ Math.max(1, Number(process.env.BOPO_ASSISTANT_MAX_TOOL_ROUNDS) || DEFAULT_MAX_TOOL_ROUNDS)
887
+ );
888
+ const timeoutMs = Math.min(
889
+ 300_000,
890
+ Math.max(10_000, Number(process.env.BOPO_ASSISTANT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS)
891
+ );
892
+
893
+ const provider = brain as DirectApiProvider;
894
+ const apiTurn = await runAssistantWithTools({
895
+ provider,
896
+ system: buildSystemPrompt(input.companyId, company.name, ceoPersona),
897
+ chatHistory,
898
+ tools: ASSISTANT_TOOLS,
899
+ executeTool: (name, args) => executeAssistantTool(input.db, input.companyId, name, args),
900
+ maxToolRounds: maxRounds,
901
+ timeoutMs
902
+ });
903
+ text = apiTurn.text;
904
+ toolRoundCount = apiTurn.toolRoundCount;
905
+
906
+ const assistantRowApi = await insertAssistantMessage(input.db, {
907
+ threadId: thread.id,
908
+ companyId: input.companyId,
909
+ role: "assistant",
910
+ body: text,
911
+ metadataJson: JSON.stringify({
912
+ mode: "api",
913
+ brain: provider,
914
+ toolRoundCount,
915
+ model: apiTurn.runtimeModelId
916
+ })
917
+ });
918
+
919
+ await recordCompanyAssistantTurnLedger({
920
+ db: input.db,
921
+ companyId: input.companyId,
922
+ threadId: thread.id,
923
+ assistantMessageId: assistantRowApi.id,
924
+ mode: "api",
925
+ brain: provider,
926
+ runtimeModelId: apiTurn.runtimeModelId,
927
+ tokenInput: apiTurn.tokenInput,
928
+ tokenOutput: apiTurn.tokenOutput
929
+ });
930
+
931
+ await appendAuditEvent(input.db, {
932
+ companyId: input.companyId,
933
+ actorType: input.actorType as "human" | "agent" | "system",
934
+ actorId: input.actorId,
935
+ eventType: "company_assistant.turn",
936
+ entityType: "company_assistant_message",
937
+ entityId: assistantRowApi.id,
938
+ payload: {
939
+ threadId: thread.id,
940
+ userMessageId: userRow.id,
941
+ assistantMessageId: assistantRowApi.id,
942
+ toolRoundCount,
943
+ mode: "api",
944
+ brain: provider
945
+ }
946
+ });
947
+
948
+ return {
949
+ userMessageId: userRow.id,
950
+ assistantMessageId: assistantRowApi.id,
951
+ assistantBody: text,
952
+ toolRoundCount,
953
+ mode: "api",
954
+ brain: provider,
955
+ threadId: thread.id
956
+ };
957
+ }
958
+
959
+ const assistantRow = await insertAssistantMessage(input.db, {
960
+ threadId: thread.id,
961
+ companyId: input.companyId,
962
+ role: "assistant",
963
+ body: text,
964
+ metadataJson: JSON.stringify({
965
+ mode: "cli",
966
+ brain,
967
+ cliElapsedMs: cliElapsedMs ?? null,
968
+ toolRoundCount: 0
969
+ })
970
+ });
971
+
972
+ await recordCompanyAssistantTurnLedger({
973
+ db: input.db,
974
+ companyId: input.companyId,
975
+ threadId: thread.id,
976
+ assistantMessageId: assistantRow.id,
977
+ mode: "cli",
978
+ brain,
979
+ runtimeModelId: null,
980
+ tokenInput: cliMetered.tokenInput,
981
+ tokenOutput: cliMetered.tokenOutput,
982
+ runtimeUsdCost: cliMetered.usdCost
983
+ });
984
+
985
+ await appendAuditEvent(input.db, {
986
+ companyId: input.companyId,
987
+ actorType: input.actorType as "human" | "agent" | "system",
988
+ actorId: input.actorId,
989
+ eventType: "company_assistant.turn",
990
+ entityType: "company_assistant_message",
991
+ entityId: assistantRow.id,
992
+ payload: {
993
+ threadId: thread.id,
994
+ userMessageId: userRow.id,
995
+ assistantMessageId: assistantRow.id,
996
+ toolRoundCount: 0,
997
+ mode: "cli",
998
+ brain
999
+ }
1000
+ });
1001
+
1002
+ return {
1003
+ userMessageId: userRow.id,
1004
+ assistantMessageId: assistantRow.id,
1005
+ assistantBody: text,
1006
+ toolRoundCount: 0,
1007
+ mode: "cli",
1008
+ brain,
1009
+ threadId: thread.id,
1010
+ cliElapsedMs
1011
+ };
1012
+ }