bopodev-db 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-db",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,67 @@
1
+ CREATE TABLE "work_loops" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
4
+ "project_id" text NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE,
5
+ "parent_issue_id" text REFERENCES "issues"("id") ON DELETE SET NULL,
6
+ "goal_ids_json" text DEFAULT '[]' NOT NULL,
7
+ "title" text NOT NULL,
8
+ "description" text,
9
+ "assignee_agent_id" text NOT NULL REFERENCES "agents"("id") ON DELETE RESTRICT,
10
+ "priority" text DEFAULT 'medium' NOT NULL,
11
+ "status" text DEFAULT 'active' NOT NULL,
12
+ "concurrency_policy" text DEFAULT 'coalesce_if_active' NOT NULL,
13
+ "catch_up_policy" text DEFAULT 'skip_missed' NOT NULL,
14
+ "last_triggered_at" timestamp,
15
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
16
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE TABLE "work_loop_triggers" (
20
+ "id" text PRIMARY KEY NOT NULL,
21
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
22
+ "work_loop_id" text NOT NULL REFERENCES "work_loops"("id") ON DELETE CASCADE,
23
+ "kind" text DEFAULT 'schedule' NOT NULL,
24
+ "label" text,
25
+ "enabled" boolean DEFAULT true NOT NULL,
26
+ "cron_expression" text NOT NULL,
27
+ "timezone" text DEFAULT 'UTC' NOT NULL,
28
+ "next_run_at" timestamp,
29
+ "last_fired_at" timestamp,
30
+ "last_result" text,
31
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
32
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
33
+ );
34
+ --> statement-breakpoint
35
+ CREATE TABLE "work_loop_runs" (
36
+ "id" text PRIMARY KEY NOT NULL,
37
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
38
+ "work_loop_id" text NOT NULL REFERENCES "work_loops"("id") ON DELETE CASCADE,
39
+ "trigger_id" text REFERENCES "work_loop_triggers"("id") ON DELETE SET NULL,
40
+ "source" text NOT NULL,
41
+ "status" text DEFAULT 'received' NOT NULL,
42
+ "triggered_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
43
+ "idempotency_key" text,
44
+ "payload_json" text DEFAULT '{}' NOT NULL,
45
+ "linked_issue_id" text REFERENCES "issues"("id") ON DELETE SET NULL,
46
+ "coalesced_into_run_id" text REFERENCES "work_loop_runs"("id") ON DELETE SET NULL,
47
+ "failure_reason" text,
48
+ "completed_at" timestamp,
49
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
50
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
51
+ );
52
+ --> statement-breakpoint
53
+ ALTER TABLE "issues" ADD COLUMN "loop_id" text REFERENCES "work_loops"("id") ON DELETE SET NULL;
54
+ --> statement-breakpoint
55
+ ALTER TABLE "issues" ADD COLUMN "loop_run_id" text REFERENCES "work_loop_runs"("id") ON DELETE SET NULL;
56
+ --> statement-breakpoint
57
+ CREATE INDEX "work_loops_company_status_idx" ON "work_loops" ("company_id","status");
58
+ --> statement-breakpoint
59
+ CREATE INDEX "work_loop_triggers_company_next_run_idx" ON "work_loop_triggers" ("company_id","next_run_at");
60
+ --> statement-breakpoint
61
+ CREATE INDEX "work_loop_triggers_work_loop_idx" ON "work_loop_triggers" ("work_loop_id");
62
+ --> statement-breakpoint
63
+ CREATE INDEX "work_loop_runs_work_loop_created_idx" ON "work_loop_runs" ("work_loop_id","created_at");
64
+ --> statement-breakpoint
65
+ CREATE INDEX "work_loop_runs_trigger_idempotency_idx" ON "work_loop_runs" ("trigger_id","idempotency_key");
66
+ --> statement-breakpoint
67
+ CREATE INDEX "issues_loop_id_idx" ON "issues" ("loop_id");
@@ -0,0 +1,20 @@
1
+ CREATE TABLE "company_assistant_threads" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
4
+ "created_at" timestamp DEFAULT now() NOT NULL,
5
+ "updated_at" timestamp DEFAULT now() NOT NULL
6
+ );
7
+
8
+ CREATE INDEX "company_assistant_threads_company_idx" ON "company_assistant_threads" ("company_id");
9
+
10
+ CREATE TABLE "company_assistant_messages" (
11
+ "id" text PRIMARY KEY NOT NULL,
12
+ "thread_id" text NOT NULL REFERENCES "company_assistant_threads"("id") ON DELETE CASCADE,
13
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
14
+ "role" text NOT NULL,
15
+ "body" text NOT NULL,
16
+ "metadata_json" text,
17
+ "created_at" timestamp DEFAULT now() NOT NULL
18
+ );
19
+
20
+ CREATE INDEX "company_assistant_messages_thread_created_idx" ON "company_assistant_messages" ("thread_id","created_at");
@@ -0,0 +1,7 @@
1
+ ALTER TABLE "cost_ledger" ADD COLUMN "cost_category" text;
2
+ --> statement-breakpoint
3
+ ALTER TABLE "cost_ledger" ADD COLUMN "assistant_thread_id" text REFERENCES "company_assistant_threads"("id") ON DELETE SET NULL;
4
+ --> statement-breakpoint
5
+ ALTER TABLE "cost_ledger" ADD COLUMN "assistant_message_id" text REFERENCES "company_assistant_messages"("id") ON DELETE SET NULL;
6
+ --> statement-breakpoint
7
+ CREATE INDEX "idx_cost_ledger_company_category_created" ON "cost_ledger" ("company_id", "cost_category", "created_at");
@@ -36,6 +36,27 @@
36
36
  "when": 1742900000000,
