@gotcos/glasses-server 6.63.0 → 6.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## 6.64.0 — 2026-10-06 — Meeting context keys
2
+
3
+ - Meeting rows and details carry `contextKeys`, `contextSupported`, and, when files can't be added, `contextReason` (plus `contextAmbiguous`). The fields are additive and read-only. They are the keys COS Control 0.5.258 files a meeting's dropped screenshots, slides and documents under, so the files go to every Work card linked to the meeting.
4
+ - The keys are read from ids the meeting already carries: `g2:<sessionId>` from its `.g2-chunks.json` (or the server's own recordings copy), `ff:<id>` from its `.fireflies.json` (the top-level id, in the first 4 KB), then `g2:<id>` for every `<!-- g2-session -->` it declares.
5
+ - Nothing is written into a meeting. Identity is an exact id, never a title or a date.
6
+ - Two live meetings whose own sidecars give the same primary key are `ambiguous`. A merged scribe that only declares its captures never counts as a second claimant, so a merge whose captures are not yet retired is not ambiguous.
7
+ - Files also can't be added to:
8
+ - an iCloud conflict copy (`conflict_copy`);
9
+ - a meeting with no source id (`no_source_id`);
10
+ - an imported, re-derived or direct-library record (`read_only_record`).
11
+ - A standalone recording is keyed by its session, and its detail keeps its old shape (no `librarySource`).
12
+ - `GET /api/meetings/context-keys?recordId=…` (1 to 50 ids, read-only) answers the keys behind a Work card's saved meeting links.
13
+ - A `standalone:` id gives its session.
14
+ - An `ops:` id is found at its path, after the G2 rename (same month, by the date and time in the filename), or under the same name in another domain of the same month.
15
+ - Two candidates give no answer.
16
+ - Control asks before each send, so a card finds the files whichever record id it was linked under.
17
+ - The primary-key index reads only sidecar heads (4 KB), never a meeting body. It is rebuilt per month when that folder changes, and at least once a minute. Measured on 2026-10-06: 649 of 656 meetings from August to October carry a key; an earlier build of the index, which also read meeting bodies, took 376 ms cold and 1 ms warm over the whole tree on the author's Mac (a read-only scratch script).
18
+ - Pairs with COS Control 0.5.258. Older clients ignore the fields.
19
+
20
+ ## 6.63.1 — 2026-10-06 — Weather survives a restart
21
+
22
+ - Home weather survives a server restart. The last good weather and place are kept in `<data>/welcome-weather.json`; when a forecast lookup fails (timeout, HTTP error, bad payload), the route answers with that saved weather if it is under 3 hours old, never older. A failed forecast or place lookup is now logged once per kind per 10 minutes, with the cause and whether a saved weather was served. Before, after a restart the first failed lookup sent the glasses no weather and nothing said why. Pairs with glasses 6.10.603, which also retries on Home and keeps its own last good weather.
23
+ - Security: `proxy-addr` 2.0.8 (through Express), for GHSA-jqcg-44mw-7w3h (IP spoofing through an IPv4-mapped IPv6 trust subnet). Lockfile only.
24
+
1
25
  ## 6.63.0 — 2026-10-05 — Background forks
2
26
 
3
27
  - Background forks. `POST /api/agent-sessions/:provider/:threadId/fork` accepts an optional `clientForkId`: once every gate passes it answers 202 and runs the fork in the background, and `GET /api/agent-session-forks/:clientForkId` returns `running` or the exact answer the synchronous route would have sent. A repeated id replays its job and never makes a second copy. Outcomes are kept in `<data>/fork-jobs.json` (7 days, 200 rows); a fork still running when the server stops reads back as orphan-possible ("look in Sessions before forking again"), never as failed. Without the id the route is unchanged. Why: on 2026-10-05 COS Control waited 5 minutes for a 6.5-minute fork that succeeded and told Miles the server had stopped. Pairs with COS Control 0.5.257; older clients keep the synchronous route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.63.0",
3
+ "version": "6.64.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -106,7 +106,7 @@ import { recoveryRouter } from './routes/recovery.js'
106
106
  import { promptEditRouter } from './routes/prompt-edit.js'
107
107
  import { lensGistRouter } from './routes/lens-gist.js'
108
108
  import { bookmarksRouter } from './routes/bookmarks.js'
109
- import { welcomeContextRouter } from './routes/welcome-context.js'
109
+ import { setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
110
110
  import { liveCuesRouter } from './routes/live-cues.js'
111
111
  import { tasksRouter } from './routes/tasks.js'
112
112
  import { memoryRouter } from './routes/memory.js'
@@ -143,6 +143,7 @@ import { createWorkIntakeRouter } from './routes/work-intake.js'
143
143
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
144
144
  import { createJevRouter } from './routes/jev.js'
145
145
  import { dataPath } from './lib/data-dir.js'
146
+ import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
146
147
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
147
148
  import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
148
149
  import { createWorkBoardReader } from './lib/work-board-reader.js'
@@ -1065,6 +1066,11 @@ app.use('/api', recoveryRouter)
1065
1066
  app.use('/api', promptEditRouter)
1066
1067
  app.use('/api', lensGistRouter)
1067
1068
  app.use('/api', bookmarksRouter)
1069
+ // 6.63.1: the last good weather and place survive a restart (<data>/welcome-weather.json).
1070
+ setWeatherPersistence({
1071
+ load: () => { const r = loadJsonOrQuarantine<unknown>(dataPath('welcome-weather.json')); return r.status === 'ok' ? r.data : null },
1072
+ save: (value) => durableAtomicWriteFileSync(dataPath('welcome-weather.json'), `${JSON.stringify(value)}\n`),
1073
+ })
1068
1074
  app.use('/api', welcomeContextRouter)
1069
1075
  app.use('/api', liveCuesRouter)
1070
1076
  app.use('/api', tasksRouter)
@@ -20,6 +20,7 @@ import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
20
  import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
21
  import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
22
22
  import { dataPath } from './data-dir.js'
23
+ import { firefliesSidecarId, meetingContextFields, meetingContextKeys, type MeetingContextFields } from './meeting-context-keys.js'
23
24
 
24
25
  /**
25
26
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -743,6 +744,102 @@ export function resolveMergedScribe(
743
744
  return { status: 'resolved', ...only, path, content }
744
745
  }
745
746
 
747
+ // ── Meeting context keys (6.64.0) ───────────────────────────────────────────
748
+ //
749
+ // What a meeting's dropped files are filed under in COS Control. See `meeting-context-keys.ts` for why the key is read
750
+ // from source ids and never written. Two live meetings with the same PRIMARY key are ambiguous, so the list and the
751
+ // detail both need to know how many meetings claim each primary key across every domain: that index is kept per month
752
+ // folder and recomputed only when the folder's own stamp moves (every writer of meetings renames into place, so a new or
753
+ // renamed meeting moves it). A warm read is one stat per month folder.
754
+
755
+ /** The ordered keys for one canonical meeting, from what sits beside it and what it declares. */
756
+ function meetingKeysFor(monthDir: string, filename: string, content: string | null, recordingsRoot: string | null, month: string): string[] {
757
+ let sessionId = sidecarListHints(monthDir, filename).sessionId
758
+ if (!sessionId && recordingsRoot) {
759
+ const fallbackDir = join(recordingsRoot, month)
760
+ if (existsSync(fallbackDir)) sessionId = sidecarListHints(fallbackDir, filename).sessionId
761
+ }
762
+ const firefliesId = firefliesSidecarId(monthDir, filename)
763
+ // The file is read for its markers only when neither sidecar names it: the primary key is then the first it declares.
764
+ let declared: string[] = []
765
+ if (content !== null) declared = mergedScribeSessions(content)
766
+ else if (!sessionId && !firefliesId) {
767
+ try { declared = mergedScribeSessions(readFileSync(join(monthDir, filename), 'utf-8')) } catch { declared = [] }
768
+ }
769
+ return meetingContextKeys({ g2SessionId: sessionId, firefliesId, declared })
770
+ }
771
+
772
+ interface PrimaryKeyMonth { stamp: string; primaries: string[]; builtAt: number }
773
+ const primaryKeyCache = new Map<string, PrimaryKeyMonth>()
774
+ let primaryKeyReads = 0
775
+ /** A month is re-read at least this often even when its folder's stamp has not moved: a sidecar rewritten in place
776
+ * leaves the folder alone (QA 2026-10-06). One month re-read costs about 3 ms. */
777
+ const PRIMARY_KEY_MAX_AGE_MS = 60_000
778
+
779
+ /** The primary key a meeting's OWN sidecars give it, or undefined. A meeting that only declares `<!-- g2-session -->`
780
+ * markers (a merged scribe with no sidecar) claims nothing here: it holds those captures, and counting it would make
781
+ * every merge whose captures are not yet retired read as two meetings fighting over one key (QA 2026-10-06). It also
782
+ * means the index never reads a meeting body, so a cold build costs 4 KB heads only. */
783
+ function sidecarPrimaryKey(monthDir: string, filename: string, recordingsRoot: string | null, month: string): string | undefined {
784
+ let sessionId = sidecarListHints(monthDir, filename).sessionId
785
+ if (!sessionId && recordingsRoot) {
786
+ const fallbackDir = join(recordingsRoot, month)
787
+ if (existsSync(fallbackDir)) sessionId = sidecarListHints(fallbackDir, filename).sessionId
788
+ }
789
+ return meetingContextKeys({ g2SessionId: sessionId, firefliesId: firefliesSidecarId(monthDir, filename) })[0]
790
+ }
791
+
792
+ /** How many live, canonical meetings take each key as their PRIMARY key from their own sidecars, across every domain. */
793
+ export function meetingPrimaryKeyClaims(operationsDir: string, recordingsRoot: string | null = dataPath('recordings')): Map<string, number> {
794
+ const claims = new Map<string, number>()
795
+ for (const domain of discoverMeetingDomains(operationsDir)) {
796
+ const meetingsBase = join(operationsDir, domain, 'meetings')
797
+ let months: string[]
798
+ try { months = readdirSync(meetingsBase).filter(name => MONTH_PATTERN.test(name)) } catch { continue }
799
+ for (const month of months) {
800
+ const monthDir = join(meetingsBase, month)
801
+ let stamp: string
802
+ try { stamp = statStamp(statSync(monthDir)) } catch { primaryKeyCache.delete(monthDir); continue }
803
+ let entry = primaryKeyCache.get(monthDir)
804
+ if (!entry || entry.stamp !== stamp || Date.now() - entry.builtAt > PRIMARY_KEY_MAX_AGE_MS) {
805
+ let names: string[] = []
806
+ try {
807
+ names = readdirSync(monthDir).filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name)).slice(0, MAX_LIST_CANDIDATES)
808
+ } catch { names = [] }
809
+ const primaries: string[] = []
810
+ for (const name of names) {
811
+ primaryKeyReads++
812
+ const key = sidecarPrimaryKey(monthDir, name, recordingsRoot, month)
813
+ if (key) primaries.push(key)
814
+ }
815
+ entry = { stamp, primaries, builtAt: Date.now() }
816
+ primaryKeyCache.set(monthDir, entry)
817
+ }
818
+ for (const key of entry.primaries) claims.set(key, (claims.get(key) ?? 0) + 1)
819
+ }
820
+ }
821
+ return claims
822
+ }
823
+
824
+ /** Diagnostics and the cost tests. */
825
+ export function meetingPrimaryKeyCacheStats(): { months: number; reads: number } {
826
+ return { months: primaryKeyCache.size, reads: primaryKeyReads }
827
+ }
828
+ export function resetMeetingPrimaryKeyCache(): void {
829
+ primaryKeyCache.clear()
830
+ primaryKeyReads = 0
831
+ }
832
+
833
+ /** A meeting's context fields, given its keys and the claims index. The index counts meetings whose OWN sidecar gives
834
+ * the key; a markers-only scribe is not in it, so beside its one live capture it reads 1 (fine), and beside two live
835
+ * copies of that capture it reads 2: its key is contested, so it is ambiguous too. */
836
+ function contextFieldsFor(filename: string, keys: string[], claims: Map<string, number>): MeetingContextFields {
837
+ return meetingContextFields(keys, {
838
+ conflictCopy: ICLOUD_CONFLICT_COPY.test(filename),
839
+ claimants: keys.length > 0 ? claims.get(keys[0]) ?? 1 : 1,
840
+ })
841
+ }
842
+
746
843
  export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
