@gotcos/glasses-server 6.46.1 → 6.47.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 +13 -0
- package/package.json +6 -2
- package/server/index.ts +76 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/voice.ts +18 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows for the imported library, and the supersession rule (6.47.0, WS5).
|
|
3
|
+
*
|
|
4
|
+
* WHAT SUPERSESSION IS. A merge is ADDITIVE: the Fireflies import, the G2
|
|
5
|
+
* recordings and the merged record all exist on disk afterwards, because that is
|
|
6
|
+
* what makes Undo possible (D12). But a person scrolling their meetings must see
|
|
7
|
+
* ONE row for one meeting, so the LIST drops the inputs a derived record already
|
|
8
|
+
* holds. Nothing is deleted; the sources stay reachable from the merged record's
|
|
9
|
+
* detail through its `sources[]`.
|
|
10
|
+
*
|
|
11
|
+
* WHY A FILTER RATHER THAN A MERGE RULE. `mergeMeetingSources` dedupes by
|
|
12
|
+
* sessionId, and 6.46.1 leans on its group ORDER so an operations copy beats the
|
|
13
|
+
* store copy of the same capture. Teaching it about derived records would have
|
|
14
|
+
* meant a second precedence rule inside the first. Worse, a split piece has NO
|
|
15
|
+
* sessionId (it is a span of a recording, not a capture), so two pieces of one
|
|
16
|
+
* recording would have collapsed onto each other. Supersession therefore runs
|
|
17
|
+
* BEFORE the merge, as its own pass, and derived rows dedupe on `recordId`.
|
|
18
|
+
*
|
|
19
|
+
* COST. Every helper here is free until a derived record exists: the scan reads
|
|
20
|
+
* the imports root's `.derived.json` sidecars, and when there are none, each
|
|
21
|
+
* function returns its input untouched without opening a meeting file. That
|
|
22
|
+
* matters because `probeMeetings` sums day counts across every month on every
|
|
23
|
+
* morning brief.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import { dataPath } from './data-dir.js'
|
|
29
|
+
import {
|
|
30
|
+
cosOperationsMeetingsConfigured,
|
|
31
|
+
discoverMeetingDomains,
|
|
32
|
+
findCosOperationsMeetingBySessionId,
|
|
33
|
+
listCosOperationsMeetingDays,
|
|
34
|
+
listDirectLibraryMeetingDays,
|
|
35
|
+
mergedScribeSessions,
|
|
36
|
+
resolveCosOperationsDir,
|
|
37
|
+
resolveMeetingLibrary,
|
|
38
|
+
sidecarSessionId,
|
|
39
|
+
} from './cos-operations-meetings.js'
|
|
40
|
+
import { g2RecordingsReachOperations } from './g2-ops-handoff.js'
|
|
41
|
+
import {
|
|
42
|
+
type ImportedMeetingLibrary,
|
|
43
|
+
type ImportedRecordKind,
|
|
44
|
+
IMPORTED_DOMAIN,
|
|
45
|
+
IMPORTED_FILENAME_PATTERN,
|
|
46
|
+
IMPORTED_MONTH_PATTERN,
|
|
47
|
+
getImportedMeetingLibrary,
|
|
48
|
+
importHash,
|
|
49
|
+
importRecordId,
|
|
50
|
+
} from './imported-meeting-library.js'
|
|
51
|
+
import { getMeetingStore, type MeetingMeta, type MeetingStore } from './meeting-store.js'
|
|
52
|
+
|
|
53
|
+
/** The merged-scribe markers live with the scribe reader; re-exported for callers. */
|
|
54
|
+
export {
|
|
55
|
+
G2_SESSION_MARKER,
|
|
56
|
+
MERGE_ACTION_MARKER,
|
|
57
|
+
mergedScribeActionId,
|
|
58
|
+
mergedScribeSessions,
|
|
59
|
+
} from './cos-operations-meetings.js'
|
|
60
|
+
|
|
61
|
+
const DAY_FILE_PREFIX = /^(\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))_/
|
|
62
|
+
/** The month folder a day-prefixed filename belongs to. */
|
|
63
|
+
const MONTH_FROM_DAY_FILE = /^(\d{4}-(?:0[1-9]|1[0-2]))-(?:0[1-9]|[12]\d|3[01])_/
|
|
64
|
+
|
|
65
|
+
/** One derived record's identity and inputs, read from its `.derived.json`. */
|
|
66
|
+
export interface DerivedRecordSummary {
|
|
67
|
+
recordId: string
|
|
68
|
+
month: string
|
|
69
|
+
filename: string
|
|
70
|
+
kind: 'merge' | 'split'
|
|
71
|
+
actionId?: string
|
|
72
|
+
/** G2 captures the record holds, in the sidecar's own order (start order). */
|
|
73
|
+
g2SessionIds: string[]
|
|
74
|
+
/** Fireflies meetings the record holds: the primary first, then alternates. */
|
|
75
|
+
firefliesIds: string[]
|
|
76
|
+
pieceIndex?: number
|
|
77
|
+
/** The capture whose content defined a split piece, when it names one. */
|
|
78
|
+
sourceSessionId?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Everything a derived record has taken over. Empty until one exists. */
|
|
82
|
+
export interface SupersededInputs {
|
|
83
|
+
g2Sessions: Set<string>
|
|
84
|
+
/** `imported:fireflies:<h16>` record ids of the imports a derived record holds. */
|
|
85
|
+
importRecordIds: Set<string>
|
|
86
|
+
isEmpty: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
90
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stringField(source: Record<string, unknown>, key: string): string | undefined {
|
|
94
|
+
const value = source[key]
|
|
95
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read one `.derived.json` into the fields the surfaces need.
|
|
100
|
+
*
|
|
101
|
+
* Tolerant on purpose: a sidecar from a newer version, or one that lost a field,
|
|
102
|
+
* must degrade to "this record exists" rather than take the whole list down with
|
|
103
|
+
* it. The one field it cannot do without is `inputs`, because that is what
|
|
104
|
+
* supersession is computed from.
|
|
105
|
+
*/
|
|
106
|
+
export function summarizeDerivedSidecar(
|
|
107
|
+
sidecar: unknown,
|
|
108
|
+
ref: { recordId: string; month: string; filename: string; kind: ImportedRecordKind },
|
|
109
|
+
): DerivedRecordSummary | null {
|
|
110
|
+
const doc = asRecord(sidecar)
|
|
111
|
+
if (!doc) return null
|
|
112
|
+
const inputs = Array.isArray(doc.inputs) ? doc.inputs : []
|
|
113
|
+
const g2SessionIds: string[] = []
|
|
114
|
+
const firefliesIds: string[] = []
|
|
115
|
+
for (const entry of inputs) {
|
|
116
|
+
const input = asRecord(entry)
|
|
117
|
+
if (!input) continue
|
|
118
|
+
const id = stringField(input, 'id')
|
|
119
|
+
if (!id) continue
|
|
120
|
+
if (input.kind === 'g2') g2SessionIds.push(id)
|
|
121
|
+
else if (input.kind === 'fireflies') firefliesIds.push(id)
|
|
122
|
+
}
|
|
123
|
+
const pieces = Array.isArray(doc.pieces) ? doc.pieces : []
|
|
124
|
+
const firstPiece = asRecord(pieces[0])
|
|
125
|
+
const pieceIndex = typeof firstPiece?.index === 'number' && Number.isInteger(firstPiece.index)
|
|
126
|
+
? firstPiece.index
|
|
127
|
+
: undefined
|
|
128
|
+
const pieceSessions = Array.isArray(firstPiece?.sessionIds) ? firstPiece.sessionIds : []
|
|
129
|
+
const sourceSessionId = typeof pieceSessions[0] === 'string' ? pieceSessions[0] as string : undefined
|
|
130
|
+
const actionId = stringField(doc, 'actionId')
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
recordId: ref.recordId,
|
|
134
|
+
month: ref.month,
|
|
135
|
+
filename: ref.filename,
|
|
136
|
+
kind: ref.kind === 'piece' ? 'split' : 'merge',
|
|
137
|
+
...(actionId ? { actionId } : {}),
|
|
138
|
+
g2SessionIds,
|
|
139
|
+
firefliesIds,
|
|
140
|
+
...(ref.kind === 'piece' && pieceIndex !== undefined ? { pieceIndex } : {}),
|
|
141
|
+
...(ref.kind === 'piece' && sourceSessionId ? { sourceSessionId } : {}),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Derived record filenames on disk, without opening anything. */
|
|
146
|
+
function derivedFilenames(
|
|
147
|
+
library: ImportedMeetingLibrary,
|
|
148
|
+
): Array<{ month: string; filename: string; kind: ImportedRecordKind; hash: string }> {
|
|
149
|
+
const found: Array<{ month: string; filename: string; kind: ImportedRecordKind; hash: string }> = []
|
|
150
|
+
let months: string[]
|
|
151
|
+
try {
|
|
152
|
+
months = readdirSync(library.root).filter(name => IMPORTED_MONTH_PATTERN.test(name))
|
|
153
|
+
} catch {
|
|
154
|
+
return found
|
|
155
|
+
}
|
|
156
|
+
for (const month of months) {
|
|
157
|
+
let names: string[]
|
|
158
|
+
try {
|
|
159
|
+
names = readdirSync(join(library.root, month))
|
|
160
|
+
} catch {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
for (const filename of names) {
|
|
164
|
+
const match = filename.match(IMPORTED_FILENAME_PATTERN)
|
|
165
|
+
if (!match) continue
|
|
166
|
+
const kind = match[2] as ImportedRecordKind
|
|
167
|
+
if (kind === 'fireflies') continue
|
|
168
|
+
found.push({ month, filename, kind, hash: match[3] })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return found
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Every derived record in the imports library.
|
|
176
|
+
*
|
|
177
|
+
* Reads only the derived sidecars — never a markdown file — through the
|
|
178
|
+
* library's own bounded reader, so a 32 MiB sidecar is capped and an iCloud
|
|
179
|
+
* conflict copy is refused by the filename pattern before it is opened.
|
|
180
|
+
*/
|
|
181
|
+
export function readDerivedRecords(
|
|
182
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
183
|
+
): DerivedRecordSummary[] {
|
|
184
|
+
const records: DerivedRecordSummary[] = []
|
|
185
|
+
for (const { month, filename, kind, hash } of derivedFilenames(library)) {
|
|
186
|
+
const summary = summarizeDerivedSidecar(library.readSidecar(month, filename), {
|
|
187
|
+
recordId: importRecordId(kind, hash),
|
|
188
|
+
month,
|
|
189
|
+
filename,
|
|
190
|
+
kind,
|
|
191
|
+
})
|
|
192
|
+
if (summary) records.push(summary)
|
|
193
|
+
}
|
|
194
|
+
return records
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function supersededInputsOf(records: readonly DerivedRecordSummary[]): SupersededInputs {
|
|
198
|
+
const g2Sessions = new Set<string>()
|
|
199
|
+
const importRecordIds = new Set<string>()
|
|
200
|
+
for (const record of records) {
|
|
201
|
+
for (const sessionId of record.g2SessionIds) g2Sessions.add(sessionId)
|
|
202
|
+
for (const firefliesId of record.firefliesIds) {
|
|
203
|
+
importRecordIds.add(importRecordId('fireflies', importHash(firefliesId)))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { g2Sessions, importRecordIds, isEmpty: g2Sessions.size === 0 && importRecordIds.size === 0 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Drop the rows a derived record already holds.
|
|
211
|
+
*
|
|
212
|
+
* A row is superseded when its session is one a derived record holds, or when it
|
|
213
|
+
* IS the import a derived record holds. A derived row is never superseded: pieces
|
|
214
|
+
* of one long recording each stand alone, and grouping (WS3) guarantees one
|
|
215
|
+
* merged record per capture.
|
|
216
|
+
*/
|
|
217
|
+
export function dropSupersededRows(rows: readonly MeetingMeta[], superseded: SupersededInputs): MeetingMeta[] {
|
|
218
|
+
if (superseded.isEmpty) return [...rows]
|
|
219
|
+
return rows.filter(row => {
|
|
220
|
+
if (row.derivedKind) return true
|
|
221
|
+
if (row.recordId && superseded.importRecordIds.has(row.recordId)) return false
|
|
222
|
+
if (row.sessionId && superseded.g2Sessions.has(row.sessionId)) return false
|
|
223
|
+
return true
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Derived rows dedupe on `recordId`, because a split piece has no session. */
|
|
228
|
+
export function dedupeDerivedRows(rows: readonly MeetingMeta[]): MeetingMeta[] {
|
|
229
|
+
const seen = new Set<string>()
|
|
230
|
+
const kept: MeetingMeta[] = []
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
const key = row.recordId ?? `${row.month}:${row.filename}`
|
|
233
|
+
if (seen.has(key)) continue
|
|
234
|
+
seen.add(key)
|
|
235
|
+
kept.push(row)
|
|
236
|
+
}
|
|
237
|
+
return kept
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function withDerivedIdentity(row: MeetingMeta, record: DerivedRecordSummary | undefined): MeetingMeta {
|
|
241
|
+
if (!record) return row
|
|
242
|
+
const identified: MeetingMeta = { ...row }
|
|
243
|
+
if (record.actionId) identified.actionId = record.actionId
|
|
244
|
+
if (record.kind === 'merge') {
|
|
245
|
+
// The earliest capture, so the row that replaced it can still open the
|
|
246
|
+
// speaker panel. `g2SessionIds` keeps the rest reachable.
|
|
247
|
+
if (record.g2SessionIds.length > 0) {
|
|
248
|
+
identified.sessionId = record.g2SessionIds[0]
|
|
249
|
+
identified.g2SessionIds = [...record.g2SessionIds]
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// A piece is a SPAN, not a capture. A sessionId here would make two pieces of
|
|
253
|
+
// one recording collapse onto each other in mergeMeetingSources.
|
|
254
|
+
delete identified.sessionId
|
|
255
|
+
if (record.pieceIndex !== undefined) identified.pieceIndex = record.pieceIndex
|
|
256
|
+
if (record.sourceSessionId) identified.sourceSessionId = record.sourceSessionId
|
|
257
|
+
}
|
|
258
|
+
return identified
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The imported library's rows, split into imports and derived records, with the
|
|
263
|
+
* derived rows carrying their action and their inputs.
|
|
264
|
+
*
|
|
265
|
+
* The limit is doubled before it reaches the library because supersession runs
|
|
266
|
+
* after this: a superseded import and the merged record that replaced it are two
|
|
267
|
+
* rows on the same date, and asking for exactly `limit` would let the pair push
|
|
268
|
+
* an older meeting out of a page that ends up holding only one of them.
|
|
269
|
+
*/
|
|
270
|
+
export function listImportedLibraryRows(
|
|
271
|
+
options: { limit?: number; domain?: string; month?: string; day?: string } = {},
|
|
272
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
273
|
+
records: readonly DerivedRecordSummary[] = readDerivedRecords(library),
|
|
274
|
+
): { imports: MeetingMeta[]; derived: MeetingMeta[] } {
|
|
275
|
+
const byRecordId = new Map(records.map(record => [record.recordId, record]))
|
|
276
|
+
const imports: MeetingMeta[] = []
|
|
277
|
+
const derived: MeetingMeta[] = []
|
|
278
|
+
const rows = library.list({
|
|
279
|
+
...options,
|
|
280
|
+
...(typeof options.limit === 'number' ? { limit: options.limit * 2 } : {}),
|
|
281
|
+
})
|
|
282
|
+
for (const row of rows) {
|
|
283
|
+
if (row.librarySource === 'imported') {
|
|
284
|
+
imports.push(row as MeetingMeta)
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
derived.push(withDerivedIdentity(row as MeetingMeta, byRecordId.get(row.recordId)))
|
|
288
|
+
}
|
|
289
|
+
return { imports, derived: dedupeDerivedRows(derived) }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Month folders of the imported library, newest first. */
|
|
293
|
+
export function importedLibraryMonths(
|
|
294
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
295
|
+
): string[] {
|
|
296
|
+
try {
|
|
297
|
+
return readdirSync(library.root).filter(name => IMPORTED_MONTH_PATTERN.test(name)).sort().reverse()
|
|
298
|
+
} catch {
|
|
299
|
+
return []
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A derived record's inputs, each with the record id that opens it.
|
|
305
|
+
*
|
|
306
|
+
* This is how "merged automatically" stays honest: the sources are not gone, and
|
|
307
|
+
* the detail names exactly where each one still lives. A G2 input's id is
|
|
308
|
+
* resolved against the trees rather than assumed, because the same capture is
|
|
309
|
+
* `ops:...` on one Mac and `standalone:...` on another, and a link built from the
|
|
310
|
+
* wrong assumption opens nothing.
|
|
311
|
+
*/
|
|
312
|
+
export function derivedSourcesFor(
|
|
313
|
+
record: DerivedRecordSummary,
|
|
314
|
+
store: MeetingStore = getMeetingStore(),
|
|
315
|
+
): NonNullable<MeetingMeta['sources']> {
|
|
316
|
+
const sources: NonNullable<MeetingMeta['sources']> = []
|
|
317
|
+
for (const id of record.firefliesIds) {
|
|
318
|
+
sources.push({ kind: 'fireflies', id, recordId: importRecordId('fireflies', importHash(id)) })
|
|
319
|
+
}
|
|
320
|
+
for (const id of record.g2SessionIds) {
|
|
321
|
+
const recordId = g2RecordIdFor(id, store)
|
|
322
|
+
sources.push({ kind: 'g2', id, ...(recordId ? { recordId } : {}) })
|
|
323
|
+
}
|
|
324
|
+
return sources
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function g2RecordIdFor(sessionId: string, store: MeetingStore): string | undefined {
|
|
328
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
329
|
+
const operations = findCosOperationsMeetingBySessionId(sessionId)
|
|
330
|
+
if (operations) return `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
return store.findBySessionId(sessionId) ? `standalone:${sessionId}` : undefined
|
|
334
|
+
} catch {
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The derived record behind a `blended:` row, or null when there is none. */
|
|
340
|
+
export function findDerivedRecord(
|
|
341
|
+
recordId: string,
|
|
342
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
343
|
+
): DerivedRecordSummary | null {
|
|
344
|
+
if (!/^blended:[0-9a-f]{16}$/.test(recordId)) return null
|
|
345
|
+
return readDerivedRecords(library).find(record => record.recordId === recordId) ?? null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Sessions the rows in hand already declare they hold.
|
|
350
|
+
*
|
|
351
|
+
* Used for the operations tree, where a merged scribe carries its inputs as
|
|
352
|
+
* `<!-- g2-session -->` markers rather than a `.derived.json`: the list has the
|
|
353
|
+
* markers in hand, so the filter costs nothing beyond what it already read.
|
|
354
|
+
*/
|
|
355
|
+
export function supersededFromRows(rows: readonly MeetingMeta[]): SupersededInputs {
|
|
356
|
+
const g2Sessions = new Set<string>()
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
if (!row.derivedKind) continue
|
|
359
|
+
for (const sessionId of row.g2SessionIds ?? []) g2Sessions.add(sessionId)
|
|
360
|
+
if (row.sessionId) g2Sessions.add(row.sessionId)
|
|
361
|
+
}
|
|
362
|
+
return { g2Sessions, importRecordIds: new Set<string>(), isEmpty: g2Sessions.size === 0 }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Day counts from filenames, summed across groups. Shared with the list. */
|
|
366
|
+
export function mergeDayCounts(
|
|
367
|
+
groups: Array<Array<{ date: string; count: number }>>,
|
|
368
|
+
): Array<{ date: string; count: number }> {
|
|
369
|
+
const counts = new Map<string, number>()
|
|
370
|
+
for (const group of groups) {
|
|
371
|
+
for (const { date, count } of group) {
|
|
372
|
+
counts.set(date, (counts.get(date) ?? 0) + count)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return [...counts.entries()]
|
|
376
|
+
.filter(([, count]) => count > 0)
|
|
377
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
378
|
+
.map(([date, count]) => ({ date, count }))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Day counts for the imported library in one month.
|
|
383
|
+
*
|
|
384
|
+
* An import a derived record has taken over is dropped, so the calendar dot and
|
|
385
|
+
* the list agree. For `all` and `imported` nothing is opened: the record id is a
|
|
386
|
+
* pure function of the filename's hash. A filter on the meeting's OWN domain has
|
|
387
|
+
* to read each record's sidecar head, which is what `list()` already does.
|
|
388
|
+
*/
|
|
389
|
+
export function importedLibraryDayCounts(
|
|
390
|
+
month: string,
|
|
391
|
+
superseded: SupersededInputs,
|
|
392
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
393
|
+
domain = 'all',
|
|
394
|
+
): Array<{ date: string; count: number }> {
|
|
395
|
+
const counts = new Map<string, number>()
|
|
396
|
+
if (domain !== 'all' && domain !== IMPORTED_DOMAIN) {
|
|
397
|
+
for (const row of library.list({ month, domain })) {
|
|
398
|
+
if (row.librarySource === 'imported' && superseded.importRecordIds.has(row.recordId)) continue
|
|
399
|
+
counts.set(row.date, (counts.get(row.date) ?? 0) + 1)
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
let names: string[]
|
|
403
|
+
try {
|
|
404
|
+
names = readdirSync(join(library.root, month))
|
|
405
|
+
} catch {
|
|
406
|
+
return []
|
|
407
|
+
}
|
|
408
|
+
for (const filename of names) {
|
|
409
|
+
const match = filename.match(IMPORTED_FILENAME_PATTERN)
|
|
410
|
+
if (!match) continue
|
|
411
|
+
const kind = match[2] as ImportedRecordKind
|
|
412
|
+
if (kind === 'fireflies' && superseded.importRecordIds.has(importRecordId('fireflies', match[3]))) continue
|
|
413
|
+
counts.set(match[1], (counts.get(match[1]) ?? 0) + 1)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, count]) => ({ date, count }))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Day counts with supersession ─────────────────────────────────────────────
|
|
420
|
+
//
|
|
421
|
+
// The calendar dot and the list must agree, and the list drops superseded rows,
|
|
422
|
+
// so the count has to drop them too. The existing day-count helpers are FILENAME
|
|
423
|
+
// scans and are uncapped, which is why they can describe a whole month the
|
|
424
|
+
// 50-row list cannot; that property is kept. What is added is a subtraction, and
|
|
425
|
+
// the subtraction is the only part that opens a file — a 4 KB sidecar head per
|
|
426
|
+
// candidate — so it is gated on there being anything to subtract at all.
|
|
427
|
+
|
|
428
|
+
export type SupersededLayout = 'direct' | 'multi_domain' | 'standalone'
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The session a meeting file declares, with the same fallback the LIST uses.
|
|
432
|
+
*
|
|
433
|
+
* The operations tree gitignores `.g2-chunks.json`, so a recording whose markdown arrived
|
|
434
|
+
* through git or iCloud sits there with no sidecar beside it. `listCosOperationsMeetings`
|
|
435
|
+
* handles that by looking under the server's own recordings root by the same stem; this did
|
|
436
|
+
* not, so a row the list DROPPED was still counted, and the calendar dot said two where the
|
|
437
|
+
* list showed one. A count that can disagree with the list it counts is the bug being fixed.
|
|
438
|
+
*/
|
|
439
|
+
function droppedSessionId(directory: string, filename: string, recordingsRoot: string): string | undefined {
|
|
440
|
+
const direct = sidecarSessionId(directory, filename)
|
|
441
|
+
if (direct) return direct
|
|
442
|
+
const month = filename.match(MONTH_FROM_DAY_FILE)?.[1]
|
|
443
|
+
if (!month) return undefined
|
|
444
|
+
const fallbackDir = join(recordingsRoot, month)
|
|
445
|
+
if (!existsSync(fallbackDir)) return undefined
|
|
446
|
+
return sidecarSessionId(fallbackDir, filename)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One `-1` per file in `directories` whose chunk sidecar names a dropped session. */
|
|
450
|
+
function sessionDropCounts(
|
|
451
|
+
directories: readonly string[],
|
|
452
|
+
sessions: ReadonlySet<string>,
|
|
453
|
+
recordingsRoot: string = dataPath('recordings'),
|
|
454
|
+
): Array<{ date: string; count: number }> {
|
|
455
|
+
if (sessions.size === 0) return []
|
|
456
|
+
const drops: Array<{ date: string; count: number }> = []
|
|
457
|
+
for (const directory of directories) {
|
|
458
|
+
let names: string[]
|
|
459
|
+
try {
|
|
460
|
+
names = readdirSync(directory).filter(name => name.endsWith('.md'))
|
|
461
|
+
} catch {
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
for (const filename of names) {
|
|
465
|
+
const date = filename.match(DAY_FILE_PREFIX)?.[1]
|
|
466
|
+
if (!date) continue
|
|
467
|
+
const sessionId = droppedSessionId(directory, filename, recordingsRoot)
|
|
468
|
+
if (!sessionId || !sessions.has(sessionId)) continue
|
|
469
|
+
drops.push({ date, count: -1 })
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return drops
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Month folders of the operations tree that the domain filter admits. */
|
|
476
|
+
function operationsMonthDirs(month: string, domainFilter: string): string[] {
|
|
477
|
+
const operationsDir = resolveCosOperationsDir()
|
|
478
|
+
if (!operationsDir) return []
|
|
479
|
+
const discovered = discoverMeetingDomains(operationsDir)
|
|
480
|
+
const domains = domainFilter === 'all'
|
|
481
|
+
? discovered
|
|
482
|
+
: discovered.includes(domainFilter) ? [domainFilter] : []
|
|
483
|
+
return domains.map(domain => join(operationsDir, domain, 'meetings', month))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Sessions declared by merged scribes in the operations tree for one month.
|
|
489
|
+
*
|
|
490
|
+
* READS EVERY SCRIBE IN THE MONTH, deliberately. A cheaper gate was tried first
|
|
491
|
+
* — scan only where the server has written a pipeline decision file — and it was
|
|
492
|
+
* wrong: the ROW filter reads these markers unconditionally, so a tree holding a
|
|
493
|
+
* merged scribe with no decision file beside it showed one row while the
|
|
494
|
+
* calendar dot said two. A count that can disagree with the list it counts is
|
|
495
|
+
* worse than a count that costs a read, and in this layout the scribe is the
|
|
496
|
+
* only place the merge is recorded.
|
|
497
|
+
*
|
|
498
|
+
* NOT WHEN THE CALLER ALREADY KNOWS. `/api/meetings` has just read every scribe
|
|
499
|
+
* in this month to build its rows, and each merged one told it which sessions it
|
|
500
|
+
* holds. Reading the same files again to learn the same thing doubled the cost
|
|
501
|
+
* of every month request on a pipeline Mac. The caller passes what it declared;
|
|
502
|
+
* only a caller with no list of its own — `probeMeetings` — pays for the scan.
|
|
503
|
+
*
|
|
504
|
+
* The markers sit before `## Transcript`, past the summary, decisions, action
|
|
505
|
+
* items and attendees, so there is no head-sized prefix that reliably holds
|
|
506
|
+
* them: when this does read, it reads the file.
|
|
507
|
+
*/
|
|
508
|
+
function operationsMergedSessions(
|
|
509
|
+
directories: readonly string[],
|
|
510
|
+
declared: ReadonlySet<string> | undefined,
|
|
511
|
+
): Set<string> {
|
|
512
|
+
if (declared) return new Set(declared)
|
|
513
|
+
const sessions = new Set<string>()
|
|
514
|
+
for (const directory of directories) {
|
|
515
|
+
let names: string[]
|
|
516
|
+
try {
|
|
517
|
+
names = readdirSync(directory).filter(name => name.endsWith('.md'))
|
|
518
|
+
} catch {
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
521
|
+
for (const filename of names) {
|
|
522
|
+
let content = ''
|
|
523
|
+
try {
|
|
524
|
+
content = readFileSync(join(directory, filename), 'utf8')
|
|
525
|
+
} catch {
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
for (const sessionId of mergedScribeSessions(content)) sessions.add(sessionId)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return sessions
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Day counts for one month across every source the list shows, with supersession
|
|
536
|
+
* applied. The list and `probeMeetings` share it so a dot and a row can never
|
|
537
|
+
* disagree about how many meetings a day holds.
|
|
538
|
+
*/
|
|
539
|
+
export function supersededDayCounts(
|
|
540
|
+
month: string,
|
|
541
|
+
layout: SupersededLayout,
|
|
542
|
+
options: {
|
|
543
|
+
domain?: string
|
|
544
|
+
store?: MeetingStore
|
|
545
|
+
library?: ImportedMeetingLibrary
|
|
546
|
+
/** Whether a COS pipeline files this Mac's recordings. Read live by default. */
|
|
547
|
+
pipeline?: boolean
|
|
548
|
+
/**
|
|
549
|
+
* Sessions the CALLER's rows already declare they hold.
|
|
550
|
+
*
|
|
551
|
+
* `/api/meetings` has just read every scribe in this month; passing what it found here
|
|
552
|
+
* is the difference between one read per file and two. A caller with no list of its own
|
|
553
|
+
* omits it and the scan runs.
|
|
554
|
+
*/
|
|
555
|
+
declaredSessions?: ReadonlySet<string>
|
|
556
|
+
/** The server's own recordings root, for the sidecar fallback. Tests point it at a temp dir. */
|
|
557
|
+
recordingsRoot?: string
|
|
558
|
+
} = {},
|
|
559
|
+
): Array<{ date: string; count: number }> {
|
|
560
|
+
if (!IMPORTED_MONTH_PATTERN.test(month)) return []
|
|
561
|
+
const domain = options.domain ?? 'all'
|
|
562
|
+
const store = options.store ?? getMeetingStore()
|
|
563
|
+
const library = options.library ?? getImportedMeetingLibrary()
|
|
564
|
+
const superseded = supersededInputsOf(readDerivedRecords(library))
|
|
565
|
+
|
|
566
|
+
if (layout === 'multi_domain' && (options.pipeline ?? g2RecordingsReachOperations())) {
|
|
567
|
+
// Operations rows only, as the list shows them. The imports root is not read
|
|
568
|
+
// into this layout at all: on a pipeline Mac the operations tree is the one
|
|
569
|
+
// library, and a derived record here would be a second row for one meeting.
|
|
570
|
+
const directories = operationsMonthDirs(month, domain)
|
|
571
|
+
return mergeDayCounts([
|
|
572
|
+
listCosOperationsMeetingDays(month, domain),
|
|
573
|
+
sessionDropCounts(directories, operationsMergedSessions(directories, options.declaredSessions), options.recordingsRoot),
|
|
574
|
+
])
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const groups: Array<Array<{ date: string; count: number }>> = []
|
|
578
|
+
const sessionDirectories: string[] = []
|
|
579
|
+
|
|
580
|
+
if (layout !== 'standalone' && cosOperationsMeetingsConfigured()) {
|
|
581
|
+
groups.push(listCosOperationsMeetingDays(month, domain))
|
|
582
|
+
sessionDirectories.push(...operationsMonthDirs(month, domain))
|
|
583
|
+
}
|
|
584
|
+
if (layout === 'direct' && (domain === 'all' || domain === 'library')) {
|
|
585
|
+
groups.push(listDirectLibraryMeetingDays(month))
|
|
586
|
+
const root = resolveMeetingLibrary().root
|
|
587
|
+
if (root) sessionDirectories.push(join(root, month))
|
|
588
|
+
}
|
|
589
|
+
// DOMAIN-SCOPED. Every other group here honours the filter; this one did not, so a
|
|
590
|
+
// `domain=quilt` count added every personal recording in the store to it.
|
|
591
|
+
groups.push(store.listDayCounts(month, domain))
|
|
592
|
+
sessionDirectories.push(join(store.root, month))
|
|
593
|
+
groups.push(importedLibraryDayCounts(month, superseded, library, domain))
|
|
594
|
+
|
|
595
|
+
if (layout === 'multi_domain') {
|
|
596
|
+
// 6.46.1: an operations copy of a capture wins over the store copy, and the
|
|
597
|
+
// day count skips the store row it covers. Unchanged, and now uncapped.
|
|
598
|
+
const covered = new Set<string>()
|
|
599
|
+
for (const directory of operationsMonthDirs(month, domain)) {
|
|
600
|
+
let names: string[]
|
|
601
|
+
try {
|
|
602
|
+
names = readdirSync(directory).filter(name => name.endsWith('.md'))
|
|
603
|
+
} catch {
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
for (const filename of names) {
|
|
607
|
+
const sessionId = sidecarSessionId(directory, filename)
|
|
608
|
+
if (sessionId) covered.add(sessionId)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
groups.push(sessionDropCounts([join(store.root, month)], covered, options.recordingsRoot))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
groups.push(sessionDropCounts(sessionDirectories, superseded.g2Sessions, options.recordingsRoot))
|
|
615
|
+
return mergeDayCounts(groups)
|
|
616
|
+
}
|