bopodev-api 0.1.23 → 0.1.25

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.
@@ -1,9 +1,10 @@
1
1
  import { Router } from "express";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, join, resolve } from "node:path";
4
- import { and, eq } from "drizzle-orm";
4
+ import { and, desc, eq, inArray } from "drizzle-orm";
5
5
  import multer from "multer";
6
6
  import { z } from "zod";
7
+ import { IssueSchema } from "bopodev-contracts";
7
8
  import {
8
9
  addIssueAttachment,
9
10
  addIssueComment,
@@ -14,6 +15,7 @@ import {
14
15
  deleteIssueAttachment,
15
16
  deleteIssueComment,
16
17
  deleteIssue,
18
+ heartbeatRuns,
17
19
  getIssueAttachment,
18
20
  issues,
19
21
  listIssueAttachments,
@@ -27,10 +29,18 @@ import {
27
29
  } from "bopodev-db";
28
30
  import { nanoid } from "nanoid";
29
31
  import type { AppContext } from "../context";
30
- import { sendError, sendOk } from "../http";
32
+ import { sendError, sendOk, sendOkValidated } from "../http";
33
+ import {
34
+ dedupeCommentRecipients,
35
+ normalizeRecipientsForPersistence,
36
+ type CommentRecipientInput,
37
+ type PersistedCommentRecipient
38
+ } from "../lib/comment-recipients";
31
39
  import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
32
40
  import { requireCompanyScope } from "../middleware/company-scope";
33
41
  import { requirePermission } from "../middleware/request-actor";
42
+ import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
43
+ import { publishAttentionSnapshot } from "../realtime/attention";
34
44
 
35
45
  const createIssueSchema = z.object({
36
46
  projectId: z.string().min(1),
@@ -43,6 +53,8 @@ const createIssueSchema = z.object({
43
53
  .object({
44
54
  intentType: z.literal("agent_hiring_request"),
45
55
  requestedRole: z.string().nullable().optional(),
56
+ requestedRoleKey: z.string().nullable().optional(),
57
+ requestedTitle: z.string().nullable().optional(),
46
58
  requestedName: z.string().nullable().optional(),
47
59
  requestedManagerAgentId: z.string().nullable().optional(),
48
60
  requestedProviderType: z.string().nullable().optional(),
@@ -60,6 +72,14 @@ const createIssueSchema = z.object({
60
72
 
61
73
  const createIssueCommentSchema = z.object({
62
74
  body: z.string().min(1),
75
+ recipients: z
76
+ .array(
77
+ z.object({
78
+ recipientType: z.enum(["agent", "board", "member"]),
79
+ recipientId: z.string().nullable().optional()
80
+ })
81
+ )
82
+ .default([]),
63
83
  authorType: z.enum(["human", "agent", "system"]).optional(),
64
84
  authorId: z.string().optional()
65
85
  });
@@ -67,6 +87,14 @@ const createIssueCommentSchema = z.object({
67
87
  const createIssueCommentLegacySchema = z.object({
68
88
  issueId: z.string().min(1),
69
89
  body: z.string().min(1),
90
+ recipients: z
91
+ .array(
92
+ z.object({
93
+ recipientType: z.enum(["agent", "board", "member"]),
94
+ recipientId: z.string().nullable().optional()
95
+ })
96
+ )
97
+ .default([]),
70
98
  authorType: z.enum(["human", "agent", "system"]).optional(),
71
99
  authorId: z.string().optional()
72
100
  });
@@ -99,6 +127,7 @@ const ALLOWED_ATTACHMENT_EXTENSIONS = parseCsvSet(
99
127
  );
100
128
 
101
129
  type IssueAttachmentResponse = Record<string, unknown> & { id: string; downloadPath: string };
130
+ const COMMENT_RUN_ID_HEADER = "x-bopodev-run-id";
102
131
 
103
132
  function parseStringArray(value: unknown) {
104
133
  if (Array.isArray(value)) {
@@ -153,9 +182,11 @@ export function createIssuesRouter(ctx: AppContext) {
153
182
  router.get("/", async (req, res) => {
154
183
  const projectId = req.query.projectId?.toString();
155
184
  const rows = await listIssues(ctx.db, req.companyId!, projectId);
156
- return sendOk(
185
+ return sendOkValidated(
157
186
  res,
158
- rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>))
187
+ IssueSchema.array(),
188
+ rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>)),
189
+ "issues.list"
159
190
  );
160
191
  });
161
192
 
@@ -427,32 +458,13 @@ export function createIssuesRouter(ctx: AppContext) {
427
458
  if (!parsed.success) {
428
459
  return sendError(res, parsed.error.message, 422);
429
460
  }
430
- const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
431
- const comment = await addIssueComment(ctx.db, {
432
- companyId: req.companyId!,
461
+ return createIssueCommentWithRecipients(ctx, req, res, {
433
462
  issueId: req.params.issueId,
434
463
  body: parsed.data.body,
435
- authorType: author.authorType,
436
- authorId: author.authorId
437
- });
438
- await appendActivity(ctx.db, {
439
- companyId: req.companyId!,
440
- issueId: comment.issueId,
441
- actorType: comment.authorType,
442
- actorId: comment.authorId,
443
- eventType: "issue.comment_added",
444
- payload: { commentId: comment.id }
445
- });
446
- await appendAuditEvent(ctx.db, {
447
- companyId: req.companyId!,
448
- actorType: comment.authorType,
449
- actorId: comment.authorId,
450
- eventType: "issue.comment_added",
451
- entityType: "issue_comment",
452
- entityId: comment.id,
453
- payload: comment
464
+ recipients: parsed.data.recipients,
465
+ authorType: parsed.data.authorType,
466
+ authorId: parsed.data.authorId
454
467
  });
455
- return sendOk(res, comment);
456
468
  });
457
469
 
458
470
  // Backward-compatible endpoint used by older clients.
@@ -465,32 +477,13 @@ export function createIssuesRouter(ctx: AppContext) {
465
477
  if (!parsed.success) {
466
478
  return sendError(res, parsed.error.message, 422);
467
479
  }
468
- const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
469
- const comment = await addIssueComment(ctx.db, {
470
- companyId: req.companyId!,
480
+ return createIssueCommentWithRecipients(ctx, req, res, {
471
481
  issueId: parsed.data.issueId,
472
482
  body: parsed.data.body,
473
- authorType: author.authorType,
474
- authorId: author.authorId
475
- });
476
- await appendActivity(ctx.db, {
477
- companyId: req.companyId!,
478
- issueId: comment.issueId,
479
- actorType: comment.authorType,
480
- actorId: comment.authorId,
481
- eventType: "issue.comment_added",
482
- payload: { commentId: comment.id }
483
+ recipients: parsed.data.recipients,
484
+ authorType: parsed.data.authorType,
485
+ authorId: parsed.data.authorId
483
486
  });
484
- await appendAuditEvent(ctx.db, {
485
- companyId: req.companyId!,
486
- actorType: comment.authorType,
487
- actorId: comment.authorId,
488
- eventType: "issue.comment_added",
489
- entityType: "issue_comment",
490
- entityId: comment.id,
491
- payload: comment
492
- });
493
- return sendOk(res, comment);
494
487
  });
495
488
 
496
489
  router.put("/:issueId/comments/:commentId", async (req, res) => {
@@ -503,11 +496,16 @@ export function createIssuesRouter(ctx: AppContext) {
503
496
  return sendError(res, parsed.error.message, 422);
504
497
  }
505
498
 
499
+ const body =
500
+ req.actor?.type === "agent" ? sanitizeCommentBodyForAuthor(parsed.data.body, "agent") : parsed.data.body;
501
+ if (req.actor?.type === "agent" && !body) {
502
+ return sendError(res, "Agent comments must include non-emoji text.", 422);
503
+ }
506
504
  const comment = await updateIssueComment(ctx.db, {
507
505
  companyId: req.companyId!,
508
506
  issueId: req.params.issueId,
509
507
  id: req.params.commentId,
510
- body: parsed.data.body
508
+ body
511
509
  });
512
510
  if (!comment) {
513
511
  return sendError(res, "Comment not found.", 404);
@@ -516,13 +514,15 @@ export function createIssuesRouter(ctx: AppContext) {
516
514
  await appendActivity(ctx.db, {
517
515
  companyId: req.companyId!,
518
516
  issueId: req.params.issueId,
519
- actorType: "human",
517
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
518
+ actorId: req.actor?.id,
520
519
  eventType: "issue.comment_updated",
521
520
  payload: { commentId: comment.id }
522
521
  });
523
522
  await appendAuditEvent(ctx.db, {
524
523
  companyId: req.companyId!,
525
- actorType: "human",
524
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
525
+ actorId: req.actor?.id,
526
526
  eventType: "issue.comment_updated",
527
527
  entityType: "issue_comment",
528
528
  entityId: comment.id,
@@ -544,13 +544,15 @@ export function createIssuesRouter(ctx: AppContext) {
544
544
  await appendActivity(ctx.db, {
545
545
  companyId: req.companyId!,
546
546
  issueId: req.params.issueId,
547
- actorType: "human",
547
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
548
+ actorId: req.actor?.id,
548
549
  eventType: "issue.comment_deleted",
549
550
  payload: { commentId: req.params.commentId }
550
551
  });
551
552
  await appendAuditEvent(ctx.db, {
552
553
  companyId: req.companyId!,
553
- actorType: "human",
554
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
555
+ actorId: req.actor?.id,
554
556
  eventType: "issue.comment_deleted",
555
557
  entityType: "issue_comment",
556
558
  entityId: req.params.commentId,
@@ -680,20 +682,249 @@ function parsePayload(payloadJson: string) {
680
682
  }
681
683
  }
682
684
 
685
+ async function createIssueCommentWithRecipients(
686
+ ctx: AppContext,
687
+ req: {
688
+ companyId?: string;
689
+ actor?: { type?: "board" | "member" | "agent"; id?: string };
690
+ requestId?: string;
691
+ header(name: string): string | undefined;
692
+ },
693
+ res: Parameters<typeof sendOk>[0],
694
+ input: {
695
+ issueId: string;
696
+ body: string;
697
+ recipients: CommentRecipientInput[];
698
+ authorType?: "human" | "agent" | "system";
699
+ authorId?: string;
700
+ }
701
+ ) {
702
+ const spoofValidationError = validateCommentAuthorInput(req.actor?.type, req.actor?.id, {
703
+ authorType: input.authorType,
704
+ authorId: input.authorId
705
+ });
706
+ if (spoofValidationError) {
707
+ return sendError(res, spoofValidationError, 422);
708
+ }
709
+ const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, {
710
+ authorType: input.authorType,
711
+ authorId: input.authorId
712
+ });
713
+ let normalizedRecipients: PersistedCommentRecipient[];
714
+ try {
715
+ normalizedRecipients = await normalizeCommentRecipients(ctx, req.companyId!, input.recipients);
716
+ } catch (error) {
717
+ return sendError(res, error instanceof Error ? error.message : "Invalid recipients.", 422);
718
+ }
719
+ const runId = await resolveIssueCommentRunId(ctx, {
720
+ companyId: req.companyId!,
721
+ actorType: req.actor?.type,
722
+ actorId: req.actor?.id,
723
+ runIdHeader: req.header(COMMENT_RUN_ID_HEADER)
724
+ });
725
+ const sanitizedBody = sanitizeCommentBodyForAuthor(input.body, author.authorType);
726
+ if (!sanitizedBody) {
727
+ return sendError(res, "Agent/system comments must include non-emoji text.", 422);
728
+ }
729
+ const comment = await addIssueComment(ctx.db, {
730
+ companyId: req.companyId!,
731
+ issueId: input.issueId,
732
+ body: sanitizedBody,
733
+ authorType: author.authorType,
734
+ authorId: author.authorId,
735
+ runId,
736
+ recipients: normalizedRecipients
737
+ });
738
+ await appendActivity(ctx.db, {
739
+ companyId: req.companyId!,
740
+ issueId: comment.issueId,
741
+ actorType: coerceActorType(comment.authorType),
742
+ actorId: comment.authorId,
743
+ eventType: "issue.comment_added",
744
+ payload: {
745
+ commentId: comment.id,
746
+ runId: comment.runId ?? null,
747
+ recipientCount: normalizedRecipients.length
748
+ }
749
+ });
750
+ await appendAuditEvent(ctx.db, {
751
+ companyId: req.companyId!,
752
+ actorType: coerceActorType(comment.authorType),
753
+ actorId: comment.authorId,
754
+ eventType: "issue.comment_added",
755
+ entityType: "issue_comment",
756
+ entityId: comment.id,
757
+ payload: comment
758
+ });
759
+ if (normalizedRecipients.some((recipient) => recipient.recipientType === "board")) {
760
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
761
+ }
762
+ triggerIssueCommentDispatchWorker(ctx.db, req.companyId!, {
763
+ requestId: req.requestId,
764
+ realtimeHub: ctx.realtimeHub,
765
+ limit: 10
766
+ });
767
+ return sendOk(res, comment);
768
+ }
769
+
683
770
  function resolveIssueCommentAuthor(
684
771
  actorType: "board" | "member" | "agent" | undefined,
685
772
  actorId: string | undefined,
686
773
  input: { authorType?: "human" | "agent" | "system"; authorId?: string }
687
774
  ) {
688
- const inferredAuthorType = actorType === "agent" ? "agent" : "human";
689
- const authorType = input.authorType ?? inferredAuthorType;
690
- if (input.authorId) {
691
- return { authorType, authorId: input.authorId };
775
+ if ((actorType === "board" || actorType === undefined) && (input.authorType || input.authorId)) {
776
+ return {
777
+ authorType: input.authorType ?? "human",
778
+ authorId: input.authorId
779
+ };
780
+ }
781
+ if (actorType === "agent") {
782
+ return { authorType: "agent" as const, authorId: actorId };
783
+ }
784
+ return { authorType: "human" as const, authorId: actorId };
785
+ }
786
+
787
+ function validateCommentAuthorInput(
788
+ actorType: "board" | "member" | "agent" | undefined,
789
+ actorId: string | undefined,
790
+ input: { authorType?: "human" | "agent" | "system"; authorId?: string }
791
+ ) {
792
+ if (!input.authorType && !input.authorId) {
793
+ return null;
794
+ }
795
+ if (actorType !== "member" && actorType !== "agent") {
796
+ // Board/default channels are trusted for migration/backfill and may set explicit authorship.
797
+ return null;
798
+ }
799
+ const expectedAuthorType = actorType === "agent" ? "agent" : "human";
800
+ if (input.authorType && input.authorType !== expectedAuthorType) {
801
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
802
+ }
803
+ if (input.authorId && actorId && input.authorId !== actorId) {
804
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
805
+ }
806
+ if (input.authorId && !actorId) {
807
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
808
+ }
809
+ return null;
810
+ }
811
+
812
+ function coerceActorType(value: string | null | undefined): "human" | "agent" | "system" {
813
+ if (value === "agent" || value === "system") {
814
+ return value;
815
+ }
816
+ return "human";
817
+ }
818
+
819
+ function sanitizeCommentBodyForAuthor(body: string, authorType: "human" | "agent" | "system") {
820
+ if (authorType === "human") {
821
+ return body;
822
+ }
823
+ const withoutEmoji = body.replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, "");
824
+ const trimmed = withoutEmoji.trim();
825
+ const extractedSummary = extractSummaryFromJsonLikeText(trimmed);
826
+ const isPureJsonLike =
827
+ /^\s*\{[\s\S]*\}\s*$/m.test(trimmed) || /^\s*```(?:json)?[\s\S]*```\s*$/im.test(trimmed);
828
+ if (isPureJsonLike && extractedSummary) {
829
+ return extractedSummary;
830
+ }
831
+ const trailingJsonSummary = trimmed.match(/^(?<main>[\s\S]*?)\n+\{[\s\S]*"summary"\s*:\s*"[\s\S]*?"[\s\S]*\}\s*$/);
832
+ if (trailingJsonSummary?.groups?.main) {
833
+ return trailingJsonSummary.groups.main.trim();
834
+ }
835
+ return trimmed;
836
+ }
837
+
838
+ function extractSummaryFromJsonLikeText(input: string) {
839
+ const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
840
+ const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
841
+ if (!candidate) {
842
+ return null;
843
+ }
844
+ try {
845
+ const parsed = JSON.parse(candidate) as Record<string, unknown>;
846
+ const summary = parsed.summary;
847
+ if (typeof summary === "string" && summary.trim().length > 0) {
848
+ return summary.trim();
849
+ }
850
+ } catch {
851
+ // Fall through to regex extraction for loosely-formatted JSON.
852
+ }
853
+ const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
854
+ const summary = summaryMatch?.[1]
855
+ ?.replace(/\\"/g, "\"")
856
+ .replace(/\\n/g, " ")
857
+ .replace(/\s+/g, " ")
858
+ .trim();
859
+ return summary && summary.length > 0 ? summary : null;
860
+ }
861
+
862
+ async function resolveIssueCommentRunId(
863
+ ctx: AppContext,
864
+ input: {
865
+ companyId: string;
866
+ actorType: "board" | "member" | "agent" | undefined;
867
+ actorId: string | undefined;
868
+ runIdHeader: string | undefined;
869
+ }
870
+ ) {
871
+ const runId = input.runIdHeader?.trim();
872
+ if (runId) {
873
+ const [run] = await ctx.db
874
+ .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId })
875
+ .from(heartbeatRuns)
876
+ .where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, runId)))
877
+ .limit(1);
878
+ if (!run) {
879
+ return null;
880
+ }
881
+ if (input.actorType === "agent" && input.actorId && run.agentId !== input.actorId) {
882
+ return null;
883
+ }
884
+ return run.id;
885
+ }
886
+ if (input.actorType !== "agent" || !input.actorId) {
887
+ return null;
888
+ }
889
+ const [latestRun] = await ctx.db
890
+ .select({ id: heartbeatRuns.id })
891
+ .from(heartbeatRuns)
892
+ .where(
893
+ and(
894
+ eq(heartbeatRuns.companyId, input.companyId),
895
+ eq(heartbeatRuns.agentId, input.actorId),
896
+ eq(heartbeatRuns.status, "started")
897
+ )
898
+ )
899
+ .orderBy(desc(heartbeatRuns.startedAt))
900
+ .limit(1);
901
+ return latestRun?.id ?? null;
902
+ }
903
+
904
+ async function normalizeCommentRecipients(
905
+ ctx: AppContext,
906
+ companyId: string,
907
+ recipients: CommentRecipientInput[]
908
+ ): Promise<PersistedCommentRecipient[]> {
909
+ if (recipients.length === 0) {
910
+ return [] as PersistedCommentRecipient[];
692
911
  }
693
- if (authorType === "agent" && actorId) {
694
- return { authorType, authorId: actorId };
912
+ const deduped = dedupeCommentRecipients(recipients);
913
+ const agentIds = deduped
914
+ .filter((recipient) => recipient.recipientType === "agent" && recipient.recipientId)
915
+ .map((recipient) => recipient.recipientId as string);
916
+ if (agentIds.length > 0) {
917
+ const existingAgents = await ctx.db
918
+ .select({ id: agents.id })
919
+ .from(agents)
920
+ .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)));
921
+ const existingAgentIds = new Set(existingAgents.map((agent) => agent.id));
922
+ const missing = agentIds.find((id) => !existingAgentIds.has(id));
923
+ if (missing) {
924
+ throw new Error(`Recipient agent not found: ${missing}`);
925
+ }
695
926
  }
696
- return { authorType, authorId: undefined };
927
+ return normalizeRecipientsForPersistence(deduped);
697
928
  }
698
929
 
699
930
  async function validateIssueAssignmentScope(
@@ -2,9 +2,11 @@ import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  getHeartbeatRun,
5
+ listCompanies,
5
6
  listAgents,
6
7
  listAuditEvents,
7
8
  listCostEntries,
9
+ listGoals,
8
10
  listHeartbeatRunMessages,
9
11
  listHeartbeatRuns,
10
12
  listModelPricing,
@@ -15,7 +17,7 @@ import type { AppContext } from "../context";
15
17
  import { sendError, sendOk } from "../http";
16
18
  import { requireCompanyScope } from "../middleware/company-scope";
17
19
  import { requirePermission } from "../middleware/request-actor";
18
- import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
20
+ import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
19
21
 
20
22
  export function createObservabilityRouter(ctx: AppContext) {
21
23
  const router = Router();
@@ -266,6 +268,65 @@ export function createObservabilityRouter(ctx: AppContext) {
266
268
  }
267
269
  });
268
270
 
271
+ router.get("/memory/:agentId/context-preview", async (req, res) => {
272
+ const companyId = req.companyId!;
273
+ const agentId = req.params.agentId;
274
+ const projectIds = typeof req.query.projectIds === "string"
275
+ ? req.query.projectIds
276
+ .split(",")
277
+ .map((entry) => entry.trim())
278
+ .filter(Boolean)
279
+ : [];
280
+ const queryText = typeof req.query.query === "string" ? req.query.query.trim() : "";
281
+ const [agents, goals, companies] = await Promise.all([
282
+ listAgents(ctx.db, companyId),
283
+ listGoals(ctx.db, companyId),
284
+ listCompanies(ctx.db)
285
+ ]);
286
+ const agent = agents.find((entry) => entry.id === agentId);
287
+ if (!agent) {
288
+ return sendError(res, "Agent not found", 404);
289
+ }
290
+ const company = companies.find((entry) => entry.id === companyId);
291
+ const memoryContext = await loadAgentMemoryContext({
292
+ companyId,
293
+ agentId,
294
+ projectIds,
295
+ queryText: queryText.length > 0 ? queryText : undefined
296
+ });
297
+ const activeCompanyGoals = goals
298
+ .filter((goal) => goal.status === "active" && goal.level === "company")
299
+ .map((goal) => goal.title);
300
+ const activeProjectGoals = goals
301
+ .filter((goal) => goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId))
302
+ .map((goal) => goal.title);
303
+ const activeAgentGoals = goals
304
+ .filter((goal) => goal.status === "active" && goal.level === "agent")
305
+ .map((goal) => goal.title);
306
+ const compiledPreview = [
307
+ `Agent: ${agent.name} (${agent.role})`,
308
+ `Company mission: ${company?.mission ?? "No mission set"}`,
309
+ `Company goals: ${activeCompanyGoals.length > 0 ? activeCompanyGoals.join(" | ") : "None"}`,
310
+ `Project goals: ${activeProjectGoals.length > 0 ? activeProjectGoals.join(" | ") : "None"}`,
311
+ `Agent goals: ${activeAgentGoals.length > 0 ? activeAgentGoals.join(" | ") : "None"}`,
312
+ `Tacit notes: ${memoryContext.tacitNotes ?? "None"}`,
313
+ `Durable facts: ${memoryContext.durableFacts.join(" | ") || "None"}`,
314
+ `Daily notes: ${memoryContext.dailyNotes.join(" | ") || "None"}`
315
+ ].join("\n");
316
+ return sendOk(res, {
317
+ agentId,
318
+ projectIds,
319
+ companyMission: company?.mission ?? null,
320
+ goalContext: {
321
+ companyGoals: activeCompanyGoals,
322
+ projectGoals: activeProjectGoals,
323
+ agentGoals: activeAgentGoals
324
+ },
325
+ memoryContext,
326
+ compiledPreview
327
+ });
328
+ });
329
+
269
330
  router.get("/plugins/runs", async (req, res) => {
270
331
  const companyId = req.companyId!;
271
332
  const pluginId = typeof req.query.pluginId === "string" && req.query.pluginId.trim() ? req.query.pluginId.trim() : undefined;
@@ -2,6 +2,7 @@ import { Router } from "express";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
+ import { ProjectSchema } from "bopodev-contracts";
5
6
  import {
6
7
  appendAuditEvent,
7
8
  createProject,
@@ -15,7 +16,7 @@ import {
15
16
  updateProjectWorkspace
16
17
  } from "bopodev-db";
17
18
  import type { AppContext } from "../context";
18
- import { sendError, sendOk } from "../http";
19
+ import { sendError, sendOk, sendOkValidated } from "../http";
19
20
  import { normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
20
21
  import { requireCompanyScope } from "../middleware/company-scope";
21
22
  import { requirePermission } from "../middleware/request-actor";
@@ -50,6 +51,7 @@ const createProjectSchema = z.object({
50
51
  description: z.string().optional(),
51
52
  status: projectStatusSchema.default("planned"),
52
53
  plannedStartAt: z.string().optional(),
54
+ monthlyBudgetUsd: z.number().positive().default(100),
53
55
  executionWorkspacePolicy: executionWorkspacePolicySchema.optional().nullable(),
54
56
  workspace: z
55
57
  .object({
@@ -69,6 +71,7 @@ const updateProjectSchema = z
69
71
  description: z.string().nullable().optional(),
70
72
  status: projectStatusSchema.optional(),
71
73
  plannedStartAt: z.string().nullable().optional(),
74
+ monthlyBudgetUsd: z.number().positive().optional(),
72
75
  executionWorkspacePolicy: executionWorkspacePolicySchema.nullable().optional(),
73
76
  goalIds: z.array(z.string().min(1)).optional()
74
77
  })
@@ -122,7 +125,7 @@ export function createProjectsRouter(ctx: AppContext) {
122
125
  router.get("/", async (req, res) => {
123
126
  const projects = await listProjects(ctx.db, req.companyId!);
124
127
  const withDiagnostics = await Promise.all(projects.map((project) => enrichProjectDiagnostics(req.companyId!, project)));
125
- return sendOk(res, withDiagnostics);
128
+ return sendOkValidated(res, ProjectSchema.array(), withDiagnostics, "projects.list");
126
129
  });
127
130
 
128
131
  router.post("/", async (req, res) => {
@@ -140,6 +143,7 @@ export function createProjectsRouter(ctx: AppContext) {
140
143
  description: parsed.data.description,
141
144
  status: parsed.data.status,
142
145
  plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
146
+ monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
143
147
  executionWorkspacePolicy: parsed.data.executionWorkspacePolicy ?? null
144
148
  });
145
149
  if (!project) {
@@ -216,6 +220,7 @@ export function createProjectsRouter(ctx: AppContext) {
216
220
  status: parsed.data.status,
217
221
  plannedStartAt:
218
222
  parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
223
+ monthlyBudgetUsd: parsed.data.monthlyBudgetUsd === undefined ? undefined : parsed.data.monthlyBudgetUsd.toFixed(4),
219
224
  executionWorkspacePolicy: parsed.data.executionWorkspacePolicy
220
225
  });
221
226
  if (!project) {
@@ -101,7 +101,7 @@ export async function ensureOnboardingSeed(input: {
101
101
  const resolvedCompanyName = companyRow.name;
102
102
  await ensureCompanyBuiltinTemplateDefaults(db, companyId);
103
103
  const agents = await listAgents(db, companyId);
104
- const existingCeo = agents.find((agent) => agent.role === "CEO" || agent.name === "CEO");
104
+ const existingCeo = agents.find((agent) => agent.roleKey === "ceo" || agent.role === "CEO" || agent.name === "CEO");
105
105
  let ceoCreated = false;
106
106
  let ceoMigrated = false;
107
107
  let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
@@ -130,6 +130,8 @@ export async function ensureOnboardingSeed(input: {
130
130
  const ceo = await createAgent(db, {
131
131
  companyId,
132
132
  role: "CEO",
133
+ roleKey: "ceo",
134
+ title: "CEO",
133
135
  name: "CEO",
134
136
  providerType: agentProvider,
135
137
  heartbeatCron: "*/5 * * * *",
package/src/server.ts CHANGED
@@ -10,6 +10,7 @@ import { createApp } from "./app";
10
10
  import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
11
11
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
12
12
  import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
13
+ import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
13
14
  import { attachRealtimeHub } from "./realtime/hub";
14
15
  import {
15
16
  isAuthenticatedMode,
@@ -106,7 +107,8 @@ async function main() {
106
107
  bootstrapLoaders: {
107
108
  governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
108
109
  "office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId),
109
- "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
110
+ "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId),
111
+ attention: (companyId) => loadAttentionRealtimeSnapshot(db, companyId)
110
112
  }
111
113
  });
112
114
  const app = createApp({ db, deploymentMode, allowedOrigins, getRuntimeHealth, realtimeHub });