37
37
  "tag": "0004_agents_capabilities",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1743000000000,
44
+ "tag": "0005_work_loops",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1743100000000,
51
+ "tag": "0006_company_assistant_chat",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1743200000000,
58
+ "tag": "0007_cost_ledger_company_assistant",
59
+ "breakpoints": true
39
60
  }
40
61
  ]
41
62
  }
@@ -0,0 +1,126 @@
1
+ import { and, asc, count, desc, eq, gte, lt } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { BopoDb } from "../client";
4
+ import { companyAssistantMessages, companyAssistantThreads } from "../schema";
5
+
6
+ export type AssistantMessageRole = "user" | "assistant" | "system";
7
+
8
+ export async function getOrCreateAssistantThread(db: BopoDb, companyId: string) {
9
+ const [existing] = await db
10
+ .select()
11
+ .from(companyAssistantThreads)
12
+ .where(eq(companyAssistantThreads.companyId, companyId))
13
+ .orderBy(desc(companyAssistantThreads.updatedAt))
14
+ .limit(1);
15
+ if (existing) {
16
+ return existing;
17
+ }
18
+ return createAssistantThreadRow(db, companyId);
19
+ }
20
+
21
+ async function createAssistantThreadRow(db: BopoDb, companyId: string) {
22
+ const id = nanoid(16);
23
+ const now = new Date();
24
+ await db.insert(companyAssistantThreads).values({
25
+ id,
26
+ companyId,
27
+ createdAt: now,
28
+ updatedAt: now
29
+ });
30
+ const [row] = await db.select().from(companyAssistantThreads).where(eq(companyAssistantThreads.id, id)).limit(1);
31
+ return row!;
32
+ }
33
+
34
+ /** New empty thread; previous threads and messages remain in the database. */
35
+ export async function createAssistantThread(db: BopoDb, companyId: string) {
36
+ return createAssistantThreadRow(db, companyId);
37
+ }
38
+
39
+ export async function getAssistantThreadById(db: BopoDb, companyId: string, threadId: string) {
40
+ const [row] = await db
41
+ .select()
42
+ .from(companyAssistantThreads)
43
+ .where(and(eq(companyAssistantThreads.id, threadId), eq(companyAssistantThreads.companyId, companyId)))
44
+ .limit(1);
45
+ return row ?? null;
46
+ }
47
+
48
+ export async function touchAssistantThread(db: BopoDb, threadId: string) {
49
+ await db
50
+ .update(companyAssistantThreads)
51
+ .set({ updatedAt: new Date() })
52
+ .where(eq(companyAssistantThreads.id, threadId));
53
+ }
54
+
55
+ export async function insertAssistantMessage(
56
+ db: BopoDb,
57
+ input: {
58
+ threadId: string;
59
+ companyId: string;
60
+ role: AssistantMessageRole;
61
+ body: string;
62
+ metadataJson?: string | null;
63
+ }
64
+ ) {
65
+ const id = nanoid(16);
66
+ await db.insert(companyAssistantMessages).values({
67
+ id,
68
+ threadId: input.threadId,
69
+ companyId: input.companyId,
70
+ role: input.role,
71
+ body: input.body,
72
+ metadataJson: input.metadataJson ?? null
73
+ });
74
+ await touchAssistantThread(db, input.threadId);
75
+ const [row] = await db.select().from(companyAssistantMessages).where(eq(companyAssistantMessages.id, id)).limit(1);
76
+ return row!;
77
+ }
78
+
79
+ export async function listAssistantMessages(db: BopoDb, threadId: string, limit = 100) {
80
+ const capped = Math.min(Math.max(1, limit), 200);
81
+ return db
82
+ .select()
83
+ .from(companyAssistantMessages)
84
+ .where(eq(companyAssistantMessages.threadId, threadId))
85
+ .orderBy(asc(companyAssistantMessages.createdAt))
86
+ .limit(capped);
87
+ }
88
+
89
+ /** Threads with at least one message in `[startInclusive, endExclusive)` on `created_at`. */
90
+ export async function listAssistantChatThreadStatsInCreatedAtRange(
91
+ db: BopoDb,
92
+ companyId: string,
93
+ startInclusive: Date,
94
+ endExclusive: Date
95
+ ): Promise<Array<{ threadId: string; messageCount: number }>> {
96
+ const rows = await db
97
+ .select({
98
+ threadId: companyAssistantMessages.threadId,
99
+ messageCount: count()
100
+ })
101
+ .from(companyAssistantMessages)
102
+ .where(
103
+ and(
104
+ eq(companyAssistantMessages.companyId, companyId),
105
+ gte(companyAssistantMessages.createdAt, startInclusive),
106
+ lt(companyAssistantMessages.createdAt, endExclusive)
107
+ )
108
+ )
109
+ .groupBy(companyAssistantMessages.threadId);
110
+ return rows.map((r) => ({
111
+ threadId: r.threadId,
112
+ messageCount: Number(r.messageCount) || 0
113
+ }));
114
+ }
115
+
116
+ /** Threads with at least one message in the UTC calendar month (for callers without local bounds). */
117
+ export async function listAssistantChatThreadStatsInUtcMonth(
118
+ db: BopoDb,
119
+ companyId: string,
120
+ year: number,
121
+ month1Based: number
122
+ ): Promise<Array<{ threadId: string; messageCount: number }>> {
123
+ const startUtc = new Date(Date.UTC(year, month1Based - 1, 1, 0, 0, 0, 0));
124
+ const endExclusiveUtc = new Date(Date.UTC(year, month1Based, 1, 0, 0, 0, 0));
125
+ return listAssistantChatThreadStatsInCreatedAtRange(db, companyId, startUtc, endExclusiveUtc);
126
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./helpers";
2
2
  export * from "./companies";
3
+ export * from "./company-assistant-chat";
3
4
  export * from "./legacy";
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, gt, inArray, notInArray, sql } from "drizzle-orm";
1
+ import { and, asc, desc, eq, gt, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { BopoDb } from "../client";
4
4
  import {
@@ -521,6 +521,8 @@ export async function createIssue(
521
521
  assigneeAgentId?: string | null;
522
522
  labels?: string[];
523
523
  tags?: string[];
524
+ loopId?: string | null;
525
+ loopRunId?: string | null;
524
526
  }
525
527
  ) {
526
528
  await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
@@ -546,7 +548,9 @@ export async function createIssue(
546
548
  priority: input.priority ?? "none",
547
549
  assigneeAgentId: input.assigneeAgentId ?? null,
548
550
  labelsJson: JSON.stringify(input.labels ?? []),
549
- tagsJson: JSON.stringify(input.tags ?? [])
551
+ tagsJson: JSON.stringify(input.tags ?? []),
552
+ loopId: input.loopId ?? null,
553
+ loopRunId: input.loopRunId ?? null
550
554
  })
551
555
  .returning();
552
556
  if (!row) {
@@ -1514,6 +1518,10 @@ export async function appendCost(
1514
1518
  projectId?: string | null;
1515
1519
  issueId?: string | null;
1516
1520
  agentId?: string | null;
1521
+ /** Discriminator for reporting (e.g. `company_assistant`); null for heartbeat / legacy */
1522
+ costCategory?: string | null;
1523
+ assistantThreadId?: string | null;
1524
+ assistantMessageId?: string | null;
1517
1525
  }
1518
1526
  ) {
1519
1527
  const id = nanoid(14);
@@ -1521,6 +1529,9 @@ export async function appendCost(
1521
1529
  id,
1522
1530
  companyId: input.companyId,
1523
1531
  runId: input.runId ?? null,
1532
+ costCategory: input.costCategory ?? null,
1533
+ assistantThreadId: input.assistantThreadId ?? null,
1534
+ assistantMessageId: input.assistantMessageId ?? null,
1524
1535
  providerType: input.providerType,
1525
1536
  runtimeModelId: input.runtimeModelId ?? null,
1526
1537
  pricingProviderType: input.pricingProviderType ?? null,
@@ -1546,6 +1557,83 @@ export async function listCostEntries(db: BopoDb, companyId: string, limit = 200
1546
1557
  .limit(limit);
1547
1558
  }
1548
1559
 
1560
+ export type CostLedgerAggregate = {
1561
+ rowCount: number;
1562
+ tokenInput: number;
1563
+ tokenOutput: number;
1564
+ /** Sum of `usd_cost` as a decimal string (full precision from DB). */
1565
+ usdTotal: string;
1566
+ };
1567
+
1568
+ /** Sum every ledger row for the company in `[startInclusive, endExclusive)` (typically UTC month). */
1569
+ export async function aggregateCompanyCostLedgerInRange(
1570
+ db: BopoDb,
1571
+ companyId: string,
1572
+ startInclusive: Date,
1573
+ endExclusive: Date
1574
+ ): Promise<CostLedgerAggregate> {
1575
+ const [row] = await db
1576
+ .select({
1577
+ rowCount: sql<number>`count(*)::int`,
1578
+ tokenInput: sql<string>`coalesce(sum(${costLedger.tokenInput})::text, '0')`,
1579
+ tokenOutput: sql<string>`coalesce(sum(${costLedger.tokenOutput})::text, '0')`,
1580
+ usdTotal: sql<string>`coalesce(sum(${costLedger.usdCost})::text, '0')`
1581
+ })
1582
+ .from(costLedger)
1583
+ .where(
1584
+ and(
1585
+ eq(costLedger.companyId, companyId),
1586
+ gte(costLedger.createdAt, startInclusive),
1587
+ lt(costLedger.createdAt, endExclusive)
1588
+ )
1589
+ );
1590
+
1591
+ const parseBigIntish = (v: string) => {
1592
+ try {
1593
+ return Number(BigInt(v));
1594
+ } catch {
1595
+ return Number.parseInt(v, 10) || 0;
1596
+ }
1597
+ };
1598
+
1599
+ return {
1600
+ rowCount: Number(row?.rowCount ?? 0),
1601
+ tokenInput: parseBigIntish(String(row?.tokenInput ?? "0")),
1602
+ tokenOutput: parseBigIntish(String(row?.tokenOutput ?? "0")),
1603
+ usdTotal: String(row?.usdTotal ?? "0").trim() || "0"
1604
+ };
1605
+ }
1606
+
1607
+ export async function aggregateCompanyCostLedgerAllTime(
1608
+ db: BopoDb,
1609
+ companyId: string
1610
+ ): Promise<CostLedgerAggregate> {
1611
+ const [row] = await db
1612
+ .select({
1613
+ rowCount: sql<number>`count(*)::int`,
1614
+ tokenInput: sql<string>`coalesce(sum(${costLedger.tokenInput})::text, '0')`,
1615
+ tokenOutput: sql<string>`coalesce(sum(${costLedger.tokenOutput})::text, '0')`,
1616
+ usdTotal: sql<string>`coalesce(sum(${costLedger.usdCost})::text, '0')`
1617
+ })
1618
+ .from(costLedger)
1619
+ .where(eq(costLedger.companyId, companyId));
1620
+
1621
+ const parseBigIntish = (v: string) => {
1622
+ try {
1623
+ return Number(BigInt(v));
1624
+ } catch {
1625
+ return Number.parseInt(v, 10) || 0;
1626
+ }
1627
+ };
1628
+
1629
+ return {
1630
+ rowCount: Number(row?.rowCount ?? 0),
1631
+ tokenInput: parseBigIntish(String(row?.tokenInput ?? "0")),
1632
+ tokenOutput: parseBigIntish(String(row?.tokenOutput ?? "0")),
1633
+ usdTotal: String(row?.usdTotal ?? "0").trim() || "0"
1634
+ };
1635
+ }
1636
+
1549
1637
  export async function listHeartbeatRuns(db: BopoDb, companyId: string, limit = 100) {
1550
1638
  return db
1551
1639
  .select()
package/src/schema.ts CHANGED
@@ -121,6 +121,75 @@ export const issues = pgTable("issues", {
121
121
  externalLink: text("external_link"),
122
122
  isClaimed: boolean("is_claimed").notNull().default(false),
123
123
  claimedByHeartbeatRunId: text("claimed_by_heartbeat_run_id"),
124
+ /** Set when issue was created by a scheduled/manual work loop run (FK enforced in SQL migration). */
125
+ loopId: text("loop_id"),
126
+ loopRunId: text("loop_run_id"),
127
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
128
+ updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
129
+ });
130
+
131
+ export const workLoops = pgTable("work_loops", {
132
+ id: text("id").primaryKey(),
133
+ companyId: text("company_id")
134
+ .notNull()
135
+ .references(() => companies.id, { onDelete: "cascade" }),
136
+ projectId: text("project_id")
137
+ .notNull()
138
+ .references(() => projects.id, { onDelete: "cascade" }),
139
+ parentIssueId: text("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
140
+ goalIdsJson: text("goal_ids_json").notNull().default("[]"),
141
+ title: text("title").notNull(),
142
+ description: text("description"),
143
+ assigneeAgentId: text("assignee_agent_id")
144
+ .notNull()
145
+ .references(() => agents.id, { onDelete: "restrict" }),
146
+ priority: text("priority").notNull().default("medium"),
147
+ status: text("status").notNull().default("active"),
148
+ concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
149
+ catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
150
+ lastTriggeredAt: timestamp("last_triggered_at", { mode: "date" }),
151
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
152
+ updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
153
+ });
154
+
155
+ export const workLoopTriggers = pgTable("work_loop_triggers", {
156
+ id: text("id").primaryKey(),
157
+ companyId: text("company_id")
158
+ .notNull()
159
+ .references(() => companies.id, { onDelete: "cascade" }),
160
+ workLoopId: text("work_loop_id")
161
+ .notNull()
162
+ .references(() => workLoops.id, { onDelete: "cascade" }),
163
+ kind: text("kind").notNull().default("schedule"),
164
+ label: text("label"),
165
+ enabled: boolean("enabled").notNull().default(true),
166
+ cronExpression: text("cron_expression").notNull(),
167
+ timezone: text("timezone").notNull().default("UTC"),
168
+ nextRunAt: timestamp("next_run_at", { mode: "date" }),
169
+ lastFiredAt: timestamp("last_fired_at", { mode: "date" }),
170
+ lastResult: text("last_result"),
171
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
172
+ updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
173
+ });
174
+
175
+ export const workLoopRuns = pgTable("work_loop_runs", {
176
+ id: text("id").primaryKey(),
177
+ companyId: text("company_id")
178
+ .notNull()
179
+ .references(() => companies.id, { onDelete: "cascade" }),
180
+ workLoopId: text("work_loop_id")
181
+ .notNull()
182
+ .references(() => workLoops.id, { onDelete: "cascade" }),
183
+ triggerId: text("trigger_id").references(() => workLoopTriggers.id, { onDelete: "set null" }),
184
+ source: text("source").notNull(),
185
+ status: text("status").notNull().default("received"),
186
+ triggeredAt: timestamp("triggered_at", { mode: "date" }).defaultNow().notNull(),
187
+ idempotencyKey: text("idempotency_key"),
188
+ payloadJson: text("payload_json").notNull().default("{}"),
189
+ linkedIssueId: text("linked_issue_id").references(() => issues.id, { onDelete: "set null" }),
190
+ coalescedIntoRunId: text("coalesced_into_run_id"),
191
+ failureReason: text("failure_reason"),
192
+ completedAt: timestamp("completed_at", { mode: "date" }),
124
193
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
125
194
  updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
126
195
  });
@@ -297,6 +366,29 @@ export const attentionInboxStates = pgTable(
297
366
  (table) => [primaryKey({ columns: [table.companyId, table.actorId, table.itemKey] })]
298
367
  );
299
368
 
369
+ export const companyAssistantThreads = pgTable("company_assistant_threads", {
370
+ id: text("id").primaryKey(),
371
+ companyId: text("company_id")
372
+ .notNull()
373
+ .references(() => companies.id, { onDelete: "cascade" }),
374
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
375
+ updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
376
+ });
377
+
378
+ export const companyAssistantMessages = pgTable("company_assistant_messages", {
379
+ id: text("id").primaryKey(),
380
+ threadId: text("thread_id")
381
+ .notNull()
382
+ .references(() => companyAssistantThreads.id, { onDelete: "cascade" }),
383
+ companyId: text("company_id")
384
+ .notNull()
385
+ .references(() => companies.id, { onDelete: "cascade" }),
386
+ role: text("role").notNull(),
387
+ body: text("body").notNull(),
388
+ metadataJson: text("metadata_json"),
389
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
390
+ });
391
+
300
392
  export const costLedger = pgTable("cost_ledger", {
301
393
  id: text("id").primaryKey(),
302
394
  companyId: text("company_id")
@@ -306,6 +398,10 @@ export const costLedger = pgTable("cost_ledger", {
306
398
  projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
307
399
  issueId: text("issue_id").references(() => issues.id, { onDelete: "set null" }),
308
400
  agentId: text("agent_id").references(() => agents.id, { onDelete: "set null" }),
401
+ /** e.g. `company_assistant` for owner-assistant chat; null for heartbeat and legacy rows */
402
+ costCategory: text("cost_category"),
403
+ assistantThreadId: text("assistant_thread_id").references(() => companyAssistantThreads.id, { onDelete: "set null" }),
404
+ assistantMessageId: text("assistant_message_id").references(() => companyAssistantMessages.id, { onDelete: "set null" }),
309
405
  providerType: text("provider_type").notNull(),
310
406
  runtimeModelId: text("runtime_model_id"),
311
407
  pricingProviderType: text("pricing_provider_type"),
@@ -445,6 +541,9 @@ export const schema = {
445
541
  goals,
446
542
  agents,
447
543
  issues,
544
+ workLoops,
545
+ workLoopTriggers,
546
+ workLoopRuns,
448
547
  issueGoals,
449
548
  issueComments,
450
549
  issueAttachments,
@@ -464,7 +563,9 @@ export const schema = {
464
563
  templateVersions,
465
564
  templateInstalls,
466
565
  agentIssueLabels,
467
- projectWorkspaces
566
+ projectWorkspaces,
567
+ companyAssistantThreads,
568
+ companyAssistantMessages
468
569
  };
469
570
 
470
571
  export const touchUpdatedAtSql = sql`CURRENT_TIMESTAMP`;