@dudousxd/nestjs-agent-store-drizzle 0.7.1 → 0.8.0

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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
2
2
  import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
3
- import { MessageRole, ToolCallRequest, ToolResult, MessageUsage, ToolKind, ToolCallStatus, UsagePurpose, AgentStore, CreateThreadInput, ThreadSummary, ThreadDetail, AppendMessageInput, StoredMessage, RecordToolCallInput, UpdateToolCallInput, RecordUsageInput, AgentGovernanceQueries, AgentPricingStore, GovernanceRange, ModelSpendRow, ActorSpendRow, ThreadSpendRow, UsageTrendPoint, ToolCallActivityRow, ThreadActivityRow, RunMetrics, RunAgentBreakdownRow, RunErrorBreakdownRow, RunTrendPoint, RecentRunRow, PendingApprovalRow, ToolStatRow, GovernancePageQuery, ToolCallWhere, GovernancePage, ThreadWhere, RunWhere, ModelPriceInput, CurrentModelPrice } from '@dudousxd/nestjs-agent-core';
3
+ import { MessageRole, ToolCallRequest, ToolResult, MessageUsage, ToolKind, ToolCallStatus, UsagePurpose, AgentStore, CreateThreadInput, ThreadSummary, ThreadDetail, AppendMessageInput, StoredMessage, RecordToolCallInput, UpdateToolCallInput, RecordUsageInput, AgentGovernanceQueries, AgentPricingStore, GovernanceRange, ModelSpendRow, ActorSpendRow, ThreadSpendRow, UsageTrendPoint, ToolCallActivityRow, ThreadActivityRow, RunMetrics, RunAgentBreakdownRow, RunErrorBreakdownRow, RunTrendPoint, RecentRunRow, PendingApprovalRow, ToolStatRow, GovernancePageQuery, ToolCallWhere, GovernancePage, ThreadWhere, RunWhere, ApprovalWhere, GovernanceRunDetail, GovernanceThreadDetailQuery, GovernanceThreadDetail, ModelPriceInput, CurrentModelPrice } from '@dudousxd/nestjs-agent-core';
4
4
  import { Type, DynamicModule } from '@nestjs/common';
5
5
 
6
6
  /**
@@ -2550,6 +2550,14 @@ declare class DrizzleGovernanceQueries implements AgentGovernanceQueries {
2550
2550
  spendByThread(range: GovernanceRange, limit: number): Promise<ThreadSpendRow[]>;
2551
2551
  usageTrend(range: GovernanceRange): Promise<UsageTrendPoint[]>;
2552
2552
  recentToolCalls(limit: number): Promise<ToolCallActivityRow[]>;
2553
+ /**
2554
+ * Message counts and token totals for a whole set of threads in TWO grouped queries, not two per
2555
+ * thread — a 200-row page used to cost four hundred round trips. Mirrors the MikroORM adapter's
2556
+ * `threadRollups`, which reaches the same numbers without a QueryBuilder.
2557
+ */
2558
+ private threadRollups;
2559
+ /** Decorate an ordered thread list with its batched rollups, preserving the incoming order. */
2560
+ private toThreadActivityRows;
2553
2561
  recentThreads(limit: number): Promise<ThreadActivityRow[]>;
2554
2562
  private runsInRange;
2555
2563
  runMetrics(range: GovernanceRange): Promise<RunMetrics>;
