@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 +24 -0
- package/package.json +1 -1
- package/server/index.ts +7 -1
- package/server/lib/cos-operations-meetings.ts +175 -0
- package/server/lib/meeting-context-keys.ts +135 -0
- package/server/lib/meeting-store.ts +22 -0
- package/server/routes/meetings.ts +57 -6
- package/server/routes/welcome-context.ts +78 -5
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.
|
|
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(
|
|
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(
|
|
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}¤t=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) => {
|