747
844
  recordId?: string
748
845
  librarySource?: 'direct_library' | 'cos_operations'
@@ -799,6 +896,7 @@ export function listCosOperationsMeetings(options: {
799
896
  : discovered.includes(domainFilter) ? [domainFilter] : []
800
897
 
801
898
  const allMeetings: CosOperationsMeetingMeta[] = []
899
+ let claims: Map<string, number> | undefined
802
900
 
803
901
  for (const domain of domains) {
804
902
  const meetingsBase = join(operationsDir, domain, 'meetings')
@@ -857,6 +955,11 @@ export function listCosOperationsMeetings(options: {
857
955
  if (actionId) meta.actionId = actionId
858
956
  }
859
957
  if (options.day && meta.date !== options.day) continue
958
+ // 6.64.0: the keys this meeting's dropped files are filed under, from the ids just read (no extra read but
959
+ // the Fireflies sidecar's 4 KB head).
960
+ const firefliesId = firefliesSidecarId(monthDir, file)
961
+ const keys = meetingContextKeys({ g2SessionId: hints.sessionId, firefliesId, declared })
962
+ Object.assign(meta, contextFieldsFor(file, keys, claims ??= meetingPrimaryKeyClaims(operationsDir, recordingsRoot)))
860
963
  allMeetings.push(meta)
861
964
  } catch { /* skip unreadable files */ }
862
965
  }
@@ -1013,9 +1116,14 @@ export function getCosOperationsMeetingDetail(
1013
1116
  meta.mutable = true
1014
1117
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${resolvedFilename}`
1015
1118
  const source = boundedMeetingSource(content)
1119
+ // 6.64.0: the same keys the list row carries, so Control files a drop from either surface under one key.
1120
+ const recordingsRoot = dataPath('recordings')
1121
+ const keys = meetingKeysFor(monthDir, resolvedFilename, content, recordingsRoot, month)
1122
+ const context = contextFieldsFor(resolvedFilename, keys, meetingPrimaryKeyClaims(operationsDir, recordingsRoot))
1016
1123
 
1017
1124
  return {
1018
1125
  ...meta,
1126
+ ...context,
1019
1127
  summary: extractSummary(content),
1020
1128
  topics: extractTopics(content),
1021
1129
  decisions: extractDecisions(content),
@@ -1026,6 +1134,73 @@ export function getCosOperationsMeetingDetail(
1026
1134
  }
1027
1135
  }
1028
1136
 
1137
+ /**
1138
+ * 6.64.0: the context keys a Work card's saved meeting link answers to, without reading the meeting's body.
1139
+ *
1140
+ * WHY. A card links a meeting by record id, and a meeting's record id changes over its life: a fresh capture is
1141
+ * `standalone:<sessionId>`, sync files it as `ops:<domain>:<month>:<file>`, the G2 enrichment renames the file, and a
1142
+ * refile moves it to another domain. The files Miles dropped are filed under the meeting's KEY, which does not change.
1143
+ * COS Control asks here for the keys of every link a card carries, so a card finds its meetings' files whichever record
1144
+ * id it was linked under (QA, 2026-10-06).
1145
+ *
1146
+ * Exact ids only, as everywhere in this module: a `standalone:` id names its session; an `ops:` id is found at its own
1147
+ * path, then by the rename rule the detail route already uses (the date and time in a G2 filename, in the same month),
1148
+ * then by the SAME filename in another domain of the same month (a domain move keeps the name). Two candidates are no
1149
+ * answer. Imported, re-derived and direct-library records are read-only.
1150
+ */
1151
+ export function meetingContextForRecord(recordId: string, recordingsRoot: string | null = dataPath('recordings')): MeetingContextFields & { resolvedRecordId?: string } {
1152
+ const standalone = /^standalone:([A-Za-z0-9_-]{3,96})$/.exec(recordId)
1153
+ const operationsDir = resolveCosOperationsDir()
1154
+ if (standalone) {
1155
+ // A capture two operations meetings both carry is contested here too (QA round 2, N3).
1156
+ const keys = meetingContextKeys({ g2SessionId: standalone[1] })
1157
+ const claimants = operationsDir && keys.length > 0 ? meetingPrimaryKeyClaims(operationsDir, recordingsRoot).get(keys[0]) ?? 1 : 1
1158
+ return meetingContextFields(keys, { claimants })
1159
+ }
1160
+ const ops = /^ops:([^:/\\]+):(\d{4}-\d{2}):([^/\\]+\.md)$/.exec(recordId)
1161
+ if (!ops || !operationsDir) return { contextKeys: [], contextSupported: false, contextReason: 'read_only_record' }
1162
+ const [, domain, month, filename] = ops
1163
+ const domains = discoverMeetingDomains(operationsDir)
1164
+ if (!safeName(domain) || !MONTH_PATTERN.test(month) || basename(filename) !== filename) {
1165
+ return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
1166
+ }
1167
+ const at = (d: string, f: string) => join(operationsDir, d, 'meetings', month, f)
1168
+ const isFile = (path: string) => { try { return statSync(path).isFile() } catch { return false } }
1169
+ let found: { domain: string; filename: string } | null = domains.includes(domain) && isFile(at(domain, filename)) ? { domain, filename } : null
1170
+ if (!found) {
1171
+ const heads = (d: string) => {
1172
+ try {
1173
+ return readdirSync(join(operationsDir, d, 'meetings', month))
1174
+ .filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name))
1175
+ .slice(0, MAX_LIST_CANDIDATES)
1176
+ .map(name => ({ filename: name, content: safeRegularFile(realpathSync(join(operationsDir, d, 'meetings', month)), join(operationsDir, d, 'meetings', month, name), 4_000) ?? '' }))
1177
+ } catch { return [] }
1178
+ }
1179
+ // Renamed in its own domain, then the same name in another domain, then renamed in another domain: one answer only.
1180
+ const ownRename = domains.includes(domain) ? matchRenamedMeetingFilename(filename, heads(domain)) : undefined
1181
+ if (ownRename) found = { domain, filename: ownRename }
1182
+ else {
1183
+ const sameName = domains.filter(d => d !== domain && isFile(at(d, filename)))
1184
+ if (sameName.length === 1) found = { domain: sameName[0], filename }
1185
+ else if (sameName.length === 0) {
1186
+ const renamed = domains.filter(d => d !== domain)
1187
+ .map(d => ({ domain: d, filename: matchRenamedMeetingFilename(filename, heads(d)) }))
1188
+ .filter((hit): hit is { domain: string; filename: string } => Boolean(hit.filename))
1189
+ if (renamed.length === 1) found = renamed[0]
1190
+ }
1191
+ }
1192
+ }
1193
+ if (!found) return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
1194
+ const monthDir = join(operationsDir, found.domain, 'meetings', month)
1195
+ // The same keys the row and the detail carry: the markers too, so the file is read (as the detail reads it).
1196
+ let content: string | null = null
1197
+ try { content = readFileSync(join(monthDir, found.filename), 'utf-8') } catch { content = null }
1198
+ const keys = meetingKeysFor(monthDir, found.filename, content, recordingsRoot, month)
1199
+ const fields = contextFieldsFor(found.filename, keys, meetingPrimaryKeyClaims(operationsDir, recordingsRoot))
1200
+ const resolved = `ops:${found.domain}:${month}:${found.filename}`
1201
+ return resolved === recordId ? fields : { ...fields, resolvedRecordId: resolved }
1202
+ }
1203
+
1029
1204
  export function getDirectLibraryMeetingDetail(month: string, filename: string): MeetingDetail | null {
1030
1205
  const inspection = resolveMeetingLibrary()
1031
1206
  if (inspection.layout !== 'direct' || !inspection.root) return null
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Meeting context keys (6.64.0): the ids COS Control files a meeting's dropped files under.
3
+ *
4
+ * Miles, 2026-10-06: "link" meetings with files dragged onto them, the way Work cards take files, so a screen or slide
5
+ * shared in a meeting becomes agent context. COS Control keeps the files on the Mac (`~/cos-data/meeting-context`); the
6
+ * server only says which key a meeting answers to.
7
+ *
8
+ * READ-ONLY, FROM IDS THE MEETING ALREADY CARRIES. Plan v1 wrote a marker into the meeting file and failed review: more
9
+ * than a dozen writers regenerate, archive or race those files, and a write changes the hashes merge suggestions, merge
10
+ * decisions, Work review and speaker review depend on. So the key is read from the meeting's own source ids:
11
+ * - `g2:<sessionId>` from its `.g2-chunks.json` sidecar (the speaker system's key; moves with every G2 rename);
12
+ * - `ff:<id>` from its `.fireflies.json` sidecar;
13
+ * - `g2:<id>` for every `<!-- g2-session -->` the file declares, which is how a merged meeting also shows the files of
14
+ * the capture it absorbed.
15
+ * Measured 2026-10-06: 649 of 656 meetings from August to October carry at least one.
16
+ *
17
+ * IDENTITY IS AN EXACT ID. Never a title or a date (the rule `resolveMergedScribe` already keeps). Two live meetings whose
18
+ * own sidecars give the same PRIMARY key are `ambiguous`: Control adds and removes nothing there and never files a card
19
+ * link under their keys (files already dropped stay with the cards linked to the meeting they were dropped on).
20
+ */
21
+
22
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+
25
+ /** A key as Control stores it. The id part is the server's own marker grammar (`G2_SESSION_MARKER`). */
26
+ export const MEETING_CONTEXT_KEY = /^(?:g2|ff):[A-Za-z0-9:_-]{3,96}$/
27
+ const SOURCE_ID = /^[A-Za-z0-9:_-]{3,96}$/
28
+
29
+ const SIDECAR_HEAD_BYTES = 4096
30
+
31
+ /**
32
+ * The value of a TOP-LEVEL string field in the head of a JSON object, or undefined.
33
+ *
34
+ * A Fireflies sidecar is up to megabytes of sentences, so only its head is read, and a regex alone would also match an
35
+ * `"id"` nested inside `participants` or `sentences` if the key order ever changed. This walks the head tracking depth
36
+ * and strings, and answers only for a key at depth 1.
37
+ */
38
+ export function topLevelStringField(head: string, field: string): string | undefined {
39
+ let depth = 0
40
+ let i = 0
41
+ while (i < head.length) {
42
+ const c = head[i]
43
+ if (c === '"') {
44
+ let j = i + 1
45
+ let raw = ''
46
+ while (j < head.length && head[j] !== '"') {
47
+ if (head[j] === '\\') { raw += head.slice(j, j + 2); j += 2; continue }
48
+ raw += head[j]; j += 1
49
+ }
50
+ if (j >= head.length) return undefined
51
+ // A key at depth 1 is followed by a colon; read its value if it is the one asked for.
52
+ let k = j + 1
53
+ while (k < head.length && /\s/.test(head[k])) k += 1
54
+ if (depth === 1 && head[k] === ':' && raw === field) {
55
+ k += 1
56
+ while (k < head.length && /\s/.test(head[k])) k += 1
57
+ if (head[k] !== '"') return undefined
58
+ const end = head.indexOf('"', k + 1)
59
+ if (end < 0) return undefined
60
+ const value = head.slice(k + 1, end)
61
+ return value.includes('\\') ? undefined : value
62
+ }
63
+ i = j + 1
64
+ continue
65
+ }
66
+ if (c === '{' || c === '[') depth += 1
67
+ else if (c === '}' || c === ']') depth -= 1
68
+ i += 1
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /** The Fireflies transcript id in `<stem>.fireflies.json` beside a meeting, if there is one. Reads 4 KB, never a link. */
74
+ export function firefliesSidecarId(monthDir: string, meetingFilename: string): string | undefined {
75
+ if (!meetingFilename.endsWith('.md')) return undefined
76
+ const path = join(monthDir, meetingFilename.replace(/\.md$/, '.fireflies.json'))
77
+ let fd: number | null = null
78
+ try {
79
+ const stat = lstatSync(path)
80
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) return undefined
81
+ if (dirname(realpathSync(path)) !== realpathSync(monthDir)) return undefined
82
+ // Non-blocking and no-follow (hazard-invariants): a FIFO planted under the sidecar's name must never hang the
83
+ // server's one thread, and a link swapped in after the lstat is refused at the open itself.
84
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
85
+ if (!fstatSync(fd).isFile()) return undefined
86
+ const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
87
+ const read = readSync(fd, buffer, 0, buffer.length, 0)
88
+ const id = topLevelStringField(buffer.subarray(0, read).toString('utf8'), 'id')
89
+ return id && SOURCE_ID.test(id) ? id : undefined
90
+ } catch {
91
+ return undefined
92
+ } finally {
93
+ if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
94
+ }
95
+ }
96
+
97
+ /** The ordered, de-duplicated keys for one meeting. The first is primary: the capture's own sidecar, then Fireflies,
98
+ * then what the file declares. */
99
+ export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[] }): string[] {
100
+ const keys: string[] = []
101
+ const add = (prefix: 'g2' | 'ff', id: string | undefined) => {
102
+ if (!id || !SOURCE_ID.test(id)) return
103
+ const key = `${prefix}:${id}`
104
+ if (!keys.includes(key)) keys.push(key)
105
+ }
106
+ add('g2', sources.g2SessionId)
107
+ add('ff', sources.firefliesId)
108
+ for (const id of sources.declared ?? []) add('g2', id)
109
+ return keys
110
+ }
111
+
112
+ export interface MeetingContextFields {
113
+ contextKeys: string[]
114
+ contextSupported: boolean
115
+ contextAmbiguous?: true
116
+ contextReason?: 'no_source_id' | 'read_only_record' | 'conflict_copy' | 'ambiguous'
117
+ }
118
+
119
+ /**
120
+ * What a row or a detail says about its files.
121
+ *
122
+ * `claimants` is how many live meetings have this meeting's primary key as THEIR primary key (this one included). Two
123
+ * or more is ambiguous: the keys still go out, so Control can show files already there, but nothing is added or sent.
124
+ */
125
+ export function meetingContextFields(keys: string[], options: { conflictCopy?: boolean; claimants?: number } = {}): MeetingContextFields {
126
+ if (options.conflictCopy) return { contextKeys: [], contextSupported: false, contextReason: 'conflict_copy' }
127
+ if (keys.length === 0) return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
128
+ if ((options.claimants ?? 1) > 1) return { contextKeys: keys, contextSupported: false, contextAmbiguous: true, contextReason: 'ambiguous' }
129
+ return { contextKeys: keys, contextSupported: true }
130
+ }
131
+
132
+ /** Fields for a record this release does not key: a direct library, an import or a re-derived (blended) record. */
133
+ export function readOnlyContext(): MeetingContextFields {
134
+ return { contextKeys: [], contextSupported: false, contextReason: 'read_only_record' }
135
+ }
@@ -136,6 +136,16 @@ export interface MeetingMeta {
136
136
  sources?: Array<{ kind: 'g2' | 'fireflies'; id: string; recordId?: string }>
137
137
  /** Present only when the server can state a truthful local record. */
138
138
  canonicalRecord?: string
139
+ /** 6.64.0, additive and read-only. The keys COS Control files a meeting's dropped files under, from source ids the
140
+ * meeting already carries (`g2:<sessionId>`, `ff:<firefliesId>`, then every `<!-- g2-session -->` it declares).
141
+ * First = primary. Never computed from a title or a date, and nothing is written into the meeting. See
142
+ * `meeting-context-keys.ts`. */
143
+ contextKeys?: string[]
144
+ /** False when files cannot be added here: a re-derived or read-only record, a conflict copy, a meeting with no
145
+ * source id, or two meetings claiming one key. `contextReason` says which. */
146
+ contextSupported?: boolean
147
+ contextAmbiguous?: boolean
148
+ contextReason?: 'no_source_id' | 'read_only_record' | 'conflict_copy' | 'ambiguous'
139
149
  /** Additive. Unique sidecar speakers + whether a human correction landed. */
140
150
  voiceReview?: {
141
151
  voices: number
@@ -579,6 +589,18 @@ export class MeetingStore {
579
589
  return detail
580
590
  }
581
591
 
592
+ /** 6.64.0: the session in a stored recording's sidecar, for its context key only. The detail itself keeps its shape
593
+ * (no sessionId, its recordId unchanged). Undefined when the month, the file or the sidecar is not readable. */
594
+ storedSessionId(month: string, filename: string): string | undefined {
595
+ if (!MONTH_PATTERN.test(month) || !SAFE_FILENAME_PATTERN.test(filename) || basename(filename) !== filename) return undefined
596
+ const rootReal = this.rootRealpath()
597
+ if (!rootReal) return undefined
598
+ const monthDir = join(this.root, month)
599
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
600
+ if (!monthReal) return undefined
601
+ return this.sidecarSessionId(monthDir, monthReal, filename)
602
+ }
603
+
582
604
  private rootRealpath(): string | null {
583
605
  return existingRootRealpath(
584
606
  this.root,
@@ -10,11 +10,13 @@ import {
10
10
  listDirectLibraryMeetingMonths,
11
11
  listCosOperationsMeetings,
12
12
  listCosOperationsMeetingMonths,
13
+ meetingContextForRecord,
13
14
  resolveMeetingLibrary,
14
15
  } from '../lib/cos-operations-meetings.js'
15
16
  import type { MeetingMeta, MeetingDetail } from '../lib/meeting-store.js'
16
17
  import { meetingListLimit } from '../lib/meeting-store.js'
17
18
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
19
+ import { meetingContextFields, meetingContextKeys, readOnlyContext } from '../lib/meeting-context-keys.js'
18
20
  import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
19
21
  import {
20
22
  type ImportedMeetingLibrary,
@@ -57,6 +59,19 @@ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
57
59
  }
58
60
  }
59
61
 
62
+ /**
63
+ * 6.64.0 context keys for a row or detail the operations tree did not key. A standalone recording is keyed by its own
64
+ * session (it is a capture this server holds); a direct library, an import and a re-derived record are read-only here.
65
+ * A row the operations tree keyed already carries its fields and is left alone.
66
+ */
67
+ export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: MeetingMeta['librarySource'] = meeting.librarySource): T {
68
+ if (meeting.contextKeys !== undefined) return meeting
69
+ if (source === 'standalone_recordings') {
70
+ return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId })) }
71
+ }
72
+ return { ...meeting, ...readOnlyContext() }
73
+ }
74
+
60
75
  function mergeMeetingSources(groups: MeetingMeta[][], limit: number, max = 50): MeetingMeta[] {
61
76
  const seenSessions = new Set<string>()
62
77
  const seenExact = new Set<string>()
@@ -187,6 +202,22 @@ export function resolveSavedMeetingDetail(
187
202
  return { ...detail, recordId: withStandaloneIdentity(detail).recordId }
188
203
  }
189
204
 
205
+ /** The detail the routes answer with: `resolveSavedMeetingDetail` plus its 6.64.0 context keys. */
206
+ function savedMeetingDetailWithContext(
207
+ descriptor: { domain: string; month: string; filename: string },
208
+ store: MeetingStore,
209
+ importsLibrary: ImportedMeetingLibrary,
210
+ ): MeetingDetail {
211
+ const detail = resolveSavedMeetingDetail(descriptor, store, importsLibrary)
212
+ // The store's detail carries no librarySource (a pinned contract); the identity above made its recordId `standalone:`.
213
+ const source = detail.librarySource ?? (detail.recordId?.startsWith('standalone:') ? 'standalone_recordings' : undefined)
214
+ if (source === 'standalone_recordings' && detail.contextKeys === undefined) {
215
+ const sessionId = detail.sessionId || store.storedSessionId(descriptor.month, detail.filename || descriptor.filename)
216
+ return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId })) }
217
+ }
218
+ return withMeetingContext(detail, source)
219
+ }
220
+
190
221
  export function createMeetingsRouter(
191
222
  store: MeetingStore = getMeetingStore(),
192
223
  // Injectable so a test can drive a whole imports library without reaching the
@@ -255,7 +286,7 @@ export function createMeetingsRouter(
255
286
  ? supersededDayCounts(filters.month, 'direct', { domain, store, library: importsLibrary })
256
287
  : []
257
288
  res.json({
258
- meetings,
289
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
259
290
  months,
260
291
  days,
261
292
  source: operations.length > 0 || imported.present ? 'mixed_library' : 'direct_library',
@@ -288,7 +319,7 @@ export function createMeetingsRouter(
288
319
  // back above the row. A capped page pays for the scan instead.
289
320
  const listSawWholeMonth = rows.length < limit
290
321
  res.json({
291
- meetings,
322
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
292
323
  months: listCosOperationsMeetingMonths(domain),
293
324
  // The rows above already read every scribe in this month and told us which
294
325
  // sessions the merged ones hold. Handing that over is the difference between
@@ -329,7 +360,7 @@ export function createMeetingsRouter(
329
360
  ? supersededDayCounts(filters.month, 'multi_domain', { domain, store, library: importsLibrary, pipeline: false })
330
361
  : []
331
362
  res.json({
332
- meetings,
363
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
333
364
  months: uniqueSortedMonths([
334
365
  listCosOperationsMeetingMonths(domain),
335
366
  store.listMonths(),
@@ -359,7 +390,7 @@ export function createMeetingsRouter(
359
390
  imported.drop(imported.imports),
360
391
  ], limit, sourceLimit)
361
392
  res.json({
362
- meetings,
393
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
363
394
  months: uniqueSortedMonths([store.listMonths(), importedLibraryMonths(importsLibrary)]),
364
395
  days: filters.month ? supersededDayCounts(filters.month, 'standalone', { domain, store, library: importsLibrary }) : [],
365
396
  source: imported.present ? 'mixed_library' : 'standalone_recordings',
@@ -371,6 +402,26 @@ export function createMeetingsRouter(
371
402
  }
372
403
  })
373
404
 
405
+ // 6.64.0: the context keys behind a Work card's saved meeting links, by record id (at most 50 per call). Read-only and
406
+ // body-free: COS Control asks for every link a card carries, so a card finds the files dropped on its meetings
407
+ // whichever record id it was linked under (`meetingContextForRecord`).
408
+ router.get('/meetings/context-keys', (req, res) => {
409
+ const raw = req.query.recordId
410
+ const ids = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).filter((id): id is string => typeof id === 'string')
411
+ if (ids.length === 0 || ids.length > 50 || ids.some(id => id.length === 0 || id.length > 2048 || /[\u0000-\u001f]/.test(id))) {
412
+ res.status(400).json({ error: 'one to fifty recordId values are required', reason: 'invalid_record_ids' })
413
+ return
414
+ }
415
+ res.set('Cache-Control', 'private, no-store')
416
+ try {
417
+ const keys: Record<string, ReturnType<typeof meetingContextForRecord>> = {}
418
+ for (const id of [...new Set(ids)]) keys[id] = meetingContextForRecord(id)
419
+ res.json({ keys })
420
+ } catch (error) {
421
+ sendMeetingStoreError(res, error)
422
+ }
423
+ })
424
+
374
425
  // Literal path before /meetings/:domain/:month/:filename.
375
426
  router.get('/meetings/search', async (req, res) => {
376
427
  try {
@@ -406,7 +457,7 @@ export function createMeetingsRouter(
406
457
  }
407
458
  res.set('Cache-Control', 'private, no-store')
408
459
 
409
- res.json(resolveSavedMeetingDetail({ domain, month, filename }, store, importsLibrary))
460
+ res.json(savedMeetingDetailWithContext({ domain, month, filename }, store, importsLibrary))
410
461
  } catch (error) {
411
462
  sendMeetingStoreError(res, error)
412
463
  }
@@ -418,7 +469,7 @@ export function createMeetingsRouter(
418
469
  try {
419
470
  res.set('Cache-Control', 'private, no-store')
420
471
 
421
- res.json(resolveSavedMeetingDetail({ domain: String(req.params.domain), month: String(req.params.month), filename: String(req.params.filename) }, store, importsLibrary))
472
+ res.json(savedMeetingDetailWithContext({ domain: String(req.params.domain), month: String(req.params.month), filename: String(req.params.filename) }, store, importsLibrary))
422
473
  } catch (error) {
423
474
  sendMeetingStoreError(res, error)
424
475
  }
@@ -25,6 +25,72 @@ let lastKnownCity = ''
25
25
  let cachedNextEvent: NextEventPayload | null = null
26
26
  let calendarFetchedAt = 0
27
27
 
28
+ // 6.63.1: after a server restart the first forecast lookup can fail and, with nothing cached, the
29
+ // glasses Home got no weather until the app was relaunched. The last good weather and
30
+ // place now survive a restart, and a failed lookup says why in the log (it was silent).
31
+ /** A saved weather older than this is never served after a restart. */
32
+ export const WEATHER_PERSIST_MAX_AGE_MS = 3 * 60 * 60_000
33
+ const WEATHER_FAILURE_LOG_MS = 10 * 60_000
34
+
35
+ export interface WeatherPersistence {
36
+ load(): unknown
37
+ save(value: unknown): void
38
+ }
39
+ let weatherPersistence: WeatherPersistence | null = null
40
+ let weatherRestored = false
41
+ const lastFailureLogAt = new Map<string, number>()
42
+
43
+ /** Wired by the server (index.ts, `<data>/welcome-weather.json`); off by default so tests touch nothing. */
44
+ export function setWeatherPersistence(p: WeatherPersistence | null): void {
45
+ weatherPersistence = p
46
+ weatherRestored = false
47
+ }
48
+
49
+ function restoreWeatherOnce(): void {
50
+ if (weatherRestored || !weatherPersistence) return
51
+ weatherRestored = true
52
+ try {
53
+ const v = weatherPersistence.load() as Record<string, unknown> | null
54
+ if (!v || typeof v !== 'object') return
55
+ const at = typeof v.fetchedAt === 'number' ? v.fetchedAt : 0
56
+ const w = v.weather as WeatherPayload | undefined
57
+ if (typeof v.lat === 'number' && typeof v.lon === 'number' && lastKnownLat == null) { lastKnownLat = v.lat; lastKnownLon = v.lon }
58
+ if (typeof v.city === 'string' && !lastKnownCity) lastKnownCity = v.city
59
+ const fresh = at > 0 && at <= Date.now() + 60_000 && Date.now() - at <= WEATHER_PERSIST_MAX_AGE_MS
60
+ if (fresh && w && typeof w.temp === 'string' && typeof w.desc === 'string' && !cachedWeather) {
61
+ cachedWeather = { temp: w.temp, desc: w.desc, location: typeof w.location === 'string' ? w.location : '' }
62
+ // Expired for the TTL check, so the next request still tries a fresh lookup; kept as the fallback.
63
+ weatherFetchedAt = 0
64
+ }
65
+ } catch (error) {
66
+ console.warn(`[welcome-context] saved weather unreadable: ${error instanceof Error ? error.message : error}`)
67
+ }
68
+ }
69
+
70
+ function persistWeather(): void {
71
+ if (!weatherPersistence) return
72
+ try {
73
+ weatherPersistence.save({ lat: lastKnownLat ?? null, lon: lastKnownLon ?? null, city: lastKnownCity, weather: cachedWeather, fetchedAt: weatherFetchedAt })
74
+ } catch (error) {
75
+ console.warn(`[welcome-context] saving weather failed: ${error instanceof Error ? error.message : error}`)
76
+ }
77
+ }
78
+
79
+ /** One line per failure kind per 10 minutes: the cause, never the coordinates. */
80
+ function noteWeatherFailure(kind: 'forecast' | 'geocode', reason: string): void {
81
+ const key = `${kind}:${reason}`
82
+ const now = Date.now()
83
+ if (now - (lastFailureLogAt.get(key) ?? 0) < WEATHER_FAILURE_LOG_MS) return
84
+ lastFailureLogAt.set(key, now)
85
+ console.warn(`[welcome-context] ${kind} lookup failed: ${reason}${cachedWeather ? ' (serving the last good weather)' : ' (no weather to serve)'}`)
86
+ }
87
+
88
+ function failureReason(error: unknown): string {
89
+ const name = error instanceof Error ? error.name : ''
90
+ if (name === 'TimeoutError' || name === 'AbortError') return 'timeout'
91
+ return error instanceof Error ? (error.message.slice(0, 80) || name || 'error') : 'error'
92
+ }
93
+
28
94
  /** Full WMO 4677 — discrete codes, exact match required. */
29
95
  export const WMO_CODES: Record<number, string> = {
30
96
  0: 'Clear',
@@ -108,7 +174,7 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
108
174
  `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
109
175
  { signal: AbortSignal.timeout(GEOCODE_TIMEOUT_MS) },
110
176
  )
111
- if (!res.ok) return lastKnownCity
177
+ if (!res.ok) { noteWeatherFailure('geocode', `http ${res.status}`); return lastKnownCity }
112
178
  const data = await res.json() as {
113
179
  city?: string
114
180
  locality?: string
@@ -122,7 +188,8 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
122
188
  lastKnownCity = region ? `${city}, ${region}` : city
123
189
  }
124
190
  return lastKnownCity
125
- } catch {
191
+ } catch (error) {
192
+ noteWeatherFailure('geocode', failureReason(error))
126
193
  return lastKnownCity
127
194
  }
128
195
  }
