@gotcos/glasses-server 6.21.30 → 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 CHANGED
@@ -1,3 +1,54 @@
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
+
1
52
  ## 6.21.30
2
53
 
3
54
  - **A name you removed by hand is now stated, and the stale write-up is called
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.30",
3
+ "version": "6.21.31",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- export const COS_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
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
- const DOMAIN_ABBR: Record<string, string> = {
24
- quilt: 'Q',
25
- sprocket_rocket: 'SR',
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: DOMAIN_ABBR[domain] || '?',
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 COS_MEETING_DOMAINS) {
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
- ? [...COS_MEETING_DOMAINS]
368
- : COS_MEETING_DOMAINS.includes(domainFilter as typeof COS_MEETING_DOMAINS[number])
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
- if (!(COS_MEETING_DOMAINS as readonly string[]).includes(domain)) return null
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
+ }
@@ -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 || 'personal').trim().toLowerCase()
133
- if (!DOMAIN_PATTERN.test(domain)) {
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') || 'personal'
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
- domainAbbr: domain === 'personal' ? 'P' : domain.slice(0, 2).toUpperCase() || '?',
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' && !DOMAIN_PATTERN.test(domain)) {
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 (!DOMAIN_PATTERN.test(domain)) {
579
+ if (!isSafeDomainName(domain)) {
569
580
  throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
570
581
  }
571
582
  if (!MONTH_PATTERN.test(month)) {
@@ -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 {
@@ -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: body?.domain as string | undefined,
577
+ domain: filedDomain,
567
578
  transcript: cleanFinalTranscript(transcript),
568
579
  startTime,
569
580
  durationMs,
@@ -1670,7 +1681,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1670
1681
  const saved = store.save({
1671
1682
  sessionId,
1672
1683
  title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
1673
- domain: typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
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
+ ),
1674
1689
  transcript,
1675
1690
  startTime: capture.startTime,
1676
1691
  durationMs: capture.durationMs,