@gotcos/glasses-server 6.21.29 → 6.21.31
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 +85 -0
- package/package.json +1 -1
- package/server/lib/cos-operations-meetings.ts +25 -13
- package/server/lib/domains.ts +253 -0
- package/server/lib/meeting-scribe-content.ts +61 -8
- package/server/lib/meeting-store.ts +17 -6
- package/server/lib/profile.ts +9 -0
- package/server/routes/meeting.ts +32 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
+
## 6.21.31
|
|
2
|
+
|
|
3
|
+
Domains belong to the user. Four places in this codebase hardcoded ONE user's
|
|
4
|
+
business units, a fifth pretended to make them configurable, and two of them
|
|
5
|
+
disagreed with each other. A second person set up their own COS on 2026-08-08 and
|
|
6
|
+
nothing she could name would work.
|
|
7
|
+
|
|
8
|
+
- **New `lib/domains.ts` — one definition of each of these, replacing five.** What
|
|
9
|
+
it replaced: `['quilt','sprocket_rocket','hermit_crabs','personal']` in the
|
|
10
|
+
operations lister (used for listing, sidecar lookup, filtering, AND as the
|
|
11
|
+
path-traversal guard); a hand-written badge table; `domain.slice(0,2)` in the
|
|
12
|
+
meeting store, which rendered `sprocket_rocket` as "SP" while the lister said
|
|
13
|
+
"SR"; and `getDomainKeywords()` in `profile.ts`, exported with **zero call
|
|
14
|
+
sites** — a whole configuration chain built and never connected, which reads as
|
|
15
|
+
"domains are configurable" to anyone who greps for it.
|
|
16
|
+
|
|
17
|
+
- **Domains are the UNION of your configuration and what is on disk.** The union
|
|
18
|
+
is load-bearing, not incidental. Configured with no folder yet: still listed, so
|
|
19
|
+
a new install can be routed before any folder exists. On disk but unconfigured:
|
|
20
|
+
still listed, so a folder made by hand never becomes invisible — and that is
|
|
21
|
+
what makes this change safe for an existing install, whose profile configures
|
|
22
|
+
nothing and whose folders resolve exactly as before. Nothing is written to any
|
|
23
|
+
existing profile. Neither: the defaults.
|
|
24
|
+
|
|
25
|
+
- **Defaults are `personal` and `business`, badged P and B.** Two, not four, and
|
|
26
|
+
only a genuinely fresh COS ever sees them. Set your own in
|
|
27
|
+
`.cos-profile.json` — a bare list works (`"domains": ["personal","work"]`), or
|
|
28
|
+
objects with `keywords` and an `abbr` override.
|
|
29
|
+
|
|
30
|
+
- **A meeting with no domain from the client is now routed by content.** Keyword
|
|
31
|
+
scoring over title and transcript, counting DISTINCT matched keywords so one
|
|
32
|
+
word repeated forty times cannot outvote four different signals, word-boundary
|
|
33
|
+
matched so "car" does not fire inside "carrier". Deliberately not a model call:
|
|
34
|
+
this runs on every save. Nothing scoring falls back to `personal`, the safe
|
|
35
|
+
direction — a work meeting misfiled as personal is a nuisance the user fixes,
|
|
36
|
+
while a personal conversation filed under a business domain can be pasted into
|
|
37
|
+
a work channel.
|
|
38
|
+
|
|
39
|
+
- **A domain name is checked for SAFETY, not style.** The old save-path pattern
|
|
40
|
+
`/^[a-z][a-z0-9_]{0,31}$/` accepted `sprocket_rocket` and rejected `DNP study`,
|
|
41
|
+
so a user could select that folder and then never save a meeting into it. The
|
|
42
|
+
store also lowercased the name, which turned `DNP study` into `dnp study` and
|
|
43
|
+
matched no directory on disk. Spaces and mixed case are now fine; traversal,
|
|
44
|
+
control characters and hidden names are still refused.
|
|
45
|
+
|
|
46
|
+
- **A domain must hold the shape the lister reads.** A `meetings/` folder is no
|
|
47
|
+
longer enough: it must contain at least one `YYYY-MM` month directory. Measured
|
|
48
|
+
on a real install, `operations/archive/meetings/` holds domain names rather than
|
|
49
|
+
months, so a bare directory check listed it as a domain with permanently zero
|
|
50
|
+
meetings. Structural, so no blocklist of names was needed.
|
|
51
|
+
|
|
52
|
+
## 6.21.30
|
|
53
|
+
|
|
54
|
+
- **A name you removed by hand is now stated, and the stale write-up is called
|
|
55
|
+
out.** De-attribution rewrites the sidecar, the attendee list and the transcript
|
|
56
|
+
labels, but deliberately leaves narrative prose alone, because substituting into
|
|
57
|
+
a written sentence mangles grammar and can hit the wrong person. The applied
|
|
58
|
+
correction row has recorded `proseStale: true` for exactly this since the feature
|
|
59
|
+
shipped — and NOTHING read it.
|
|
60
|
+
|
|
61
|
+
Real case, 2026-08-07: Miles removed "Clem Ukaoma" from a personal call that was
|
|
62
|
+
only him and Queen (his father's voice matched a similar profile). All 8 label
|
|
63
|
+
sites were rewritten correctly. The LLM summary still opened "Miles, Queen, and
|
|
64
|
+
Clem talk through..." and the payload said nothing at all. The allowlist covered
|
|
65
|
+
it only as "not confirmed", which is far too weak: he did not fail to confirm
|
|
66
|
+
that person, he explicitly said they were not in the room.
|
|
67
|
+
|
|
68
|
+
The payload now carries `removedNames` and states it above the write-up: *"You
|
|
69
|
+
removed "Clem Ukaoma" from this meeting. The write-up below was written before
|
|
70
|
+
that and still uses the name: treat every mention of it as a capture error, not
|
|
71
|
+
a participant."* The prose is left intact — the record stays, the correction
|
|
72
|
+
travels beside it.
|
|
73
|
+
|
|
74
|
+
- **The overlap note is gated on how long the meeting RAN, not on voiced time.**
|
|
75
|
+
Gating on voiced time tripped it on 16 of 23 real meetings, because any overlap
|
|
76
|
+
at all exceeds the union of voiced speech. At 70% it stopped being a signal and
|
|
77
|
+
became boilerplate on something pasted into Slack. Rows adding to more than the
|
|
78
|
+
meeting length is the genuinely confusing case — 71m of rows inside a 66-minute
|
|
79
|
+
meeting — and that is roughly a tenth of meetings.
|
|
80
|
+
|
|
81
|
+
Worth recording for the next person who tests this: only the WORD-TIMING path
|
|
82
|
+
can overflow. Measured, the chunk-estimate path credits a contested second to
|
|
83
|
+
one speaker (two speakers at identical timestamps gave MU 273s and Gina 0s), so
|
|
84
|
+
a chunk-sourced meeting can never trip the note.
|
|
85
|
+
|
|
1
86
|
## 6.21.29
|
|
2
87
|
|
|
3
88
|
Everything below was found by two rounds of adversarial review of 6.21.28 against
|
package/package.json
CHANGED
|
@@ -15,16 +15,26 @@
|
|
|
15
15
|
|
|
16
16
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
17
17
|
import { basename, join, resolve } from 'node:path'
|
|
18
|
+
import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
|
|
18
19
|
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
19
20
|
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The four domains of ONE user's COS. Retained as the documented example layout
|
|
24
|
+
* and as test material — never as the set this module will accept.
|
|
25
|
+
*/
|
|
26
|
+
export const EXAMPLE_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Domain resolution, safe-name checks and badges have exactly ONE definition
|
|
30
|
+
* each, in `domains.ts`, shared with the meeting store and mirrored by the
|
|
31
|
+
* Control picker. Re-exported because this module's callers import them.
|
|
32
|
+
*/
|
|
33
|
+
export { domainAbbreviation, isSafeDomainName } from './domains.js'
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
hermit_crabs: 'HC',
|
|
27
|
-
personal: 'P',
|
|
35
|
+
/** Immediate subdirectories of `operationsDir` holding a `meetings/` tree. */
|
|
36
|
+
export function discoverMeetingDomains(operationsDir: string): string[] {
|
|
37
|
+
return discoveredDomains(operationsDir)
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
@@ -197,7 +207,7 @@ function parseMeetingMeta(content: string, filename: string, domain: string): Co
|
|
|
197
207
|
date,
|
|
198
208
|
...(time ? { time } : {}),
|
|
199
209
|
domain,
|
|
200
|
-
domainAbbr:
|
|
210
|
+
domainAbbr: deriveAbbr(domain),
|
|
201
211
|
source,
|
|
202
212
|
duration,
|
|
203
213
|
durationMinutes,
|
|
@@ -316,7 +326,7 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
316
326
|
const operationsDir = resolveCosOperationsDir()
|
|
317
327
|
if (!operationsDir) return null
|
|
318
328
|
|
|
319
|
-
for (const domain of
|
|
329
|
+
for (const domain of discoverMeetingDomains(operationsDir)) {
|
|
320
330
|
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
321
331
|
let months: string[]
|
|
322
332
|
try {
|
|
@@ -363,11 +373,10 @@ export function listCosOperationsMeetings(options: {
|
|
|
363
373
|
|
|
364
374
|
const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
|
|
365
375
|
const domainFilter = options.domain || 'all'
|
|
376
|
+
const discovered = discoverMeetingDomains(operationsDir)
|
|
366
377
|
const domains = domainFilter === 'all'
|
|
367
|
-
?
|
|
368
|
-
:
|
|
369
|
-
? [domainFilter]
|
|
370
|
-
: []
|
|
378
|
+
? discovered
|
|
379
|
+
: discovered.includes(domainFilter) ? [domainFilter] : []
|
|
371
380
|
|
|
372
381
|
const allMeetings: CosOperationsMeetingMeta[] = []
|
|
373
382
|
|
|
@@ -416,7 +425,10 @@ export function getCosOperationsMeetingDetail(
|
|
|
416
425
|
const operationsDir = resolveCosOperationsDir()
|
|
417
426
|
if (!operationsDir) return null
|
|
418
427
|
|
|
419
|
-
|
|
428
|
+
// Two separate jobs, and the old single membership test conflated them:
|
|
429
|
+
// traversal safety, then whether this domain actually exists here.
|
|
430
|
+
if (!safeName(domain)) return null
|
|
431
|
+
if (!discoverMeetingDomains(operationsDir).includes(domain)) return null
|
|
420
432
|
if (!/^\d{4}-\d{2}$/.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) {
|
|
421
433
|
return null
|
|
422
434
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Which domains this COS has, and which one a meeting belongs to.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Four places independently hardcoded ONE user's business
|
|
4
|
+
// domains, and a fifth pretended to make them configurable:
|
|
5
|
+
//
|
|
6
|
+
// cos-operations-meetings.ts ['quilt','sprocket_rocket','hermit_crabs','personal']
|
|
7
|
+
// cos-control-macos the same four, in the folder-picker validator
|
|
8
|
+
// cos-glasses-app two abbreviation maps (display-pages.ts:843, :1104)
|
|
9
|
+
// meeting-store.ts domain.slice(0,2), which renders sprocket_rocket "SP"
|
|
10
|
+
// profile.ts getDomainKeywords() — exported, ZERO call sites
|
|
11
|
+
//
|
|
12
|
+
// The two abbreviation schemes already disagreed with each other, and
|
|
13
|
+
// getDomainKeywords was a whole configuration chain built and never connected —
|
|
14
|
+
// which reads as "domains are configurable" to anyone who greps for it.
|
|
15
|
+
//
|
|
16
|
+
// A second person set up their own COS on 2026-08-08. Nothing she could name
|
|
17
|
+
// would work: the picker demanded a `quilt/` tree, and a domain like `DNP study`
|
|
18
|
+
// was rejected on save by a pattern permitting only lowercase and underscores.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { loadProfileObject } from './profile.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Defaults for a COS that has never been configured and has no folders yet.
|
|
26
|
+
*
|
|
27
|
+
* Two, not four. `personal` and `business` are the split almost everyone has, and
|
|
28
|
+
* the alternative — shipping the author's own business units — is what created
|
|
29
|
+
* this file.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_DOMAINS = ['personal', 'business'] as const
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The domain a meeting falls back to when nothing else decides.
|
|
35
|
+
*
|
|
36
|
+
* `personal` on purpose, and it is the SAFE direction: a work meeting filed under
|
|
37
|
+
* personal is a misfiling the user notices and fixes, while a personal
|
|
38
|
+
* conversation filed under a business domain can end up pasted into a work
|
|
39
|
+
* channel. Matches the standing correction that G2 recordings default to personal
|
|
40
|
+
* and get reclassified by content.
|
|
41
|
+
*/
|
|
42
|
+
export const FALLBACK_DOMAIN = 'personal'
|
|
43
|
+
|
|
44
|
+
/** Keyword seeds for the two defaults, used only when the user has set none. */
|
|
45
|
+
const DEFAULT_KEYWORDS: Record<string, string[]> = {
|
|
46
|
+
business: [
|
|
47
|
+
'client', 'customer', 'revenue', 'pipeline', 'roadmap', 'sprint', 'standup',
|
|
48
|
+
'quarter', 'invoice', 'proposal', 'stakeholder', 'deadline', 'launch',
|
|
49
|
+
'budget', 'campaign', 'hiring', 'onboarding', 'contract', 'vendor', 'demo',
|
|
50
|
+
],
|
|
51
|
+
personal: [
|
|
52
|
+
'family', 'kids', 'wife', 'husband', 'partner', 'doctor', 'dentist',
|
|
53
|
+
'school', 'vacation', 'holiday', 'dinner', 'birthday', 'weekend', 'church',
|
|
54
|
+
'grocery', 'house', 'insurance', 'therapy', 'workout', 'anniversary',
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DomainConfig {
|
|
59
|
+
name: string
|
|
60
|
+
/** Badge override. Derived when absent. */
|
|
61
|
+
abbr?: string
|
|
62
|
+
/** Words that route a meeting here. Case-insensitive, word-boundary matched. */
|
|
63
|
+
keywords?: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is this name safe as a path component AND as a domain label?
|
|
68
|
+
*
|
|
69
|
+
* A safety check, not a naming policy. Permits spaces and mixed case, because
|
|
70
|
+
* `DNP study` is a real domain and the previous pattern (`^[a-z][a-z0-9_]{0,31}$`)
|
|
71
|
+
* silently encoded one author's snake_case habit as a requirement — it accepted
|
|
72
|
+
* `sprocket_rocket` and rejected `DNP study`.
|
|
73
|
+
*/
|
|
74
|
+
export function isSafeDomainName(name: string): boolean {
|
|
75
|
+
if (!name || name.length > 64) return false
|
|
76
|
+
if (name !== name.trim()) return false
|
|
77
|
+
if (name === '.' || name === '..' || name.startsWith('.')) return false
|
|
78
|
+
if (/[/\\\0]/.test(name)) return false
|
|
79
|
+
// Control characters would corrupt a path or a markdown header.
|
|
80
|
+
if (/[\x00-\x1f\x7f]/.test(name)) return false
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The user's configured domains, or [] when unset. Malformed entries dropped. */
|
|
85
|
+
export function configuredDomains(): DomainConfig[] {
|
|
86
|
+
const raw = loadProfileObject().domains
|
|
87
|
+
if (!Array.isArray(raw)) return []
|
|
88
|
+
const out: DomainConfig[] = []
|
|
89
|
+
const seen = new Set<string>()
|
|
90
|
+
for (const entry of raw) {
|
|
91
|
+
// A bare string is accepted: `"domains": ["personal", "work"]` is the
|
|
92
|
+
// shortest thing a user will reach for, and rejecting it would be hostile.
|
|
93
|
+
const name = typeof entry === 'string'
|
|
94
|
+
? entry
|
|
95
|
+
: (entry && typeof entry === 'object' && typeof (entry as DomainConfig).name === 'string')
|
|
96
|
+
? (entry as DomainConfig).name
|
|
97
|
+
: ''
|
|
98
|
+
const trimmed = name.trim()
|
|
99
|
+
if (!isSafeDomainName(trimmed) || seen.has(trimmed.toLowerCase())) continue
|
|
100
|
+
seen.add(trimmed.toLowerCase())
|
|
101
|
+
const obj: Partial<DomainConfig> =
|
|
102
|
+
(entry && typeof entry === 'object') ? entry as Partial<DomainConfig> : {}
|
|
103
|
+
out.push({
|
|
104
|
+
name: trimmed,
|
|
105
|
+
...(typeof obj.abbr === 'string' && obj.abbr.trim() ? { abbr: obj.abbr.trim().slice(0, 4) } : {}),
|
|
106
|
+
...(Array.isArray(obj.keywords)
|
|
107
|
+
? { keywords: obj.keywords.filter((k): k is string => typeof k === 'string' && !!k.trim()) }
|
|
108
|
+
: {}),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Immediate subdirectories of `operationsDir` holding a `meetings/` tree. */
|
|
115
|
+
export function discoveredDomains(operationsDir: string | null): string[] {
|
|
116
|
+
if (!operationsDir || !existsSync(operationsDir)) return []
|
|
117
|
+
let names: string[]
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(operationsDir, { withFileTypes: true })
|
|
120
|
+
.filter(e => e.isDirectory()).map(e => e.name)
|
|
121
|
+
} catch { return [] }
|
|
122
|
+
return names.filter(isSafeDomainName).filter(name => {
|
|
123
|
+
const meetings = join(operationsDir, name, 'meetings')
|
|
124
|
+
try { if (!statSync(meetings).isDirectory()) return false } catch { return false }
|
|
125
|
+
// A `meetings/` folder is not enough: it must hold at least one YYYY-MM month
|
|
126
|
+
// directory, which is the only shape the lister reads. Measured on a real
|
|
127
|
+
// install, `operations/archive/meetings/` contains domain names rather than
|
|
128
|
+
// months, so accepting any `meetings/` folder listed it as a domain with
|
|
129
|
+
// permanently zero meetings. Structural, so it needs no blocklist of names.
|
|
130
|
+
try {
|
|
131
|
+
return readdirSync(meetings, { withFileTypes: true })
|
|
132
|
+
.some(e => e.isDirectory() && /^\d{4}-\d{2}$/.test(e.name))
|
|
133
|
+
} catch { return false }
|
|
134
|
+
}).sort()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every domain this COS has, as a UNION of configuration and what is on disk.
|
|
139
|
+
*
|
|
140
|
+
* A union rather than a replacement, and the distinction is load-bearing:
|
|
141
|
+
*
|
|
142
|
+
* - Configured but no folder yet: still listed, so a brand-new user can be
|
|
143
|
+
* ROUTED before any folder exists.
|
|
144
|
+
* - On disk but not configured: still listed, so a folder someone made by hand
|
|
145
|
+
* never becomes invisible. This is also what protects an existing install —
|
|
146
|
+
* Miles has no `domains` in his profile, discovery finds his four, and the
|
|
147
|
+
* union is exactly his four. Nothing is written to his profile and nothing
|
|
148
|
+
* about his setup changes.
|
|
149
|
+
* - Neither: the DEFAULTS. Only a genuinely fresh COS ever sees them, which is
|
|
150
|
+
* why defaulting to two is safe.
|
|
151
|
+
*
|
|
152
|
+
* Configured order is preserved and comes first; discovered extras follow,
|
|
153
|
+
* sorted, so the list is stable across calls.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveDomains(operationsDir: string | null): DomainConfig[] {
|
|
156
|
+
const configured = configuredDomains()
|
|
157
|
+
const discovered = discoveredDomains(operationsDir)
|
|
158
|
+
if (configured.length === 0 && discovered.length === 0) {
|
|
159
|
+
return DEFAULT_DOMAINS.map(name => ({ name, keywords: DEFAULT_KEYWORDS[name] }))
|
|
160
|
+
}
|
|
161
|
+
const known = new Set(configured.map(d => d.name.toLowerCase()))
|
|
162
|
+
return [
|
|
163
|
+
...configured,
|
|
164
|
+
...discovered.filter(name => !known.has(name.toLowerCase())).map(name => ({ name })),
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Just the names, in resolved order. */
|
|
169
|
+
export function domainNames(operationsDir: string | null): string[] {
|
|
170
|
+
return resolveDomains(operationsDir).map(d => d.name)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Short badge for a domain.
|
|
175
|
+
*
|
|
176
|
+
* An explicit `abbr` wins; otherwise initials of up to two words. Derivation
|
|
177
|
+
* reproduces the old hand-written table exactly — quilt Q, personal P,
|
|
178
|
+
* hermit_crabs HC, sprocket_rocket SR — which is why that table is gone. The old
|
|
179
|
+
* `meeting-store` version used `slice(0,2)` and rendered sprocket_rocket "SP", so
|
|
180
|
+
* the two schemes in this codebase disagreed with each other.
|
|
181
|
+
*/
|
|
182
|
+
export function domainAbbreviation(domain: string, config?: DomainConfig[]): string {
|
|
183
|
+
const hit = config?.find(d => d.name.toLowerCase() === domain.toLowerCase())
|
|
184
|
+
if (hit?.abbr) return hit.abbr
|
|
185
|
+
const words = domain.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
186
|
+
if (words.length === 0) return '?'
|
|
187
|
+
// A short single word IS its own badge: the companion uses the short forms `sr`
|
|
188
|
+
// and `hc`, and taking a first initial would render them "S" and "H".
|
|
189
|
+
if (words.length === 1 && words[0].length <= 3) return words[0].toUpperCase()
|
|
190
|
+
return words.slice(0, 2).map(w => w[0].toUpperCase()).join('')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Which domain does this meeting belong to?
|
|
195
|
+
*
|
|
196
|
+
* Keyword scoring over the title and body, highest score wins. Deliberately NOT
|
|
197
|
+
* an LLM call: this runs on every save, and a per-meeting model call would breach
|
|
198
|
+
* the standing rule that every recurring LLM caller must justify its volume.
|
|
199
|
+
*
|
|
200
|
+
* Scoring counts DISTINCT matched keywords rather than total occurrences, so one
|
|
201
|
+
* word repeated forty times cannot outvote four different signals. Word-boundary
|
|
202
|
+
* matched, so "car" does not fire inside "carrier".
|
|
203
|
+
*
|
|
204
|
+
* Returns null when nothing scores, rather than picking. The caller decides, and
|
|
205
|
+
* an unscored meeting must not be asserted into a business domain by accident.
|
|
206
|
+
*/
|
|
207
|
+
export function classifyDomain(
|
|
208
|
+
text: string,
|
|
209
|
+
domains: DomainConfig[],
|
|
210
|
+
): { domain: string; score: number; matched: string[] } | null {
|
|
211
|
+
const haystack = text.toLowerCase()
|
|
212
|
+
if (!haystack.trim()) return null
|
|
213
|
+
let best: { domain: string; score: number; matched: string[] } | null = null
|
|
214
|
+
for (const d of domains) {
|
|
215
|
+
const keywords = d.keywords?.length ? d.keywords : DEFAULT_KEYWORDS[d.name.toLowerCase()] ?? []
|
|
216
|
+
const matched: string[] = []
|
|
217
|
+
for (const kw of keywords) {
|
|
218
|
+
const needle = kw.toLowerCase().trim()
|
|
219
|
+
if (!needle) continue
|
|
220
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
221
|
+
if (new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}([^\\p{L}\\p{N}]|$)`, 'u').test(haystack)) {
|
|
222
|
+
matched.push(needle)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Strictly greater, so the FIRST domain in resolved order wins a tie. Ties are
|
|
226
|
+
// then deterministic and under the user's control via ordering.
|
|
227
|
+
if (matched.length > 0 && (!best || matched.length > best.score)) {
|
|
228
|
+
best = { domain: d.name, score: matched.length, matched }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return best
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The domain to file a meeting under: the client's choice, else inference, else
|
|
236
|
+
* the safe fallback.
|
|
237
|
+
*
|
|
238
|
+
* The fallback prefers a domain the user actually has — `personal` when present,
|
|
239
|
+
* otherwise the first resolved domain — so a COS with no `personal` domain does
|
|
240
|
+
* not have one invented for it.
|
|
241
|
+
*/
|
|
242
|
+
export function domainForMeeting(
|
|
243
|
+
explicit: string | undefined,
|
|
244
|
+
text: string,
|
|
245
|
+
operationsDir: string | null,
|
|
246
|
+
): string {
|
|
247
|
+
const domains = resolveDomains(operationsDir)
|
|
248
|
+
if (explicit && explicit.trim() && isSafeDomainName(explicit.trim())) return explicit.trim()
|
|
249
|
+
const inferred = classifyDomain(text, domains)
|
|
250
|
+
if (inferred) return inferred.domain
|
|
251
|
+
const personal = domains.find(d => d.name.toLowerCase() === FALLBACK_DOMAIN)
|
|
252
|
+
return personal?.name ?? domains[0]?.name ?? FALLBACK_DOMAIN
|
|
253
|
+
}
|
|
@@ -314,10 +314,19 @@ export interface AttendeeRenderOptions {
|
|
|
314
314
|
*/
|
|
315
315
|
unattributedMs: number
|
|
316
316
|
/**
|
|
317
|
-
* UNION of all voiced time.
|
|
318
|
-
* the crosstalk note in `renderAttendees`. 0 disables the check.
|
|
317
|
+
* UNION of all voiced time. Only used to phrase the crosstalk note.
|
|
319
318
|
*/
|
|
320
319
|
voicedMs: number
|
|
320
|
+
/**
|
|
321
|
+
* How long the meeting RAN. This is what the overlap note is gated on.
|
|
322
|
+
*
|
|
323
|
+
* Gating on `voicedMs` instead tripped on 16 of 23 real meetings, because any
|
|
324
|
+
* overlap at all exceeds the union of voiced time — at 70% the line stops being
|
|
325
|
+
* a signal and becomes boilerplate on something Miles pastes into Slack. Rows
|
|
326
|
+
* adding to more than the meeting LENGTH is the genuinely confusing case (71m
|
|
327
|
+
* of rows inside a 66-minute meeting), and that is ~10% of meetings.
|
|
328
|
+
*/
|
|
329
|
+
durationMs: number
|
|
321
330
|
}
|
|
322
331
|
|
|
323
332
|
/** The attendee block, floor-applied. */
|
|
@@ -348,9 +357,9 @@ export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions
|
|
|
348
357
|
// right; adding them is what misleads, so say so rather than shrink anyone.
|
|
349
358
|
const rowTotal = named.reduce((t, v) => t + (Number.isFinite(v.speakingMs) ? v.speakingMs : 0), 0) +
|
|
350
359
|
(rest.length > 0 && Number.isFinite(o.unattributedMs) ? o.unattributedMs : 0)
|
|
351
|
-
if (o.
|
|
352
|
-
out.push(`- (these overlap: people talked over each other, so the rows add to ` +
|
|
353
|
-
`more than the ${mmss(o.
|
|
360
|
+
if (o.durationMs > 0 && rowTotal > o.durationMs * 1.02) {
|
|
361
|
+
out.push(`- (these overlap: people talked over each other, so the rows add up to ` +
|
|
362
|
+
`more than the ${mmss(o.durationMs)} the meeting ran)`)
|
|
354
363
|
}
|
|
355
364
|
return out.length > 0 ? out.join('\n') : '- (no voices identified)'
|
|
356
365
|
}
|
|
@@ -363,7 +372,32 @@ export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions
|
|
|
363
372
|
* into a model has no other way to tell which. Naming the confirmed set is short
|
|
364
373
|
* and complete.
|
|
365
374
|
*/
|
|
366
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Names a human explicitly REMOVED from this meeting, still present in the prose.
|
|
377
|
+
*
|
|
378
|
+
* De-attribution rewrites the sidecar, the attendee list and the transcript
|
|
379
|
+
* labels, but deliberately leaves narrative prose alone — substituting into a
|
|
380
|
+
* written sentence mangles grammar and can hit the wrong person. So the applied
|
|
381
|
+
* correction row records `proseStale: true` and, until now, NOTHING read it.
|
|
382
|
+
*
|
|
383
|
+
* Real case, 2026-08-07: Miles removed "Clem Ukaoma" from a personal call that
|
|
384
|
+
* was only him and Queen (his father's voice matched a similar profile). All 8
|
|
385
|
+
* label sites were rewritten correctly; the LLM summary still opened "Miles,
|
|
386
|
+
* Queen, and Clem talk through..." and the payload said nothing. An allowlist
|
|
387
|
+
* ("this name was not confirmed") is far too weak here — he did not fail to
|
|
388
|
+
* confirm this person, he explicitly said they were not in the room.
|
|
389
|
+
*/
|
|
390
|
+
export interface RemovedName {
|
|
391
|
+
label: string
|
|
392
|
+
/** True when narrative prose still carries the name. */
|
|
393
|
+
proseStale: boolean
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function renderProvenance(
|
|
397
|
+
voices: AttendeeLine[],
|
|
398
|
+
coverage: number | null,
|
|
399
|
+
removed: RemovedName[] = [],
|
|
400
|
+
): string {
|
|
367
401
|
const named = voices.filter(v => v.asserted)
|
|
368
402
|
// Quoted because a label may legitimately contain a comma — `invalidLabelReason`
|
|
369
403
|
// rejects brackets and newlines but not commas, and a typed "Smith, John" would
|
|
@@ -384,6 +418,20 @@ export function renderProvenance(voices: AttendeeLine[], coverage: number | null
|
|
|
384
418
|
'comes from the raw capture or the write-up and was NOT confirmed. Some may be ' +
|
|
385
419
|
'people mentioned rather than people present.',
|
|
386
420
|
]
|
|
421
|
+
// Before the coverage caveat: an explicit human removal outranks every other
|
|
422
|
+
// statement in this block, and it must not read as one more hedge.
|
|
423
|
+
const stale = removed.filter(r => r.proseStale).map(r => `"${r.label}"`)
|
|
424
|
+
const quiet = removed.filter(r => !r.proseStale).map(r => `"${r.label}"`)
|
|
425
|
+
if (stale.length > 0) {
|
|
426
|
+
lines.push(
|
|
427
|
+
`You removed ${stale.join(', ')} from this meeting. The write-up below was ` +
|
|
428
|
+
'written before that and still uses the name: treat every mention of it as a ' +
|
|
429
|
+
'capture error, not a participant.',
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
if (quiet.length > 0) {
|
|
433
|
+
lines.push(`You also removed ${quiet.join(', ')} from this meeting.`)
|
|
434
|
+
}
|
|
387
435
|
if (coverage !== null && coverage < SHARE_COVERAGE_FLOOR) {
|
|
388
436
|
lines.push(
|
|
389
437
|
`Only ${Math.round(coverage * 100)}% of the voice in this meeting was ` +
|
|
@@ -411,6 +459,10 @@ export interface ClipboardInput {
|
|
|
411
459
|
capturedChars: number
|
|
412
460
|
/** UNION of voiced time, for the crosstalk-overflow note. */
|
|
413
461
|
voicedMs: number
|
|
462
|
+
/** How long the meeting ran. Gates the crosstalk-overflow note. */
|
|
463
|
+
durationMs: number
|
|
464
|
+
/** Names a human explicitly de-attributed from this meeting. */
|
|
465
|
+
removed: RemovedName[]
|
|
414
466
|
title: string
|
|
415
467
|
date: string
|
|
416
468
|
durationMin: number
|
|
@@ -445,9 +497,10 @@ function header(i: ClipboardInput, compact: boolean): string[] {
|
|
|
445
497
|
parts.push(
|
|
446
498
|
'', '## Who spoke',
|
|
447
499
|
renderAttendees(i.attendees, {
|
|
448
|
-
coverage: i.coverage, unattributedMs: i.unattributedMs,
|
|
500
|
+
coverage: i.coverage, unattributedMs: i.unattributedMs,
|
|
501
|
+
voicedMs: i.voicedMs, durationMs: i.durationMs,
|
|
449
502
|
}),
|
|
450
|
-
'', renderProvenance(i.attendees, i.coverage),
|
|
503
|
+
'', renderProvenance(i.attendees, i.coverage, i.removed),
|
|
451
504
|
)
|
|
452
505
|
for (const [heading, body] of [
|
|
453
506
|
['Summary', i.scribe.summary],
|
|
@@ -17,6 +17,7 @@ import { createHash } from 'node:crypto'
|
|
|
17
17
|
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
18
18
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
19
19
|
import { dataPath } from './data-dir.js'
|
|
20
|
+
import { FALLBACK_DOMAIN, domainAbbreviation, isSafeDomainName } from './domains.js'
|
|
20
21
|
import type {
|
|
21
22
|
ProviderCandidateRecord,
|
|
22
23
|
IndexedTranscriptChunk,
|
|
@@ -128,9 +129,16 @@ function normalizeSessionId(value: string): string {
|
|
|
128
129
|
return value
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Trim, validate, and PRESERVE CASE.
|
|
134
|
+
*
|
|
135
|
+
* Lowercasing was silently destructive: a domain folder named `DNP study` became
|
|
136
|
+
* `dnp study`, which then matched no directory on disk. Existing all-lowercase
|
|
137
|
+
* domains are unaffected.
|
|
138
|
+
*/
|
|
131
139
|
function normalizeDomain(value?: string): string {
|
|
132
|
-
const domain = (value ||
|
|
133
|
-
if (!
|
|
140
|
+
const domain = (value || FALLBACK_DOMAIN).trim()
|
|
141
|
+
if (!isSafeDomainName(domain)) {
|
|
134
142
|
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
135
143
|
}
|
|
136
144
|
return domain
|
|
@@ -272,7 +280,7 @@ function parseDurationMinutes(duration: string): number | undefined {
|
|
|
272
280
|
function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
|
|
273
281
|
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
274
282
|
const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
|
|
275
|
-
const domain = parseField(content, 'Domain') ||
|
|
283
|
+
const domain = parseField(content, 'Domain') || FALLBACK_DOMAIN
|
|
276
284
|
const duration = parseField(content, 'Duration')
|
|
277
285
|
const transcript = extractSection(content, ['Transcript'], true)
|
|
278
286
|
const storedSummary = extractSection(content, ['Summary'])
|
|
@@ -293,7 +301,10 @@ function parseMeeting(content: string, filename: string, month: string): Meeting
|
|
|
293
301
|
title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
|
|
294
302
|
date,
|
|
295
303
|
domain,
|
|
296
|
-
|
|
304
|
+
// Shared derivation. This was slice(0,2), which rendered sprocket_rocket as
|
|
305
|
+
// "SP" while cos-operations-meetings rendered it "SR" — two schemes in one
|
|
306
|
+
// codebase, disagreeing with each other.
|
|
307
|
+
domainAbbr: domainAbbreviation(domain),
|
|
297
308
|
source: parseField(content, 'Source'),
|
|
298
309
|
duration,
|
|
299
310
|
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
@@ -535,7 +546,7 @@ export class MeetingStore {
|
|
|
535
546
|
list(options: { limit?: number; domain?: string } = {}): MeetingMeta[] {
|
|
536
547
|
const limit = Math.max(1, Math.min(50, Math.trunc(options.limit ?? 20)))
|
|
537
548
|
const domain = options.domain ?? 'all'
|
|
538
|
-
if (domain !== 'all' && !
|
|
549
|
+
if (domain !== 'all' && !isSafeDomainName(domain)) {
|
|
539
550
|
throw new MeetingStoreError('Invalid domain filter', 400, 'invalid_domain')
|
|
540
551
|
}
|
|
541
552
|
const rootReal = this.existingRootRealpath()
|
|
@@ -565,7 +576,7 @@ export class MeetingStore {
|
|
|
565
576
|
}
|
|
566
577
|
|
|
567
578
|
detail(domain: string, month: string, filename: string): MeetingDetail {
|
|
568
|
-
if (!
|
|
579
|
+
if (!isSafeDomainName(domain)) {
|
|
569
580
|
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
570
581
|
}
|
|
571
582
|
if (!MONTH_PATTERN.test(month)) {
|
package/server/lib/profile.ts
CHANGED
|
@@ -41,6 +41,15 @@ function profilePath(): string {
|
|
|
41
41
|
|
|
42
42
|
let profileCache: Record<string, unknown> | null = null
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The whole profile object, for callers that need a key this module has no
|
|
46
|
+
* dedicated accessor for. Read-only by convention — mutate via
|
|
47
|
+
* updateProfileFields so the atomic write and cache-bust chain still applies.
|
|
48
|
+
*/
|
|
49
|
+
export function loadProfileObject(): Record<string, unknown> {
|
|
50
|
+
return loadProfile()
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function loadProfile(): Record<string, unknown> {
|
|
45
54
|
if (profileCache) return profileCache
|
|
46
55
|
try {
|
package/server/routes/meeting.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { Router } from 'express'
|
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
9
9
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
10
10
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
11
|
-
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
11
|
+
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
14
|
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
@@ -111,7 +111,9 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
111
111
|
import {
|
|
112
112
|
cosOperationsMeetingsConfigured,
|
|
113
113
|
findCosOperationsMeetingBySessionId,
|
|
114
|
+
resolveCosOperationsDir,
|
|
114
115
|
} from '../lib/cos-operations-meetings.js'
|
|
116
|
+
import { domainForMeeting, resolveDomains } from '../lib/domains.js'
|
|
115
117
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
116
118
|
import {
|
|
117
119
|
DEATTRIBUTED_PREFIX,
|
|
@@ -560,10 +562,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
560
562
|
|
|
561
563
|
// Initial canonical text + structured metadata are published before any
|
|
562
564
|
// live state is removed or background work is scheduled.
|
|
565
|
+
// The client's choice wins. When it sends none — the normal case for a G2
|
|
566
|
+
// save — infer from the content instead of filing everything under one
|
|
567
|
+
// domain. Keyword scoring, no model call: this runs on every save, and a
|
|
568
|
+
// per-meeting LLM call would breach the recurring-caller rule.
|
|
569
|
+
const filedDomain = domainForMeeting(
|
|
570
|
+
body?.domain as string | undefined,
|
|
571
|
+
`${(body?.title as string | undefined) ?? ''}\n${transcript}`,
|
|
572
|
+
resolveCosOperationsDir(),
|
|
573
|
+
)
|
|
563
574
|
const saved = store.save({
|
|
564
575
|
sessionId,
|
|
565
576
|
title: body?.title as string | undefined,
|
|
566
|
-
domain:
|
|
577
|
+
domain: filedDomain,
|
|
567
578
|
transcript: cleanFinalTranscript(transcript),
|
|
568
579
|
startTime,
|
|
569
580
|
durationMs,
|
|
@@ -880,10 +891,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
880
891
|
coverage,
|
|
881
892
|
// The UNION, never the sum of per-voice figures — crosstalk is counted once.
|
|
882
893
|
unattributedMs: review.unattributedSpeakingMs,
|
|
883
|
-
// Union of ALL voiced time. Only used to notice that the per-voice rows add
|
|
884
|
-
// to more than the meeting, which happens whenever people talk over
|
|
885
|
-
// each other — 34 of 323 real meetings.
|
|
886
894
|
voicedMs: review.voicedMs,
|
|
895
|
+
// The overlap note is gated on how long the meeting RAN. Gating on voiced
|
|
896
|
+
// time tripped on 16 of 23 real meetings.
|
|
897
|
+
durationMs: review.durationMs || 0,
|
|
898
|
+
// Names this human explicitly de-attributed. The ledger has recorded
|
|
899
|
+
// `proseStale` on every applied de-attribution since the feature shipped and
|
|
900
|
+
// NOTHING has ever read it: on 2026-08-07 Miles removed "Clem Ukaoma" from a
|
|
901
|
+
// call that was only him and Queen, all 8 label sites were rewritten, and the
|
|
902
|
+
// summary still opened "Miles, Queen, and Clem talk through..." with no
|
|
903
|
+
// indication anywhere in the payload.
|
|
904
|
+
removed: appliedCorrections(sessionId)
|
|
905
|
+
.filter(r => isUnattributed(r.to))
|
|
906
|
+
.map(r => ({ label: r.from, proseStale: r.proseStale === true })),
|
|
887
907
|
// Which business this is. The sibling /speakers route has always carried
|
|
888
908
|
// this and this one dropped it, so a personal 1:1 about someone's
|
|
889
909
|
// compensation was byte-identical to a marketing sync.
|
|
@@ -921,6 +941,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
921
941
|
// no write-up yet.
|
|
922
942
|
capturedChars: clip.capturedChars,
|
|
923
943
|
domain: clip.domain,
|
|
944
|
+
// So the panel can warn above the write-up, not just the clipboard.
|
|
945
|
+
removedNames: clip.removed,
|
|
924
946
|
coverage,
|
|
925
947
|
sharesReported: coverage !== null && coverage >= SHARE_COVERAGE_FLOOR,
|
|
926
948
|
clipboardSummary: summaryText,
|
|
@@ -1659,7 +1681,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1659
1681
|
const saved = store.save({
|
|
1660
1682
|
sessionId,
|
|
1661
1683
|
title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
|
|
1662
|
-
domain:
|
|
1684
|
+
domain: domainForMeeting(
|
|
1685
|
+
typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
|
|
1686
|
+
`${typeof body.title === 'string' ? body.title : ''}\n${transcript}`,
|
|
1687
|
+
resolveCosOperationsDir(),
|
|
1688
|
+
),
|
|
1663
1689
|
transcript,
|
|
1664
1690
|
startTime: capture.startTime,
|
|
1665
1691
|
durationMs: capture.durationMs,
|