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 +8 -4
- package/src/app.ts +2 -0
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/observability.ts +132 -1
- package/src/services/company-assistant-brain.ts +50 -0
- package/src/services/company-assistant-cli.ts +388 -0
- package/src/services/company-assistant-context-snapshot.ts +287 -0
- package/src/services/company-assistant-llm.ts +375 -0
- package/src/services/company-assistant-service.ts +1012 -0
- package/src/services/company-file-archive-service.ts +444 -0
- package/src/services/company-file-import-service.ts +279 -0
- package/src/services/memory-file-service.ts +70 -0
- package/src/services/template-catalog.ts +19 -6
|
@@ -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
|
+
}
|