@@ -2565,8 +2573,10 @@ declare class DrizzleGovernanceQueries implements AgentGovernanceQueries {
2565
2573
  pendingApprovals(limit: number): Promise<PendingApprovalRow[]>;
2566
2574
  /**
2567
2575
  * Per-tool call/failure/rejection/latency rollup over the range, highest call count first.
2568
- * `p95ExecutionMs` is computed over calls that recorded a non-null `executionMs` (regardless of
2569
- * their final status); `null` when none did.
2576
+ * `p50ExecutionMs`/`p95ExecutionMs` are computed over calls that recorded a non-null `executionMs`
2577
+ * (regardless of their final status); both `null` when none did. Percentiles are taken in-process
2578
+ * off the sorted sample rather than in SQL, matching the MikroORM adapter — MySQL has no
2579
+ * `PERCENTILE_CONT` and one portable implementation beats three dialect-specific ones.
2570
2580
  */
2571
2581
  toolStats(range: GovernanceRange): Promise<ToolStatRow[]>;
2572
2582
  /**
@@ -2588,6 +2598,32 @@ declare class DrizzleGovernanceQueries implements AgentGovernanceQueries {
2588
2598
  * values outside the known literals can never match a row — short-circuits to an empty page.
2589
2599
  */
2590
2600
  runsPage(query: GovernancePageQuery<RunWhere>): Promise<GovernancePage<RecentRunRow>>;
2601
+ /**
2602
+ * Paged, filterable approvals inbox, oldest first — the same ordering as {@link pendingApprovals},
2603
+ * now with the `total` that method structurally cannot report. The join to `agent_message` /
2604
+ * `agent_thread` is unconditional (it already resolves title/actor/agentName), so the `threadId`/
2605
+ * `actorRef`/`agentName` filters cost nothing extra.
2606
+ *
2607
+ * The order is TOTAL (`created_at asc, id asc`, and `id` is the primary key), so no row can land on
2608
+ * two pages or on none for a given snapshot. Ascending order also means a newly requested approval
2609
+ * appends past the last page instead of shifting the page an operator is currently reading.
2610
+ */
2611
+ approvalsPage(query: GovernancePageQuery<ApprovalWhere>): Promise<GovernancePage<PendingApprovalRow>>;
2612
+ /**
2613
+ * One run, its owning thread's headline and its tool calls — TWO queries regardless of how many
2614
+ * tools the run called. `toolCalls` comes off `agent_tool_call.run_id`, so a run recorded before
2615
+ * that column was written reports none.
2616
+ */
2617
+ runDetail(runId: string): Promise<GovernanceRunDetail | null>;
2618
+ /**
2619
+ * One thread with its lifetime usage rollup, its newest runs and its newest messages. A fixed six
2620
+ * queries (thread, usage, message count, message page, that page's tool-call counts, runs+count) —
2621
+ * the per-message tool-call counts are ONE grouped read over the returned message ids, not one per
2622
+ * message. Soft-deleted threads are returned with `deleted: true`; an audit still needs them.
2623
+ */
2624
+ threadDetail(query: GovernanceThreadDetailQuery): Promise<GovernanceThreadDetail | null>;
2625
+ /** Tool-call counts for a set of messages in ONE grouped query — the detail view's no-N+1 guarantee. */
2626
+ private toolCallCountsByMessage;
2591
2627
  }
2592
2628
 
2593
2629
  /**
package/dist/index.js CHANGED
@@ -508,8 +508,8 @@ var DrizzleAgentStore = class {
508
508
  const start = /* @__PURE__ */ new Date(`${day}T00:00:00.000Z`);
509
509
  const end = /* @__PURE__ */ new Date(`${day}T23:59:59.999Z`);
510
510
  const rows = await this.db.select().from(agentTokenUsage).where(and(eq(agentTokenUsage.actorRef, actorRef), gte(agentTokenUsage.createdAt, start), lte(agentTokenUsage.createdAt, end)));
