@wirecat/cli-core 0.388.0 → 0.390.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/CHANGELOG.md CHANGED
@@ -5,7 +5,20 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.389.0 — 22.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`readMeetingStats`: talk time per speaker and meetings per week.** Over the meetings search would read, with the
13
+ same filters: per speaker (a linked person, else the identity, else the transcript's name) lines, words, talk time,
14
+ meetings and share of talk; per week (Monday in a chosen zone) meetings and talk time. One transcript per meeting,
15
+ so a local transcript beside the provider's does not count twice. `meetings.meetingStats` on the store and
16
+ `aggregateMeetingStats` for rows a host already has.
17
+
18
+ ### Fixed
19
+
20
+ - **A recorded one-shot command's `meta.run_id` names the run it kept**, so `runs show <run_id>` finds it. It named
21
+ the process's own run, which is never kept.
9
22
 
10
23
  ## 0.388.0 — 22.10.2026
11
24
 
@@ -79,6 +79,7 @@ export const baseContext = (command, resolveSettings) => {
79
79
  keepFailed: settings.keepFailedRuns,
80
80
  trace: settings.trace,
81
81
  keepDays: settings.keepRunsForDays,
82
+ processRun: !environment.agent,
82
83
  format,
83
84
  streams,
84
85
  env,
@@ -2,9 +2,9 @@ import { Command } from "commander";
2
2
  import type { Messenger } from "./context.js";
3
3
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
4
4
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
5
+ error?: undefined;
5
6
  path: string;
6
7
  exists: boolean;
7
- error?: undefined;
8
8
  } | {
9
9
  path: string;
10
10
  exists: boolean;
@@ -10,6 +10,7 @@ import { isCliFailure, isCommanderFailure } from "../errors/failures.js";
10
10
  import { failureContext } from "../errors/reasons.js";
11
11
  import { envName, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
12
12
  import { recorded, wasSettled } from "../runs/recording.js";
13
+ import { processRun, useAsProcessRun } from "../runs/scope.js";
13
14
  import { keyForCommand } from "../sends/permissions.js";
14
15
  import { logParsedArgv, logVersion } from "./argv-log.js";
15
16
  import { commandPathOf } from "./command-contract.js";
@@ -104,6 +105,15 @@ const RELAY_MARGIN_MS = 10_000;
104
105
  * message: it tells a script nothing it can branch on, and exits 1 for every kind of failure alike.
105
106
  */
106
107
  export const run = async (argv, definition, options = {}) => {
108
+ const outer = processRun();
109
+ try {
110
+ return await runOnce(argv, definition, options);
111
+ }
112
+ finally {
113
+ useAsProcessRun(outer);
114
+ }
115
+ };
116
+ const runOnce = async (argv, definition, options) => {
107
117
  const streams = options.streams ?? processStreams;
108
118
  if (!options.streams)
109
119
  quietBrokenPipes();
@@ -40,6 +40,7 @@ export { createMeetingSeriesInventoryReader } from "./series-inventory.js";
40
40
  export type { MeetingSeriesCandidate, MeetingSeriesInventoryPage, MeetingSeriesInventoryPort, MeetingSeriesInventoryRead, MeetingSeriesInventoryRequest, MeetingSeriesInventoryResult, } from "./series-inventory-types.js";
41
41
  export { validateMeetingSeriesInventoryCursor } from "./series-inventory-validation.js";
42
42
  export type { MeetingSource, SourceParticipant } from "./source.js";
43
+ export { aggregateMeetingStats, type MeetingSpeakerStats, type MeetingStats, type MeetingStatsInput, type MeetingStatsRow, type MeetingStatsSelection, type MeetingStatsStore, type MeetingWeekStats, } from "./stats.js";
43
44
  export type { ChatInput, ContractCase, EventCandidateFilter, IdentityInput, MeetingDetails, MeetingFilter, MeetingHitSource, MeetingInput, MeetingSave, MeetingStore, NewRecord, ParticipantInput, SearchHit, TranscriptInput, TranscriptRowInput, } from "./store.js";
44
45
  export type { MeetingTranscriptReceiptStore, MeetingTranscriptStore, TranscriptAppend, TranscriptAppendReceipt, UnlinkedTranscriptInput, } from "./transcript-store.js";
45
46
  export { parseTranscriptText, TranscriptTextError, type TranscriptTextHeader } from "./transcript-text.js";
@@ -28,6 +28,7 @@ export { validateMeetingHitSearch } from "./search-validation.js";
28
28
  export { validateMeetingSeriesHistory, } from "./series-history.js";
29
29
  export { createMeetingSeriesInventoryReader } from "./series-inventory.js";
30
30
  export { validateMeetingSeriesInventoryCursor } from "./series-inventory-validation.js";
31
+ export { aggregateMeetingStats, } from "./stats.js";
31
32
  export { parseTranscriptText, TranscriptTextError } from "./transcript-text.js";
32
33
  export { transcribeMeeting, } from "./transcription.js";
33
34
  export { createUpcomingMeetingReader } from "./upcoming.js";
@@ -38,7 +38,6 @@ export declare const meetingTools: ({
38
38
  max_read_bytes?: undefined;
39
39
  cursor?: undefined;
40
40
  } | {
41
- limit?: undefined;
42
41
  event_id?: undefined;
43
42
  series_id?: undefined;
44
43
  page?: undefined;
@@ -54,11 +53,11 @@ export declare const meetingTools: ({
54
53
  format?: undefined;
55
54
  transcript_id?: undefined;
56
55
  query?: undefined;
56
+ limit?: undefined;
57
57
  scan_limit?: undefined;
58
58
  max_read_bytes?: undefined;
59
59
  cursor?: undefined;
60
60
  } | {
61
- limit?: undefined;
62
61
  event_id?: undefined;
63
62
  series_id?: undefined;
64
63
  page?: undefined;
@@ -76,11 +75,11 @@ export declare const meetingTools: ({
76
75
  format?: undefined;
77
76
  transcript_id?: undefined;
78
77
  query?: undefined;
78
+ limit?: undefined;
79
79
  scan_limit?: undefined;
80
80
  max_read_bytes?: undefined;
81
81
  cursor?: undefined;
82
82
  } | {
83
- limit?: undefined;
84
83
  event_id?: undefined;
85
84
  series_id?: undefined;
86
85
  page?: undefined;
@@ -102,6 +101,7 @@ export declare const meetingTools: ({
102
101
  minimum: number;
103
102
  };
104
103
  query?: undefined;
104
+ limit?: undefined;
105
105
  scan_limit?: undefined;
106
106
  max_read_bytes?: undefined;
107
107
  cursor?: undefined;
@@ -188,7 +188,6 @@ export declare const meetingTools: ({
188
188
  max_read_bytes?: undefined;
189
189
  cursor?: undefined;
190
190
  } | {
191
- limit?: undefined;
192
191
  event_id?: undefined;
193
192
  series_id?: undefined;
194
193
  page?: undefined;
@@ -204,11 +203,11 @@ export declare const meetingTools: ({
204
203
  format?: undefined;
205
204
  transcript_id?: undefined;
206
205
  query?: undefined;
206
+ limit?: undefined;
207
207
  scan_limit?: undefined;
208
208
  max_read_bytes?: undefined;
209
209
  cursor?: undefined;
210
210
  } | {
211
- limit?: undefined;
212
211
  event_id?: undefined;
213
212
  series_id?: undefined;
214
213
  page?: undefined;
@@ -226,11 +225,11 @@ export declare const meetingTools: ({
226
225
  format?: undefined;
227
226
  transcript_id?: undefined;
228
227
  query?: undefined;
228
+ limit?: undefined;
229
229
  scan_limit?: undefined;
230
230
  max_read_bytes?: undefined;
231
231
  cursor?: undefined;
232
232
  } | {
233
- limit?: undefined;
234
233
  event_id?: undefined;
235
234
  series_id?: undefined;
236
235
  page?: undefined;
@@ -252,6 +251,7 @@ export declare const meetingTools: ({
252
251
  minimum: number;
253
252
  };
254
253
  query?: undefined;
254
+ limit?: undefined;
255
255
  scan_limit?: undefined;
256
256
  max_read_bytes?: undefined;
257
257
  cursor?: undefined;
@@ -338,7 +338,6 @@ export declare const meetingTools: ({
338
338
  max_read_bytes?: undefined;
339
339
  cursor?: undefined;
340
340
  } | {
341
- limit?: undefined;
342
341
  event_id?: undefined;
343
342
  series_id?: undefined;
344
343
  page?: undefined;
@@ -354,11 +353,11 @@ export declare const meetingTools: ({
354
353
  format?: undefined;
355
354
  transcript_id?: undefined;
356
355
  query?: undefined;
356
+ limit?: undefined;
357
357
  scan_limit?: undefined;
358
358
  max_read_bytes?: undefined;
359
359
  cursor?: undefined;
360
360
  } | {
361
- limit?: undefined;
362
361
  event_id?: undefined;
363
362
  series_id?: undefined;
364
363
  page?: undefined;
@@ -376,11 +375,11 @@ export declare const meetingTools: ({
376
375
  format?: undefined;
377
376
  transcript_id?: undefined;
378
377
  query?: undefined;
378
+ limit?: undefined;
379
379
  scan_limit?: undefined;
380
380
  max_read_bytes?: undefined;
381
381
  cursor?: undefined;
382
382
  } | {
383
- limit?: undefined;
384
383
  event_id?: undefined;
385
384
  series_id?: undefined;
386
385
  page?: undefined;
@@ -402,6 +401,7 @@ export declare const meetingTools: ({
402
401
  minimum: number;
403
402
  };
404
403
  query?: undefined;
404
+ limit?: undefined;
405
405
  scan_limit?: undefined;
406
406
  max_read_bytes?: undefined;
407
407
  cursor?: undefined;
@@ -488,7 +488,6 @@ export declare const meetingTools: ({
488
488
  max_read_bytes?: undefined;
489
489
  cursor?: undefined;
490
490
  } | {
491
- limit?: undefined;
492
491
  event_id?: undefined;
493
492
  series_id?: undefined;
494
493
  page?: undefined;
@@ -504,11 +503,11 @@ export declare const meetingTools: ({
504
503
  format?: undefined;
505
504
  transcript_id?: undefined;
506
505
  query?: undefined;
506
+ limit?: undefined;
507
507
  scan_limit?: undefined;
508
508
  max_read_bytes?: undefined;
509
509
  cursor?: undefined;
510
510
  } | {
511
- limit?: undefined;
512
511
  event_id?: undefined;
513
512
  series_id?: undefined;
514
513
  page?: undefined;
@@ -526,11 +525,11 @@ export declare const meetingTools: ({
526
525
  format?: undefined;
527
526
  transcript_id?: undefined;
528
527
  query?: undefined;
528
+ limit?: undefined;
529
529
  scan_limit?: undefined;
530
530
  max_read_bytes?: undefined;
531
531
  cursor?: undefined;
532
532
  } | {
533
- limit?: undefined;
534
533
  event_id?: undefined;
535
534
  series_id?: undefined;
536
535
  page?: undefined;
@@ -552,6 +551,7 @@ export declare const meetingTools: ({
552
551
  minimum: number;
553
552
  };
554
553
  query?: undefined;
554
+ limit?: undefined;
555
555
  scan_limit?: undefined;
556
556
  max_read_bytes?: undefined;
557
557
  cursor?: undefined;
@@ -638,7 +638,6 @@ export declare const meetingTools: ({
638
638
  max_read_bytes?: undefined;
639
639
  cursor?: undefined;
640
640
  } | {
641
- limit?: undefined;
642
641
  event_id?: undefined;
643
642
  series_id?: undefined;
644
643
  page?: undefined;
@@ -654,11 +653,11 @@ export declare const meetingTools: ({
654
653
  format?: undefined;
655
654
  transcript_id?: undefined;
656
655
  query?: undefined;
656
+ limit?: undefined;
657
657
  scan_limit?: undefined;
658
658
  max_read_bytes?: undefined;
659
659
  cursor?: undefined;
660
660
  } | {
661
- limit?: undefined;
662
661
  event_id?: undefined;
663
662
  series_id?: undefined;
664
663
  page?: undefined;
@@ -676,11 +675,11 @@ export declare const meetingTools: ({
676
675
  format?: undefined;
677
676
  transcript_id?: undefined;
678
677
  query?: undefined;
678
+ limit?: undefined;
679
679
  scan_limit?: undefined;
680
680
  max_read_bytes?: undefined;
681
681
  cursor?: undefined;
682
682
  } | {
683
- limit?: undefined;
684
683
  event_id?: undefined;
685
684
  series_id?: undefined;
686
685
  page?: undefined;
@@ -702,6 +701,7 @@ export declare const meetingTools: ({
702
701
  minimum: number;
703
702
  };
704
703
  query?: undefined;
704
+ limit?: undefined;
705
705
  scan_limit?: undefined;
706
706
  max_read_bytes?: undefined;
707
707
  cursor?: undefined;
@@ -0,0 +1,59 @@
1
+ import type { MeetingSearchOptions } from "./search-store.js";
2
+ /** The meetings the numbers cover: the same filters as meeting search. */
3
+ export type MeetingStatsSelection = Pick<MeetingSearchOptions, "accountId" | "meetingId" | "eventId" | "meetingSeriesId" | "since" | "until" | "participantIdentityId" | "personId" | "projectId" | "tagId" | "signal">;
4
+ export interface MeetingStatsInput extends MeetingStatsSelection {
5
+ /** The zone a week starts on Monday in; UTC when not given. */
6
+ timeZone?: string;
7
+ /** Most transcript lines to read (default 500000); more is an error, never a silent cut. */
8
+ maxRows?: number;
9
+ }
10
+ /** One transcript line as the numbers need it: who said it, when, for how long, in how many words. */
11
+ export interface MeetingStatsRow {
12
+ meetingId: number;
13
+ startedAt: number | null;
14
+ startMs: number;
15
+ endMs: number;
16
+ words: number;
17
+ personId: number | null;
18
+ personName: string | null;
19
+ identityId: number | null;
20
+ identityName: string | null;
21
+ speakerName: string | null;
22
+ }
23
+ export interface MeetingSpeakerStats {
24
+ /** `person:<id>`, `identity:<id>`, `name:<lowercased name>` or `unknown`. */
25
+ key: string;
26
+ name: string | null;
27
+ personId: number | null;
28
+ identityId: number | null;
29
+ meetings: number;
30
+ lines: number;
31
+ words: number;
32
+ talkMs: number;
33
+ /** This speaker's part of all talk time in the selection, 0–1. */
34
+ share: number;
35
+ }
36
+ export interface MeetingWeekStats {
37
+ /** The Monday the week starts on, in the chosen zone: `2026-10-19`. */
38
+ weekStart: string;
39
+ meetings: number;
40
+ talkMs: number;
41
+ }
42
+ export interface MeetingStats {
43
+ timeZone: string;
44
+ meetings: number;
45
+ lines: number;
46
+ words: number;
47
+ talkMs: number;
48
+ speakers: MeetingSpeakerStats[];
49
+ weeks: MeetingWeekStats[];
50
+ }
51
+ export interface MeetingStatsStore {
52
+ meetingStats(input: MeetingStatsInput): Promise<MeetingStats>;
53
+ }
54
+ /**
55
+ * Talk time, lines and words per speaker, and meetings and talk time per week. A speaker is a linked person when the
56
+ * line's participant has one, else the participant's identity, else the name the transcript gives; lines with none
57
+ * are `unknown`. Talk time is each line's end less its start, so overlapping speech counts for everyone speaking.
58
+ */
59
+ export declare const aggregateMeetingStats: (rows: readonly MeetingStatsRow[], timeZone?: string) => MeetingStats;
@@ -0,0 +1,95 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ const zoneOf = (timeZone) => {
3
+ const zone = timeZone ?? "UTC";
4
+ try {
5
+ new Intl.DateTimeFormat("en", { timeZone: zone });
6
+ }
7
+ catch {
8
+ throw new CliError("validation_error", "expected an IANA time zone such as Europe/Madrid");
9
+ }
10
+ return zone;
11
+ };
12
+ const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
13
+ const weekStart = (at, format) => {
14
+ const parts = Object.fromEntries(format.formatToParts(new Date(at)).map((part) => [part.type, part.value]));
15
+ const day = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
16
+ return new Date(day - DAYS.indexOf(parts.weekday ?? "Mon") * 86_400_000).toISOString().slice(0, 10);
17
+ };
18
+ /** Lines with no speaker at all; a constant, since a `key: "…"` literal reads as a permission key to the key test. */
19
+ const UNKNOWN_SPEAKER = "unknown";
20
+ const speakerOf = (row) => row.personId !== null
21
+ ? { key: `person:${row.personId}`, name: row.personName ?? row.identityName ?? row.speakerName }
22
+ : row.identityId !== null
23
+ ? { key: `identity:${row.identityId}`, name: row.identityName ?? row.speakerName }
24
+ : row.speakerName?.trim()
25
+ ? { key: `name:${row.speakerName.trim().toLowerCase()}`, name: row.speakerName.trim() }
26
+ : { key: UNKNOWN_SPEAKER, name: null };
27
+ /**
28
+ * Talk time, lines and words per speaker, and meetings and talk time per week. A speaker is a linked person when the
29
+ * line's participant has one, else the participant's identity, else the name the transcript gives; lines with none
30
+ * are `unknown`. Talk time is each line's end less its start, so overlapping speech counts for everyone speaking.
31
+ */
32
+ export const aggregateMeetingStats = (rows, timeZone) => {
33
+ const zone = zoneOf(timeZone);
34
+ const format = new Intl.DateTimeFormat("en-US", {
35
+ timeZone: zone,
36
+ year: "numeric",
37
+ month: "2-digit",
38
+ day: "2-digit",
39
+ weekday: "short",
40
+ });
41
+ const speakers = new Map();
42
+ const weeks = new Map();
43
+ const meetings = new Set();
44
+ let lines = 0;
45
+ let words = 0;
46
+ let talkMs = 0;
47
+ for (const row of rows) {
48
+ const talk = Math.max(0, row.endMs - row.startMs);
49
+ meetings.add(row.meetingId);
50
+ lines += 1;
51
+ words += row.words;
52
+ talkMs += talk;
53
+ const who = speakerOf(row);
54
+ const held = speakers.get(who.key) ?? {
55
+ key: who.key,
56
+ name: who.name ?? null,
57
+ personId: row.personId,
58
+ identityId: row.personId === null ? row.identityId : null,
59
+ meetings: 0,
60
+ lines: 0,
61
+ words: 0,
62
+ talkMs: 0,
63
+ share: 0,
64
+ seen: new Set(),
65
+ };
66
+ held.seen.add(row.meetingId);
67
+ held.lines += 1;
68
+ held.words += row.words;
69
+ held.talkMs += talk;
70
+ speakers.set(who.key, held);
71
+ if (row.startedAt !== null) {
72
+ const week = weeks.get(weekStart(row.startedAt, format)) ?? { meetings: new Set(), talkMs: 0 };
73
+ week.meetings.add(row.meetingId);
74
+ week.talkMs += talk;
75
+ weeks.set(weekStart(row.startedAt, format), week);
76
+ }
77
+ }
78
+ return {
79
+ timeZone: zone,
80
+ meetings: meetings.size,
81
+ lines,
82
+ words,
83
+ talkMs,
84
+ speakers: [...speakers.values()]
85
+ .map(({ seen, ...speaker }) => ({
86
+ ...speaker,
87
+ meetings: seen.size,
88
+ share: talkMs === 0 ? 0 : Math.round((speaker.talkMs / talkMs) * 1000) / 1000,
89
+ }))
90
+ .sort((a, b) => b.talkMs - a.talkMs || a.key.localeCompare(b.key)),
91
+ weeks: [...weeks.entries()]
92
+ .map(([start, week]) => ({ weekStart: start, meetings: week.meetings.size, talkMs: week.talkMs }))
93
+ .sort((a, b) => a.weekStart.localeCompare(b.weekStart)),
94
+ };
95
+ };
@@ -19,6 +19,11 @@ export interface RecordingOptions {
19
19
  now?: () => Date;
20
20
  /** The caller's trace; otherwise the enclosing run's, otherwise `TRACEPARENT` from `env`. */
21
21
  traceId?: string;
22
+ /**
23
+ * A one-shot command prints its answer after the run ends, outside `inRun`: naming the run as the process's lets
24
+ * that answer's `meta.run_id` name the run `runs show` finds. Never in a server, where runs overlap.
25
+ */
26
+ processRun?: boolean;
22
27
  }
23
28
  /**
24
29
  * Runs one command with the diagnostics turned on, and **finalizes on every path**.
@@ -4,7 +4,7 @@ import { providerErrorKey, renderEvent } from "./events.js";
4
4
  import { DIAGNOSTIC_LOG_EVENTS } from "./log-events.js";
5
5
  import { logAttributes } from "./log-keys.js";
6
6
  import { newRunId, runsDirFor, startRun } from "./run.js";
7
- import { enclosingTraceId, inRun, traceOf } from "./scope.js";
7
+ import { enclosingTraceId, inRun, traceOf, useAsProcessRun } from "./scope.js";
8
8
  /**
9
9
  * Runs one command with the diagnostics turned on, and **finalizes on every path**.
10
10
  *
@@ -15,13 +15,16 @@ import { enclosingTraceId, inRun, traceOf } from "./scope.js";
15
15
  export const recorded = async (options, body) => {
16
16
  const recording = startRecording(options);
17
17
  try {
18
- const answer = await inRun({
18
+ const scope = {
19
19
  runId: recording.runId,
20
20
  command: options.command,
21
21
  startedAt: Date.now(),
22
22
  profile: options.profile,
23
23
  ...(recording.traceId ? { traceId: recording.traceId } : {}),
24
- }, () => body(recording.events));
24
+ };
25
+ if (options.processRun)
26
+ useAsProcessRun(scope);
27
+ const answer = await inRun(scope, () => body(recording.events));
25
28
  await recording.succeed(answer);
26
29
  return answer;
27
30
  }
@@ -23,7 +23,9 @@ export declare const typedCommand: (tool: string, words: string, profile?: strin
23
23
  * For a one-shot command whose action runs outside `inRun` — max's `max bot`, recorded from commander's hooks, which
24
24
  * cannot wrap the action — so its answers' `meta.run_id` names the run `runs show` finds. Never in a server.
25
25
  */
26
- export declare const useAsProcessRun: (scope: RunScope) => void;
26
+ export declare const useAsProcessRun: (scope: RunScope | undefined) => void;
27
+ /** The run `useAsProcessRun` named, if any: a command run in-process puts it back when it ends. */
28
+ export declare const processRun: () => RunScope | undefined;
27
29
  /** The trace of the run this one is opened inside — an MCP call's command keeps its caller's trace. */
28
30
  export declare const enclosingTraceId: () => string | undefined;
29
31
  /**
@@ -33,6 +33,8 @@ export const typedCommand = (tool, words, profile = currentRun().profile) => [to
33
33
  export const useAsProcessRun = (scope) => {
34
34
  processScope = scope;
35
35
  };
36
+ /** The run `useAsProcessRun` named, if any: a command run in-process puts it back when it ends. */
37
+ export const processRun = () => processScope;
36
38
  /** The trace of the run this one is opened inside — an MCP call's command keeps its caller's trace. */
37
39
  export const enclosingTraceId = () => scopes.getStore()?.traceId;
38
40
  /**
@@ -67,6 +67,7 @@ export { readMail } from "./mail-search.js";
67
67
  export { type MeetingHitSearchRequest, refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
68
68
  export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
69
69
  export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
70
+ export { type MeetingStatsRequest, readMeetingStats } from "./meeting-stats.js";
70
71
  export { chatIdIn, storedChatId } from "./message-resolution.js";
71
72
  export type { AroundWindow, FoundMessage, ListWindow, MessagesService, MessageTarget, Pinned, Reacted, SearchFound, SearchQuery, SendRequest, Starred, } from "./messages.js";
72
73
  export { DELETE_AT_ONCE, messagesService } from "./messages.js";
@@ -48,6 +48,7 @@ export { readMail } from "./mail-search.js";
48
48
  export { refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
49
49
  export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
50
50
  export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
51
+ export { readMeetingStats } from "./meeting-stats.js";
51
52
  export { chatIdIn, storedChatId } from "./message-resolution.js";
52
53
  export { DELETE_AT_ONCE, messagesService } from "./messages.js";
53
54
  export { searchStore } from "./messages-archive-search.js";
@@ -0,0 +1,13 @@
1
+ import type { MeetingHitSearchRequest, MeetingStats } from "../meetings/index.js";
2
+ import type { MessageStore } from "../store/store.js";
3
+ import { resolveMeetingSelection } from "./meeting-hit-search.js";
4
+ export type MeetingStatsRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & {
5
+ timeZone?: string;
6
+ maxRows?: number;
7
+ };
8
+ /** Talk time and lines per speaker and meetings per week over the meetings search would read, names resolved alike. */
9
+ export declare const readMeetingStats: (store: Pick<MessageStore, "meetings"> & {
10
+ knowledge?: MessageStore["knowledge"];
11
+ }, request: MeetingStatsRequest) => Promise<MeetingStats & {
12
+ selection: Awaited<ReturnType<typeof resolveMeetingSelection>>["selection"];
13
+ }>;
@@ -0,0 +1,12 @@
1
+ import { resolveMeetingSelection } from "./meeting-hit-search.js";
2
+ /** Talk time and lines per speaker and meetings per week over the meetings search would read, names resolved alike. */
3
+ export const readMeetingStats = async (store, request) => {
4
+ const { timeZone, maxRows, ...selecting } = request;
5
+ const { input, selection } = await resolveMeetingSelection(store, selecting);
6
+ const stats = await store.meetings.meetingStats({
7
+ ...input,
8
+ ...(timeZone === undefined ? {} : { timeZone }),
9
+ ...(maxRows === undefined ? {} : { maxRows }),
10
+ });
11
+ return { ...stats, selection };
12
+ };
@@ -0,0 +1,7 @@
1
+ import { type MeetingStatsStore } from "../../meetings/index.js";
2
+ import type { StoreContext } from "./open.js";
3
+ /**
4
+ * One transcript per meeting — its earliest current one — so a local transcript beside the provider's does not count
5
+ * the talk twice. Words are counted from spaces in SQL, so no transcript text leaves the database.
6
+ */
7
+ export declare const meetingStatsOver: ({ database }: StoreContext) => MeetingStatsStore;
@@ -0,0 +1,55 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { aggregateMeetingStats } from "../../meetings/index.js";
3
+ import { checkMeetingCancelled } from "./meeting-operation.js";
4
+ import { positiveReadInteger } from "./meeting-reads.js";
5
+ import { meetingSelectionSql } from "./meeting-selection.js";
6
+ import { read } from "./transaction.js";
7
+ const MOST_ROWS = 500_000;
8
+ /**
9
+ * One transcript per meeting — its earliest current one — so a local transcript beside the provider's does not count
10
+ * the talk twice. Words are counted from spaces in SQL, so no transcript text leaves the database.
11
+ */
12
+ export const meetingStatsOver = ({ database }) => ({
13
+ async meetingStats(input) {
14
+ checkMeetingCancelled(input.signal);
15
+ positiveReadInteger(input.accountId, "accountId");
16
+ const maxRows = input.maxRows ?? MOST_ROWS;
17
+ if (!Number.isSafeInteger(maxRows) || maxRows < 1 || maxRows > MOST_ROWS)
18
+ throw new CliError("validation_error", `meeting stats read 1 through ${MOST_ROWS} transcript lines`);
19
+ return read(database, () => {
20
+ const { clauses, params } = meetingSelectionSql(input);
21
+ const sql = `WITH selected AS (SELECT m.id, m.started_at FROM meetings m WHERE ${clauses.join(" AND ")}),
22
+ chosen AS (SELECT min(t.id) AS id, t.meeting_id FROM meeting_transcripts t JOIN selected s ON s.id = t.meeting_id
23
+ WHERE t.deleted_at IS NULL AND t.superseded_at IS NULL GROUP BY t.meeting_id)
24
+ SELECT c.meeting_id, s.started_at, r.start_ms, r.end_ms,
25
+ length(trim(r.text)) - length(replace(trim(r.text), ' ', '')) + (length(trim(r.text)) > 0) AS words,
26
+ l.person_id, person.name AS person_name, p.identity_id, i.name AS identity_name,
27
+ coalesce(r.speaker_name, p.display_name) AS speaker_name
28
+ FROM chosen c JOIN selected s ON s.id = c.meeting_id
29
+ JOIN meeting_transcript_rows r ON r.meeting_transcript_id = c.id
30
+ LEFT JOIN meeting_participants p ON p.id = r.speaker_participant_id
31
+ LEFT JOIN identities i ON i.id = p.identity_id
32
+ LEFT JOIN identity_links l ON l.identity_id = p.identity_id
33
+ LEFT JOIN persons person ON person.id = l.person_id
34
+ LIMIT ?`;
35
+ const rows = database.prepare(sql).all(...params, maxRows + 1);
36
+ if (rows.length > maxRows)
37
+ throw new CliError("validation_error", `the selection has more than ${maxRows} transcript lines; narrow it`);
38
+ checkMeetingCancelled(input.signal);
39
+ const nullable = (value) => (value === null || value === undefined ? null : Number(value));
40
+ const text = (value) => (value === null || value === undefined ? null : String(value));
41
+ return aggregateMeetingStats(rows.map((row) => ({
42
+ meetingId: Number(row.meeting_id),
43
+ startedAt: nullable(row.started_at),
44
+ startMs: Number(row.start_ms),
45
+ endMs: Number(row.end_ms),
46
+ words: Number(row.words),
47
+ personId: nullable(row.person_id),
48
+ personName: text(row.person_name),
49
+ identityId: nullable(row.identity_id),
50
+ identityName: text(row.identity_name),
51
+ speakerName: text(row.speaker_name),
52
+ })), input.timeZone);
53
+ });
54
+ },
55
+ });
@@ -1,4 +1,4 @@
1
- import type { Attachment, MeetingContextStore, MeetingParticipantSearchStore, MeetingPreparationContextStore, MeetingPreparationKnowledgeStore, MeetingPreparationNotesStore, MeetingRelatedHistoryStore, MeetingSearchIndexStore, MeetingSearchStore, MeetingSeriesHistoryStore, MeetingStore, MeetingTranscriptReceiptStore, MeetingTranscriptStore, NewRecord } from "../../meetings/index.js";
1
+ import type { Attachment, MeetingContextStore, MeetingParticipantSearchStore, MeetingPreparationContextStore, MeetingPreparationKnowledgeStore, MeetingPreparationNotesStore, MeetingRelatedHistoryStore, MeetingSearchIndexStore, MeetingSearchStore, MeetingSeriesHistoryStore, MeetingStatsStore, MeetingStore, MeetingTranscriptReceiptStore, MeetingTranscriptStore, NewRecord } from "../../meetings/index.js";
2
2
  import type { CacheDatabase } from "../driver.js";
3
3
  import { type MeetingContextReadCapabilities, type MeetingReadCapabilities } from "./meeting-reads.js";
4
4
  import type { StoreContext } from "./open.js";
@@ -11,7 +11,7 @@ export declare const saveAttachment: (database: CacheDatabase, type: string, par
11
11
  export declare const drainMeetingIndex: (database: CacheDatabase, batch?: number) => number;
12
12
  /** Each word of the query, as a prefix, all required. */
13
13
  export declare const wordsQuery: (query: string) => string | null;
14
- export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore;
14
+ export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore & MeetingStatsStore;
15
15
  /** The `MeetingStore` of `src/meetings/` over the shared store. */
16
16
  export declare const meetingStoreOver: (context: StoreContext) => StoredMeetings;
17
17
  export {};
@@ -16,6 +16,7 @@ import { meetingPreparationNotesOver } from "./meeting-preparation-notes.js";
16
16
  import { meetingReadsOver } from "./meeting-reads.js";
17
17
  import { meetingRelatedHistoryOver } from "./meeting-related-history.js";
18
18
  import { meetingSeriesHistoryOver } from "./meeting-series-history.js";
19
+ import { meetingStatsOver } from "./meeting-stats.js";
19
20
  import { invalidateMeetingStems } from "./meeting-stem-state.js";
20
21
  import { meetingOf, transcriptOf, transcriptRowOf } from "./meeting-values.js";
21
22
  import { queryFilter } from "./query-filter.js";
@@ -571,6 +572,7 @@ export const meetingStoreOver = (context) => {
571
572
  ...meetingPreparationNotesOver(context),
572
573
  ...meetingRelatedHistoryOver(context),
573
574
  ...meetingHitSearchOver(context),
575
+ ...meetingStatsOver(context),
574
576
  ...meetingParticipantSearchOver(context),
575
577
  ...meetingHitIndexOver(context),
576
578
  ...meetingContextLinksOver(context),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.388.0",
3
+ "version": "0.390.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",