@gotcos/glasses-server 6.98.1 → 6.99.16
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 +227 -0
- package/package.json +2 -1
- package/server/index.ts +170 -11
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +830 -0
- package/server/lib/drop-settings.ts +56 -0
- package/server/lib/drop-store.ts +450 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +13 -0
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +262 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +43 -4
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +325 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +174 -0
- package/server/web-control/panes/cos-chat.js +1118 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +314 -0
- package/server/web-control/panes/meetings.js +2568 -0
- package/server/web-control/panes/messages.js +718 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { dataPath } from './data-dir.js'
|
|
4
|
+
import { normalizeTitleSteer } from './meeting-title-steer.js'
|
|
5
|
+
|
|
6
|
+
const SESSION_ID_RE = /^[A-Za-z0-9:_-]{3,96}$/
|
|
7
|
+
const MAX_FILE_BYTES = 32 * 1024 * 1024
|
|
8
|
+
const SETTLE_WINDOW = 3
|
|
9
|
+
const LEVEL_LIMIT = 120
|
|
10
|
+
|
|
11
|
+
export interface LiveSessionSummary {
|
|
12
|
+
sessionId: string
|
|
13
|
+
mtimeMs: number
|
|
14
|
+
stamp: string
|
|
15
|
+
sourceType?: string
|
|
16
|
+
speakerPolicy?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LiveTranscriptTurn {
|
|
20
|
+
i: number
|
|
21
|
+
elapsedMs: number
|
|
22
|
+
speaker: string
|
|
23
|
+
text: string
|
|
24
|
+
originalSpeaker?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LiveChunkLevel {
|
|
28
|
+
i: number
|
|
29
|
+
level: number
|
|
30
|
+
speechSec: number | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LiveTranscriptReadResult {
|
|
34
|
+
sessionId: string
|
|
35
|
+
ended: boolean
|
|
36
|
+
unchanged: boolean
|
|
37
|
+
stamp?: string
|
|
38
|
+
startTime?: number | null
|
|
39
|
+
lastActivityAt?: number | null
|
|
40
|
+
maxIndex?: number
|
|
41
|
+
settledThrough?: number
|
|
42
|
+
turns?: LiveTranscriptTurn[]
|
|
43
|
+
speakerRevision?: number
|
|
44
|
+
fullSnapshot?: boolean
|
|
45
|
+
sourceType?: string
|
|
46
|
+
speakerPolicy?: string
|
|
47
|
+
levels?: LiveChunkLevel[]
|
|
48
|
+
/** 6.99.5: the name typed for this recording while it runs (the session file's `titleSteer`); absent when none. */
|
|
49
|
+
titleSteer?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isValidLiveSessionId(id: string): boolean {
|
|
53
|
+
return typeof id === 'string' && SESSION_ID_RE.test(id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fileStamp(stat: { mtimeMs: number; size: number }): string {
|
|
57
|
+
return `${Math.floor(stat.mtimeMs)}.${stat.size}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validIndexes(value: unknown): number[] {
|
|
61
|
+
if (!Array.isArray(value)) return []
|
|
62
|
+
const out: number[] = []
|
|
63
|
+
for (const item of value) {
|
|
64
|
+
if (typeof item === 'number' && Number.isInteger(item) && item >= 0 && item < 10_000_000) {
|
|
65
|
+
out.push(item)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseLevels(value: unknown): LiveChunkLevel[] | undefined {
|
|
72
|
+
if (!Array.isArray(value)) return undefined
|
|
73
|
+
const byIndex = new Map<number, LiveChunkLevel>()
|
|
74
|
+
for (const row of value) {
|
|
75
|
+
if (!row || typeof row !== 'object') continue
|
|
76
|
+
const r = row as Record<string, unknown>
|
|
77
|
+
const idx = validIndexes([r.i])[0]
|
|
78
|
+
const level = typeof r.level === 'number' && Number.isFinite(r.level) ? r.level : -1
|
|
79
|
+
if (idx === undefined || level < 0 || level > 1) continue
|
|
80
|
+
let speechSec: number | null = null
|
|
81
|
+
if (r.speechSec === null || r.speechSec === undefined) {
|
|
82
|
+
speechSec = null
|
|
83
|
+
} else if (typeof r.speechSec === 'number' && Number.isFinite(r.speechSec) && r.speechSec >= 0) {
|
|
84
|
+
speechSec = r.speechSec
|
|
85
|
+
} else {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
byIndex.set(idx, { i: idx, level, speechSec })
|
|
89
|
+
}
|
|
90
|
+
const sorted = [...byIndex.keys()].sort((a, b) => a - b)
|
|
91
|
+
return sorted.slice(-LEVEL_LIMIT).map(k => byIndex.get(k)!)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listLiveTranscriptSessions(baseDataDir = dataPath()): LiveSessionSummary[] {
|
|
95
|
+
const activeDir = resolve(baseDataDir, 'active-sessions')
|
|
96
|
+
let names: string[] = []
|
|
97
|
+
try {
|
|
98
|
+
const dirStat = lstatSync(activeDir)
|
|
99
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return []
|
|
100
|
+
names = readdirSync(activeDir)
|
|
101
|
+
} catch {
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rows: LiveSessionSummary[] = []
|
|
106
|
+
for (const name of names) {
|
|
107
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue
|
|
108
|
+
const id = name.slice(0, -5)
|
|
109
|
+
if (!isValidLiveSessionId(id)) continue
|
|
110
|
+
const full = resolve(activeDir, name)
|
|
111
|
+
if (resolve(full, '..') !== activeDir) continue
|
|
112
|
+
try {
|
|
113
|
+
const st = lstatSync(full)
|
|
114
|
+
if (!st.isFile() || st.isSymbolicLink()) continue
|
|
115
|
+
rows.push({
|
|
116
|
+
sessionId: id,
|
|
117
|
+
mtimeMs: Math.floor(st.mtimeMs),
|
|
118
|
+
stamp: fileStamp(st),
|
|
119
|
+
...(id.startsWith('phone_') ? { sourceType: 'iphone_microphone', speakerPolicy: 'anonymous' } : {}),
|
|
120
|
+
})
|
|
121
|
+
} catch {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
rows.sort((a, b) => (b.mtimeMs !== a.mtimeMs ? b.mtimeMs - a.mtimeMs : a.sessionId.localeCompare(b.sessionId)))
|
|
127
|
+
return rows
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readLiveTranscriptSession(options: {
|
|
131
|
+
baseDataDir?: string
|
|
132
|
+
sessionId: string
|
|
133
|
+
since?: number
|
|
134
|
+
knownStamp?: string | null
|
|
135
|
+
knownSpeakerRevision?: number | null
|
|
136
|
+
}): LiveTranscriptReadResult {
|
|
137
|
+
const { sessionId } = options
|
|
138
|
+
if (!isValidLiveSessionId(sessionId)) {
|
|
139
|
+
throw new Error('invalid_session_id')
|
|
140
|
+
}
|
|
141
|
+
const since = typeof options.since === 'number' && Number.isInteger(options.since) ? options.since : -1
|
|
142
|
+
if (since < -1 || since >= 10_000_000) {
|
|
143
|
+
throw new Error('invalid_since')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const activeDir = resolve(options.baseDataDir ?? dataPath(), 'active-sessions')
|
|
147
|
+
const filePath = join(activeDir, `${sessionId}.json`)
|
|
148
|
+
if (resolve(filePath, '..') !== activeDir) {
|
|
149
|
+
throw new Error('invalid_session_id')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let st: ReturnType<typeof lstatSync>
|
|
153
|
+
try {
|
|
154
|
+
st = lstatSync(filePath)
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
157
|
+
return { sessionId, ended: true, unchanged: false }
|
|
158
|
+
}
|
|
159
|
+
throw new Error('unreadable')
|
|
160
|
+
}
|
|
161
|
+
if (!st.isFile() || st.isSymbolicLink()) {
|
|
162
|
+
throw new Error('unsafe_file')
|
|
163
|
+
}
|
|
164
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
165
|
+
throw new Error('too_large')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const stamp = fileStamp(st)
|
|
169
|
+
if (options.knownStamp && options.knownStamp === stamp) {
|
|
170
|
+
return {
|
|
171
|
+
sessionId,
|
|
172
|
+
ended: false,
|
|
173
|
+
unchanged: true,
|
|
174
|
+
stamp,
|
|
175
|
+
...(sessionId.startsWith('phone_') ? { sourceType: 'iphone_microphone', speakerPolicy: 'anonymous' } : {}),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let parsed: Record<string, unknown>
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error('parse_failed')
|
|
184
|
+
}
|
|
185
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId !== sessionId) {
|
|
186
|
+
throw new Error('session_mismatch')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const chunks = new Map<number, LiveTranscriptTurn>()
|
|
190
|
+
const rawChunks = Array.isArray(parsed.chunksIndexed) ? parsed.chunksIndexed : []
|
|
191
|
+
for (const entry of rawChunks) {
|
|
192
|
+
if (!entry || typeof entry !== 'object') continue
|
|
193
|
+
const row = entry as Record<string, unknown>
|
|
194
|
+
const idx = validIndexes([row.i])[0]
|
|
195
|
+
const c = row.c && typeof row.c === 'object' ? (row.c as Record<string, unknown>) : null
|
|
196
|
+
if (idx === undefined || !c || typeof c.text !== 'string' || !c.text.trim()) continue
|
|
197
|
+
const elapsedMs = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? Math.max(0, Math.round(c.elapsed)) : 0
|
|
198
|
+
const speaker = typeof c.speaker === 'string' ? c.speaker.trim() : ''
|
|
199
|
+
chunks.set(idx, { i: idx, elapsedMs, speaker, text: c.text })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let speakerRevision: number | undefined
|
|
203
|
+
if (typeof parsed.speakerRevision === 'number' && Number.isInteger(parsed.speakerRevision) && parsed.speakerRevision >= 0) {
|
|
204
|
+
speakerRevision = parsed.speakerRevision
|
|
205
|
+
if (Array.isArray(parsed.speakerProjectedChunksIndexed)) {
|
|
206
|
+
for (const proj of parsed.speakerProjectedChunksIndexed) {
|
|
207
|
+
if (!proj || typeof proj !== 'object') continue
|
|
208
|
+
const pRow = proj as Record<string, unknown>
|
|
209
|
+
const idx = validIndexes([pRow.i])[0]
|
|
210
|
+
const pc = pRow.c && typeof pRow.c === 'object' ? (pRow.c as Record<string, unknown>) : null
|
|
211
|
+
const orig = idx !== undefined ? chunks.get(idx) : undefined
|
|
212
|
+
if (orig && pc && typeof pc.speaker === 'string' && pc.speaker.trim()) {
|
|
213
|
+
orig.originalSpeaker = orig.speaker
|
|
214
|
+
orig.speaker = pc.speaker.trim()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fullSnapshot = speakerRevision !== undefined && speakerRevision !== (options.knownSpeakerRevision ?? undefined)
|
|
221
|
+
const settled = new Set<number>(chunks.keys())
|
|
222
|
+
for (const idx of validIndexes(parsed.asrCompletedIndices)) settled.add(idx)
|
|
223
|
+
if (parsed.emptyCompletions && typeof parsed.emptyCompletions === 'object') {
|
|
224
|
+
for (const k of Object.keys(parsed.emptyCompletions as Record<string, unknown>)) {
|
|
225
|
+
const n = Number.parseInt(k, 10)
|
|
226
|
+
if (Number.isInteger(n) && n >= 0) settled.add(n)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const received = validIndexes(parsed.receivedIndices)
|
|
231
|
+
const declared = validIndexes([parsed.maxChunkIndex])[0] ?? -1
|
|
232
|
+
const maxSettled = settled.size > 0 ? Math.max(...settled) : -1
|
|
233
|
+
const maxReceived = received.length > 0 ? Math.max(...received) : -1
|
|
234
|
+
const newest = Math.max(declared, maxReceived, maxSettled)
|
|
235
|
+
|
|
236
|
+
let settledThrough = -1
|
|
237
|
+
if (newest >= 0) {
|
|
238
|
+
for (let i = 0; i <= newest; i++) {
|
|
239
|
+
if (settled.has(i) || i < newest - SETTLE_WINDOW) {
|
|
240
|
+
settledThrough = i
|
|
241
|
+
} else {
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const sortedIndices = [...chunks.keys()].sort((a, b) => a - b)
|
|
248
|
+
const turns = sortedIndices
|
|
249
|
+
.filter(idx => fullSnapshot || idx > since)
|
|
250
|
+
.map(idx => chunks.get(idx)!)
|
|
251
|
+
|
|
252
|
+
const levels = parseLevels(parsed.chunkLevels)
|
|
253
|
+
// The live name, read the way the steer store reads its own ledger: normalized, and dropped (never trimmed to fit)
|
|
254
|
+
// when it is not a string or is over the cap. The web pane names the recording with it, as COS Control does.
|
|
255
|
+
let titleSteer = ''
|
|
256
|
+
try { titleSteer = normalizeTitleSteer(typeof parsed.titleSteer === 'string' ? parsed.titleSteer : null) } catch { titleSteer = '' }
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
sessionId,
|
|
260
|
+
ended: false,
|
|
261
|
+
unchanged: false,
|
|
262
|
+
stamp,
|
|
263
|
+
startTime: typeof parsed.startTime === 'number' ? parsed.startTime : null,
|
|
264
|
+
lastActivityAt: typeof parsed.lastActivityAt === 'number' ? parsed.lastActivityAt : null,
|
|
265
|
+
maxIndex: sortedIndices.length > 0 ? sortedIndices[sortedIndices.length - 1] : -1,
|
|
266
|
+
settledThrough,
|
|
267
|
+
turns,
|
|
268
|
+
...(speakerRevision !== undefined ? { speakerRevision, fullSnapshot } : {}),
|
|
269
|
+
...(sessionId.startsWith('phone_') ? { sourceType: 'iphone_microphone', speakerPolicy: 'anonymous' } : {}),
|
|
270
|
+
...(levels ? { levels } : {}),
|
|
271
|
+
...(titleSteer ? { titleSteer } : {}),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { getOwnerName } from './profile.js'
|
|
2
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
3
|
+
import { getNeedsYouReader, needsYouCount, type NeedsYouBlock } from './learning-needs-you.js'
|
|
4
|
+
|
|
5
|
+
export const READ_ONLY_MEMORY_WEB_OPS = new Set([
|
|
6
|
+
'status', 'learning.list', 'learning.review', 'learning.event', 'learning.status',
|
|
7
|
+
'memories.list', 'memories.search', 'memory.detail', 'graph.status', 'profile.owner',
|
|
8
|
+
'graph.search', 'graph.entity', 'graph.passages', 'graph.setup', 'graph.ask',
|
|
9
|
+
'graph.progress', 'memory.guardrails', 'graph.merge.preview', 'graph.merge.status',
|
|
10
|
+
'graph.duplicates', 'cues.summary', 'copy',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export const MUTATING_MEMORY_WEB_OPS = new Set([
|
|
14
|
+
'learning.decide', 'profile.owner.set', 'graph.build', 'graph.ingest',
|
|
15
|
+
'graph.setup.sources', 'graph.setup.owner', 'graph.sample', 'graph.setup.embedding',
|
|
16
|
+
'graph.setup.extraction', 'memory.review', 'memory.guardrails.set',
|
|
17
|
+
'memory.guardrails.run', 'graph.merge', 'graph.schedule',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const WORKSPACE_READ_ACTIONS = new Set([
|
|
21
|
+
'status', 'graph_overview', 'graph_expand', 'graph_paths', 'graph_resolve',
|
|
22
|
+
'list_explorations', 'get_exploration', 'policy_get', 'memory_page', 'learning_page',
|
|
23
|
+
'review_page', 'get_memory', 'trace_summary', 'source_status', 'current_sources',
|
|
24
|
+
'identity_split_preview', 'identity_status', 'rule_page', 'rule_evaluation',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export function isMemoryWebOpReadOnly(op: string, args: Record<string, unknown>): boolean {
|
|
28
|
+
if (READ_ONLY_MEMORY_WEB_OPS.has(op)) return true
|
|
29
|
+
if (op === 'workspace.request') {
|
|
30
|
+
return WORKSPACE_READ_ACTIONS.has(typeof args.action === 'string' ? args.action : '')
|
|
31
|
+
}
|
|
32
|
+
if (op === 'memory.guardrails.run') return args.apply !== true
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function boundedInt(val: unknown, fallback: number, low: number, high: number): number {
|
|
37
|
+
const n = typeof val === 'number' ? Math.trunc(val) : typeof val === 'string' ? Number.parseInt(val, 10) : fallback
|
|
38
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, low), high) : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanText(val: unknown, limit: number): string {
|
|
42
|
+
return String(typeof val === 'string' ? val : '').slice(0, limit).replace(/\n/g, ' ')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LEARNING_KINDS = new Set([
|
|
46
|
+
'captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked',
|
|
47
|
+
'dismissed', 'reverted', 'consolidated', 'previewed', 'accepted', 'pruned',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
function filterLearningKind(val: unknown): string | null {
|
|
51
|
+
if (typeof val !== 'string' || !val || val.length > 160) return null
|
|
52
|
+
const parts = val.split(',').map(s => s.trim()).filter(Boolean)
|
|
53
|
+
return parts.length > 0 && parts.every(p => LEARNING_KINDS.has(p)) ? parts.join(',') : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LoopbackFetchFn = (
|
|
57
|
+
path: string,
|
|
58
|
+
init?: { method?: string; body?: unknown; timeoutMs?: number },
|
|
59
|
+
) => Promise<{ status: number; body: Record<string, any> }>
|
|
60
|
+
|
|
61
|
+
export async function dispatchMemoriesWebOp(
|
|
62
|
+
op: string,
|
|
63
|
+
args: Record<string, unknown>,
|
|
64
|
+
loopbackFetch: LoopbackFetchFn,
|
|
65
|
+
serverVersion = 'unknown',
|
|
66
|
+
readNeedsYou: () => Promise<NeedsYouBlock | null> = () => getNeedsYouReader().read(),
|
|
67
|
+
): Promise<{ ok: boolean; message: string; details: Record<string, unknown> }> {
|
|
68
|
+
const reply = (ok: boolean, message: string, details: Record<string, unknown> = {}) => ({ ok, message, details })
|
|
69
|
+
|
|
70
|
+
switch (op) {
|
|
71
|
+
case 'workspace.request': {
|
|
72
|
+
const r = await loopbackFetch('/api/context/memory/workspace', { method: 'POST', body: args, timeoutMs: 25_000 })
|
|
73
|
+
return r.status === 200
|
|
74
|
+
? reply(true, 'Memory workspace', r.body)
|
|
75
|
+
: reply(false, r.body.message || r.body.error || `Workspace error (${r.status})`)
|
|
76
|
+
}
|
|
77
|
+
case 'status': {
|
|
78
|
+
const [ctx, health, needsYou] = await Promise.all([
|
|
79
|
+
loopbackFetch('/api/context/status', { timeoutMs: 10_000 }),
|
|
80
|
+
loopbackFetch('/api/health', { timeoutMs: 5_000 }),
|
|
81
|
+
readNeedsYou().catch(() => null),
|
|
82
|
+
])
|
|
83
|
+
const c = ctx.body
|
|
84
|
+
const learning = (c.learning ?? {}) as Record<string, any>
|
|
85
|
+
const graph = (c.graph ?? {}) as Record<string, any>
|
|
86
|
+
const memory = (c.memory ?? {}) as Record<string, any>
|
|
87
|
+
const threads = (c.threads ?? {}) as Record<string, any>
|
|
88
|
+
const owner = getOwnerName()
|
|
89
|
+
return reply(true, 'Status refreshed', {
|
|
90
|
+
runtimeState: 'ready',
|
|
91
|
+
installedVersion: health.body.server_version ?? serverVersion,
|
|
92
|
+
ownerName: owner === 'User' ? null : owner,
|
|
93
|
+
contextScriptsDirectory: COS_SCRIPTS_DIR,
|
|
94
|
+
contextAvailable: c.available === true,
|
|
95
|
+
memoryAvailable: memory.available === true,
|
|
96
|
+
memoryTotal: memory.total ?? 0,
|
|
97
|
+
threadsAvailable: threads.available === true,
|
|
98
|
+
threadsTotal: threads.total ?? 0,
|
|
99
|
+
learningAvailable: learning.available === true,
|
|
100
|
+
// Control's count (the gated needs-you list the helper overlays on the Mac), or null
|
|
101
|
+
// when it cannot be read. Never `learning.to_review`: that is an object of counts, and
|
|
102
|
+
// the page rendered it as "To review (NaN)".
|
|
103
|
+
learningToReview: needsYouCount(needsYou),
|
|
104
|
+
learningTotal: learning.total ?? 0,
|
|
105
|
+
learningRewardEnabled: needsYou ? needsYou.reward_enabled === true : learning.reward_enabled === true,
|
|
106
|
+
...(needsYou ? { needs_you: needsYou } : {}),
|
|
107
|
+
graphAvailable: graph.available === true,
|
|
108
|
+
graphIndexState: graph.index_state ?? null,
|
|
109
|
+
graphEntities: graph.entities ?? null,
|
|
110
|
+
graphRelationships: graph.relationships ?? null,
|
|
111
|
+
context: c,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
case 'learning.list': {
|
|
115
|
+
const limit = boundedInt(args.limit, 50, 1, 50)
|
|
116
|
+
const days = boundedInt(args.days, 90, 1, 3650)
|
|
117
|
+
let path = `/api/context/learning?days=${days}&limit=${limit}`
|
|
118
|
+
const kind = filterLearningKind(args.kind)
|
|
119
|
+
if (kind) path += `&kind=${encodeURIComponent(kind)}`
|
|
120
|
+
if (typeof args.sinceTs === 'string' && args.sinceTs) path += `&since_ts=${encodeURIComponent(args.sinceTs)}`
|
|
121
|
+
if (typeof args.sinceEventId === 'string' && args.sinceEventId) path += `&since_event_id=${encodeURIComponent(args.sinceEventId)}`
|
|
122
|
+
const r = await loopbackFetch(path, { timeoutMs: 15_000 })
|
|
123
|
+
if (r.status !== 200) return reply(false, r.body.message || r.body.error || 'Learning unavailable')
|
|
124
|
+
const rows = Array.isArray(r.body.events) ? r.body.events : []
|
|
125
|
+
return reply(true, rows.length === 0 ? 'No learning yet' : `${rows.length} shown`, {
|
|
126
|
+
state: rows.length === 0 ? 'empty' : 'ready',
|
|
127
|
+
events: rows, shown: rows.length, total: r.body.total ?? null,
|
|
128
|
+
nextCursor: r.body.next_cursor ?? null, coverage: r.body.coverage ?? {}, days,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
case 'learning.review': {
|
|
132
|
+
const limit = boundedInt(args.limit, 200, 1, 200)
|
|
133
|
+
const r = await loopbackFetch(`/api/context/learning/review?limit=${limit}`, { timeoutMs: 15_000 })
|
|
134
|
+
if (r.status !== 200) return reply(false, r.body.message || r.body.error || 'Review list unavailable')
|
|
135
|
+
const rows = Array.isArray(r.body.events) ? r.body.events : []
|
|
136
|
+
return reply(true, rows.length === 0 ? 'Nothing to review' : `${rows.length} to review`, {
|
|
137
|
+
state: rows.length === 0 ? 'empty' : 'ready',
|
|
138
|
+
events: rows, shown: rows.length, total: r.body.total ?? null,
|
|
139
|
+
reviewCount: r.body.review_count ?? r.body.total ?? null, coverage: r.body.coverage ?? {},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
case 'learning.event': {
|
|
143
|
+
const r = await loopbackFetch(`/api/context/learning/${encodeURIComponent(cleanText(args.id, 64))}`, { timeoutMs: 15_000 })
|
|
144
|
+
return r.status === 200 ? reply(true, 'Learning event', r.body) : reply(false, r.body.message || r.body.error || 'Not found')
|
|
145
|
+
}
|
|
146
|
+
case 'learning.status': {
|
|
147
|
+
const r = await loopbackFetch('/api/context/learning/status', { timeoutMs: 15_000 })
|
|
148
|
+
return r.status === 200 ? reply(true, 'Learning status', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
149
|
+
}
|
|
150
|
+
case 'learning.decide': {
|
|
151
|
+
const id = cleanText(args.id, 200), decision = cleanText(args.decision, 16), note = cleanText(args.note, 400)
|
|
152
|
+
const r = await loopbackFetch(`/api/context/learning/${encodeURIComponent(id)}/review`, { method: 'POST', body: { decision, note }, timeoutMs: 15_000 })
|
|
153
|
+
return r.status === 200 ? reply(true, decision === 'dismissed' ? 'Proposal dismissed' : 'Proposal restored', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
154
|
+
}
|
|
155
|
+
case 'memories.list': {
|
|
156
|
+
const limit = boundedInt(args.limit, 50, 1, 50)
|
|
157
|
+
const [listRes, ovRes] = await Promise.all([
|
|
158
|
+
loopbackFetch(`/api/memory?limit=${limit}`, { timeoutMs: 15_000 }),
|
|
159
|
+
loopbackFetch('/api/memory/overview', { timeoutMs: 15_000 }).catch(() => ({ status: 503, body: {} as Record<string, any> })),
|
|
160
|
+
])
|
|
161
|
+
if (listRes.status !== 200) return reply(false, listRes.body.error || 'Memories unavailable')
|
|
162
|
+
const rows = Array.isArray(listRes.body.items) ? listRes.body.items : []
|
|
163
|
+
return reply(true, rows.length === 0 ? 'No memories yet' : `${rows.length} shown`, {
|
|
164
|
+
state: rows.length === 0 ? 'empty' : 'ready',
|
|
165
|
+
memories: rows, shown: rows.length, total: ovRes.body.total ?? rows.length,
|
|
166
|
+
byType: ovRes.body.by_type ?? {}, root: COS_SCRIPTS_DIR,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
case 'memories.search': {
|
|
170
|
+
const q = cleanText(args.q, 160), limit = boundedInt(args.limit, 20, 1, 50)
|
|
171
|
+
const r = await loopbackFetch(`/api/memory/search?q=${encodeURIComponent(q)}&limit=${limit}`, { timeoutMs: 15_000 })
|
|
172
|
+
return r.status === 200 ? reply(true, 'Memory search', r.body) : reply(false, r.body.message || r.body.error || 'Search failed')
|
|
173
|
+
}
|
|
174
|
+
case 'memory.detail': {
|
|
175
|
+
const r = await loopbackFetch(`/api/memory/${encodeURIComponent(cleanText(args.id, 200))}`, { timeoutMs: 15_000 })
|
|
176
|
+
return r.status === 200 ? reply(true, 'Memory detail', r.body) : reply(false, r.body.message || r.body.error || 'Not found')
|
|
177
|
+
}
|
|
178
|
+
case 'memory.review': {
|
|
179
|
+
const id = cleanText(args.id, 200), decision = cleanText(args.decision, 16), note = cleanText(args.note, 400)
|
|
180
|
+
const r = await loopbackFetch(`/api/context/memory/${encodeURIComponent(id)}/review`, { method: 'POST', body: { decision, note }, timeoutMs: 25_000 })
|
|
181
|
+
return r.status === 200 ? reply(true, decision === 'prune' ? 'Memory pruned' : 'Memory accepted', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
182
|
+
}
|
|
183
|
+
case 'memory.guardrails': {
|
|
184
|
+
const r = await loopbackFetch('/api/context/memory-guardrails', { timeoutMs: 20_000 })
|
|
185
|
+
return r.status === 200 ? reply(true, 'Guardrails', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
186
|
+
}
|
|
187
|
+
case 'memory.guardrails.set': {
|
|
188
|
+
const rawPatch = typeof args.patch === 'string' ? args.patch : JSON.stringify(args.patch ?? {})
|
|
189
|
+
let parsedPatch: unknown = {}
|
|
190
|
+
try { parsedPatch = JSON.parse(rawPatch) } catch { return reply(false, 'Invalid guardrails JSON') }
|
|
191
|
+
const r = await loopbackFetch('/api/context/memory-guardrails', { method: 'PUT', body: parsedPatch, timeoutMs: 20_000 })
|
|
192
|
+
return r.status === 200 ? reply(true, 'Guardrails saved', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
193
|
+
}
|
|
194
|
+
case 'memory.guardrails.run': {
|
|
195
|
+
const payload: Record<string, unknown> = { days: boundedInt(args.days, 30, 1, 3650) }
|
|
196
|
+
if (args.apply === true) payload.apply = true
|
|
197
|
+
if (typeof args.llm === 'boolean') payload.llm = args.llm
|
|
198
|
+
const r = await loopbackFetch('/api/context/memory-guardrails/run', { method: 'POST', body: payload, timeoutMs: payload.llm === false ? 75_000 : 410_000 })
|
|
199
|
+
return r.status === 200 ? reply(true, 'Guardrails scan complete', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
200
|
+
}
|
|
201
|
+
case 'profile.owner': {
|
|
202
|
+
const r = await loopbackFetch('/api/context/profile/owner', { timeoutMs: 10_000 })
|
|
203
|
+
return r.status === 200 ? reply(true, 'Owner profile', r.body) : reply(false, 'Unavailable')
|
|
204
|
+
}
|
|
205
|
+
case 'profile.owner.set': {
|
|
206
|
+
const r = await loopbackFetch('/api/context/profile/owner', {
|
|
207
|
+
method: 'POST', body: { owner_name: cleanText(args.name, 120), expected_owner: cleanText(args.expected, 120) }, timeoutMs: 10_000,
|
|
208
|
+
})
|
|
209
|
+
return r.status === 200 ? reply(true, 'Owner profile updated', r.body) : reply(false, r.body.error || 'Failed')
|
|
210
|
+
}
|
|
211
|
+
case 'graph.status': {
|
|
212
|
+
const r = await loopbackFetch('/api/context/graph/status', { timeoutMs: 15_000 })
|
|
213
|
+
return r.status === 200 ? reply(true, 'Knowledge graph status', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
214
|
+
}
|
|
215
|
+
case 'graph.search': {
|
|
216
|
+
const q = cleanText(args.q, 160), limit = boundedInt(args.limit, 30, 1, 30), offset = boundedInt(args.offset, 0, 0, 100_000)
|
|
217
|
+
const r = await loopbackFetch(`/api/context/graph/search?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}`, { timeoutMs: 20_000 })
|
|
218
|
+
if (r.status !== 200) return reply(false, r.body.message || r.body.error || 'Graph search failed')
|
|
219
|
+
const items = Array.isArray(r.body.items) ? r.body.items : []
|
|
220
|
+
return reply(true, items.length === 0 ? 'No matching entities' : 'Lookup ready', {
|
|
221
|
+
state: items.length === 0 ? 'empty' : 'ready', items, count: items.length,
|
|
222
|
+
total: r.body.total ?? null, indexState: r.body.index_state ?? null,
|
|
223
|
+
matcher: r.body.matcher ?? null, window: r.body.window ?? null, offset, limit,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
case 'graph.entity': {
|
|
227
|
+
const id = cleanText(args.id, 200), limit = boundedInt(args.limit, 30, 1, 30), offset = boundedInt(args.offset, 0, 0, 100_000)
|
|
228
|
+
const r = await loopbackFetch(`/api/context/graph/entity?id=${encodeURIComponent(id)}&offset=${offset}&limit=${limit}`, { timeoutMs: 20_000 })
|
|
229
|
+
if (r.status === 404) return reply(true, 'Entity not found', { found: false, index_state: r.body.error === 'index_missing' ? 'missing' : 'ready' })
|
|
230
|
+
return r.status === 200 ? reply(true, 'Entity', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
231
|
+
}
|
|
232
|
+
case 'graph.passages': {
|
|
233
|
+
const limit = boundedInt(args.limit, 5, 1, 5), source = cleanText(args.source, 200), target = cleanText(args.target, 200), entity = cleanText(args.entity, 200)
|
|
234
|
+
const path = source && target
|
|
235
|
+
? `/api/context/graph/passages?relationA=${encodeURIComponent(source)}&relationB=${encodeURIComponent(target)}&limit=${limit}`
|
|
236
|
+
: `/api/context/graph/passages?entity=${encodeURIComponent(entity)}&limit=${limit}`
|
|
237
|
+
const r = await loopbackFetch(path, { timeoutMs: 20_000 })
|
|
238
|
+
return r.status === 200 ? reply(true, 'Passages ready', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
239
|
+
}
|
|
240
|
+
case 'graph.build': {
|
|
241
|
+
const r = await loopbackFetch('/api/context/graph/index', { method: 'POST', timeoutMs: 15_000 })
|
|
242
|
+
return r.status === 202 || r.status === 200 ? reply(true, 'Index build started', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
243
|
+
}
|
|
244
|
+
case 'graph.ingest': {
|
|
245
|
+
const r = await loopbackFetch('/api/context/graph/ingest', { method: 'POST', body: { limit: boundedInt(args.limit, 10, 1, 50) }, timeoutMs: 25_000 })
|
|
246
|
+
return r.status === 202 || r.status === 200 ? reply(true, 'Indexing started', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
247
|
+
}
|
|
248
|
+
case 'graph.progress': {
|
|
249
|
+
const r = await loopbackFetch('/api/context/graph/ingest/progress', { timeoutMs: 15_000 })
|
|
250
|
+
return r.status === 200 ? reply(true, 'Ingest progress', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
251
|
+
}
|
|
252
|
+
case 'graph.setup': {
|
|
253
|
+
const r = await loopbackFetch('/api/context/graph/setup', { timeoutMs: 25_000 })
|
|
254
|
+
return r.status === 200 ? reply(true, 'Knowledge setup', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
255
|
+
}
|
|
256
|
+
case 'graph.setup.sources': {
|
|
257
|
+
const r = await loopbackFetch('/api/context/graph/setup/sources', { method: 'POST', body: { action: cleanText(args.action, 16), path: cleanText(args.path, 1000) }, timeoutMs: 20_000 })
|
|
258
|
+
return r.status === 200 ? reply(true, 'Sources updated', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
259
|
+
}
|
|
260
|
+
case 'graph.setup.owner': {
|
|
261
|
+
const r = await loopbackFetch('/api/context/graph/setup/owner', { method: 'POST', body: {}, timeoutMs: 15_000 })
|
|
262
|
+
return r.status === 200 ? reply(true, 'This Mac is now the ingestion owner', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
263
|
+
}
|
|
264
|
+
case 'graph.sample': {
|
|
265
|
+
const r = await loopbackFetch('/api/context/graph/setup/sample', { method: 'POST', body: { limit: boundedInt(args.limit, 3, 1, 3) }, timeoutMs: 95_000 })
|
|
266
|
+
return r.status === 202 || r.status === 200 ? reply(true, 'Sample indexing started', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
267
|
+
}
|
|
268
|
+
case 'graph.ask': {
|
|
269
|
+
const r = await loopbackFetch('/api/context/graph/ask', { method: 'POST', body: { q: cleanText(args.q, 400) }, timeoutMs: 160_000 })
|
|
270
|
+
return r.status === 200 ? reply(true, 'Answered', r.body) : reply(false, r.body.message || r.body.error || 'Graph question failed')
|
|
271
|
+
}
|
|
272
|
+
case 'graph.setup.embedding': {
|
|
273
|
+
const payload: Record<string, unknown> = {}
|
|
274
|
+
if (typeof args.provider === 'string' && args.provider) payload.provider = cleanText(args.provider, 24)
|
|
275
|
+
if (typeof args.local_only === 'boolean') payload.local_only = args.local_only
|
|
276
|
+
if (typeof args.model === 'string' && args.model) payload.model = cleanText(args.model, 120)
|
|
277
|
+
if (args.fetch === true) payload.fetch = true
|
|
278
|
+
const r = await loopbackFetch('/api/context/graph/setup/embedding', { method: 'POST', body: payload, timeoutMs: 95_000 })
|
|
279
|
+
return r.status === 200 ? reply(true, 'Embedding updated', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
280
|
+
}
|
|
281
|
+
case 'graph.setup.extraction': {
|
|
282
|
+
const r = await loopbackFetch('/api/context/graph/setup/extraction', { method: 'POST', body: { tier: cleanText(args.tier, 16) }, timeoutMs: 20_000 })
|
|
283
|
+
return r.status === 200 ? reply(true, 'Extraction updated', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
284
|
+
}
|
|
285
|
+
case 'graph.schedule': {
|
|
286
|
+
const r = await loopbackFetch('/api/context/graph/setup/schedule', { method: 'POST', body: { enabled: args.enabled === true, interval_s: boundedInt(args.intervalS, 3600, 900, 86_400) }, timeoutMs: 25_000 })
|
|
287
|
+
return r.status === 200 ? reply(true, 'Schedule updated', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
288
|
+
}
|
|
289
|
+
case 'graph.merge.preview': {
|
|
290
|
+
const r = await loopbackFetch('/api/context/graph/merge/preview', { method: 'POST', body: { source: cleanText(args.source, 200), target: cleanText(args.target, 200) }, timeoutMs: 25_000 })
|
|
291
|
+
return r.status === 200 ? reply(true, 'Preview ready', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
292
|
+
}
|
|
293
|
+
case 'graph.merge': {
|
|
294
|
+
const source = cleanText(args.source, 200), target = cleanText(args.target, 200)
|
|
295
|
+
const payload: Record<string, unknown> = { source, target, confirm: args.confirm === true }
|
|
296
|
+
if (args.rule && typeof args.rule === 'object') payload.rule = args.rule
|
|
297
|
+
const r = await loopbackFetch('/api/context/graph/merge', { method: 'POST', body: payload, timeoutMs: 25_000 })
|
|
298
|
+
return r.status === 202 || r.status === 200 ? reply(true, `Merging ${source} into ${target}`, r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
299
|
+
}
|
|
300
|
+
case 'graph.merge.status': {
|
|
301
|
+
const r = await loopbackFetch('/api/context/graph/merge', { timeoutMs: 15_000 })
|
|
302
|
+
return r.status === 200 ? reply(true, 'Merge status', r.body) : reply(false, r.body.message || r.body.error || 'Unavailable')
|
|
303
|
+
}
|
|
304
|
+
case 'graph.duplicates': {
|
|
305
|
+
const r = await loopbackFetch(`/api/context/graph/duplicates?limit=${boundedInt(args.limit, 25, 1, 100)}`, { timeoutMs: 35_000 })
|
|
306
|
+
return r.status === 200 ? reply(true, 'Duplicates ready', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
307
|
+
}
|
|
308
|
+
case 'cues.summary': {
|
|
309
|
+
// Rating cues opens Control's native cue pane, which the browser does not have, so the
|
|
310
|
+
// page is told cues are unavailable and hides "Cues to rate" rather than show a button
|
|
311
|
+
// that does nothing. The counts use Control's keys for when a web cue pane exists.
|
|
312
|
+
const r = await loopbackFetch('/api/live-cues/history?days=7&limit=200', { timeoutMs: 10_000 })
|
|
313
|
+
const counts = (r.status === 200 ? r.body.counts ?? {} : {}) as Record<string, any>
|
|
314
|
+
const unrated = typeof counts.unrated === 'number' ? counts.unrated : 0
|
|
315
|
+
const rated = typeof counts.rated === 'number' ? counts.rated : 0
|
|
316
|
+
return reply(true, 'Cues', { available: false, unrated, rated, total: unrated + rated })
|
|
317
|
+
}
|
|
318
|
+
case 'copy':
|
|
319
|
+
return reply(true, 'Copied', {})
|
|
320
|
+
default:
|
|
321
|
+
return reply(false, `This page cannot ask for ${op}.`, {})
|
|
322
|
+
}
|
|
323
|
+
}
|