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.
- package/package.json +8 -4
- package/src/app.ts +4 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +255 -2
- package/src/services/agent-operating-file-service.ts +116 -0
- 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/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +105 -1
- package/src/services/template-apply-service.ts +33 -0
- package/src/services/template-catalog.ts +19 -6
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/worker/scheduler.ts +26 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
import {
|
|
4
|
+
executeAgentRuntime,
|
|
5
|
+
executePromptRuntime,
|
|
6
|
+
ensureOpenCodeModelConfiguredAndAvailable,
|
|
7
|
+
hasTrustFlag,
|
|
8
|
+
resolveCursorLaunchConfig,
|
|
9
|
+
type AgentRuntimeConfig,
|
|
10
|
+
type RuntimeExecutionOutput
|
|
11
|
+
} from "bopodev-agent-sdk";
|
|
12
|
+
import {
|
|
13
|
+
normalizeRuntimeConfig,
|
|
14
|
+
resolveDefaultRuntimeModelForProvider,
|
|
15
|
+
requiresRuntimeCwd
|
|
16
|
+
} from "../lib/agent-config";
|
|
17
|
+
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
18
|
+
import { buildCompanyAssistantContextSnapshot } from "./company-assistant-context-snapshot";
|
|
19
|
+
import type { AskCliBrainId } from "./company-assistant-brain";
|
|
20
|
+
|
|
21
|
+
const MAX_EXTRACTED_CLI_CHARS = 60_000;
|
|
22
|
+
|
|
23
|
+
function codexStdoutLooksLikeNdjsonStream(stdout: string): boolean {
|
|
24
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
25
|
+
if (lines.length === 0) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
let jsonLines = 0;
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (!line.startsWith("{")) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
JSON.parse(line);
|
|
35
|
+
jsonLines++;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return jsonLines > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Codex `--json` / stream-json stdout is newline-delimited JSON events.
|
|
45
|
+
* Prefer the last completed assistant-facing item; never treat `turn.completed` metadata as the reply if it looks like JSON.
|
|
46
|
+
*/
|
|
47
|
+
function extractCodexStreamAssistantText(stdout: string): string | null {
|
|
48
|
+
let lastMessage: string | null = null;
|
|
49
|
+
let lastTurnResult: string | null = null;
|
|
50
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
51
|
+
const line = rawLine.trim();
|
|
52
|
+
if (!line.startsWith("{")) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(line) as unknown;
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!parsed || typeof parsed !== "object") {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const rec = parsed as Record<string, unknown>;
|
|
65
|
+
const type = typeof rec.type === "string" ? rec.type : "";
|
|
66
|
+
if (type === "item.completed") {
|
|
67
|
+
const item = rec.item;
|
|
68
|
+
if (!item || typeof item !== "object") {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const ir = item as Record<string, unknown>;
|
|
72
|
+
const itemType = typeof ir.type === "string" ? ir.type.toLowerCase() : "";
|
|
73
|
+
if (
|
|
74
|
+
itemType === "tool_use" ||
|
|
75
|
+
itemType === "tool_result" ||
|
|
76
|
+
itemType === "command_execution" ||
|
|
77
|
+
itemType === "reasoning"
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (itemType === "agent_message" || itemType === "agentmessage" || itemType === "message") {
|
|
82
|
+
const text = extractCodexAgentMessageItemText(ir);
|
|
83
|
+
if (text) {
|
|
84
|
+
lastMessage = text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (type === "turn.completed") {
|
|
90
|
+
const result = typeof rec.result === "string" ? rec.result.trim() : "";
|
|
91
|
+
if (result && !result.startsWith("{") && !result.startsWith("[")) {
|
|
92
|
+
lastTurnResult = result;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const chosen = lastMessage ?? lastTurnResult;
|
|
97
|
+
if (!chosen) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return chosen.length > MAX_EXTRACTED_CLI_CHARS ? `${chosen.slice(0, MAX_EXTRACTED_CLI_CHARS)}\n…(truncated)` : chosen;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractCodexAgentMessageItemText(item: Record<string, unknown>): string {
|
|
104
|
+
if (typeof item.text === "string" && item.text.trim()) {
|
|
105
|
+
return item.text.trim();
|
|
106
|
+
}
|
|
107
|
+
if (typeof item.message === "string" && item.message.trim()) {
|
|
108
|
+
return item.message.trim();
|
|
109
|
+
}
|
|
110
|
+
const content = item.content;
|
|
111
|
+
if (Array.isArray(content)) {
|
|
112
|
+
const parts = content.map((c) => {
|
|
113
|
+
if (typeof c === "string") {
|
|
114
|
+
return c;
|
|
115
|
+
}
|
|
116
|
+
if (c && typeof c === "object" && !Array.isArray(c)) {
|
|
117
|
+
const o = c as Record<string, unknown>;
|
|
118
|
+
if (typeof o.text === "string") {
|
|
119
|
+
return o.text;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return "";
|
|
123
|
+
});
|
|
124
|
+
const joined = parts.join("").trim();
|
|
125
|
+
if (joined) {
|
|
126
|
+
return joined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function extractAssistantBodyFromRuntime(out: RuntimeExecutionOutput, providerType: AskCliBrainId): string {
|
|
133
|
+
const comment = out.finalRunOutput?.employee_comment?.trim();
|
|
134
|
+
if (comment) {
|
|
135
|
+
return comment;
|
|
136
|
+
}
|
|
137
|
+
// Codex NDJSON: prefer last agent_message over parsedUsage (often token / turn metadata, not the reply).
|
|
138
|
+
if (providerType === "codex") {
|
|
139
|
+
const fromStream = extractCodexStreamAssistantText(out.stdout ?? "");
|
|
140
|
+
if (fromStream) {
|
|
141
|
+
return fromStream;
|
|
142
|
+
}
|
|
143
|
+
if (codexStdoutLooksLikeNdjsonStream(out.stdout ?? "")) {
|
|
144
|
+
return [
|
|
145
|
+
"I ran Codex but only got internal stream events back—no final message to show you.",
|
|
146
|
+
"",
|
|
147
|
+
"Try asking again in one short sentence, pick another brain (e.g. Claude Code or Cursor), or run `codex login` / update the CLI if you expect Codex to reply here."
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const summary = out.parsedUsage?.summary?.trim();
|
|
152
|
+
if (summary) {
|
|
153
|
+
return summary;
|
|
154
|
+
}
|
|
155
|
+
const stdout = out.stdout?.trim();
|
|
156
|
+
if (stdout) {
|
|
157
|
+
return stdout.length > MAX_EXTRACTED_CLI_CHARS
|
|
158
|
+
? `${stdout.slice(0, MAX_EXTRACTED_CLI_CHARS)}\n…(truncated)`
|
|
159
|
+
: stdout;
|
|
160
|
+
}
|
|
161
|
+
if (!out.ok) {
|
|
162
|
+
const err = out.stderr?.trim() || out.failureType || "runtime error";
|
|
163
|
+
return `Assistant runtime failed (${out.failureType ?? "error"}, exit ${out.code ?? "?"}): ${err.slice(0, 4000)}`;
|
|
164
|
+
}
|
|
165
|
+
return "No output from assistant runtime.";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function usageFromAssistantRuntime(out: RuntimeExecutionOutput): {
|
|
169
|
+
tokenInput: number;
|
|
170
|
+
tokenOutput: number;
|
|
171
|
+
usdCost: number;
|
|
172
|
+
} {
|
|
173
|
+
const u = out.parsedUsage;
|
|
174
|
+
return {
|
|
175
|
+
tokenInput: Math.max(0, Math.floor(Number(u?.tokenInput ?? 0) || 0)),
|
|
176
|
+
tokenOutput: Math.max(0, Math.floor(Number(u?.tokenOutput ?? 0) || 0)),
|
|
177
|
+
usdCost: Math.max(0, Number(u?.usdCost ?? 0) || 0)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function finishOwnerCliTurn(runtime: RuntimeExecutionOutput, providerType: AskCliBrainId, startedMs: number) {
|
|
182
|
+
const usage = usageFromAssistantRuntime(runtime);
|
|
183
|
+
return {
|
|
184
|
+
assistantBody: extractAssistantBodyFromRuntime(runtime, providerType),
|
|
185
|
+
provider: providerType,
|
|
186
|
+
elapsedMs: Date.now() - startedMs,
|
|
187
|
+
tokenInput: usage.tokenInput,
|
|
188
|
+
tokenOutput: usage.tokenOutput,
|
|
189
|
+
usdCost: usage.usdCost
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildOwnerCliInstructions(ceoDisplayName: string) {
|
|
194
|
+
return [
|
|
195
|
+
`You are **${ceoDisplayName}**, the company's CEO in Bopo. The owner/operator is chatting with you in Chat—reply like a real person: short paragraphs, plain language, warm and direct. Use contractions when they sound natural.`,
|
|
196
|
+
"**Answer only what they asked.** Do not volunteer status briefings or extra metrics from the snapshot—agent state, approvals, spend, tokens, runs, issue lists, etc.—unless the question clearly calls for it. Greetings (“hi”, “hello”) get a short friendly reply and an offer to help; **do not** recite costs, idle agents, or operational summaries.",
|
|
197
|
+
"When you **do** need facts, use ONLY the JSON snapshot below (issues, goals, agents, memory, approvals, runs, **costAndUsage**). For spend/tokens: **`monthToDateUtc`** is the full UTC calendar month to date (exact DB sum); **`allTime`** is lifetime totals; **`recentSample`** is just the newest rows for examples—never treat its `totalsInListedRows` as monthly figures. If something is not in the snapshot, say you do not have it—do not invent data.",
|
|
198
|
+
"Do **not** paste raw JSON, NDJSON lines, token counts, thread ids, or CLI event logs. Summarize only what answers the question. Use a short bullet list only when comparing several items the user asked about.",
|
|
199
|
+
"When the runtime expects structured JSON, put your natural-language answer for the operator in employee_comment (or the tool’s primary summary field)."
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assistantCliTimeoutMs(runtimeTimeoutSec: number): number {
|
|
204
|
+
if (runtimeTimeoutSec > 0) {
|
|
205
|
+
return Math.min(30 * 60 * 1000, runtimeTimeoutSec * 1000);
|
|
206
|
+
}
|
|
207
|
+
const env = Number(process.env.BOPO_ASSISTANT_CLI_TIMEOUT_MS);
|
|
208
|
+
if (Number.isFinite(env) && env > 0) {
|
|
209
|
+
return Math.min(30 * 60 * 1000, Math.max(120_000, env));
|
|
210
|
+
}
|
|
211
|
+
return 15 * 60 * 1000;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function resolveOwnerAssistantNormalizedRuntime(db: BopoDb, companyId: string) {
|
|
215
|
+
const defaultCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
216
|
+
await mkdir(defaultCwd, { recursive: true });
|
|
217
|
+
return normalizeRuntimeConfig({ legacy: {}, defaultRuntimeCwd: defaultCwd });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ownerAssistantBaseEnv(companyId: string): Record<string, string> {
|
|
221
|
+
return {
|
|
222
|
+
BOPODEV_COMPANY_ID: companyId,
|
|
223
|
+
BOPODEV_ACTOR_TYPE: "human",
|
|
224
|
+
BOPODEV_ACTOR_ID: "owner_assistant",
|
|
225
|
+
BOPODEV_ACTOR_COMPANIES: companyId
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function buildOwnerCliPrompt(
|
|
230
|
+
db: BopoDb,
|
|
231
|
+
companyId: string,
|
|
232
|
+
userMessage: string,
|
|
233
|
+
ceoDisplayName: string
|
|
234
|
+
) {
|
|
235
|
+
const snapshot = await buildCompanyAssistantContextSnapshot(db, companyId, userMessage);
|
|
236
|
+
return [
|
|
237
|
+
buildOwnerCliInstructions(ceoDisplayName),
|
|
238
|
+
"",
|
|
239
|
+
"## Operator question",
|
|
240
|
+
userMessage,
|
|
241
|
+
"",
|
|
242
|
+
"## Company snapshot (JSON)",
|
|
243
|
+
snapshot
|
|
244
|
+
].join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function runCompanyAssistantBrainCliTurn(input: {
|
|
248
|
+
db: BopoDb;
|
|
249
|
+
companyId: string;
|
|
250
|
+
providerType: AskCliBrainId;
|
|
251
|
+
userMessage: string;
|
|
252
|
+
ceoDisplayName: string;
|
|
253
|
+
}): Promise<{
|
|
254
|
+
assistantBody: string;
|
|
255
|
+
provider: string;
|
|
256
|
+
elapsedMs: number;
|
|
257
|
+
tokenInput: number;
|
|
258
|
+
tokenOutput: number;
|
|
259
|
+
usdCost: number;
|
|
260
|
+
}> {
|
|
261
|
+
const { providerType } = input;
|
|
262
|
+
const n = await resolveOwnerAssistantNormalizedRuntime(input.db, input.companyId);
|
|
263
|
+
const cwd = n.runtimeCwd?.trim();
|
|
264
|
+
if (requiresRuntimeCwd(providerType) && !cwd) {
|
|
265
|
+
throw new Error("Could not resolve a runtime working directory for this company.");
|
|
266
|
+
}
|
|
267
|
+
const timeoutMs = assistantCliTimeoutMs(n.runtimeTimeoutSec);
|
|
268
|
+
const baseEnv = { ...ownerAssistantBaseEnv(input.companyId), ...n.runtimeEnv };
|
|
269
|
+
const model = n.runtimeModel?.trim() || resolveDefaultRuntimeModelForProvider(providerType) || "";
|
|
270
|
+
|
|
271
|
+
const prompt = await buildOwnerCliPrompt(
|
|
272
|
+
input.db,
|
|
273
|
+
input.companyId,
|
|
274
|
+
input.userMessage,
|
|
275
|
+
input.ceoDisplayName
|
|
276
|
+
);
|
|
277
|
+
const started = Date.now();
|
|
278
|
+
|
|
279
|
+
if (providerType === "codex" || providerType === "claude_code") {
|
|
280
|
+
const config: AgentRuntimeConfig = {
|
|
281
|
+
command: n.runtimeCommand,
|
|
282
|
+
args: n.runtimeArgs,
|
|
283
|
+
cwd,
|
|
284
|
+
timeoutMs,
|
|
285
|
+
interruptGraceSec: n.interruptGraceSec,
|
|
286
|
+
retryCount: providerType === "codex" ? 1 : 0,
|
|
287
|
+
env: baseEnv,
|
|
288
|
+
model: model || undefined,
|
|
289
|
+
thinkingEffort: n.runtimeThinkingEffort,
|
|
290
|
+
bootstrapPrompt: n.bootstrapPrompt,
|
|
291
|
+
runPolicy: n.runPolicy
|
|
292
|
+
};
|
|
293
|
+
const runtime = await executeAgentRuntime(providerType, prompt, config);
|
|
294
|
+
return finishOwnerCliTurn(runtime, providerType, started);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (providerType === "gemini_cli") {
|
|
298
|
+
const command = n.runtimeCommand?.trim() || "gemini";
|
|
299
|
+
const args = ["--output-format", "stream-json", "--approval-mode", "yolo", "--sandbox=none"];
|
|
300
|
+
if (model) {
|
|
301
|
+
args.push("--model", model);
|
|
302
|
+
}
|
|
303
|
+
args.push(...(n.runtimeArgs ?? []));
|
|
304
|
+
args.push(prompt);
|
|
305
|
+
const runtime = await executePromptRuntime(
|
|
306
|
+
command,
|
|
307
|
+
prompt,
|
|
308
|
+
{
|
|
309
|
+
cwd: cwd!,
|
|
310
|
+
args,
|
|
311
|
+
timeoutMs,
|
|
312
|
+
retryCount: 0,
|
|
313
|
+
env: baseEnv,
|
|
314
|
+
model: model || undefined,
|
|
315
|
+
interruptGraceSec: n.interruptGraceSec
|
|
316
|
+
},
|
|
317
|
+
{ provider: "gemini_cli" }
|
|
318
|
+
);
|
|
319
|
+
return finishOwnerCliTurn(runtime, providerType, started);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (providerType === "opencode") {
|
|
323
|
+
const resolvedModel = model || resolveDefaultRuntimeModelForProvider("opencode");
|
|
324
|
+
if (!resolvedModel) {
|
|
325
|
+
throw new Error("OpenCode requires a model id (provider/model format).");
|
|
326
|
+
}
|
|
327
|
+
await ensureOpenCodeModelConfiguredAndAvailable({
|
|
328
|
+
model: resolvedModel,
|
|
329
|
+
command: n.runtimeCommand,
|
|
330
|
+
cwd,
|
|
331
|
+
env: baseEnv
|
|
332
|
+
});
|
|
333
|
+
const cmd = n.runtimeCommand?.trim() || "opencode";
|
|
334
|
+
const args = ["run", "--format", "json", "--model", resolvedModel, ...(n.runtimeArgs ?? [])];
|
|
335
|
+
const runtime = await executePromptRuntime(
|
|
336
|
+
cmd,
|
|
337
|
+
prompt,
|
|
338
|
+
{
|
|
339
|
+
cwd: cwd!,
|
|
340
|
+
args,
|
|
341
|
+
timeoutMs,
|
|
342
|
+
retryCount: 0,
|
|
343
|
+
env: baseEnv,
|
|
344
|
+
model: resolvedModel,
|
|
345
|
+
interruptGraceSec: n.interruptGraceSec
|
|
346
|
+
},
|
|
347
|
+
{ provider: "opencode" }
|
|
348
|
+
);
|
|
349
|
+
return finishOwnerCliTurn(runtime, providerType, started);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (providerType === "cursor") {
|
|
353
|
+
const cursorLaunch = await resolveCursorLaunchConfig({
|
|
354
|
+
command: n.runtimeCommand,
|
|
355
|
+
args: n.runtimeArgs,
|
|
356
|
+
cwd,
|
|
357
|
+
env: baseEnv
|
|
358
|
+
});
|
|
359
|
+
const c = cwd!;
|
|
360
|
+
const buildArgs = () => {
|
|
361
|
+
const baseArgs = [...cursorLaunch.prefixArgs, "-p", "--output-format", "stream-json", "--workspace", c];
|
|
362
|
+
if (model) {
|
|
363
|
+
baseArgs.push("--model", model);
|
|
364
|
+
}
|
|
365
|
+
if (!hasTrustFlag(n.runtimeArgs ?? [])) {
|
|
366
|
+
baseArgs.push("--yolo");
|
|
367
|
+
}
|
|
368
|
+
return [...baseArgs, ...(n.runtimeArgs ?? [])];
|
|
369
|
+
};
|
|
370
|
+
const runtime = await executePromptRuntime(
|
|
371
|
+
cursorLaunch.command,
|
|
372
|
+
prompt,
|
|
373
|
+
{
|
|
374
|
+
cwd: c,
|
|
375
|
+
args: buildArgs(),
|
|
376
|
+
timeoutMs,
|
|
377
|
+
retryCount: 0,
|
|
378
|
+
env: baseEnv,
|
|
379
|
+
model: model || undefined,
|
|
380
|
+
interruptGraceSec: n.interruptGraceSec
|
|
381
|
+
},
|
|
382
|
+
{ provider: "cursor" }
|
|
383
|
+
);
|
|
384
|
+
return finishOwnerCliTurn(runtime, providerType, started);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
throw new Error(`CLI assistant not implemented for ${providerType}.`);
|
|
388
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { BopoDb } from "bopodev-db";
|
|
2
|
+
import {
|
|
3
|
+
aggregateCompanyCostLedgerAllTime,
|
|
4
|
+
aggregateCompanyCostLedgerInRange,
|
|
5
|
+
getCompany,
|
|
6
|
+
listAgents,
|
|
7
|
+
listApprovalRequests,
|
|
8
|
+
listCostEntries,
|
|
9
|
+
listGoals,
|
|
10
|
+
listHeartbeatRuns,
|
|
11
|
+
listIssueGoalIdsBatch,
|
|
12
|
+
listIssues,
|
|
13
|
+
listProjects
|
|
14
|
+
} from "bopodev-db";
|
|
15
|
+
import { loadAgentMemoryContext } from "./memory-file-service";
|
|
16
|
+
|
|
17
|
+
const MAX_SNAPSHOT_CHARS = 96_000;
|
|
18
|
+
|
|
19
|
+
function parseJsonArray(raw: string | null | undefined): string[] {
|
|
20
|
+
if (!raw) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const v = JSON.parse(raw) as unknown;
|
|
25
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function serializeIssue(row: Record<string, unknown>, goalIds: string[]) {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
projectId: row.projectId,
|
|
35
|
+
parentIssueId: row.parentIssueId ?? null,
|
|
36
|
+
loopId: row.loopId ?? null,
|
|
37
|
+
title: row.title,
|
|
38
|
+
body: row.body ?? null,
|
|
39
|
+
status: row.status,
|
|
40
|
+
priority: row.priority,
|
|
41
|
+
assigneeAgentId: row.assigneeAgentId ?? null,
|
|
42
|
+
labels: parseJsonArray(row.labelsJson as string),
|
|
43
|
+
tags: parseJsonArray(row.tagsJson as string),
|
|
44
|
+
goalIds,
|
|
45
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COST_SNAPSHOT_ROW_LIMIT = 60;
|
|
50
|
+
|
|
51
|
+
function serializeCostRow(row: {
|
|
52
|
+
id: string;
|
|
53
|
+
runId: string | null;
|
|
54
|
+
projectId: string | null;
|
|
55
|
+
issueId: string | null;
|
|
56
|
+
agentId: string | null;
|
|
57
|
+
providerType: string;
|
|
58
|
+
runtimeModelId: string | null;
|
|
59
|
+
tokenInput: number;
|
|
60
|
+
tokenOutput: number;
|
|
61
|
+
usdCost: string;
|
|
62
|
+
usdCostStatus: string | null;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
}) {
|
|
65
|
+
return {
|
|
66
|
+
id: row.id,
|
|
67
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
68
|
+
providerType: row.providerType,
|
|
69
|
+
runtimeModelId: row.runtimeModelId ?? null,
|
|
70
|
+
agentId: row.agentId ?? null,
|
|
71
|
+
runId: row.runId ?? null,
|
|
72
|
+
issueId: row.issueId ?? null,
|
|
73
|
+
projectId: row.projectId ?? null,
|
|
74
|
+
tokenInput: row.tokenInput,
|
|
75
|
+
tokenOutput: row.tokenOutput,
|
|
76
|
+
usdCost: String(row.usdCost),
|
|
77
|
+
usdCostStatus: row.usdCostStatus ?? null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sumCostRows(
|
|
82
|
+
rows: Array<{ tokenInput: number; tokenOutput: number; usdCost: string }>
|
|
83
|
+
): { tokenInput: number; tokenOutput: number; usd: number } {
|
|
84
|
+
let tokenInput = 0;
|
|
85
|
+
let tokenOutput = 0;
|
|
86
|
+
let usd = 0;
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
tokenInput += Number(r.tokenInput) || 0;
|
|
89
|
+
tokenOutput += Number(r.tokenOutput) || 0;
|
|
90
|
+
usd += Number.parseFloat(String(r.usdCost)) || 0;
|
|
91
|
+
}
|
|
92
|
+
return { tokenInput, tokenOutput, usd };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sanitizeAgentRow(row: Record<string, unknown>) {
|
|
96
|
+
return {
|
|
97
|
+
id: row.id,
|
|
98
|
+
name: row.name,
|
|
99
|
+
role: row.role,
|
|
100
|
+
roleKey: row.roleKey ?? null,
|
|
101
|
+
title: row.title ?? null,
|
|
102
|
+
capabilities: row.capabilities ?? null,
|
|
103
|
+
status: row.status,
|
|
104
|
+
managerAgentId: row.managerAgentId ?? null,
|
|
105
|
+
providerType: row.providerType,
|
|
106
|
+
heartbeatCron: row.heartbeatCron,
|
|
107
|
+
canHireAgents: row.canHireAgents ?? null
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveMemoryAnchorAgentId(
|
|
112
|
+
agents: Awaited<ReturnType<typeof listAgents>>
|
|
113
|
+
): string | null {
|
|
114
|
+
const active = agents
|
|
115
|
+
.filter((a) => a.status !== "terminated")
|
|
116
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
117
|
+
return active[0]?.id ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read-only JSON bundle for owner-assistant CLI runs.
|
|
122
|
+
* Memory uses the first non-terminated agent as anchor (company + agent memory roots), or omits agent-only roots when none exist.
|
|
123
|
+
*/
|
|
124
|
+
export async function buildCompanyAssistantContextSnapshot(
|
|
125
|
+
db: BopoDb,
|
|
126
|
+
companyId: string,
|
|
127
|
+
userQueryHint: string
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
const agents = await listAgents(db, companyId);
|
|
130
|
+
const memoryAgentId = resolveMemoryAnchorAgentId(agents);
|
|
131
|
+
|
|
132
|
+
const company = await getCompany(db, companyId);
|
|
133
|
+
const projects = await listProjects(db, companyId);
|
|
134
|
+
const allIssues = await listIssues(db, companyId);
|
|
135
|
+
const issuesSorted = [...allIssues].sort((a, b) => {
|
|
136
|
+
const ta = a.updatedAt instanceof Date ? a.updatedAt.getTime() : 0;
|
|
137
|
+
const tb = b.updatedAt instanceof Date ? b.updatedAt.getTime() : 0;
|
|
138
|
+
return tb - ta;
|
|
139
|
+
});
|
|
140
|
+
let issueLimit = 45;
|
|
141
|
+
let issuesSlice = issuesSorted.slice(0, issueLimit);
|
|
142
|
+
let goalMap = await listIssueGoalIdsBatch(
|
|
143
|
+
db,
|
|
144
|
+
companyId,
|
|
145
|
+
issuesSlice.map((r) => r.id)
|
|
146
|
+
);
|
|
147
|
+
const goals = await listGoals(db, companyId);
|
|
148
|
+
const approvals = await listApprovalRequests(db, companyId);
|
|
149
|
+
const pending = approvals.filter((r) => r.status === "pending").slice(0, 25);
|
|
150
|
+
const runs = await listHeartbeatRuns(db, companyId, 18);
|
|
151
|
+
const costRowsRaw = await listCostEntries(db, companyId, COST_SNAPSHOT_ROW_LIMIT);
|
|
152
|
+
const costRows = costRowsRaw.map((r) =>
|
|
153
|
+
serializeCostRow({
|
|
154
|
+
id: r.id,
|
|
155
|
+
runId: r.runId ?? null,
|
|
156
|
+
projectId: r.projectId ?? null,
|
|
157
|
+
issueId: r.issueId ?? null,
|
|
158
|
+
agentId: r.agentId ?? null,
|
|
159
|
+
providerType: r.providerType,
|
|
160
|
+
runtimeModelId: r.runtimeModelId ?? null,
|
|
161
|
+
tokenInput: r.tokenInput ?? 0,
|
|
162
|
+
tokenOutput: r.tokenOutput ?? 0,
|
|
163
|
+
usdCost: String(r.usdCost ?? "0"),
|
|
164
|
+
usdCostStatus: r.usdCostStatus ?? null,
|
|
165
|
+
createdAt: r.createdAt instanceof Date ? r.createdAt : new Date(String(r.createdAt))
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
const costTotalsRecent = sumCostRows(costRows);
|
|
169
|
+
|
|
170
|
+
const monthRef = new Date();
|
|
171
|
+
const yUtc = monthRef.getUTCFullYear();
|
|
172
|
+
const mUtc = monthRef.getUTCMonth();
|
|
173
|
+
const monthStartUtc = new Date(Date.UTC(yUtc, mUtc, 1, 0, 0, 0, 0));
|
|
174
|
+
const monthEndExclusiveUtc = new Date(Date.UTC(yUtc, mUtc + 1, 1, 0, 0, 0, 0));
|
|
175
|
+
const [costMonthUtc, costAllTime] = await Promise.all([
|
|
176
|
+
aggregateCompanyCostLedgerInRange(db, companyId, monthStartUtc, monthEndExclusiveUtc),
|
|
177
|
+
aggregateCompanyCostLedgerAllTime(db, companyId)
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
const memoryContext = memoryAgentId
|
|
181
|
+
? await loadAgentMemoryContext({
|
|
182
|
+
companyId,
|
|
183
|
+
agentId: memoryAgentId,
|
|
184
|
+
projectIds: [],
|
|
185
|
+
queryText: userQueryHint.trim() || undefined
|
|
186
|
+
})
|
|
187
|
+
: {
|
|
188
|
+
memoryRoot: "",
|
|
189
|
+
tacitNotes: undefined as string | undefined,
|
|
190
|
+
durableFacts: [] as string[],
|
|
191
|
+
dailyNotes: [] as string[]
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const buildPayload = () => ({
|
|
195
|
+
company: company
|
|
196
|
+
? { id: company.id, name: company.name, mission: company.mission ?? null }
|
|
197
|
+
: { error: "not_found" },
|
|
198
|
+
projects: projects.map((p) => ({
|
|
199
|
+
id: p.id,
|
|
200
|
+
name: p.name,
|
|
201
|
+
status: p.status,
|
|
202
|
+
description: p.description ?? null
|
|
203
|
+
})),
|
|
204
|
+
issues: issuesSlice.map((r) =>
|
|
205
|
+
serializeIssue(r as unknown as Record<string, unknown>, goalMap.get(r.id) ?? [])
|
|
206
|
+
),
|
|
207
|
+
goals: goals.map((g) => ({
|
|
208
|
+
id: g.id,
|
|
209
|
+
title: g.title,
|
|
210
|
+
status: g.status,
|
|
211
|
+
level: g.level,
|
|
212
|
+
projectId: g.projectId ?? null,
|
|
213
|
+
description: g.description ?? null
|
|
214
|
+
})),
|
|
215
|
+
agents: agents
|
|
216
|
+
.filter((a) => a.status !== "terminated")
|
|
217
|
+
.map((a) => sanitizeAgentRow(a as unknown as Record<string, unknown>)),
|
|
218
|
+
pendingApprovals: pending.map((r) => ({
|
|
219
|
+
id: r.id,
|
|
220
|
+
action: r.action,
|
|
221
|
+
status: r.status,
|
|
222
|
+
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt)
|
|
223
|
+
})),
|
|
224
|
+
recentHeartbeatRuns: runs.map((r) => ({
|
|
225
|
+
id: r.id,
|
|
226
|
+
agentId: r.agentId,
|
|
227
|
+
status: r.status,
|
|
228
|
+
startedAt: r.startedAt instanceof Date ? r.startedAt.toISOString() : String(r.startedAt),
|
|
229
|
+
finishedAt: r.finishedAt
|
|
230
|
+
? r.finishedAt instanceof Date
|
|
231
|
+
? r.finishedAt.toISOString()
|
|
232
|
+
: String(r.finishedAt)
|
|
233
|
+
: null
|
|
234
|
+
})),
|
|
235
|
+
costAndUsage: {
|
|
236
|
+
note: `monthToDateUtc and allTime are exact sums over cost_ledger in the database. recentEntries (max ${COST_SNAPSHOT_ROW_LIMIT}) is for line-level detail only—do not treat totalsInListedRows as monthly or all-time totals.`,
|
|
237
|
+
monthToDateUtc: {
|
|
238
|
+
calendarMonth: `${yUtc}-${String(mUtc + 1).padStart(2, "0")}`,
|
|
239
|
+
rangeStartUtcInclusive: monthStartUtc.toISOString(),
|
|
240
|
+
rangeEndUtcExclusive: monthEndExclusiveUtc.toISOString(),
|
|
241
|
+
ledgerRowCount: costMonthUtc.rowCount,
|
|
242
|
+
tokenInput: costMonthUtc.tokenInput,
|
|
243
|
+
tokenOutput: costMonthUtc.tokenOutput,
|
|
244
|
+
usdTotal: costMonthUtc.usdTotal
|
|
245
|
+
},
|
|
246
|
+
allTime: {
|
|
247
|
+
ledgerRowCount: costAllTime.rowCount,
|
|
248
|
+
tokenInput: costAllTime.tokenInput,
|
|
249
|
+
tokenOutput: costAllTime.tokenOutput,
|
|
250
|
+
usdTotal: costAllTime.usdTotal
|
|
251
|
+
},
|
|
252
|
+
recentSample: {
|
|
253
|
+
rowCount: costRows.length,
|
|
254
|
+
totalsInListedRows: {
|
|
255
|
+
usd: costTotalsRecent.usd,
|
|
256
|
+
tokenInput: costTotalsRecent.tokenInput,
|
|
257
|
+
tokenOutput: costTotalsRecent.tokenOutput
|
|
258
|
+
},
|
|
259
|
+
entries: costRows
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
memoryContext: {
|
|
263
|
+
memoryRoot: memoryContext.memoryRoot,
|
|
264
|
+
tacitNotes: memoryContext.tacitNotes ?? null,
|
|
265
|
+
durableFacts: memoryContext.durableFacts ?? [],
|
|
266
|
+
dailyNotes: memoryContext.dailyNotes ?? []
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
let payload = buildPayload();
|
|
271
|
+
let raw = JSON.stringify(payload);
|
|
272
|
+
while (raw.length > MAX_SNAPSHOT_CHARS && issueLimit > 8) {
|
|
273
|
+
issueLimit -= 8;
|
|
274
|
+
issuesSlice = issuesSorted.slice(0, issueLimit);
|
|
275
|
+
goalMap = await listIssueGoalIdsBatch(
|
|
276
|
+
db,
|
|
277
|
+
companyId,
|
|
278
|
+
issuesSlice.map((r) => r.id)
|
|
279
|
+
);
|
|
280
|
+
payload = buildPayload();
|
|
281
|
+
raw = JSON.stringify(payload);
|
|
282
|
+
}
|
|
283
|
+
if (raw.length > MAX_SNAPSHOT_CHARS) {
|
|
284
|
+
return `${raw.slice(0, MAX_SNAPSHOT_CHARS)}\n…(snapshot truncated)`;
|
|
285
|
+
}
|
|
286
|
+
return raw;
|
|
287
|
+
}
|