@@ -144,6 +211,7 @@ export async function fetchWeather(
144
211
  queryLat?: number,
145
212
  queryLon?: number,
146
213
  ): Promise<WeatherPayload | null> {
214
+ restoreWeatherOnce()
147
215
  const resolved = resolveWeatherCoords(queryLat, queryLon)
148
216
  if (!resolved) return null
149
217
 
@@ -183,14 +251,14 @@ export async function fetchWeather(
183
251
  `https://api.open-meteo.com/v1/forecast?latitude=${useLat}&longitude=${useLon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&timezone=auto`,
184
252
  { signal: AbortSignal.timeout(FORECAST_TIMEOUT_MS) },
185
253
  )
186
- if (!res.ok) return cachedWeather
254
+ if (!res.ok) { noteWeatherFailure('forecast', `http ${res.status}`); return cachedWeather }
187
255
 
188
256
  const data = await res.json() as {
189
257
  current?: { temperature_2m?: number; weather_code?: number }
190
258
  }
191
259
  const temp = data.current?.temperature_2m
192
260
  const code = data.current?.weather_code
193
- if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
261
+ if (typeof temp !== 'number' || typeof code !== 'number') { noteWeatherFailure('forecast', 'bad payload'); return cachedWeather }
194
262
 
195
263
  await ensureCityLabel(useLat, useLon, source)
196
264
 
@@ -200,8 +268,10 @@ export async function fetchWeather(
200
268
  location: lastKnownCity || `${useLat.toFixed(2)}, ${useLon.toFixed(2)}`,
201
269
  }
202
270
  weatherFetchedAt = Date.now()
271
+ persistWeather()
203
272
  return cachedWeather
204
- } catch {
273
+ } catch (error) {
274
+ noteWeatherFailure('forecast', failureReason(error))
205
275
  return cachedWeather
206
276
  }
207
277
  }
@@ -272,6 +342,9 @@ export function _resetWelcomeContextCachesForTests(): void {
272
342
  lastKnownCity = ''
273
343
  cachedNextEvent = null
274
344
  calendarFetchedAt = 0
345
+ weatherPersistence = null
346
+ weatherRestored = false
347
+ lastFailureLogAt.clear()
275
348
  }
276
349
 
277
350
  welcomeContextRouter.get('/welcome-context', async (req, res) => {