@gotcos/glasses-server 6.61.1 → 6.61.2
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 +15 -0
- package/README.md +6 -5
- package/package.json +2 -1
- package/server/lib/client-liveness.ts +5 -4
- package/server/lib/cos-operations-meetings.ts +208 -25
- package/server/lib/held-naming-batches.ts +3 -0
- package/server/lib/permission-broker.ts +8 -6
- package/server/routes/meeting.ts +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## 6.61.2
|
|
2
|
+
|
|
3
|
+
### Renaming a voice works on merged meetings whose recording was filed in a different folder
|
|
4
|
+
|
|
5
|
+
- **What was broken.** When COS merges a glasses recording into a call's write-up, the two are often filed under different areas: the recording under personal, the call under quilt. The server looked for the merged write-up only beside the recording, so it missed it. On 2026-10-02 that was 65 of the 199 merged meetings on Miles's Mac, among them Chris <> Miles Sync on 9/10 (8 voices). Opening Speakers on one showed the raw recording name, and renaming or removing a voice there either failed with "Meeting source changed; reopen the meeting" (when the request carried the record the meetings list shows) or renamed the voice in the recording only and left the meeting's attendees and transcript saying the old name (what COS Control 0.5.254 does; it reported "meeting markdown unreadable"). Naming held voices on these meetings followed the recording's own pointer instead: for 63 it left the meeting's transcript as it was and only marked it as having newer labels, and for 2 (the 9/10 meeting among them) the pointer named a file that had moved, so it failed.
|
|
6
|
+
- **What works now.** The server looks in every area's folder for the recording's month, and also the next or previous month when the recording is dated the first or last day of a month (none of the 199 needed that; a call that starts just before midnight can). It picks the meeting that says it holds the recording with `<!-- g2-session: <id> -->`. Speakers shows that meeting's title and area, renaming, removing or confirming a voice updates that meeting, naming held voices edits it the way it already did for merges filed beside their recording, and the record matches the meetings list row. Merged meetings beside their recording work as before.
|
|
7
|
+
- **Only an exact marker counts.** A title, a file name or an id that differs by one character never matches. iCloud conflict copies (`Name 2.md`) and duplicate folders (`2026-09 2`) are never read as meetings.
|
|
8
|
+
- **Two meetings claiming one recording: neither is chosen.** The recording keeps its own record, the server logs `merged_scribe_ambiguous` with both paths, a rename says so in `markdownSkipped`, and naming held voices refuses until one is fixed. None of the 199 is in that state.
|
|
9
|
+
- **Speed.** The meetings list does not use this lookup and is unchanged. On a copy of Miles's 2,640 meetings, median of 9 runs, two runs each: the default list 313 and 317 ms on 6.61.1, 312 and 315 ms on 6.61.2; one month 84 and 83 ms, then 84 and 84 ms. Opening Speakers on a merged meeting reads that month's write-ups across every area once after a start or a change (49 to 51 ms the first time, against 35 ms on 6.61.1) and afterwards only checks their timestamps: 28 to 29 ms, the same as 6.61.1.
|
|
10
|
+
- **Checked on copies of the real meetings.** All 65 cross-folder meetings resolve to the meeting the list shows (0 of 65 on 6.61.1), all 134 same-folder ones still do, and the 59 recordings the marker pass left for review resolve exactly as on 6.61.1 (30 to their own write-up, 29 to the recording alone, none to a merged meeting). A rename on a copy of the 9/10 meeting: 6.61.1 refused it with the list's record and changed only the recording with the Speakers record; 6.61.2 applied it to the meeting.
|
|
11
|
+
- **For clients.** No response field was added or removed. For a cross-folder merged meeting, `/api/meeting/:id/speakers` and `/content` now carry the merged meeting's `recordId`, `title`, `filename` and `domain`, the values its list row already had; every other meeting answers as before. COS Glasses 6.9.563 to 6.9.570 and COS Control 0.5.254 need no update.
|
|
12
|
+
- **Comments only.** The code comments and README now say glasses presence is a ring, temple or lens gesture, or a phone touch while COS is in front, and never the worn status (COS Glasses 6.9.570). No behaviour change; `presenceAgeMs` is unchanged.
|
|
13
|
+
- Tests: a route suite over HTTP in the pipeline layout (the list row, the Speakers record, title and area, a rename sent with either record, a removal, a confirmation, the copy text, a same-folder merge, and two claims refused), the lookup itself (every area, an area name this repo never uses, the capture's own write-up still winning, no claim, near-miss ids, a title that is not a claim, two claims in one folder or two, iCloud copies of a write-up, of a recording and of a month folder, the first and last day of a month and a year boundary, a mid-month recording not searching further, the cache seeing a new, edited, emptied or deleted write-up and a deleted month folder, a warm lookup reading nothing, a write-up changed between the scan and the read, an unreadable folder or file in another area, a FIFO never opened, suggestion sides and merged-record sources), and held naming (a cross-folder merge, two claims refused). On 6.61.1 the route suite fails 7 of 9 and the two held naming tests fail.
|
|
14
|
+
- `node server/scripts/merged-scribe-mutation-gate.mjs` (repository only, not in the package) runs from a green baseline (85 tests in 3 files), matches each target exactly once, and each mutant must fail the test named for it: 41 of 41 killed. The 6.61.0 and 6.61.1 gate (`nemotron-mutation-gate.mjs`, including the finalizing refusal and the re-read before the final pass writes) rerun on this build: baseline 169 tests in 11 files, 104 of 104 killed. Full suite 5,761 passed, 2 skipped (378 files); typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
|
|
15
|
+
|
|
1
16
|
## 6.61.1
|
|
2
17
|
|
|
3
18
|
### Fixes 6.61.0 labelling other people as the wearer: a split track is named only by a confident voiceprint, never by the glasses' default label
|
package/README.md
CHANGED
|
@@ -265,21 +265,22 @@ or the away hold's.
|
|
|
265
265
|
|
|
266
266
|
**The away hold (6.60.0).** A request that starts while you are away (the desk idle 90 s,
|
|
267
267
|
the same threshold as above) is held for up to 10 minutes, but only when the glasses or the
|
|
268
|
-
phone that show held questions report that you
|
|
268
|
+
phone that show held questions report that you touched them in the last 30 minutes.
|
|
269
269
|
The client contract (COS Glasses 6.9.563 and later):
|
|
270
270
|
|
|
271
271
|
- Poll `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`.
|
|
272
272
|
`hold=1` says this client shows held questions. `presenceAgeMs` is the milliseconds since
|
|
273
|
-
the latest real wearer evidence (glasses: a ring or
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
the latest real wearer evidence (glasses: a ring, temple or lens gesture; phone: a touch
|
|
274
|
+
while COS is in front), a whole number from 0 to 86,400,000, and is left out when there is
|
|
275
|
+
none since the app started. The worn status never counts (COS Glasses 6.9.570 and later):
|
|
276
|
+
wear detection can be turned off, and then it always reads not worn.
|
|
276
277
|
- Only a poll with `hold=1` AND a valid `presenceAgeMs` counts toward the hold. The server
|
|
277
278
|
keeps the newest such presence in memory, on the monotonic clock and the wall clock, and
|
|
278
279
|
reads its age as the larger of the two (so a Mac that slept never makes it look newer).
|
|
279
280
|
Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0
|
|
280
281
|
through 86400; under 30 it means the 30 s live window). After a server restart nothing is
|
|
281
282
|
recent until such a poll.
|
|
282
|
-
- A `hold=1` poll without presence (a connected lens nobody
|
|
283
|
+
- A `hold=1` poll without presence (a connected lens nobody touches) makes no client live and
|
|
283
284
|
holds nothing. A poll without `hold=1` (COS Glasses 6.9.562 and earlier, every older phone
|
|
284
285
|
build) keeps the 6.59.0 meaning exactly: live for 30 s, a 110 s hold, and never the away
|
|
285
286
|
hold.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.61.
|
|
3
|
+
"version": "6.61.2",
|
|
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": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
49
49
|
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
50
|
+
"!server/scripts/merged-scribe-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/nemotron-replay.ts",
|
|
51
52
|
"!server/scripts/mutation-gate.mjs",
|
|
52
53
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
// 6.60.0 (QA round 1, the contract shared with COS Glasses 6.9.563): the away hold needs
|
|
28
28
|
// more than a poll. A poll QUALIFIES only with `hold=1` (this client shows held questions;
|
|
29
29
|
// 6.9.563 and later send it) AND a finite `presenceAgeMs` within [0, 24 h]: the ms since
|
|
30
|
-
// the latest real wearer evidence (glasses: a ring or
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// the latest real wearer evidence (glasses: a ring, temple or lens gesture; phone: a touch
|
|
31
|
+
// while COS is in front). The worn status never counts: Glasses 6.9.570 dropped it because
|
|
32
|
+
// wear detection can be turned off, and then it always reads not worn. A connected lens
|
|
33
|
+
// nobody touches sends no `presenceAgeMs`, and an older client sends no `hold=1`, so neither
|
|
34
|
+
// can make the Mac hold a question for 10 minutes.
|
|
34
35
|
//
|
|
35
36
|
// Kept apart, all in memory (a restart forgets them, which fails toward 6.59.0):
|
|
36
37
|
// - the newest counting poll of any kind (`lastQuestionsPollAt`, for /api/health);
|
|
@@ -478,6 +478,12 @@ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string
|
|
|
478
478
|
* but the readable meeting is now the merged scribe, so this returns the orphan's
|
|
479
479
|
* sidecar path with the merged scribe's path, filename and title. Without that,
|
|
480
480
|
* naming a voice on a merged meeting opened a file that no longer existed.
|
|
481
|
+
*
|
|
482
|
+
* ...AND ITS DOMAIN AND MONTH (6.61.2). The merged scribe may sit in another
|
|
483
|
+
* domain folder than the sidecar, and `domain`, `month` and `filename` are what
|
|
484
|
+
* every caller builds the `ops:` recordId from, so all three describe the merged
|
|
485
|
+
* scribe: the same recordId the meetings list shows for that row. `sidecarPath`
|
|
486
|
+
* stays the capture's, wherever it is.
|
|
481
487
|
*/
|
|
482
488
|
export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
483
489
|
sidecarPath: string
|
|
@@ -486,22 +492,31 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
486
492
|
domain: string
|
|
487
493
|
month: string
|
|
488
494
|
title: string
|
|
495
|
+
/**
|
|
496
|
+
* 6.61.2. Set only when MORE THAN ONE meeting declares this session through
|
|
497
|
+
* `<!-- g2-session -->`: their operations-relative paths. The record then
|
|
498
|
+
* describes the capture alone, exactly as an unresolved merge does, because
|
|
499
|
+
* picking one of them would write a correction into a meeting chosen by
|
|
500
|
+
* directory order.
|
|
501
|
+
*/
|
|
502
|
+
mergedScribeConflict?: string[]
|
|
489
503
|
} | null {
|
|
490
504
|
const operationsDir = resolveCosOperationsDir()
|
|
491
505
|
if (!operationsDir) return null
|
|
492
506
|
|
|
493
|
-
|
|
507
|
+
const domains = discoverMeetingDomains(operationsDir)
|
|
508
|
+
for (const domain of domains) {
|
|
494
509
|
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
495
510
|
let months: string[]
|
|
496
511
|
try {
|
|
497
|
-
months = readdirSync(meetingsBase).filter(d =>
|
|
512
|
+
months = readdirSync(meetingsBase).filter(d => MONTH_PATTERN.test(d)).sort().reverse()
|
|
498
513
|
} catch { continue }
|
|
499
514
|
|
|
500
515
|
for (const month of months) {
|
|
501
516
|
const monthDir = join(meetingsBase, month)
|
|
502
517
|
let sidecars: string[]
|
|
503
518
|
try {
|
|
504
|
-
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') &&
|
|
519
|
+
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') && !ICLOUD_CONFLICT_COPY.test(f)).sort().reverse()
|
|
505
520
|
} catch { continue }
|
|
506
521
|
|
|
507
522
|
for (const sidecarName of sidecars) {
|
|
@@ -510,17 +525,26 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
510
525
|
const sidecarPath = join(monthDir, sidecarName)
|
|
511
526
|
let meetingPath = join(monthDir, meetingFilename)
|
|
512
527
|
let resolvedFilename = meetingFilename
|
|
528
|
+
let resolvedDomain = domain
|
|
529
|
+
let resolvedMonth = month
|
|
530
|
+
let mergedScribeConflict: string[] | undefined
|
|
513
531
|
let content: string | null = null
|
|
514
532
|
try {
|
|
515
533
|
content = readFileSync(meetingPath, 'utf-8')
|
|
516
534
|
} catch {
|
|
517
|
-
// The capture's own scribe is gone. Only now is it worth
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
535
|
+
// The capture's own scribe is gone. Only now is it worth looking for
|
|
536
|
+
// the merged scribe that declares this session, in EVERY domain: the
|
|
537
|
+
// pipeline re-files a merge by its Fireflies call, so a capture filed
|
|
538
|
+
// under personal is routinely merged into a scribe under quilt.
|
|
539
|
+
const merged = resolveMergedScribe(operationsDir, domains, month, sidecarName, sessionId)
|
|
540
|
+
if (merged.status === 'resolved') {
|
|
541
|
+
resolvedDomain = merged.domain
|
|
542
|
+
resolvedMonth = merged.month
|
|
521
543
|
resolvedFilename = merged.filename
|
|
522
|
-
meetingPath =
|
|
544
|
+
meetingPath = merged.path
|
|
523
545
|
content = merged.content
|
|
546
|
+
} else if (merged.status === 'ambiguous') {
|
|
547
|
+
mergedScribeConflict = merged.claimants
|
|
524
548
|
}
|
|
525
549
|
}
|
|
526
550
|
let title = resolvedFilename.replace(/\.md$/, '')
|
|
@@ -530,9 +554,10 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
530
554
|
sidecarPath,
|
|
531
555
|
meetingPath,
|
|
532
556
|
filename: resolvedFilename,
|
|
533
|
-
domain,
|
|
534
|
-
month,
|
|
557
|
+
domain: resolvedDomain,
|
|
558
|
+
month: resolvedMonth,
|
|
535
559
|
title,
|
|
560
|
+
...(mergedScribeConflict ? { mergedScribeConflict } : {}),
|
|
536
561
|
}
|
|
537
562
|
}
|
|
538
563
|
}
|
|
@@ -540,30 +565,188 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
540
565
|
return null
|
|
541
566
|
}
|
|
542
567
|
|
|
568
|
+
// ── Merged-scribe resolution across domains (6.61.2) ────────────────────────
|
|
569
|
+
//
|
|
570
|
+
// 6.47.0 looked for the merged scribe only in the sidecar's own folder. On
|
|
571
|
+
// 2026-10-02, 65 of the 199 merged scribes on the author's Mac sat in a
|
|
572
|
+
// different DOMAIN folder from their capture (38 personal -> quilt, 11 personal
|
|
573
|
+
// -> hermit_crabs, 9 personal -> sprocket_rocket, 7 between hermit_crabs,
|
|
574
|
+
// sprocket_rocket and quilt), so those sessions resolved to the retired
|
|
575
|
+
// standalone filename: the speaker review named a record the meetings list does
|
|
576
|
+
// not show, and every correction either 409'd on `record_source_mismatch` or
|
|
577
|
+
// renamed the voice in the sidecar alone.
|
|
578
|
+
//
|
|
579
|
+
// IDENTITY IS THE MARKER. A scribe holds a session only if it carries
|
|
580
|
+
// `<!-- g2-session: <exact id> -->`. No filename, title or date is ever a match,
|
|
581
|
+
// and two scribes declaring one session resolve to neither.
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* iCloud conflict copies ("Chris_Miles_Sync 2.md", "x 2.g2-chunks.json").
|
|
585
|
+
* Never canonical, and a copy of a merged scribe carries the same markers as the
|
|
586
|
+
* original, so counting it would turn every conflict copy into a duplicate claim.
|
|
587
|
+
*/
|
|
588
|
+
const ICLOUD_CONFLICT_COPY = / \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/
|
|
589
|
+
|
|
590
|
+
function shiftMonth(month: string, by: number): string {
|
|
591
|
+
const [year, monthNumber] = month.split('-').map(Number)
|
|
592
|
+
const index = year * 12 + (monthNumber - 1) + by
|
|
593
|
+
return `${String(Math.floor(index / 12)).padStart(4, '0')}-${String((index % 12) + 1).padStart(2, '0')}`
|
|
594
|
+
}
|
|
595
|
+
|
|
543
596
|
/**
|
|
544
|
-
* The
|
|
597
|
+
* The month folders a merged scribe for this capture can sit in.
|
|
545
598
|
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
599
|
+
* The capture's own month, always. Measured on 2026-10-02: all 199 merged
|
|
600
|
+
* scribes carry the capture's own date (0 of 199 a day off) and the nearest any
|
|
601
|
+
* capture came to a month edge was 7.5 hours. So a merge can land in another
|
|
602
|
+
* month only when the capture sits on a month's first or last day (a call that
|
|
603
|
+
* began before midnight and a capture that began after it, or the reverse), and
|
|
604
|
+
* those two days also search the neighbouring month. A sidecar whose name does
|
|
605
|
+
* not carry a date inside its own month searches both neighbours, because
|
|
606
|
+
* nothing says which way it leans. Never a `2026-09 2` folder: every name here
|
|
607
|
+
* is built, not listed.
|
|
548
608
|
*/
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
609
|
+
export function mergedScribeMonthsFor(sidecarMonth: string, sidecarFilename: string): string[] {
|
|
610
|
+
if (!MONTH_PATTERN.test(sidecarMonth)) return []
|
|
611
|
+
const date = /^(\d{4}-\d{2})-(\d{2})/.exec(sidecarFilename)
|
|
612
|
+
if (!date || date[1] !== sidecarMonth) {
|
|
613
|
+
return [sidecarMonth, shiftMonth(sidecarMonth, -1), shiftMonth(sidecarMonth, 1)]
|
|
614
|
+
}
|
|
615
|
+
const day = Number(date[2])
|
|
616
|
+
const [year, monthNumber] = sidecarMonth.split('-').map(Number)
|
|
617
|
+
const lastDay = new Date(Date.UTC(year, monthNumber, 0)).getUTCDate()
|
|
618
|
+
const months = [sidecarMonth]
|
|
619
|
+
if (day === 1) months.push(shiftMonth(sidecarMonth, -1))
|
|
620
|
+
if (day === lastDay) months.push(shiftMonth(sidecarMonth, 1))
|
|
621
|
+
return months
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The cost guard. A cold lookup reads every scribe in the months above across
|
|
625
|
+
// every domain (about 300 files and 11 MB for a busy month on the author's Mac);
|
|
626
|
+
// a warm one stats them and reads nothing. A month's listing is reused while the
|
|
627
|
+
// folder's own stamp is unchanged, and a scribe's markers while ITS stamp is,
|
|
628
|
+
// so an in-place edit that leaves the folder's mtime alone is still seen.
|
|
629
|
+
interface ScribeClaimEntry { stamp: string; sessions: readonly string[] }
|
|
630
|
+
interface MonthListingEntry { stamp: string; names: string[] }
|
|
631
|
+
const scribeClaimCache = new Map<string, ScribeClaimEntry>()
|
|
632
|
+
const monthListingCache = new Map<string, MonthListingEntry>()
|
|
633
|
+
let scribeClaimReads = 0
|
|
634
|
+
|
|
635
|
+
function statStamp(stat: { ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
|
|
636
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Every canonical scribe in one month folder that declares at least one session. */
|
|
640
|
+
function scribeClaimsIn(monthDir: string): Array<{ filename: string; sessions: readonly string[] }> {
|
|
641
|
+
let dirStamp: string
|
|
554
642
|
try {
|
|
555
|
-
|
|
643
|
+
dirStamp = statStamp(statSync(monthDir))
|
|
556
644
|
} catch {
|
|
557
|
-
|
|
645
|
+
for (const name of monthListingCache.get(monthDir)?.names ?? []) scribeClaimCache.delete(join(monthDir, name))
|
|
646
|
+
monthListingCache.delete(monthDir)
|
|
647
|
+
return []
|
|
558
648
|
}
|
|
559
|
-
|
|
560
|
-
|
|
649
|
+
let listing = monthListingCache.get(monthDir)
|
|
650
|
+
if (!listing || listing.stamp !== dirStamp) {
|
|
651
|
+
let names: string[]
|
|
561
652
|
try {
|
|
562
|
-
|
|
653
|
+
names = readdirSync(monthDir)
|
|
654
|
+
.filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name))
|
|
655
|
+
.sort()
|
|
656
|
+
.slice(0, MAX_LIST_CANDIDATES)
|
|
657
|
+
} catch {
|
|
658
|
+
return []
|
|
659
|
+
}
|
|
660
|
+
const kept = new Set(names)
|
|
661
|
+
for (const old of listing?.names ?? []) if (!kept.has(old)) scribeClaimCache.delete(join(monthDir, old))
|
|
662
|
+
listing = { stamp: dirStamp, names }
|
|
663
|
+
monthListingCache.set(monthDir, listing)
|
|
664
|
+
}
|
|
665
|
+
const claims: Array<{ filename: string; sessions: readonly string[] }> = []
|
|
666
|
+
for (const filename of listing.names) {
|
|
667
|
+
const path = join(monthDir, filename)
|
|
668
|
+
let stat: ReturnType<typeof statSync>
|
|
669
|
+
try {
|
|
670
|
+
stat = statSync(path)
|
|
563
671
|
} catch { continue }
|
|
564
|
-
|
|
672
|
+
// A FIFO or a folder named *.md is never read: a FIFO would block the server.
|
|
673
|
+
if (!stat.isFile()) continue
|
|
674
|
+
const stamp = statStamp(stat)
|
|
675
|
+
let entry = scribeClaimCache.get(path)
|
|
676
|
+
if (!entry || entry.stamp !== stamp) {
|
|
677
|
+
let content: string
|
|
678
|
+
try {
|
|
679
|
+
content = readFileSync(path, 'utf-8')
|
|
680
|
+
} catch { continue }
|
|
681
|
+
scribeClaimReads++
|
|
682
|
+
entry = { stamp, sessions: mergedScribeSessions(content) }
|
|
683
|
+
scribeClaimCache.set(path, entry)
|
|
684
|
+
}
|
|
685
|
+
if (entry.sessions.length > 0) claims.push({ filename, sessions: entry.sessions })
|
|
565
686
|
}
|
|
566
|
-
return
|
|
687
|
+
return claims
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** How much work the marker cache has done. Diagnostics and the cost tests read it. */
|
|
691
|
+
export function mergedScribeCacheStats(): { reads: number; scribes: number; months: number } {
|
|
692
|
+
return { reads: scribeClaimReads, scribes: scribeClaimCache.size, months: monthListingCache.size }
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Drop the marker cache. Tests use it to measure a cold lookup. */
|
|
696
|
+
export function resetMergedScribeCache(): void {
|
|
697
|
+
scribeClaimCache.clear()
|
|
698
|
+
monthListingCache.clear()
|
|
699
|
+
scribeClaimReads = 0
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export type MergedScribeResolution =
|
|
703
|
+
| { status: 'resolved'; domain: string; month: string; filename: string; path: string; content: string }
|
|
704
|
+
| { status: 'none' }
|
|
705
|
+
| { status: 'ambiguous'; claimants: string[] }
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The ONE scribe, in any domain, that declares `sessionId` through
|
|
709
|
+
* `<!-- g2-session -->`. Two or more is `ambiguous` and resolves to none of them.
|
|
710
|
+
*
|
|
711
|
+
* `read` is the final read of the chosen scribe. Injectable so a test can stand
|
|
712
|
+
* in for a scribe rewritten between the scan and this read.
|
|
713
|
+
*/
|
|
714
|
+
export function resolveMergedScribe(
|
|
715
|
+
operationsDir: string,
|
|
716
|
+
domains: readonly string[],
|
|
717
|
+
sidecarMonth: string,
|
|
718
|
+
sidecarFilename: string,
|
|
719
|
+
sessionId: string,
|
|
720
|
+
read: (path: string) => string = path => readFileSync(path, 'utf-8'),
|
|
721
|
+
): MergedScribeResolution {
|
|
722
|
+
const months = mergedScribeMonthsFor(sidecarMonth, sidecarFilename)
|
|
723
|
+
const found: Array<{ domain: string; month: string; filename: string }> = []
|
|
724
|
+
for (const domain of domains) {
|
|
725
|
+
for (const month of months) {
|
|
726
|
+
for (const claim of scribeClaimsIn(join(operationsDir, domain, 'meetings', month))) {
|
|
727
|
+
if (claim.sessions.includes(sessionId)) found.push({ domain, month, filename: claim.filename })
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (found.length === 0) return { status: 'none' }
|
|
732
|
+
if (found.length > 1) {
|
|
733
|
+
const claimants = found.map(f => `${f.domain}/meetings/${f.month}/${f.filename}`)
|
|
734
|
+
// Once per lookup, and lookups happen on a person's action (opening the
|
|
735
|
+
// speaker panel, a correction), never in a loop.
|
|
736
|
+
console.warn(`[meetings] merged_scribe_ambiguous: ${claimants.length} meetings declare session ${sessionId} (${claimants.join(', ')}); using the capture alone until one is fixed`)
|
|
737
|
+
return { status: 'ambiguous', claimants }
|
|
738
|
+
}
|
|
739
|
+
const only = found[0]
|
|
740
|
+
const path = join(operationsDir, only.domain, 'meetings', only.month, only.filename)
|
|
741
|
+
let content: string
|
|
742
|
+
try {
|
|
743
|
+
content = read(path)
|
|
744
|
+
} catch {
|
|
745
|
+
return { status: 'none' }
|
|
746
|
+
}
|
|
747
|
+
// The cache is a hint; the file just read is the identity.
|
|
748
|
+
if (!mergedScribeSessions(content).includes(sessionId)) return { status: 'none' }
|
|
749
|
+
return { status: 'resolved', ...only, path, content }
|
|
567
750
|
}
|
|
568
751
|
|
|
569
752
|
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
@@ -188,6 +188,9 @@ function resolveCopies(sessionId:string):ResolvedCopy[] {
|
|
|
188
188
|
if (operations) {
|
|
189
189
|
const found = findCosOperationsMeetingBySessionId(sessionId)
|
|
190
190
|
if (!found) throw new Error('Operations meeting copy is missing; retry after sync')
|
|
191
|
+
// 6.61.2: two meetings declare this session. Fail closed rather than let the
|
|
192
|
+
// sidecar's own blended_into pointer below pick one of them.
|
|
193
|
+
if (found.mergedScribeConflict) throw new Error(`${found.mergedScribeConflict.length} meetings declare this session; fix the duplicate before naming`)
|
|
191
194
|
paths.push({copy:'operations',sidecarPath:found.sidecarPath,meetingPath:found.meetingPath})
|
|
192
195
|
} else if (process.env.COS_OPERATIONS_DIR?.trim() || process.env.COS_SCRIPTS_DIR?.trim()) {
|
|
193
196
|
// An explicitly configured but unavailable iCloud root is not standalone.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// exactly as if no hook existed. Every path here that is not an answer ends in `{}`.
|
|
17
17
|
//
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
// WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND
|
|
19
|
+
// WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND recently present
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// The hook has no matcher, so every permission request at an idle desk reaches the route.
|
|
22
22
|
// Everything outside the gate answers `{}` at once (target: under 100 ms). A request is
|
|
@@ -67,10 +67,12 @@
|
|
|
67
67
|
// 2026-09-30 19:54: "Hold only if the glasses or phone were connected in the last 30
|
|
68
68
|
// min. A Claude Remote Control session with no glasses gets its question right away,
|
|
69
69
|
// as before."). The contract shared with COS Glasses 6.9.563 (lib/client-liveness): a
|
|
70
|
-
// poll `?client=glasses|phone&hold=1&presenceAgeMs=<int>`, the ms since a ring or
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
70
|
+
// poll `?client=glasses|phone&hold=1&presenceAgeMs=<int>`, the ms since a ring, temple or
|
|
71
|
+
// lens gesture, or a phone touch while COS is in front. The worn status is never presence
|
|
72
|
+
// (Glasses 6.9.570, Miles 2026-10-02: wear detection can be turned off, and then it always
|
|
73
|
+
// reads not worn). Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`
|
|
74
|
+
// (default 1800; below 30 s, the 30 s live window). A connected lens nobody touches sends
|
|
75
|
+
// no presence; an older client sends
|
|
74
76
|
// no `hold=1` and keeps the 6.59.0 liveness, never the away hold. In memory: after a
|
|
75
77
|
// restart nothing is recent until such a poll.
|
|
76
78
|
// Held that way, a request stays answerable while no client is polling, so a lens or phone
|
|
@@ -1057,7 +1059,7 @@ export class PermissionBroker {
|
|
|
1057
1059
|
* - `recent`: within the window, so the away hold may apply;
|
|
1058
1060
|
* - `holdLive`: a `hold=1` client polled within the live window AND its presence is
|
|
1059
1061
|
* recent, so it counts as a live client the way an older client's poll does. A
|
|
1060
|
-
* connected lens nobody
|
|
1062
|
+
* connected lens nobody touches never counts (QA round 1, Skeptic W1).
|
|
1061
1063
|
*/
|
|
1062
1064
|
private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number } {
|
|
1063
1065
|
let ageMs: number | null = null
|
package/server/routes/meeting.ts
CHANGED
|
@@ -171,6 +171,21 @@ function cosOpsPipelineConfigured(): boolean {
|
|
|
171
171
|
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Why a correction left the meeting's markdown alone when it could not read it.
|
|
176
|
+
*
|
|
177
|
+
* 6.61.2: when two meetings declare the same session the lookup chooses neither
|
|
178
|
+
* and the record falls back to the capture, whose own scribe the merge retired.
|
|
179
|
+
* "Unreadable" would send the reader looking for a missing file; the real
|
|
180
|
+
* answer is the duplicate claim, so the response names it.
|
|
181
|
+
*/
|
|
182
|
+
function markdownUnreadableReason(operations: { mergedScribeConflict?: string[] } | null): string {
|
|
183
|
+
const conflict = operations?.mergedScribeConflict
|
|
184
|
+
return conflict && conflict.length > 1
|
|
185
|
+
? `no merged meeting chosen: ${conflict.length} meetings declare this session (${conflict.join(', ')})`
|
|
186
|
+
: 'meeting markdown unreadable'
|
|
187
|
+
}
|
|
188
|
+
|
|
174
189
|
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
175
190
|
return requested == null || requested === '' || requested === expected
|
|
176
191
|
}
|
|
@@ -1331,7 +1346,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1331
1346
|
proseHits: md?.proseHits ?? [],
|
|
1332
1347
|
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
1333
1348
|
? 'partial relabel: transcript turns cannot be mapped to chunk indices'
|
|
1334
|
-
: markdownRaw === null ?
|
|
1349
|
+
: markdownRaw === null ? markdownUnreadableReason(operations) : null,
|
|
1335
1350
|
}
|
|
1336
1351
|
|
|
1337
1352
|
if (req.body?.dryRun === true || req.body?.confirm !== true) {
|
|
@@ -1684,7 +1699,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1684
1699
|
// markdown changes with no hint the document was never touched.
|
|
1685
1700
|
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
1686
1701
|
? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
|
|
1687
|
-
: markdownUnreadable ?
|
|
1702
|
+
: markdownUnreadable ? markdownUnreadableReason(operations) : null,
|
|
1688
1703
|
scope: 'meeting' as const,
|
|
1689
1704
|
chunks: plan.value.changed,
|
|
1690
1705
|
surfaces,
|