511
- const usedTokens = rows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0);
512
- const costUsd = rows.reduce((sum, row) => sum + (row.costUsd ?? 0), 0);
511
+ const usedTokens = rows.reduce((sum2, row) => sum2 + row.inputTokens + row.outputTokens, 0);
512
+ const costUsd = rows.reduce((sum2, row) => sum2 + (row.costUsd ?? 0), 0);
513
513
  return {
514
514
  usedTokens,
515
515
  costUsd
@@ -553,8 +553,8 @@ var DrizzleAgentStore = class {
553
553
  };
554
554
 
555
555
  // src/drizzle-governance-queries.ts
556
- import { bucketByActor, bucketByModel, bucketByThread, bucketUsageTrend, dayBoundsUtc } from "@dudousxd/nestjs-agent-core";
557
- import { and as and2, count, desc as desc2, eq as eq2, gte as gte2, inArray as inArray2, isNull as isNull2, like, lte as lte2, sql as sql3 } from "drizzle-orm";
556
+ import { bucketByActor, bucketByModel, bucketByThread, bucketUsageTrend, dayBoundsUtc, rollupThreadUsage, truncateDetailContent } from "@dudousxd/nestjs-agent-core";
557
+ import { and as and2, asc as asc2, count, desc as desc2, eq as eq2, gte as gte2, inArray as inArray2, isNull as isNull2, like, lte as lte2, sql as sql3, sum } from "drizzle-orm";
558
558
  var UNCLASSIFIED_ERROR_CODE = "unknown";
559
559
  var DEFAULT_AGENT_BUCKET = "(default)";
560
560
  var TOOL_CALL_STATUSES = [
@@ -609,6 +609,35 @@ function emptyPage(query) {
609
609
  };
610
610
  }
611
611
  __name(emptyPage, "emptyPage");
612
+ function toRecentRunRow(run) {
613
+ return {
614
+ runId: run.id,
615
+ threadId: run.threadId,
616
+ actorRef: run.actorRef,
617
+ agentName: run.agentName ?? null,
618
+ status: run.status,
619
+ durationMs: run.durationMs ?? null,
620
+ errorCode: run.errorCode ?? null,
621
+ errorMessage: run.errorMessage ?? null,
622
+ retries: run.retries,
623
+ startedAt: run.startedAt.toISOString(),
624
+ promptHash: run.promptHash ?? null
625
+ };
626
+ }
627
+ __name(toRecentRunRow, "toRecentRunRow");
628
+ function toRunToolCallRow(toolCall) {
629
+ return {
630
+ toolCallId: toolCall.id,
631
+ toolName: toolCall.toolName,
632
+ toolType: toolCall.toolType,
633
+ status: toolCall.status,
634
+ executionMs: toolCall.executionMs ?? null,
635
+ executedByRef: toolCall.executedByRef ?? null,
636
+ error: toolCall.error ?? null,
637
+ createdAt: toolCall.createdAt.toISOString()
638
+ };
639
+ }
640
+ __name(toRunToolCallRow, "toRunToolCallRow");
612
641
  function toUsageInput(row) {
613
642
  return {
614
643
  modelId: row.modelId,
@@ -718,25 +747,63 @@ var DrizzleGovernanceQueries = class {
718
747
  runId: row.runId
719
748
  }));
720
749
  }
