@agent-native/core 0.161.20 → 0.161.22

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.
Files changed (52) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/actions/finalize-meeting.ts +57 -21
  3. package/corpus/templates/clips/actions/get-meeting.ts +11 -3
  4. package/corpus/templates/clips/actions/request-transcript.ts +183 -8
  5. package/corpus/templates/clips/actions/update-meeting.ts +66 -14
  6. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +80 -135
  7. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  8. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +470 -35
  9. package/corpus/templates/clips/changelog/2026-08-18-meeting-notes-support-rich-editing-for-personal-notes-summar.md +6 -0
  10. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +13 -3
  11. package/corpus/templates/mail/app/components/email/EmailList.tsx +74 -22
  12. package/corpus/templates/mail/app/components/email/infinite-pagination.ts +82 -0
  13. package/corpus/templates/mail/app/hooks/use-emails.ts +1 -0
  14. package/corpus/templates/mail/app/pages/InboxPage.tsx +15 -10
  15. package/corpus/templates/mail/app/pages/inbox-zero.ts +35 -0
  16. package/corpus/templates/mail/changelog/2026-08-18-pinned-labels-load-matches.md +6 -0
  17. package/corpus/templates/slides/actions/get-deck.ts +5 -1
  18. package/corpus/templates/slides/actions/list-decks.ts +29 -8
  19. package/corpus/templates/slides/actions/view-screen.ts +7 -3
  20. package/corpus/templates/slides/changelog/2026-08-18-decks-appear-under-mine.md +6 -0
  21. package/corpus/templates/slides/shared/ownership.ts +6 -0
  22. package/dist/app-config/schema.d.ts +2 -0
  23. package/dist/app-config/workspace.d.ts +2 -0
  24. package/dist/app-config/workspace.js +18 -0
  25. package/dist/catalog.json +23 -23
  26. package/dist/client/analytics.d.ts +1 -0
  27. package/dist/client/api-path.js +7 -3
  28. package/dist/client/frame.js +20 -6
  29. package/dist/client/org/workspace-app-links.js +10 -1
  30. package/dist/collab/awareness.d.ts +2 -2
  31. package/dist/collab/struct-routes.d.ts +1 -1
  32. package/dist/deploy/build.js +26 -2
  33. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  34. package/dist/notifications/routes.d.ts +1 -1
  35. package/dist/observability/routes.d.ts +6 -6
  36. package/dist/progress/routes.d.ts +1 -1
  37. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  38. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  39. package/dist/secrets/routes.d.ts +9 -9
  40. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  41. package/dist/server/app-origin-config.d.ts +1 -0
  42. package/dist/server/app-origin-config.js +3 -0
  43. package/dist/server/auth.js +2 -2
  44. package/dist/server/cookie-namespace.js +17 -2
  45. package/dist/server/credential-provider.js +5 -2
  46. package/dist/server/derived-secret.js +3 -2
  47. package/dist/server/onboarding-html.js +34 -2
  48. package/dist/server/workspace-oauth.js +11 -6
  49. package/dist/shared/runtime-config.d.ts +2 -0
  50. package/dist/shared/runtime-config.js +12 -3
  51. package/dist/vite/client.js +5 -2
  52. package/package.json +2 -2
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8314
34
+ - template files: 8320
@@ -95,6 +95,7 @@ export default defineAction({
95
95
  // stale (see PENDING_STALE_MS). A fresh 'pending' row may still be an
96
96
  // active text-model run, so it is left alone.
97
97
  const staleBefore = new Date(Date.now() - PENDING_STALE_MS).toISOString();
98
+ const claimUpdatedAt = nowIso;
98
99
  const claimPredicate = args.force
99
100
  ? or(
100
101
  inArray(schema.meetings.transcriptStatus, ["ready", "failed"]),
@@ -241,10 +242,13 @@ export default defineAction({
241
242
 
242
243
  // Single transaction so the meetings write and the action-items
243
244
  // delete+insert can't interleave with a second concurrent finalize call.
244
- // The meetings update is CAS-guarded on transcriptStatus='pending' (this
245
- // call's own claim), and the action-items delete+insert only runs when
246
- // that CAS actually matched.
245
+ // The meetings update is CAS-guarded on both transcriptStatus='pending'
246
+ // and the timestamp written by this call's claim. If a manual edit landed
247
+ // after the claim, finish the AI fields but preserve that edit's action
248
+ // items instead of replacing them with stale model output.
249
+ let persistedActionItems = actionItems;
247
250
  await db.transaction(async (tx) => {
251
+ const finalNow = new Date().toISOString();
248
252
  const written = await tx
249
253
  .update(schema.meetings)
250
254
  .set({
@@ -252,32 +256,64 @@ export default defineAction({
252
256
  summaryMd,
253
257
  bulletsJson: JSON.stringify(bullets),
254
258
  actionItemsJson: JSON.stringify(actionItems),
255
- updatedAt: new Date().toISOString(),
259
+ updatedAt: finalNow,
256
260
  })
257
261
  .where(
258
262
  and(
259
263
  eq(schema.meetings.id, args.meetingId),
260
264
  eq(schema.meetings.transcriptStatus, "pending"),
265
+ eq(schema.meetings.updatedAt, claimUpdatedAt),
261
266
  ),
262
267
  )
263
268
  .returning({ id: schema.meetings.id });
264
- if (!written.length) return;
269
+ if (written.length) {
270
+ // Replace the per-row action items so the dedicated table mirrors the
271
+ // JSON column. The meeting-row CAS above serializes this replacement
272
+ // with update-meeting's own meeting-row transaction.
273
+ await tx
274
+ .delete(schema.meetingActionItems)
275
+ .where(eq(schema.meetingActionItems.meetingId, args.meetingId));
276
+ if (actionItems.length) {
277
+ await tx.insert(schema.meetingActionItems).values(
278
+ actionItems.map((item) => ({
279
+ id: nanoid(),
280
+ meetingId: args.meetingId,
281
+ assigneeEmail: item.assigneeEmail ?? null,
282
+ text: item.text,
283
+ dueDate: item.dueDate ?? null,
284
+ completedAt: null,
285
+ })),
286
+ );
287
+ }
288
+ return;
289
+ }
265
290
 
266
- // Replace the per-row action items so the dedicated table mirrors the
267
- // JSON column.
268
- await tx
269
- .delete(schema.meetingActionItems)
270
- .where(eq(schema.meetingActionItems.meetingId, args.meetingId));
271
- if (actionItems.length) {
272
- await tx.insert(schema.meetingActionItems).values(
273
- actionItems.map((item) => ({
274
- id: nanoid(),
275
- meetingId: args.meetingId,
276
- assigneeEmail: item.assigneeEmail ?? null,
277
- text: item.text,
278
- dueDate: item.dueDate ?? null,
279
- completedAt: null,
280
- })),
291
+ const [preserved] = await tx
292
+ .update(schema.meetings)
293
+ .set({
294
+ transcriptStatus: "ready",
295
+ summaryMd,
296
+ bulletsJson: JSON.stringify(bullets),
297
+ updatedAt: finalNow,
298
+ })
299
+ .where(
300
+ and(
301
+ eq(schema.meetings.id, args.meetingId),
302
+ eq(schema.meetings.transcriptStatus, "pending"),
303
+ ),
304
+ )
305
+ .returning({ actionItemsJson: schema.meetings.actionItemsJson });
306
+ if (!preserved?.actionItemsJson) return;
307
+
308
+ try {
309
+ const currentActionItems = JSON.parse(preserved.actionItemsJson);
310
+ if (Array.isArray(currentActionItems)) {
311
+ persistedActionItems = currentActionItems;
312
+ }
313
+ } catch (error) {
314
+ console.warn(
315
+ "[finalize-meeting] preserved action items JSON was malformed; keeping dedicated rows",
316
+ error,
281
317
  );
282
318
  }
283
319
  });
@@ -288,7 +324,7 @@ export default defineAction({
288
324
  meetingId: args.meetingId,
289
325
  summaryMd,
290
326
  bullets,
291
- actionItems,
327
+ actionItems: persistedActionItems,
292
328
  provider: result.provider,
293
329
  };
294
330
  },
@@ -17,9 +17,11 @@ interface Bullet {
17
17
  text: string;
18
18
  }
19
19
  interface ActionItem {
20
- assigneeEmail?: string;
20
+ id?: string;
21
+ assigneeEmail?: string | null;
21
22
  text: string;
22
- dueDate?: string;
23
+ dueDate?: string | null;
24
+ completedAt?: string | null;
23
25
  }
24
26
 
25
27
  /**
@@ -100,10 +102,16 @@ export default defineAction({
100
102
  .from(schema.meetingParticipants)
101
103
  .where(eq(schema.meetingParticipants.meetingId, meetingId));
102
104
 
103
- const actionItems = await db
105
+ const actionItemRows = await db
104
106
  .select()
105
107
  .from(schema.meetingActionItems)
106
108
  .where(eq(schema.meetingActionItems.meetingId, meetingId));
109
+ // Older meetings may have action items only in the JSON column. Prefer
110
+ // the dedicated rows once present, but keep those legacy meetings
111
+ // editable instead of returning an empty action-item list.
112
+ const actionItems = actionItemRows.length
113
+ ? actionItemRows
114
+ : actionItemsParsed;
107
115
 
108
116
  let recording = null;
109
117
  let transcript = null;
@@ -63,6 +63,7 @@ import {
63
63
  buildCaptionSegmentsFromText,
64
64
  normalizeTranscriptSegments,
65
65
  parseTranscriptSegments,
66
+ type TranscriptSegment,
66
67
  } from "../shared/transcript-segments.js";
67
68
  import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js";
68
69
  import cleanupTranscript from "./cleanup-transcript.js";
@@ -498,6 +499,161 @@ function fullTextSegmentJson(
498
499
  return JSON.stringify(buildCaptionSegmentsFromText(text, durationMs));
499
500
  }
500
501
 
502
+ type WordSegmenterConstructor = new (
503
+ locale?: string | string[],
504
+ options?: { granularity: "word" },
505
+ ) => {
506
+ segment: (input: string) => Iterable<{ segment: string }>;
507
+ };
508
+
509
+ function splitMeasuredText(text: string): {
510
+ units: string[];
511
+ separator: "" | " ";
512
+ } {
513
+ const normalized = text.replace(/\s+/g, " ").trim();
514
+ if (!normalized) return { units: [], separator: " " };
515
+ if (/\s/.test(text)) {
516
+ return {
517
+ units: normalized.split(" ").filter(Boolean),
518
+ separator: " ",
519
+ };
520
+ }
521
+
522
+ // CJK and other scripts often have no spaces between words. Segment those
523
+ // with the runtime's locale data when available, then fall back to Unicode
524
+ // code points so every timed cue can still receive some cleaned text.
525
+ const Segmenter = (
526
+ Intl as typeof Intl & { Segmenter?: WordSegmenterConstructor }
527
+ ).Segmenter;
528
+ if (Segmenter) {
529
+ const segmented = Array.from(
530
+ new Segmenter(undefined, { granularity: "word" }).segment(normalized),
531
+ ({ segment }) => segment,
532
+ );
533
+ if (segmented.length > 1) {
534
+ return { units: segmented, separator: "" };
535
+ }
536
+ }
537
+
538
+ return { units: Array.from(normalized), separator: "" };
539
+ }
540
+
541
+ function normalizeAlignmentText(text: string): string {
542
+ return text
543
+ .normalize("NFKC")
544
+ .toLowerCase()
545
+ .replace(/[^\p{L}\p{N}]+/gu, "");
546
+ }
547
+
548
+ function hasMeasuredAttribution(segments: TranscriptSegment[]): boolean {
549
+ return segments.some(
550
+ (segment) => segment.source !== undefined || segment.speaker !== undefined,
551
+ );
552
+ }
553
+
554
+ /**
555
+ * Rewrite attributed cues only when the cleanup is a sequence-preserving
556
+ * normalization. Without a per-word speaker map, proportional redistribution
557
+ * can move words across a speaker boundary. Returning null tells the caller to
558
+ * keep the complete original transcript instead of storing mismatched fullText
559
+ * and segmentsJson.
560
+ */
561
+ function rewriteAttributedSegmentText(
562
+ segments: TranscriptSegment[],
563
+ cleanedText: string,
564
+ ): TranscriptSegment[] | null {
565
+ const originalAlignment = normalizeAlignmentText(
566
+ segments.map((segment) => segment.text).join(" "),
567
+ );
568
+ const cleanedAlignment = normalizeAlignmentText(cleanedText);
569
+ if (!originalAlignment || originalAlignment !== cleanedAlignment) return null;
570
+
571
+ const cleanedChars = Array.from(cleanedText);
572
+ let charIndex = 0;
573
+ const rewritten: TranscriptSegment[] = [];
574
+
575
+ for (const segment of segments) {
576
+ const target = normalizeAlignmentText(segment.text);
577
+ if (!target) return null;
578
+ const startIndex = charIndex;
579
+ let normalized = "";
580
+
581
+ while (
582
+ charIndex < cleanedChars.length &&
583
+ normalized.length < target.length
584
+ ) {
585
+ normalized += normalizeAlignmentText(cleanedChars[charIndex]);
586
+ if (!target.startsWith(normalized)) return null;
587
+ charIndex += 1;
588
+ }
589
+ if (normalized !== target) return null;
590
+
591
+ // Keep punctuation and whitespace with the preceding cue. They do not
592
+ // affect alignment, but dropping them makes the displayed transcript look
593
+ // broken at every measured speaker boundary.
594
+ while (
595
+ charIndex < cleanedChars.length &&
596
+ !normalizeAlignmentText(cleanedChars[charIndex])
597
+ ) {
598
+ charIndex += 1;
599
+ }
600
+
601
+ const text = cleanedChars.slice(startIndex, charIndex).join("").trim();
602
+ if (!text) return null;
603
+ rewritten.push({ ...segment, text });
604
+ }
605
+
606
+ if (normalizeAlignmentText(cleanedChars.slice(charIndex).join(""))) {
607
+ return null;
608
+ }
609
+ return rewritten;
610
+ }
611
+
612
+ function rewriteMeasuredSegmentText(
613
+ segments: TranscriptSegment[],
614
+ cleanedText: string,
615
+ ): TranscriptSegment[] | null {
616
+ const cleaned = splitMeasuredText(cleanedText);
617
+ if (segments.length === 0 || cleaned.units.length === 0) return [];
618
+
619
+ if (hasMeasuredAttribution(segments)) {
620
+ return rewriteAttributedSegmentText(segments, cleanedText);
621
+ }
622
+
623
+ const weights = segments.map((segment) =>
624
+ Math.max(1, splitMeasuredText(segment.text).units.length),
625
+ );
626
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
627
+ let unitIndex = 0;
628
+ let weightIndex = 0;
629
+
630
+ return segments.flatMap((segment, index) => {
631
+ const isLast = index === segments.length - 1;
632
+ const remainingSegments = segments.length - index - 1;
633
+ const targetEnd = isLast
634
+ ? cleaned.units.length
635
+ : Math.round(
636
+ (cleaned.units.length * (weightIndex + weights[index])) / totalWeight,
637
+ );
638
+ // Unattributed cues can safely be dropped when cleanup removes short
639
+ // filler. Keep the measured timings for the remaining cues and preserve
640
+ // the complete cleaned text instead of reverting to stale source text.
641
+ const minimumEnd =
642
+ cleaned.units.length >= segments.length ? unitIndex + 1 : unitIndex;
643
+ const maximumEnd = Math.max(
644
+ unitIndex,
645
+ cleaned.units.length - remainingSegments,
646
+ );
647
+ const end = isLast
648
+ ? cleaned.units.length
649
+ : Math.min(maximumEnd, Math.max(minimumEnd, targetEnd));
650
+ const text = cleaned.units.slice(unitIndex, end).join(cleaned.separator);
651
+ unitIndex = end;
652
+ weightIndex += weights[index];
653
+ return text ? [{ ...segment, text }] : [];
654
+ });
655
+ }
656
+
501
657
  /**
502
658
  * Pick the segments to store after cleanup rewrites the transcript text.
503
659
  *
@@ -511,9 +667,17 @@ export function resolveCleanupSegmentsJson(
511
667
  priorSegmentsJson: string | null | undefined,
512
668
  cleanedText: string,
513
669
  durationMs: number | null | undefined,
514
- ): string {
515
- if (parseTranscriptSegments(priorSegmentsJson).length > 1) {
516
- return priorSegmentsJson as string;
670
+ ): string | null {
671
+ const priorSegments = parseTranscriptSegments(priorSegmentsJson);
672
+ if (priorSegments.length > 0) {
673
+ const rewrittenSegments = rewriteMeasuredSegmentText(
674
+ priorSegments,
675
+ cleanedText,
676
+ );
677
+ if (rewrittenSegments === null) return null;
678
+ if (rewrittenSegments.length > 0) {
679
+ return JSON.stringify(rewrittenSegments);
680
+ }
517
681
  }
518
682
  return fullTextSegmentJson(cleanedText, durationMs);
519
683
  }
@@ -785,6 +949,21 @@ async function cleanupNativeTranscript({
785
949
  return { cleaned: false, provider: result.provider };
786
950
  }
787
951
 
952
+ const cleanupSegmentsJson = resolveCleanupSegmentsJson(
953
+ segmentsJson,
954
+ cleanedText,
955
+ durationMs,
956
+ );
957
+ if (!cleanupSegmentsJson) {
958
+ await writeTranscriptCleanupState(recordingId, {
959
+ status: "unchanged",
960
+ provider: result.provider,
961
+ failureReason:
962
+ "Cleanup output could not be aligned with measured speaker cues; the original transcript was kept.",
963
+ });
964
+ return { cleaned: false, provider: result.provider };
965
+ }
966
+
788
967
  const now = new Date().toISOString();
789
968
  const language = await resolveStoredLanguage(db, recordingId);
790
969
  const updated = await db
@@ -794,11 +973,7 @@ async function cleanupNativeTranscript({
794
973
  status: "ready",
795
974
  failureReason: null,
796
975
  language,
797
- segmentsJson: resolveCleanupSegmentsJson(
798
- segmentsJson,
799
- cleanedText,
800
- durationMs,
801
- ),
976
+ segmentsJson: cleanupSegmentsJson,
802
977
  fullText: cleanedText,
803
978
  retryCount: 0,
804
979
  updatedAt: now,
@@ -1,21 +1,27 @@
1
1
  /**
2
- * Update meeting content or owner/admin-managed sharing settings.
2
+ * Update a meeting's metadata. Editor access required.
3
3
  */
4
4
 
5
5
  import { defineAction } from "@agent-native/core";
6
6
  import { writeAppState } from "@agent-native/core/application-state";
7
7
  import { assertAccess } from "@agent-native/core/sharing";
8
- import { eq } from "drizzle-orm";
8
+ import { and, eq } from "drizzle-orm";
9
9
  import { z } from "zod";
10
10
 
11
11
  import { getDb, schema } from "../server/db/index.js";
12
+ import { nanoid } from "../server/lib/recordings.js";
12
13
  import { booleanParam } from "./lib/cli-params.js";
13
14
 
14
15
  export default defineAction({
15
16
  description:
16
- "Partially update a meeting's content. Owners and share admins can also control visibility and whether share links include the transcript.",
17
+ "Partially update a meeting (title, schedule, notes, summary, action items). Only provided fields are updated.",
17
18
  schema: z.object({
18
19
  id: z.string().describe("Meeting id"),
20
+ expectedUpdatedAt: z
21
+ .string()
22
+ .min(1)
23
+ .optional()
24
+ .describe("Reject the write if the meeting changed since it was read"),
19
25
  title: z.string().optional(),
20
26
  scheduledStart: z.string().nullish(),
21
27
  scheduledEnd: z.string().nullish(),
@@ -34,13 +40,15 @@ export default defineAction({
34
40
  actionItems: z
35
41
  .array(
36
42
  z.object({
37
- assigneeEmail: z.string().email().optional(),
38
- text: z.string(),
39
- dueDate: z.string().optional(),
43
+ id: z.string().optional(),
44
+ assigneeEmail: z.string().email().nullable().optional(),
45
+ text: z.string().trim().min(1),
46
+ dueDate: z.string().nullable().optional(),
47
+ completedAt: z.string().nullable().optional(),
40
48
  }),
41
49
  )
42
50
  .optional()
43
- .describe("Replace the action item set on the meetings row JSON"),
51
+ .describe("Replace the action item set on the meeting"),
44
52
  transcriptStatus: z.enum(["idle", "pending", "ready", "failed"]).optional(),
45
53
  shareTranscript: booleanParam
46
54
  .optional()
@@ -72,17 +80,61 @@ export default defineAction({
72
80
  patch.userNotesMd = args.userNotesMd;
73
81
  if (typeof args.summaryMd === "string") patch.summaryMd = args.summaryMd;
74
82
  if (args.bullets) patch.bulletsJson = JSON.stringify(args.bullets);
75
- if (args.actionItems)
76
- patch.actionItemsJson = JSON.stringify(args.actionItems);
83
+ const normalizedActionItems = args.actionItems?.map((item) => ({
84
+ id: item.id?.trim() || nanoid(),
85
+ assigneeEmail: item.assigneeEmail ?? null,
86
+ text: item.text.trim(),
87
+ dueDate: item.dueDate ?? null,
88
+ completedAt: item.completedAt ?? null,
89
+ }));
90
+ if (normalizedActionItems !== undefined)
91
+ patch.actionItemsJson = JSON.stringify(normalizedActionItems);
77
92
  if (args.transcriptStatus) patch.transcriptStatus = args.transcriptStatus;
78
93
  if (args.shareTranscript !== undefined)
79
94
  patch.shareTranscript = args.shareTranscript;
80
95
  if (args.visibility) patch.visibility = args.visibility;
81
96
 
82
- await db
83
- .update(schema.meetings)
84
- .set(patch)
85
- .where(eq(schema.meetings.id, args.id));
97
+ // Keep the denormalized JSON used by the summary and the dedicated action
98
+ // item rows used by list/query surfaces in sync as one mutation. In
99
+ // particular, completion state and manual edits must survive a refresh.
100
+ await db.transaction(async (tx) => {
101
+ const [updatedMeeting] = await tx
102
+ .update(schema.meetings)
103
+ .set(patch)
104
+ .where(
105
+ args.expectedUpdatedAt !== undefined
106
+ ? and(
107
+ eq(schema.meetings.id, args.id),
108
+ eq(schema.meetings.updatedAt, args.expectedUpdatedAt),
109
+ )
110
+ : eq(schema.meetings.id, args.id),
111
+ )
112
+ .returning({
113
+ id: schema.meetings.id,
114
+ updatedAt: schema.meetings.updatedAt,
115
+ });
116
+ if (!updatedMeeting) {
117
+ throw new Error("Meeting changed while saving. Refresh and try again.");
118
+ }
119
+
120
+ if (normalizedActionItems !== undefined) {
121
+ await tx
122
+ .delete(schema.meetingActionItems)
123
+ .where(eq(schema.meetingActionItems.meetingId, args.id));
124
+ if (normalizedActionItems.length > 0) {
125
+ await tx.insert(schema.meetingActionItems).values(
126
+ normalizedActionItems.map((item) => ({
127
+ id: item.id,
128
+ meetingId: args.id,
129
+ assigneeEmail: item.assigneeEmail,
130
+ text: item.text,
131
+ dueDate: item.dueDate,
132
+ completedAt: item.completedAt,
133
+ })),
134
+ );
135
+ }
136
+ }
137
+ });
86
138
 
87
139
  await writeAppState("refresh-signal", { ts: Date.now() });
88
140
 
@@ -92,6 +144,6 @@ export default defineAction({
92
144
  .where(eq(schema.meetings.id, args.id))
93
145
  .limit(1);
94
146
 
95
- return { meeting };
147
+ return { meeting, actionItems: normalizedActionItems };
96
148
  },
97
149
  });