@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 +51 -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-store.ts +17 -6
- package/server/lib/profile.ts +9 -0
- package/server/routes/meeting.ts +17 -2
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
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -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,
|
|
@@ -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:
|
|
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,
|