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,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
+ }