@gotcos/glasses-server 6.45.4 → 6.46.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 +22 -0
- package/package.json +2 -2
- package/server/index.ts +2 -0
- package/server/lib/agent-session-activity.ts +200 -0
- package/server/lib/agent-session-store.ts +7 -0
- package/server/lib/cos-operations-meetings.ts +1 -1
- package/server/lib/g2-ops-handoff.ts +27 -5
- package/server/lib/held-naming-batches.ts +527 -0
- package/server/lib/held-voice-groups.ts +103 -12
- package/server/lib/meeting-batch-persistence.ts +1 -0
- package/server/lib/meeting-corrections.ts +43 -5
- package/server/lib/meeting-speaker-labels.ts +192 -0
- package/server/lib/meeting-speaker-review.ts +18 -5
- package/server/lib/meeting-sync-lock.ts +26 -0
- package/server/lib/speaker-embeddings.ts +1 -1
- package/server/lib/speaker-rename-fanout.ts +91 -177
- package/server/lib/training-audio-save.ts +24 -0
- package/server/lib/voice-directory.ts +2 -1
- package/server/lib/voice-enrolment-selection.ts +25 -0
- package/server/lib/voice-profile-store.ts +8 -3
- package/server/routes/agent-sessions.ts +25 -4
- package/server/routes/meeting.ts +5 -1
- package/server/routes/transcribe-stream.ts +13 -41
- package/server/routes/voice.ts +29 -43
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 6.46.0
|
|
2
|
+
|
|
3
|
+
Name held voice samples and preview the meeting labels they will change. Paired Control build: 0.5.223.
|
|
4
|
+
|
|
5
|
+
- Individual held samples can suggest an existing voice using the same second-best-sample scoring as groups, with a clear margin and owner-proximity caution. Suggestions remain proposals.
|
|
6
|
+
- Naming returns a server-stored preview first. Apply checks the preview, voice store, raw chunk map and each meeting copy again, holds the COS sync lock where available, and writes chunk-scoped confirmations. Wider matches must agree with the named samples and the updated voice profile in their source meeting.
|
|
7
|
+
- Corrected labels reach HQ word speakers, live chunks and operations copies. Transcript words remain unchanged; only fully aligned turn labels change. Newer speaker corrections survive re-import. Search catches up through the existing indexing queue; graph staleness is explicit.
|
|
8
|
+
- Each naming has durable copy receipts and Undo. Interrupted work requires review. Undo restores eligible labels, retains enrolled voice samples, and never resurrects deleted audio. Held audio is deleted after successful meeting receipts.
|
|
9
|
+
- Existing-name matching resolves stored spelling before preview. Owner naming requires an explicit acknowledgement and stronger acoustic match. Older clients cannot apply the new naming contract without a preview.
|
|
10
|
+
- Voice-profile merges retain stronger source evidence before applying oldest-first eviction within a source tier. Training audio has its own retention capacity, independent of profile size, and freed slots are usable without a restart.
|
|
11
|
+
- Compatibility: 6.45.5 safely ignores chunk-scoped confirmations. It does not understand reverted batch history; keep 6.46.0 for naming recovery and Undo history after rollback.
|
|
12
|
+
|
|
13
|
+
## 6.45.5
|
|
14
|
+
|
|
15
|
+
Sessions carry their last real activity and last tool (2026-09-13), for the phone Sessions list in COS Glasses 6.9.470.
|
|
16
|
+
|
|
17
|
+
- `GET /api/agent-sessions` rows and `GET /api/agent-sessions/:provider/:sessionId` add `last_activity_at` (ISO time of the newest Claude user or assistant record, or the newest Codex `response_item`) and `last_tool` (the most recent tool call in the latest turn: Claude `tool_use`, Codex function, custom tool, local shell and web search calls) when the transcript has them. Both are omitted otherwise, and Cursor rows are unchanged, so an older client sees the payload it always did.
|
|
18
|
+
- Why: a Claude Desktop relaunch appends bookkeeping records (`queue-operation` and similar) to every open transcript, so the file time moved with nobody working. On 2026-09-13, 18 of 60 listed rows shared one relaunch timestamp and sorted as the newest sessions on the Mac.
|
|
19
|
+
- `running_active` ("working now") is judged by the last real record when it is known and older than the file write (`activityClockMs`), so a relaunch no longer reads as 30 seconds of work on every held session.
|
|
20
|
+
- Cost: `lib/agent-session-activity.ts` reads the last 256 KiB of each listed transcript and widens x4 up to 16 MiB only when that tail holds no conversation record (measured record sizes reach 1.7 MB for Claude and 11.9 MB for Codex). Reads are memoized on mtime and size, least recently used evicted first, and at most 8 transcripts are read at once. Rows carry their resolved transcript path server-side only; `toEntry` projects named fields, so the path never reaches the wire.
|
|
21
|
+
- Tests: `agent-session-activity.test.ts` (relaunch bookkeeping ignored, newest tool of the latest turn only, a tool result keeps the turn open whichever marker it carries, a parsing half line dropped, every Codex tool kind, widening and its cap, re-reads on mtime or size change and not otherwise, LRU eviction, the working-now clock, bounded concurrency) and route tests for a Codex thread and a Claude thread after a relaunch, with no path on the list or the detail, plus a Claude thread this process holds: a relaunch write does not read as working on the list or the detail, and a record inside the 30 s window still does.
|
|
22
|
+
|
|
1
23
|
## 6.45.4
|
|
2
24
|
|
|
3
25
|
Two things from 2026-09-12: a dictated prompt that ended in a sentence nobody said, and held voices that could only be named one meeting at a time. Both went through a four-validator QA pass against a copy of Miles's live store before they shipped; what that pass corrected is recorded inline.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.46.0",
|
|
4
|
+
"description": "COS Glasses \u2014 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": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
package/server/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { promptDraftsRouter } from './routes/prompt-drafts.js'
|
|
|
51
51
|
import { cliDebugRouter } from './routes/cli-debug.js'
|
|
52
52
|
import { maintenanceRouter } from './routes/maintenance.js'
|
|
53
53
|
import { ttsRouter } from './routes/tts.js'
|
|
54
|
+
import { recoverInterruptedNaming } from './lib/held-naming-batches.js'
|
|
54
55
|
import { voiceRouter } from './routes/voice.js'
|
|
55
56
|
import { glossaryRouter } from './routes/glossary.js'
|
|
56
57
|
import { handoffsRouter } from './routes/handoffs.js'
|
|
@@ -684,6 +685,7 @@ app.use('/api', promptDraftsRouter)
|
|
|
684
685
|
app.use('/api', cliDebugRouter)
|
|
685
686
|
app.use('/api', maintenanceRouter)
|
|
686
687
|
app.use('/api', ttsRouter)
|
|
688
|
+
recoverInterruptedNaming()
|
|
687
689
|
app.use('/api', voiceRouter)
|
|
688
690
|
app.use('/api', glossaryRouter)
|
|
689
691
|
app.use('/api', handoffsRouter)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// When a Claude or Codex session last DID something, and the last tool it called.
|
|
2
|
+
//
|
|
3
|
+
// 6.45.5. Miles, 2026-09-13, on the phone's Sessions list: the status and the order should
|
|
4
|
+
// be "reflective". They were not, because both came from the transcript FILE's write time.
|
|
5
|
+
// A Claude Desktop relaunch appends bookkeeping records (`queue-operation` and friends) to
|
|
6
|
+
// every open transcript, so 18 of 60 rows shared one relaunch timestamp and sorted as the
|
|
7
|
+
// newest work on the Mac while their last real turn was days old.
|
|
8
|
+
//
|
|
9
|
+
// The answer is in the records themselves: Claude writes a `timestamp` on every user and
|
|
10
|
+
// assistant record, and Codex on every `response_item`. The newest record is at the end,
|
|
11
|
+
// so the reader starts from the tail and widens only when the tail holds no conversation
|
|
12
|
+
// record. Records can be very large: measured on this Mac over seven days, the biggest
|
|
13
|
+
// single records were 1.7 MB (Claude) and 11.9 MB (Codex). Reads are memoized on the
|
|
14
|
+
// file's mtime and size, so a steady list poll re-reads nothing that did not change.
|
|
15
|
+
//
|
|
16
|
+
// Cursor agent transcripts carry no per-record clock, so they answer null and the client
|
|
17
|
+
// keeps using the file time, exactly as before.
|
|
18
|
+
|
|
19
|
+
import { open, stat } from 'node:fs/promises'
|
|
20
|
+
import { AGENT_SESSION_LIST_MAX, type AgentProvider } from './agent-session-store.js'
|
|
21
|
+
|
|
22
|
+
/** First window read from the end of a transcript. */
|
|
23
|
+
export const ACTIVITY_TAIL_BYTES = 256 * 1024
|
|
24
|
+
/** Largest window the reader widens to (x4 each step) when the tail holds no conversation record. */
|
|
25
|
+
export const ACTIVITY_TAIL_MAX_BYTES = 16 * 1024 * 1024
|
|
26
|
+
/** Memo entries kept, least recently used evicted first: several full lists' worth. */
|
|
27
|
+
export const ACTIVITY_MEMO_MAX = AGENT_SESSION_LIST_MAX * 6
|
|
28
|
+
/** Transcripts read at once on a cold list, so 80 rows never hold 80 descriptors. */
|
|
29
|
+
export const ACTIVITY_READ_CONCURRENCY = 8
|
|
30
|
+
|
|
31
|
+
export interface SessionActivity {
|
|
32
|
+
/** ISO time of the newest user or assistant record (Claude) or response item (Codex). */
|
|
33
|
+
lastActivityAt: string | null
|
|
34
|
+
/** Name of the most recent tool call in the latest turn. */
|
|
35
|
+
lastTool: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NONE: SessionActivity = Object.freeze({ lastActivityAt: null, lastTool: null })
|
|
39
|
+
const memo = new Map<string, { mtimeMs: number; size: number; value: SessionActivity }>()
|
|
40
|
+
let fileReads = 0
|
|
41
|
+
|
|
42
|
+
const CODEX_TOOL_KINDS: Record<string, (payload: Record<string, unknown>) => string | null> = {
|
|
43
|
+
function_call: payload => (typeof payload.name === 'string' && payload.name ? payload.name : null),
|
|
44
|
+
custom_tool_call: payload => (typeof payload.name === 'string' && payload.name ? payload.name : null),
|
|
45
|
+
local_shell_call: () => 'local_shell',
|
|
46
|
+
web_search_call: () => 'web_search',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isoOrNull(value: unknown): string | null {
|
|
50
|
+
if (typeof value !== 'string' || !value) return null
|
|
51
|
+
const ms = Date.parse(value)
|
|
52
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function claudeToolName(message: unknown): string | null {
|
|
56
|
+
if (!message || typeof message !== 'object') return null
|
|
57
|
+
const content = (message as { content?: unknown }).content
|
|
58
|
+
if (!Array.isArray(content)) return null
|
|
59
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
60
|
+
const block = content[i] as { type?: unknown; name?: unknown } | null
|
|
61
|
+
if (block && block.type === 'tool_use' && typeof block.name === 'string' && block.name) return block.name
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A user record that starts a turn, as opposed to one that returns a tool result. */
|
|
67
|
+
function claudeTurnStart(obj: Record<string, unknown>): boolean {
|
|
68
|
+
if (obj.type !== 'user' || obj.toolUseResult) return false
|
|
69
|
+
const content = (obj.message as { content?: unknown } | undefined)?.content
|
|
70
|
+
return !(Array.isArray(content) && content.some(block => (block as { type?: unknown } | null)?.type === 'tool_result'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan transcript text from the end. `startsMidFile` drops the first line, which a tail
|
|
75
|
+
* read almost always cuts in half.
|
|
76
|
+
*
|
|
77
|
+
* The last tool is looked for only inside the latest turn: once the scan passes the user
|
|
78
|
+
* message that started it, an older tool would describe work that is already over.
|
|
79
|
+
*/
|
|
80
|
+
export function activityFromTail(provider: AgentProvider, text: string, startsMidFile: boolean): SessionActivity {
|
|
81
|
+
if (provider === 'cursor') return NONE
|
|
82
|
+
const lines = text.split('\n')
|
|
83
|
+
if (startsMidFile) lines.shift()
|
|
84
|
+
let lastActivityAt: string | null = null
|
|
85
|
+
let lastTool: string | null = null
|
|
86
|
+
let turnOpen = true
|
|
87
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
88
|
+
if (lastActivityAt !== null && (lastTool !== null || !turnOpen)) break
|
|
89
|
+
const line = lines[i].trim()
|
|
90
|
+
if (!line) continue
|
|
91
|
+
let obj: Record<string, unknown>
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(line)
|
|
94
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
|
|
95
|
+
obj = parsed as Record<string, unknown>
|
|
96
|
+
} catch {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (provider === 'claude') {
|
|
100
|
+
// Only conversation records count. Bookkeeping (`queue-operation`, `summary`,
|
|
101
|
+
// `custom-title`, `mode`...) is exactly what a relaunch writes.
|
|
102
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue
|
|
103
|
+
if (lastActivityAt === null) lastActivityAt = isoOrNull(obj.timestamp)
|
|
104
|
+
if (turnOpen && lastTool === null && obj.type === 'assistant') lastTool = claudeToolName(obj.message)
|
|
105
|
+
if (claudeTurnStart(obj)) turnOpen = false
|
|
106
|
+
} else {
|
|
107
|
+
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
|
|
108
|
+
const payload = obj.payload as Record<string, unknown>
|
|
109
|
+
if (lastActivityAt === null) lastActivityAt = isoOrNull(obj.timestamp)
|
|
110
|
+
const toolName = CODEX_TOOL_KINDS[String(payload.type ?? '')]
|
|
111
|
+
if (turnOpen && lastTool === null && toolName) lastTool = toolName(payload)
|
|
112
|
+
if (payload.type === 'message' && payload.role === 'user') turnOpen = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { lastActivityAt, lastTool }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read one transcript's activity, memoized on mtime and size. Starts with the last
|
|
120
|
+
* ACTIVITY_TAIL_BYTES and widens x4 up to ACTIVITY_TAIL_MAX_BYTES only while no
|
|
121
|
+
* conversation record has been found. Any failure answers null fields.
|
|
122
|
+
*/
|
|
123
|
+
export async function readSessionActivity(
|
|
124
|
+
provider: AgentProvider,
|
|
125
|
+
file: string,
|
|
126
|
+
opts: { memoMax?: number } = {},
|
|
127
|
+
): Promise<SessionActivity> {
|
|
128
|
+
if (provider === 'cursor' || !file) return NONE
|
|
129
|
+
try {
|
|
130
|
+
const st = await stat(file)
|
|
131
|
+
const hit = memo.get(file)
|
|
132
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
133
|
+
memo.delete(file)
|
|
134
|
+
memo.set(file, hit)
|
|
135
|
+
return hit.value
|
|
136
|
+
}
|
|
137
|
+
let value: SessionActivity = NONE
|
|
138
|
+
const handle = await open(file, 'r')
|
|
139
|
+
fileReads += 1
|
|
140
|
+
try {
|
|
141
|
+
for (let window = ACTIVITY_TAIL_BYTES; ; window *= 4) {
|
|
142
|
+
const length = Math.min(st.size, window)
|
|
143
|
+
const start = st.size - length
|
|
144
|
+
const buffer = Buffer.alloc(length)
|
|
145
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start)
|
|
146
|
+
value = activityFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0)
|
|
147
|
+
if (value.lastActivityAt !== null || start === 0 || window >= ACTIVITY_TAIL_MAX_BYTES) break
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close()
|
|
151
|
+
}
|
|
152
|
+
const max = Math.max(1, opts.memoMax ?? ACTIVITY_MEMO_MAX)
|
|
153
|
+
memo.delete(file)
|
|
154
|
+
while (memo.size >= max) {
|
|
155
|
+
const oldest = memo.keys().next().value
|
|
156
|
+
if (oldest === undefined) break
|
|
157
|
+
memo.delete(oldest)
|
|
158
|
+
}
|
|
159
|
+
memo.set(file, { mtimeMs: st.mtimeMs, size: st.size, value })
|
|
160
|
+
return value
|
|
161
|
+
} catch {
|
|
162
|
+
return NONE
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The clock "working now" is judged by: the last real record when it is known and older
|
|
168
|
+
* than the file write, else the write time. A relaunch's bookkeeping write moves the file
|
|
169
|
+
* time with nobody working, and used to read as 30 seconds of work on every held session.
|
|
170
|
+
*/
|
|
171
|
+
export function activityClockMs(mtimeMs: number, activity: SessionActivity | null | undefined): number {
|
|
172
|
+
const at = activity?.lastActivityAt ? Date.parse(activity.lastActivityAt) : Number.NaN
|
|
173
|
+
return Number.isFinite(at) && at < mtimeMs ? at : mtimeMs
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Map with at most `limit` promises in flight, preserving order. */
|
|
177
|
+
export async function mapWithConcurrency<T, R>(
|
|
178
|
+
items: readonly T[],
|
|
179
|
+
limit: number,
|
|
180
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
181
|
+
): Promise<R[]> {
|
|
182
|
+
const out = new Array<R>(items.length)
|
|
183
|
+
let next = 0
|
|
184
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
185
|
+
while (next < items.length) {
|
|
186
|
+
const index = next++
|
|
187
|
+
out[index] = await fn(items[index], index)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
await Promise.all(workers)
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Test seams. */
|
|
195
|
+
export function clearSessionActivityMemo(): void {
|
|
196
|
+
memo.clear()
|
|
197
|
+
}
|
|
198
|
+
export function sessionActivityFileReads(): number {
|
|
199
|
+
return fileReads
|
|
200
|
+
}
|
|
@@ -50,6 +50,10 @@ export interface AgentSessionRow {
|
|
|
50
50
|
pinned: boolean
|
|
51
51
|
first_prompt?: string
|
|
52
52
|
discussion_summary?: string
|
|
53
|
+
/** 6.45.5: the transcript this row was read from, when the walk resolved one. Server
|
|
54
|
+
* only: `toEntry` projects named fields, so this never reaches the wire. It lets the
|
|
55
|
+
* list read each row's last activity without resolving the file a second time. */
|
|
56
|
+
file?: string
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface AgentSessionRoots {
|
|
@@ -1076,6 +1080,7 @@ export async function listClaudeSessions(
|
|
|
1076
1080
|
provider: 'claude',
|
|
1077
1081
|
display_label: title,
|
|
1078
1082
|
project,
|
|
1083
|
+
file: candidate.file,
|
|
1079
1084
|
modified: isoFromMtime(candidate.mtimeMs),
|
|
1080
1085
|
created: isoFromMtime(candidate.birthtimeMs),
|
|
1081
1086
|
alive: false,
|
|
@@ -1159,6 +1164,7 @@ export async function listCodexSessions(
|
|
|
1159
1164
|
provider: 'codex',
|
|
1160
1165
|
display_label: title,
|
|
1161
1166
|
project: workspaceLabel(meta.cwd),
|
|
1167
|
+
file: candidate.file,
|
|
1162
1168
|
modified: isoFromMtime(candidate.mtimeMs),
|
|
1163
1169
|
created,
|
|
1164
1170
|
alive: false,
|
|
@@ -1288,6 +1294,7 @@ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots,
|
|
|
1288
1294
|
...row,
|
|
1289
1295
|
session_id: fullId,
|
|
1290
1296
|
modified,
|
|
1297
|
+
file: found,
|
|
1291
1298
|
display_label: title || row.display_label || 'Claude session',
|
|
1292
1299
|
...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
|
|
1293
1300
|
}
|
|
@@ -462,7 +462,7 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
462
462
|
const monthDir = join(meetingsBase, month)
|
|
463
463
|
let sidecars: string[]
|
|
464
464
|
try {
|
|
465
|
-
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json')).sort().reverse()
|
|
465
|
+
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') && !/ \d+(\.[A-Za-z0-9-]+)*\.json$/.test(f)).sort().reverse()
|
|
466
466
|
} catch { continue }
|
|
467
467
|
|
|
468
468
|
for (const sidecarName of sidecars) {
|
|
@@ -23,6 +23,9 @@ const DOMAIN_REVIEW_MARKER = '<!-- g2-needs-domain-review -->'
|
|
|
23
23
|
const HQ_PENDING_MARKER = '<!-- g2-hq-state: pending -->'
|
|
24
24
|
const OPERATIONS_OWNED_SIDECAR_FIELDS = new Set([
|
|
25
25
|
'blended_into',
|
|
26
|
+
'claimed_parent',
|
|
27
|
+
'correctionRevision',
|
|
28
|
+
'labelsNewerThanGraph',
|
|
26
29
|
'dedupEvidence',
|
|
27
30
|
'enrichmentState',
|
|
28
31
|
'finalPath',
|
|
@@ -124,6 +127,12 @@ function parseSidecar(path: string): Record<string, unknown> | null {
|
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
export function g2Revision(value: unknown): number {
|
|
131
|
+
if (value === undefined || value === null) return 0
|
|
132
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error('Invalid G2 revision')
|
|
133
|
+
return value
|
|
134
|
+
}
|
|
135
|
+
|
|
127
136
|
/** Merge server-owned capture truth while preserving operations-owned match state. */
|
|
128
137
|
export function mergeG2OperationsSidecar(
|
|
129
138
|
sourcePath: string,
|
|
@@ -139,8 +148,8 @@ export function mergeG2OperationsSidecar(
|
|
|
139
148
|
throw new Error(`Refusing G2 sidecar merge across sessions (${existingSession} != ${sourceSession})`)
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
const priorRevision =
|
|
143
|
-
const nextRevision = Math.max(
|
|
151
|
+
const priorRevision = g2Revision(existing.lifecycleRevision)
|
|
152
|
+
const nextRevision = Math.max(g2Revision(options.revision), g2Revision(source.lifecycleRevision))
|
|
144
153
|
if (Number.isFinite(priorRevision) && Number.isFinite(nextRevision) && nextRevision < priorRevision) {
|
|
145
154
|
throw new Error(`Refusing regressing G2 sidecar revision ${nextRevision} < ${priorRevision}`)
|
|
146
155
|
}
|
|
@@ -149,10 +158,19 @@ export function mergeG2OperationsSidecar(
|
|
|
149
158
|
for (const field of OPERATIONS_OWNED_SIDECAR_FIELDS) {
|
|
150
159
|
if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
|
|
151
160
|
}
|
|
161
|
+
const correctionFields = ['chunks', 'chunkEntries', 'speakers', 'batchSegments', 'batchApplied', 'correctionRevision', 'labelsNewerThanGraph']
|
|
162
|
+
if (g2Revision(existing.correctionRevision) > g2Revision(source.correctionRevision)) {
|
|
163
|
+
for (const field of correctionFields) {
|
|
164
|
+
if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
|
|
165
|
+
else delete merged[field]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
merged.correctionRevision = Math.max(g2Revision(existing.correctionRevision), g2Revision(source.correctionRevision))
|
|
169
|
+
merged.labelsNewerThanGraph = existing.labelsNewerThanGraph === true || source.labelsNewerThanGraph === true
|
|
152
170
|
merged.lifecycleRevision = Math.max(priorRevision || 0, nextRevision || 0)
|
|
153
|
-
const inferredFinalHqState =
|
|
171
|
+
const inferredFinalHqState = merged.batchApplied === true
|
|
154
172
|
? 'accepted'
|
|
155
|
-
:
|
|
173
|
+
: merged.batchQualityReport
|
|
156
174
|
? 'rejected'
|
|
157
175
|
: 'unavailable'
|
|
158
176
|
merged.hqState = options.hqState
|
|
@@ -184,7 +202,11 @@ export function stageRecordingIntoOperations(
|
|
|
184
202
|
const destDir = join(operationsDir, 'personal', 'meetings', month)
|
|
185
203
|
mkdirSync(destDir, { recursive: true })
|
|
186
204
|
const destPath = join(destDir, basename(localMeetingPath))
|
|
187
|
-
const
|
|
205
|
+
const sourceSidecar = parseSidecar(localMeetingPath.replace(/\.md$/, '.g2-chunks.json'))
|
|
206
|
+
const destinationSidecar = parseSidecar(destPath.replace(/\.md$/, '.g2-chunks.json'))
|
|
207
|
+
const keepMarkdown = g2Revision(destinationSidecar?.correctionRevision) > g2Revision(sourceSidecar?.correctionRevision)
|
|
208
|
+
if (keepMarkdown && !existsSync(destPath)) throw new Error('Corrected operations markdown missing; refusing stale import')
|
|
209
|
+
const patched = keepMarkdown ? readFileSync(destPath, 'utf8') : patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'), options)
|
|
188
210
|
|
|
189
211
|
const stem = basename(localMeetingPath, '.md')
|
|
190
212
|
const localDir = dirname(localMeetingPath)
|