@gotcos/glasses-server 6.46.1 → 6.48.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
@@ -108,11 +108,35 @@ function sessionFile(sessionId: string): string | null {
108
108
  return resolve(path).startsWith(resolve(dir) + '/') ? path : null
109
109
  }
110
110
 
111
+ export type CorrectionListener = (sessionId: string, row: CorrectionRow) => void
112
+
113
+ const correctionListeners = new Set<CorrectionListener>()
114
+
115
+ /**
116
+ * Be told when a speaker correction lands, wherever it came from.
117
+ *
118
+ * ONE PLACE, NOT THREE ROUTES. Relabel, confirm and de-attribute are three handlers today
119
+ * and a fourth is a plausible next release; a listener wired into each of them is a listener
120
+ * the fourth silently does not get. Every one of them closes its correction here, so this is
121
+ * where "a human changed who said what in this meeting" actually happens.
122
+ *
123
+ * A listener that throws is contained: a correction has already been recorded by the time
124
+ * this runs, and a downstream reaction failing must never make the correction look failed.
125
+ */
126
+ export function onCorrectionApplied(listener: CorrectionListener): () => void {
127
+ correctionListeners.add(listener)
128
+ return () => { correctionListeners.delete(listener) }
129
+ }
130
+
111
131
  /**
112
132
  * Append one row. Returns false rather than throwing on a bad session id or an
113
133
  * unwritable directory — but note the caller's contract: if the INTENT row
114
134
  * cannot be written, the rewrite must not proceed. An unrecorded mutation is
115
135
  * exactly what this file exists to prevent.
136
+ *
137
+ * Listeners run only AFTER the row is durable, and only when it was written: a
138
+ * subscriber told about a correction that never landed would act on a change
139
+ * that does not exist.
116
140
  */
117
141
  export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
118
142
  const path = sessionFile(sessionId)
@@ -120,10 +144,17 @@ export function appendCorrection(sessionId: string, row: CorrectionRow): boolean
120
144
  try {
121
145
  mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
122
146
  appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
123
- return true
124
147
  } catch {
125
148
  return false
126
149
  }
150
+ for (const listener of correctionListeners) {
151
+ try {
152
+ listener(sessionId, row)
153
+ } catch (error) {
154
+ console.warn('[meeting-corrections] listener failed:', error)
155
+ }
156
+ }
157
+ return true
127
158
  }
128
159
 