721
- async recentThreads(limit) {
722
- const threads = await this.db.select().from(agentThread).where(isNull2(agentThread.deletedAt)).orderBy(desc2(agentThread.updatedAt), desc2(agentThread.id)).limit(limit);
723
- const result = [];
724
- for (const thread of threads) {
725
- const [messageCountRow] = await this.db.select({
726
- value: count()
727
- }).from(agentMessage).where(eq2(agentMessage.threadId, thread.id));
728
- const usageRows = await this.db.select().from(agentTokenUsage).where(eq2(agentTokenUsage.threadId, thread.id));
729
- const totalTokens = usageRows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0);
730
- result.push({
750
+ /**
751
+ * Message counts and token totals for a whole set of threads in TWO grouped queries, not two per
752
+ * thread — a 200-row page used to cost four hundred round trips. Mirrors the MikroORM adapter's
753
+ * `threadRollups`, which reaches the same numbers without a QueryBuilder.
754
+ */
755
+ async threadRollups(threadIds) {
756
+ const rollups = new Map(threadIds.map((threadId) => [
757
+ threadId,
758
+ {
759
+ messageCount: 0,
760
+ totalTokens: 0
761
+ }
762
+ ]));
763
+ if (threadIds.length === 0) {
764
+ return rollups;
765
+ }
766
+ const messageCounts = await this.db.select({
767
+ threadId: agentMessage.threadId,
768
+ value: count()
769
+ }).from(agentMessage).where(inArray2(agentMessage.threadId, threadIds)).groupBy(agentMessage.threadId);
770
+ for (const row of messageCounts) {
771
+ const bucket = rollups.get(row.threadId);
772
+ if (bucket !== void 0) {
773
+ bucket.messageCount = row.value;
774
+ }
775
+ }
776
+ const usageTotals = await this.db.select({
777
+ threadId: agentTokenUsage.threadId,
778
+ inputTokens: sum(agentTokenUsage.inputTokens),
779
+ outputTokens: sum(agentTokenUsage.outputTokens)
780
+ }).from(agentTokenUsage).where(inArray2(agentTokenUsage.threadId, threadIds)).groupBy(agentTokenUsage.threadId);
781
+ for (const row of usageTotals) {
782
+ const bucket = rollups.get(row.threadId);
783
+ if (bucket !== void 0) {
784
+ bucket.totalTokens = Number(row.inputTokens ?? 0) + Number(row.outputTokens ?? 0);
785
+ }
786
+ }
787
+ return rollups;
788
+ }
789
+ /** Decorate an ordered thread list with its batched rollups, preserving the incoming order. */
790
+ async toThreadActivityRows(threads) {
791
+ const rollups = await this.threadRollups(threads.map((thread) => thread.id));
792
+ return threads.map((thread) => {
793
+ const rollup = rollups.get(thread.id);
794
+ return {
731
795
  threadId: thread.id,
732
796
  title: thread.title,
733
797
  actorRef: thread.actorRef,
734
- messageCount: messageCountRow?.value ?? 0,
735
- totalTokens,
798
+ messageCount: rollup?.messageCount ?? 0,
799
+ totalTokens: rollup?.totalTokens ?? 0,
736
800
  lastActivityAt: thread.updatedAt.toISOString()
737
- });
738
- }
739
- return result;
801
+ };
802
+ });
803
+ }
804
+ async recentThreads(limit) {
805
+ const threads = await this.db.select().from(agentThread).where(isNull2(agentThread.deletedAt)).orderBy(desc2(agentThread.updatedAt), desc2(agentThread.id)).limit(limit);
806
+ return this.toThreadActivityRows(threads);
740
807
  }
741
808
  async runsInRange(range) {
742
809
  const { start, end } = dayBoundsUtc(range);
@@ -838,19 +905,7 @@ var DrizzleGovernanceQueries = class {
838
905
  }
839
906
  async recentRuns(limit) {
840
907
  const runs = await this.db.select().from(agentRun).orderBy(desc2(agentRun.startedAt), desc2(agentRun.id)).limit(limit);
841
- return runs.map((run) => ({
842
- runId: run.id,
843
- threadId: run.threadId,
844
- actorRef: run.actorRef,
845
- agentName: run.agentName ?? null,
846
- status: run.status,
847
- durationMs: run.durationMs ?? null,
848
- errorCode: run.errorCode ?? null,
849
- errorMessage: run.errorMessage ?? null,
850
- retries: run.retries,
851
- startedAt: run.startedAt.toISOString(),
852
- promptHash: run.promptHash ?? null
853
- }));
908
+ return runs.map(toRecentRunRow);
854
909
  }
855
910
  /**
856
911
  * Tool calls sitting `pending_approval`, oldest first (an inbox drains from the back), joined
@@ -883,8 +938,10 @@ var DrizzleGovernanceQueries = class {
883
938
  }
884
939
  /**
885
940
  * Per-tool call/failure/rejection/latency rollup over the range, highest call count first.
886
- * `p95ExecutionMs` is computed over calls that recorded a non-null `executionMs` (regardless of
887
- * their final status); `null` when none did.
941
+ * `p50ExecutionMs`/`p95ExecutionMs` are computed over calls that recorded a non-null `executionMs`
942
+ * (regardless of their final status); both `null` when none did. Percentiles are taken in-process
943
+ * off the sorted sample rather than in SQL, matching the MikroORM adapter — MySQL has no
944
+ * `PERCENTILE_CONT` and one portable implementation beats three dialect-specific ones.
888
945
  */
889
946
  async toolStats(range) {
890
947
  const { start, end } = dayBoundsUtc(range);
@@ -917,6 +974,7 @@ var DrizzleGovernanceQueries = class {
917
974
  calls: bucket.calls,
918
975
  failed: bucket.failed,
919
976
  rejected: bucket.rejected,
977
+ p50ExecutionMs: percentileMs(bucket.executionMs, 0.5),
920
978
  p95ExecutionMs: percentileMs(bucket.executionMs, 0.95)
921
979
  });
922
980
  }
