@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. 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
+ }