129
160
  export interface CorrectionReadResult {
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Decision files: one per action, the only thing the pipeline reads (6.47.0, WS4).
3
+ *
4
+ * WHY ONE FILE PER ACTION (v3 blocker 12). The first design had the server and the pipeline
5
+ * both writing `merge-decisions.json`. Two writers on one JSON file is a lost update waiting
6
+ * for a slow disk, and neither side can tell afterwards which write survived. Here the
7
+ * server writes `decisions/<actionId>.json` and nothing else touches it; the pipeline reads
8
+ * it, does its work, and reports through stdout. The server then writes the report back into
9
+ * the same file under `result`, so a later Revert can read what actually happened rather
10
+ * than what was intended.
11
+ *
12
+ * WHY THE RENDERED CONTENT LIVES HERE AND NOT IN `.actions.json`. The patch holds transcript
13
+ * text — the whole G2 capture, alternate transcripts. `.actions.json` is read on every
14
+ * status poll and every list; putting megabytes of transcript in it would make a status call
15
+ * cost a transcript read, and would put meeting content in a file whose purpose is
16
+ * bookkeeping. The action record names the decision; the decision holds the content.
17
+ *
18
+ * MODE 0600 IN A 0700 DIRECTORY. These files contain meeting transcripts.
19
+ */
20
+
21
+ import { createHash } from 'node:crypto'
22
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { importsRoot } from './imported-meeting-library.js'
26
+ import type { PipelinePatch } from './meeting-engine/render.js'
27
+ import { securePrivateDirectory } from './secure-user-config.js'
28
+
29
+ export const DECISIONS_DIR_NAME = 'decisions'
30
+ export const DECISION_SCHEMA = 1
31
+
32
+ /** The prefix the pipeline prints its one report line with. */
33
+ export const MERGE_RESULT_PREFIX = 'COS_MERGE_RESULT='
34
+
35
+ /**
36
+ * Exit codes, as the pipeline command defines them (WS8b).
37
+ *
38
+ * 3 is NOT a failure. It means another pipeline process held the sync lock, which happens
39
+ * every hour when the scheduled sync runs; the action stays `pending` and is retried.
40
+ */
41
+ export const PIPELINE_EXIT_LOCK_BUSY = 3
42
+ /** The decision does not match the files on disk. Terminal: retrying cannot help. */
43
+ export const PIPELINE_EXIT_DECISION_INVALID = 4
44
+ /** A step failed part way, or the whole apply failed. Retried once. */
45
+ export const PIPELINE_EXIT_PARTIAL_OR_FAILED = 5
46
+
47
+ /** `a_` plus 16 hex. Enforced before the id reaches a path. */
48
+ export const ACTION_ID_PATTERN = /^a_[0-9a-f]{16}$/
49
+
50
+ export type MergeDecisionKind = 'merge' | 'split'
51
+
52
+ export interface MergeDecisionG2Input {
53
+ kind: 'g2'
54
+ sessionId: string
55
+ /** Operations-relative path of the `.g2-chunks.json`. */
56
+ sidecarRelPath: string
57
+ /** Operations-relative path of the standalone G2 scribe, when one exists. */
58
+ scribeRelPath?: string
59
+ sha256: string
60
+ }
61
+
62
+ export interface MergeDecisionFirefliesInput {
63
+ kind: 'fireflies'
64
+ firefliesId: string
65
+ /** Operations-relative path of the Fireflies scribe the patch is spliced into. */
66
+ scribeRelPath: string
67
+ /** Operations-relative path of its `.fireflies.json` sentences sidecar. */
68
+ sidecarRelPath: string
69
+ sha256: string
70
+ }
71
+
72
+ export type MergeDecisionInput = MergeDecisionG2Input | MergeDecisionFirefliesInput
73
+
74
+ export interface MergeDecisionResultOutput { path: string; sha256: string }
75
+ export interface MergeDecisionResultArchive { original: string; archive: string; sha256: string }
76
+ export interface MergeDecisionResultStamp { sidecar: string; blended_into: string }
77
+
78
+ export type MergePipelineStatus = 'applied' | 'reverted' | 'partial' | 'failed'
79
+
80
+ export interface MergePipelineResult {
81
+ schema: number
82
+ action_id: string
83
+ status: MergePipelineStatus
84
+ outputs: MergeDecisionResultOutput[]
85
+ archived: MergeDecisionResultArchive[]
86
+ retired: MergeDecisionResultArchive[]
87
+ stamps: MergeDecisionResultStamp[]
88
+ transcript_map: 'applied' | 'skipped'
89
+ step?: string
90
+ error_code?: string
91
+ }
92
+
93
+ export interface MergeDecision {
94
+ schema: number
95
+ actionId: string
96
+ kind: MergeDecisionKind
97
+ inputs: MergeDecisionInput[]
98
+ patch: PipelinePatch
99
+ speakerMap: PipelinePatch['speakerMap']
100
+ /** Operations-relative G2 scribe paths the pipeline archives and removes. */
101
+ retire: string[]
102
+ /** Written back by the server after the pipeline reports. */
103
+ result?: MergePipelineResult
104
+ }
105
+
106
+ export class DecisionError extends Error {
107
+ constructor(message: string, readonly code: string) {
108
+ super(message)
109
+ this.name = 'DecisionError'
110
+ }
111
+ }
112
+
113
+ export function assertActionId(actionId: string): string {
114
+ if (!ACTION_ID_PATTERN.test(actionId)) {
115
+ throw new DecisionError('Invalid action id', 'invalid_action_id')
116
+ }
117
+ return actionId
118
+ }
119
+
120
+ export function decisionsDir(root: string = importsRoot()): string {
121
+ return join(root, DECISIONS_DIR_NAME)
122
+ }
123
+
124
+ export function decisionPath(actionId: string, root?: string): string {
125
+ return join(decisionsDir(root), `${assertActionId(actionId)}.json`)
126
+ }
127
+
128
+ export function sha256OfFile(path: string): string | null {
129
+ try {
130
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
131
+ } catch {
132
+ return null
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Does this patch add anything at all?
138
+ *
139
+ * A patch with no sections, no rows and no markers is a decision the pipeline can apply
140
+ * successfully while changing nothing: it archives the Fireflies scribe, retires the G2
141
+ * standalone, writes the same bytes back, and reports `applied`. The capture disappears from
142
+ * the list and nothing takes its place. That is the one outcome an additive design must not
143
+ * be able to produce, and the cheapest place to make it impossible is the writer, which
144
+ * every apply and every re-drive goes through.
145
+ */
146
+ export function patchIsEmpty(patch: Pick<PipelinePatch, 'sections' | 'rows' | 'markers'> | null | undefined): boolean {
147
+ if (!patch) return true
148
+ return (patch.sections?.length ?? 0) === 0
149
+ && (patch.rows?.length ?? 0) === 0
150
+ && (patch.markers?.length ?? 0) === 0
151
+ }
152
+
153
+ /** Write one decision. The directory is created 0700 and the file 0600 every time. */
154
+ export function writeDecision(decision: MergeDecision, root?: string): string {
155
+ if (patchIsEmpty(decision.patch)) {
156
+ throw new DecisionError('A merge decision must add something to the scribe', 'patch_empty')
157
+ }
158
+ const path = decisionPath(decision.actionId, root)
159
+ securePrivateDirectory(decisionsDir(root))
160
+ durableAtomicWriteFileSync(path, `${JSON.stringify(decision, null, 2)}\n`, { mode: 0o600 })
161
+ return path
162
+ }
163
+
164
+ export function readDecision(actionId: string, root?: string): MergeDecision | null {
165
+ const path = decisionPath(actionId, root)
166
+ if (!existsSync(path)) return null
167
+ try {
168
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<MergeDecision>
169
+ if (parsed?.schema !== DECISION_SCHEMA || parsed.actionId !== actionId) return null
170
+ return parsed as MergeDecision
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Record what the pipeline reported, in the decision it was given.
178
+ *
179
+ * Revert reads this, not the action record: the archive paths and their sha256 are what let
180
+ * a restore prove it is putting back the same bytes that were moved aside.
181
+ */
182
+ export function writeDecisionResult(actionId: string, result: MergePipelineResult, root?: string): boolean {
183
+ const decision = readDecision(actionId, root)
184
+ if (!decision) return false
185
+ writeDecision({ ...decision, result }, root)
186
+ return true
187
+ }
188
+
189
+ export function deleteDecision(actionId: string, root?: string): void {
190
+ try { unlinkSync(decisionPath(actionId, root)) } catch { /* already gone */ }
191
+ }
192
+
193
+ export function listDecisionIds(root?: string): string[] {
194
+ try {
195
+ return readdirSync(decisionsDir(root))
196
+ .filter(name => name.endsWith('.json'))
197
+ .map(name => name.slice(0, -'.json'.length))
198
+ .filter(id => ACTION_ID_PATTERN.test(id))
199
+ .sort()
200
+ } catch {
201
+ return []
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Is this a report the server may act on?
207
+ *
208
+ * Deliberately strict about the three fields a Revert depends on and permissive about the
209
+ * rest: a pipeline that grows a field must not make every apply unreadable, but one that
210
+ * omits `archived` has not told us how to undo what it did.
211
+ */
212
+ export function isMergePipelineResult(value: unknown): value is MergePipelineResult {
213
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
214
+ const row = value as Record<string, unknown>
215
+ if (row.schema !== DECISION_SCHEMA) return false
216
+ if (typeof row.action_id !== 'string' || !ACTION_ID_PATTERN.test(row.action_id)) return false
217
+ if (row.status !== 'applied' && row.status !== 'reverted' && row.status !== 'partial' && row.status !== 'failed') return false
218
+ if (row.transcript_map !== 'applied' && row.transcript_map !== 'skipped') return false
219
+ for (const key of ['outputs', 'archived', 'retired', 'stamps']) {
220
+ if (!Array.isArray(row[key])) return false
221
+ }
222
+ return true
223
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fine alignment between a G2 capture and a Fireflies transcript (6.47.0, WS3).
3
+ *
4
+ * PURE. No filesystem, no network, no clock.
5
+ *
6
+ * Alignment exists ONLY to decide whether per-sentence relabelling is allowed. A capture
7
+ * that fails to align is still attached to the merged record in full — nothing is dropped,
8
+ * the Fireflies labels simply stand.
9
+ *
10
+ * WHY SENTENCE STARTS. Fireflies gives an exact `start_time` per sentence, while word
11
+ * times inside a sentence are interpolated by this engine. Anchoring on the first trigram
12
+ * of a sentence measures against a real timestamp; anchoring on arbitrary words measures
13
+ * against the interpolation and inflates the spread. Measured on 10 real pairs, sentence
14
+ * starts land at MAD 1.5 to 3.3 s.
15
+ */
16
+
17
+ import {
18
+ type FirefliesSentenceInput,
19
+ type TimedWord,
20
+ firefliesTimedWords,
21
+ median,
22
+ medianAbsoluteDeviation,
23
+ normalizeWords,
24
+ trigramKey,
25
+ uniqueTrigrams,
26
+ } from './evidence.js'
27
+
28
+ /** How far an anchor's offset may sit from the coarse offset before it is a different conversation. */
29
+ export const ALIGN_BAND_S = 300
30
+
31
+ /** Anchors needed before the offsets are a measurement rather than a coincidence. */
32
+ export const ALIGN_MIN_ANCHORS = 8
33
+
34
+ /**
35
+ * Spread allowed around the median offset, in ms.
36
+ *
37
+ * Chosen from the same 10 pairs it is measured on, which is a real weakness and is why
38
+ * the share gate and the 0.55 similarity floor, not this threshold, are what keep a wrong
39
+ * name off a sentence.
40
+ */
41
+ export const ALIGN_MAX_MAD_MS = 3500
42
+
43
+ /** Clock drift is not constant over an hour, so the offset is re-measured per window. */
44
+ export const ALIGN_WINDOW_MS = 120_000
45
+
46
+ /** A window with fewer anchors than this has no offset of its own and inherits one. */
47
+ export const ALIGN_WINDOW_MIN_ANCHORS = 3
48
+
49
+ export type AttributionState = 'aligned' | 'unaligned' | 'capture_only'
50
+
51
+ export interface AlignmentAnchor {
52
+ /** Milliseconds from the G2 capture start. */
53
+ g2Ms: number
54
+ /** `firefliesMs - g2Ms`. */
55
+ offsetMs: number
56
+ }
57
+
58
+ export interface Alignment {
59
+ aligned: boolean
60
+ anchors: number
61
+ /** Null when there were too few anchors to measure. */
62
+ madMs: number | null
63
+ /** Median offset across all anchors. Null when unmeasured. */
64
+ offsetMs: number | null
65
+ samples: AlignmentAnchor[]
66
+ windows: Map<number, number>
67
+ }
68
+
69
+ /**
70
+ * Anchors: Fireflies sentence-initial trigrams that are unique in BOTH transcripts.
71
+ *
72
+ * Unique on both sides for the same reason pairing needs it — a phrase that recurs cannot
73
+ * say which moment this is — and sentence-initial so the Fireflies time is exact.
74
+ */
75
+ export function sentenceStartAnchors(
76
+ g2Words: readonly TimedWord[],
77
+ sentences: readonly FirefliesSentenceInput[],
78
+ coarseOffsetMs: number,
79
+ bandS: number = ALIGN_BAND_S,
80
+ ): AlignmentAnchor[] {
81
+ const g2Trigrams = uniqueTrigrams(g2Words)
82
+ const firefliesTrigrams = uniqueTrigrams(firefliesTimedWords(sentences))
83
+ const anchors: AlignmentAnchor[] = []
84
+ for (const sentence of sentences) {
85
+ const tokens = normalizeWords(sentence.text)
86
+ if (tokens.length < 3) continue
87
+ const key = trigramKey(tokens[0], tokens[1], tokens[2])
88
+ if (!firefliesTrigrams.has(key)) continue
89
+ const g2Ms = g2Trigrams.get(key)
90
+ if (g2Ms === undefined) continue
91
+ const firefliesMs = (Number(sentence.start_time ?? 0) || 0) * 1000
92
+ const offsetMs = firefliesMs - g2Ms
93
+ if (Math.abs(offsetMs - coarseOffsetMs) <= bandS * 1000) anchors.push({ g2Ms, offsetMs })
94
+ }
95
+ return anchors
96
+ }
97
+
98
+ /**
99
+ * Per-window offsets over G2 time.
100
+ *
101
+ * A sparse window inherits from the nearest window that has enough anchors, earlier one
102
+ * first on a tie. Inheriting a neighbour keeps a quiet stretch on its neighbours' drift
103
+ * instead of snapping it back to the whole-meeting median, which is the larger error by
104
+ * the end of a long call.
105
+ */
106
+ export function windowOffsets(
107
+ anchors: readonly AlignmentAnchor[],
108
+ windowMs: number = ALIGN_WINDOW_MS,
109
+ minAnchors: number = ALIGN_WINDOW_MIN_ANCHORS,
110
+ ): Map<number, number> {
111
+ const byWindow = new Map<number, number[]>()
112
+ for (const anchor of anchors) {
113
+ const index = Math.floor(anchor.g2Ms / windowMs)
114
+ const bucket = byWindow.get(index)
115
+ if (bucket) bucket.push(anchor.offsetMs)
116
+ else byWindow.set(index, [anchor.offsetMs])
117
+ }
118
+ const resolved = new Map<number, number>()
119
+ for (const [index, offsets] of byWindow) {
120
+ if (offsets.length >= minAnchors) resolved.set(index, median(offsets))
121
+ }
122
+ if (resolved.size === 0) return resolved
123
+ for (const index of byWindow.keys()) {
124
+ if (resolved.has(index)) continue
125
+ let best: number | null = null
126
+ let bestDistance = Infinity
127
+ for (const candidate of resolved.keys()) {
128
+ const distance = Math.abs(candidate - index)
129
+ if (distance < bestDistance || (distance === bestDistance && best !== null && candidate < best)) {
130
+ best = candidate
131
+ bestDistance = distance
132
+ }
133
+ }
134
+ if (best !== null) resolved.set(index, resolved.get(best)!)
135
+ }
136
+ return resolved
137
+ }
138
+
139
+ /** Measure the alignment of one capture against one transcript. */
140
+ export function alignRecording(
141
+ g2Words: readonly TimedWord[],
142
+ sentences: readonly FirefliesSentenceInput[],
143
+ coarseOffsetMs: number,
144
+ ): Alignment {
145
+ const samples = sentenceStartAnchors(g2Words, sentences, coarseOffsetMs)
146
+ if (samples.length < ALIGN_MIN_ANCHORS) {
147
+ return { aligned: false, anchors: samples.length, madMs: null, offsetMs: null, samples, windows: new Map() }
148
+ }
149
+ const offsets = samples.map(s => s.offsetMs)
150
+ const offsetMs = median(offsets)
151
+ const madMs = medianAbsoluteDeviation(offsets)
152
+ return {
153
+ aligned: madMs <= ALIGN_MAX_MAD_MS,
154
+ anchors: samples.length,
155
+ madMs,
156
+ offsetMs,
157
+ samples,
158
+ windows: windowOffsets(samples),
159
+ }
160
+ }
161
+
162
+ /** The offset to use for a moment in G2 time. */
163
+ export function offsetAt(alignment: Alignment, g2Ms: number, windowMs: number = ALIGN_WINDOW_MS): number {
164
+ const fallback = alignment.offsetMs ?? 0
165
+ if (alignment.windows.size === 0) return fallback
166
+ return alignment.windows.get(Math.floor(g2Ms / windowMs)) ?? fallback
167
+ }