@@ -978,22 +1036,7 @@ var DrizzleGovernanceQueries = class {
978
1036
  value: count()
979
1037
  }).from(agentThread).where(whereClause);
980
1038
  const threads = await this.db.select().from(agentThread).where(whereClause).orderBy(desc2(agentThread.updatedAt), desc2(agentThread.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
981
- const rows = [];
982
- for (const thread of threads) {
983
- const [messageCountRow] = await this.db.select({
984
- value: count()
985
- }).from(agentMessage).where(eq2(agentMessage.threadId, thread.id));
986
- const usageRows = await this.db.select().from(agentTokenUsage).where(eq2(agentTokenUsage.threadId, thread.id));
987
- const totalTokens = usageRows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0);
988
- rows.push({
989
- threadId: thread.id,
990
- title: thread.title,
991
- actorRef: thread.actorRef,
992
- messageCount: messageCountRow?.value ?? 0,
993
- totalTokens,
994
- lastActivityAt: thread.updatedAt.toISOString()
995
- });
996
- }
1039
+ const rows = await this.toThreadActivityRows(threads);
997
1040
  return {
998
1041
  rows,
999
1042
  total: totalRow?.value ?? 0,
@@ -1016,24 +1059,146 @@ var DrizzleGovernanceQueries = class {
1016
1059
  }).from(agentRun).where(whereClause);
1017
1060
  const runs = await this.db.select().from(agentRun).where(whereClause).orderBy(desc2(agentRun.startedAt), desc2(agentRun.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
1018
1061
  return {
1019
- rows: runs.map((run) => ({
1020
- runId: run.id,
1021
- threadId: run.threadId,
1022
- actorRef: run.actorRef,
1023
- agentName: run.agentName ?? null,
1024
- status: run.status,
1025
- durationMs: run.durationMs ?? null,
1026
- errorCode: run.errorCode ?? null,
1027
- errorMessage: run.errorMessage ?? null,
1028
- retries: run.retries,
1029
- startedAt: run.startedAt.toISOString(),
1030
- promptHash: run.promptHash ?? null
1062
+ rows: runs.map(toRecentRunRow),
1063
+ total: totalRow?.value ?? 0,
1064
+ page: query.page,
1065
+ pageSize: query.pageSize
1066
+ };
1067
+ }
1068
+ /**
1069
+ * Paged, filterable approvals inbox, oldest first — the same ordering as {@link pendingApprovals},
1070
+ * now with the `total` that method structurally cannot report. The join to `agent_message` /
1071
+ * `agent_thread` is unconditional (it already resolves title/actor/agentName), so the `threadId`/
1072
+ * `actorRef`/`agentName` filters cost nothing extra.
1073
+ *
1074
+ * The order is TOTAL (`created_at asc, id asc`, and `id` is the primary key), so no row can land on
1075
+ * two pages or on none for a given snapshot. Ascending order also means a newly requested approval
1076
+ * appends past the last page instead of shifting the page an operator is currently reading.
1077
+ */
1078
+ async approvalsPage(query) {
1079
+ const filters = query.where;
1080
+ const whereClause = and2(eq2(agentToolCall.status, "pending_approval"), filters?.toolName !== void 0 ? eq2(agentToolCall.toolName, filters.toolName) : void 0, filters?.threadId !== void 0 ? eq2(agentThread.id, filters.threadId) : void 0, filters?.actorRef !== void 0 ? eq2(agentThread.actorRef, filters.actorRef) : void 0, filters?.agentName !== void 0 ? eq2(agentMessage.agentName, filters.agentName) : void 0, filters?.fromDay !== void 0 ? gte2(agentToolCall.createdAt, dayStartUtc(filters.fromDay)) : void 0, filters?.toDay !== void 0 ? lte2(agentToolCall.createdAt, dayEndUtc(filters.toDay)) : void 0);
1081
+ const [totalRow] = await this.db.select({
1082
+ value: count()
1083
+ }).from(agentToolCall).innerJoin(agentMessage, eq2(agentToolCall.messageId, agentMessage.id)).innerJoin(agentThread, eq2(agentMessage.threadId, agentThread.id)).where(whereClause);
1084
+ const rows = await this.db.select({
1085
+ toolCallId: agentToolCall.id,
1086
+ toolName: agentToolCall.toolName,
1087
+ input: agentToolCall.input,
1088
+ threadId: agentThread.id,
1089
+ threadTitle: agentThread.title,
1090
+ actorRef: agentThread.actorRef,
1091
+ agentName: agentMessage.agentName,
1092
+ requestedAt: agentToolCall.createdAt,
1093
+ runId: agentToolCall.runId
1094
+ }).from(agentToolCall).innerJoin(agentMessage, eq2(agentToolCall.messageId, agentMessage.id)).innerJoin(agentThread, eq2(agentMessage.threadId, agentThread.id)).where(whereClause).orderBy(asc2(agentToolCall.createdAt), asc2(agentToolCall.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
1095
+ return {
1096
+ rows: rows.map((row) => ({
1097
+ toolCallId: row.toolCallId,
1098
+ toolName: row.toolName,
1099
+ input: row.input,
1100
+ threadId: row.threadId,
1101
+ threadTitle: row.threadTitle,
1102
+ actorRef: row.actorRef,
1103
+ agentName: row.agentName ?? null,
1104
+ requestedAt: row.requestedAt.toISOString(),
1105
+ runId: row.runId
1031
1106
  })),
1032
1107
  total: totalRow?.value ?? 0,
1033
1108
  page: query.page,
1034
1109
  pageSize: query.pageSize
1035
1110
  };
1036
1111
  }
1112
+ /**
1113
+ * One run, its owning thread's headline and its tool calls — TWO queries regardless of how many
1114
+ * tools the run called. `toolCalls` comes off `agent_tool_call.run_id`, so a run recorded before
1115
+ * that column was written reports none.
1116
+ */
1117
+ async runDetail(runId) {
1118
+ const [row] = await this.db.select({
1119
+ run: agentRun,
1120
+ thread: agentThread
1121
+ }).from(agentRun).innerJoin(agentThread, eq2(agentRun.threadId, agentThread.id)).where(eq2(agentRun.id, runId)).limit(1);
1122
+ if (row === void 0) {
1123
+ return null;
1124
+ }
1125
+ const toolCalls = await this.db.select().from(agentToolCall).where(eq2(agentToolCall.runId, runId)).orderBy(asc2(agentToolCall.createdAt), asc2(agentToolCall.id));
1126
+ return {
1127
+ run: toRecentRunRow(row.run),
1128
+ thread: {
1129
+ threadId: row.thread.id,
1130
+ title: row.thread.title,
1131
+ actorRef: row.thread.actorRef,
1132
+ deleted: row.thread.deletedAt != null
1133
+ },
1134
+ toolCalls: toolCalls.map(toRunToolCallRow)
1135
+ };
1136
+ }
1137
+ /**
1138
+ * One thread with its lifetime usage rollup, its newest runs and its newest messages. A fixed six
1139
+ * queries (thread, usage, message count, message page, that page's tool-call counts, runs+count) —
1140
+ * the per-message tool-call counts are ONE grouped read over the returned message ids, not one per
1141
+ * message. Soft-deleted threads are returned with `deleted: true`; an audit still needs them.
1142
+ */
1143
+ async threadDetail(query) {
1144
+ const [thread] = await this.db.select().from(agentThread).where(eq2(agentThread.id, query.threadId)).limit(1);
1145
+ if (thread === void 0) {
1146
+ return null;
1147
+ }
1148
+ const pricing = await this.loadPricing();
1149
+ const usageRows = await this.db.select().from(agentTokenUsage).where(eq2(agentTokenUsage.threadId, thread.id));
1150
+ const usage = rollupThreadUsage(usageRows.map(toUsageInput), pricing);
1151
+ const [messageCountRow] = await this.db.select({
1152
+ value: count()
1153
+ }).from(agentMessage).where(eq2(agentMessage.threadId, thread.id));
1154
+ const messages = await this.db.select().from(agentMessage).where(eq2(agentMessage.threadId, thread.id)).orderBy(desc2(agentMessage.createdAt), desc2(agentMessage.id)).limit(query.messageLimit);
1155
+ const toolCallCounts = await this.toolCallCountsByMessage(messages.map((message) => message.id));
1156
+ const [runTotalRow] = await this.db.select({
1157
+ value: count()
1158
+ }).from(agentRun).where(eq2(agentRun.threadId, thread.id));
1159
+ const runs = await this.db.select().from(agentRun).where(eq2(agentRun.threadId, thread.id)).orderBy(desc2(agentRun.startedAt), desc2(agentRun.id)).limit(query.runLimit);
1160
+ return {
1161
+ thread: {
1162
+ threadId: thread.id,
1163
+ title: thread.title,
1164
+ actorRef: thread.actorRef,
1165
+ messageCount: messageCountRow?.value ?? 0,
1166
+ totalTokens: usage.totalTokens,
1167
+ lastActivityAt: thread.updatedAt.toISOString()
1168
+ },
1169
+ deleted: thread.deletedAt != null,
1170
+ usage,
1171
+ runs: runs.map(toRecentRunRow),
1172
+ runTotal: runTotalRow?.value ?? 0,
1173
+ messages: messages.map((message) => {
1174
+ const { content, truncated } = truncateDetailContent(message.content);
1175
+ return {
1176
+ messageId: message.id,
1177
+ role: message.role,
1178
+ content,
1179
+ truncated,
1180
+ agentName: message.agentName ?? null,
1181
+ toolCallCount: toolCallCounts.get(message.id) ?? 0,
1182
+ createdAt: message.createdAt.toISOString()
1183
+ };
1184
+ })
1185
+ };
1186
+ }
1187
+ /** Tool-call counts for a set of messages in ONE grouped query — the detail view's no-N+1 guarantee. */
1188
+ async toolCallCountsByMessage(messageIds) {
1189
+ const counts = /* @__PURE__ */ new Map();
1190
+ if (messageIds.length === 0) {
1191
+ return counts;
1192
+ }
1193
+ const rows = await this.db.select({
1194
+ messageId: agentToolCall.messageId,
1195
+ value: count()
1196
+ }).from(agentToolCall).where(inArray2(agentToolCall.messageId, messageIds)).groupBy(agentToolCall.messageId);
1197
+ for (const row of rows) {
1198
+ counts.set(row.messageId, row.value);
1199
+ }
1200
+ return counts;
1201
+ }
1037
1202
  };
1038
1203
 
1039
1204
  // src/drizzle-pricing-store.ts