@gotcos/glasses-server 6.99.25 → 6.99.29
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 +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -12,6 +12,7 @@ import { join } from 'node:path'
|
|
|
12
12
|
import { createInterface } from 'node:readline'
|
|
13
13
|
import { promisify } from 'node:util'
|
|
14
14
|
import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
|
|
15
|
+
import { withoutTurnFooter } from './turn-footer.js'
|
|
15
16
|
import { profileCodexHomeSubdirs } from './codex-homes.js'
|
|
16
17
|
|
|
17
18
|
const execFileAsync = promisify(execFile)
|
|
@@ -1831,7 +1832,8 @@ export async function parseAgentSession(
|
|
|
1831
1832
|
// frame. It IS the user's prompt (dictated on the glasses), so it is the
|
|
1832
1833
|
// turn's query and never an injected row; the frame is stripped.
|
|
1833
1834
|
const peer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? unwrapPeerMessage(text) : null
|
|
1834
|
-
|
|
1835
|
+
// 6.99.29: the verify footer of a glasses, phone or web turn is never the turn's query or title.
|
|
1836
|
+
const query = withoutTurnFooter(peer ?? text)
|
|
1835
1837
|
collectTurn(query, tail, peer === null && (obj.isMeta === true || obj.isCompactSummary === true))
|
|
1836
1838
|
if (!firstPrompt) firstPrompt = firstLineTitle(query)
|
|
1837
1839
|
if (!title) title = firstLineTitle(query)
|
|
@@ -33,15 +33,21 @@ import {
|
|
|
33
33
|
payloadText,
|
|
34
34
|
type AgentProvider,
|
|
35
35
|
} from './agent-session-store.js'
|
|
36
|
+
import type { SessionOutputImage } from './agent-session-output-images.js'
|
|
36
37
|
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
38
|
+
import { withoutTurnFooter } from './turn-footer.js'
|
|
37
39
|
|
|
38
40
|
export interface SessionTurn {
|
|
39
41
|
role: 'user' | 'assistant'
|
|
40
42
|
text: string
|
|
41
43
|
/** The record's ISO timestamp, when the provider writes one (Cursor does not). */
|
|
42
44
|
at?: string
|
|
45
|
+
/** Opt-in native-session output previews. No local paths or COS message identities. */
|
|
46
|
+
outputImages?: SessionOutputImage[]
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
export type SessionTurnProjection = (provider: AgentProvider, record: Record<string, unknown>, turn: SessionTurn | null) => SessionTurn | null
|
|
50
|
+
|
|
45
51
|
/** What a client may ask for in one read. Forty messages is ~10 lens pages of words. */
|
|
46
52
|
export const SESSION_TURNS_MAX = 40
|
|
47
53
|
|
|
@@ -103,10 +109,12 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
|
|
|
103
109
|
// (a teammate's report, an idle notification, another session's SendMessage),
|
|
104
110
|
// written as a plain user row with no origin. QA found 20 of those in one session
|
|
105
111
|
// rendering as "YOU".
|
|
106
|
-
|
|
112
|
+
// 6.99.29: the verify footer a glasses, phone or web turn carries is for the agent, not the reader.
|
|
113
|
+
const wrapped = unwrapPeerMessage(text)
|
|
114
|
+
const peer = wrapped === null ? null : withoutTurnFooter(wrapped)
|
|
107
115
|
if (peer !== null) return (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? shaped('user', peer, at) : null
|
|
108
116
|
if (obj.isMeta === true || obj.isCompactSummary === true) return null
|
|
109
|
-
return shaped('user', text, at)
|
|
117
|
+
return shaped('user', withoutTurnFooter(text), at)
|
|
110
118
|
}
|
|
111
119
|
if (provider === 'codex') {
|
|
112
120
|
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return null
|
|
@@ -117,22 +125,24 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
|
|
|
117
125
|
// and an attached-file message reads as the request that was typed.
|
|
118
126
|
const text = payload.role === 'user' ? codexUserText(payload) : payloadText(payload)
|
|
119
127
|
if (!text) return null
|
|
120
|
-
return shaped(payload.role, text, iso(obj.timestamp))
|
|
128
|
+
return shaped(payload.role, payload.role === 'user' ? withoutTurnFooter(text) : text, iso(obj.timestamp))
|
|
121
129
|
}
|
|
122
130
|
if (obj.role !== 'user' && obj.role !== 'assistant') return null
|
|
123
131
|
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
124
132
|
const text = message ? payloadText(message) : null
|
|
125
133
|
if (!text) return null
|
|
126
|
-
|
|
134
|
+
// QA 6.99.29: a Cursor user row carries the verify footer inside its <user_query> block, like any other turn.
|
|
135
|
+
return shaped(obj.role, obj.role === 'user' ? withoutTurnFooter(cursorQuery(text)) : text, iso(obj.timestamp))
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
/** The newest `limit` messages among these lines (file order in, file order out). */
|
|
130
|
-
export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number): SessionTurn[] {
|
|
139
|
+
export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number, project?: SessionTurnProjection): SessionTurn[] {
|
|
131
140
|
const out: SessionTurn[] = []
|
|
132
141
|
for (const line of lines) {
|
|
133
142
|
const obj = parseJsonLine(line)
|
|
134
143
|
if (!obj) continue
|
|
135
|
-
const
|
|
144
|
+
const parsed = sessionTurnFromRecord(provider, obj)
|
|
145
|
+
const turn = project ? project(provider, obj, parsed) : parsed
|
|
136
146
|
if (turn) out.push(turn)
|
|
137
147
|
}
|
|
138
148
|
return limit > 0 ? out.slice(-limit) : []
|
|
@@ -155,6 +165,7 @@ export async function readRecentSessionTurns(
|
|
|
155
165
|
path: string,
|
|
156
166
|
limit: number,
|
|
157
167
|
windows: readonly number[] = SESSION_TURNS_WINDOWS,
|
|
168
|
+
project?: SessionTurnProjection,
|
|
158
169
|
): Promise<RecentTurnsRead> {
|
|
159
170
|
const want = Math.max(0, Math.min(SESSION_TURNS_MAX, Math.floor(limit)))
|
|
160
171
|
const { size } = await stat(path)
|
|
@@ -177,7 +188,7 @@ export async function readRecentSessionTurns(
|
|
|
177
188
|
const lines = buffer.toString('utf-8').split('\n')
|
|
178
189
|
if (start > 0) lines.shift()
|
|
179
190
|
// One more than wanted, so `more` can be answered from what was read.
|
|
180
|
-
found = recentSessionTurns(provider, lines, want + 1)
|
|
191
|
+
found = recentSessionTurns(provider, lines, want + 1, project)
|
|
181
192
|
if (found.length > want || start === 0) break
|
|
182
193
|
}
|
|
183
194
|
const more = found.length > want || start > 0
|
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
// while the Mac sleeps, so on its own it would read a pre-sleep presence as younger
|
|
45
45
|
// than it is; the wall clock does advance. A wall clock stepped forward reads older
|
|
46
46
|
// (no hold), never younger.
|
|
47
|
+
//
|
|
48
|
+
// 6.99.26 (Miles 11/07, "Away rule, like phone"): COS Control Web's presence, kept apart from every
|
|
49
|
+
// stamp above. A browser poll `?client=web&hold=1&presenceAgeMs=<int>` (the ms since a touch or a
|
|
50
|
+
// key while the page was in front) records it, and the broker reads it for the away hold ONLY: it
|
|
51
|
+
// never moves the newest poll (/api/health), the 6.59.0 liveness, the `hold=1` poll stamp (which
|
|
52
|
+
// makes a stamped question's client connected without presence), or the wearer presence above. So
|
|
53
|
+
// an open tab nobody touches holds nothing, and at the desk the Mac's own pop-up still shows at once
|
|
54
|
+
// (6.99.13). Same contract and both clocks, forward-only, as the wearer presence.
|
|
47
55
|
|
|
48
56
|
/** 6.60.0: the most a client may report as `presenceAgeMs`. Anything else is no presence. */
|
|
49
57
|
export const PRESENCE_AGE_MAX_MS = 86_400_000
|
|
@@ -53,6 +61,8 @@ let lastLegacyPollAt: number | null = null
|
|
|
53
61
|
let lastHoldPollAt: number | null = null
|
|
54
62
|
let lastPresenceMono: number | null = null
|
|
55
63
|
let lastPresenceWall: number | null = null
|
|
64
|
+
let lastWebPresenceMono: number | null = null
|
|
65
|
+
let lastWebPresenceWall: number | null = null
|
|
56
66
|
|
|
57
67
|
/** 6.60.0: the monotonic clock. Milliseconds since this process started; paused while the Mac sleeps. */
|
|
58
68
|
export function monotonicNowMs(): number {
|
|
@@ -109,12 +119,35 @@ export function noteQuestionsPoll(at: number, poll: QuestionsPollFacts = {}, mon
|
|
|
109
119
|
* and the wall-clock age, so a Mac that slept never makes it read younger.
|
|
110
120
|
*/
|
|
111
121
|
export function presenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
return ageOn(lastPresenceMono, lastPresenceWall, mono, wall)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The age of a presence stamped on both clocks: the larger of the two, so a Mac that slept never reads younger. */
|
|
126
|
+
function ageOn(stampMono: number | null, stampWall: number | null, mono: number, wall: number): number | null {
|
|
127
|
+
if (stampMono === null || !Number.isFinite(mono)) return null
|
|
128
|
+
const monoAge = Math.max(0, mono - stampMono)
|
|
129
|
+
const wallAge = stampWall !== null && Number.isFinite(wall) ? wall - stampWall : 0
|
|
115
130
|
return Math.max(monoAge, wallAge)
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 6.99.26: a COS Control Web poll answered at `at` (server clock) that reported `presenceAgeMs`, the ms
|
|
135
|
+
* since a touch or a key while the page was in front. Anything outside the contract is no presence and
|
|
136
|
+
* records nothing. Forward-only on each clock, like the wearer presence.
|
|
137
|
+
*/
|
|
138
|
+
export function noteWebPresence(at: number, presenceAgeMs: number, mono: number = monotonicNowMs()): void {
|
|
139
|
+
if (!Number.isFinite(at) || !validPresenceAgeMs(presenceAgeMs) || !Number.isFinite(mono)) return
|
|
140
|
+
const presenceMono = mono - presenceAgeMs
|
|
141
|
+
const presenceWall = at - presenceAgeMs
|
|
142
|
+
if (lastWebPresenceMono === null || presenceMono > lastWebPresenceMono) lastWebPresenceMono = presenceMono
|
|
143
|
+
if (lastWebPresenceWall === null || presenceWall > lastWebPresenceWall) lastWebPresenceWall = presenceWall
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 6.99.26: how long ago COS Control Web's newest touch or key was, or null when none since this process started. */
|
|
147
|
+
export function webPresenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
|
|
148
|
+
return ageOn(lastWebPresenceMono, lastWebPresenceWall, mono, wall)
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
/** When the newest authenticated questions poll of any kind arrived, or null since boot (/api/health). */
|
|
119
152
|
export function lastQuestionsPollAt(): number | null {
|
|
120
153
|
return lastPollAt
|
|
@@ -136,4 +169,6 @@ export function __resetClientLivenessForTests(): void {
|
|
|
136
169
|
lastHoldPollAt = null
|
|
137
170
|
lastPresenceMono = null
|
|
138
171
|
lastPresenceWall = null
|
|
172
|
+
lastWebPresenceMono = null
|
|
173
|
+
lastWebPresenceWall = null
|
|
139
174
|
}
|
|
@@ -139,3 +139,39 @@ export async function readCodexSubagents(
|
|
|
139
139
|
}
|
|
140
140
|
return out
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 6.99.26 candidate (the Today summary, lib/session-subagents.ts): each parent's direct children by name, most recently
|
|
145
|
+
* updated first, at most `perParent`. The same read-only snapshot and child lifecycle reader as readCodexSubagents.
|
|
146
|
+
* Name is the child's nickname, else its role; the task is its thread title. Text only goes to the summary's prompt,
|
|
147
|
+
* cleaned there; ids and paths never leave this function. A failed read is an empty map, never a made-up zero.
|
|
148
|
+
*/
|
|
149
|
+
export interface CodexSubagentDetail { name: string; task: string; state: ChildState; at: number }
|
|
150
|
+
export async function readCodexSubagentDetails(
|
|
151
|
+
parents: readonly string[], sessionsRoot: string, now = Date.now(), perParent = 6,
|
|
152
|
+
): Promise<Map<string, CodexSubagentDetail[]>> {
|
|
153
|
+
const ids = [...new Set(parents.filter(id => UUID.test(id)).map(id => id.toLowerCase()))].slice(0, 12)
|
|
154
|
+
const out = new Map<string, CodexSubagentDetail[]>()
|
|
155
|
+
if (!ids.length) return out
|
|
156
|
+
try {
|
|
157
|
+
const home = dirname(sessionsRoot)
|
|
158
|
+
const files = (await readdir(home)).filter(n => /^state_\d+\.sqlite$/.test(n)).sort((a,b) => Number(b.match(/\d+/)![0])-Number(a.match(/\d+/)![0]))
|
|
159
|
+
if (!files.length) return out
|
|
160
|
+
// UUID validation above is the SQL parameter boundary.
|
|
161
|
+
const values = ids.map(id => `'${id}'`).join(',')
|
|
162
|
+
const query = `SELECT e.parent_thread_id AS root, t.rollout_path, t.archived, t.agent_nickname AS nick, t.agent_role AS role, t.title, t.updated_at_ms AS at
|
|
163
|
+
FROM thread_spawn_edges e LEFT JOIN threads t ON t.id=e.child_thread_id
|
|
164
|
+
WHERE e.status='open' AND e.parent_thread_id IN (${values}) ORDER BY t.updated_at_ms DESC LIMIT ${ids.length * perParent * 4}`
|
|
165
|
+
const { stdout } = await exec('/usr/bin/sqlite3', ['-readonly', '-json', join(home,files[0]), query], { timeout: 2000, maxBuffer: 2*1024*1024 })
|
|
166
|
+
const rows = JSON.parse(stdout || '[]') as Array<{root:string;rollout_path?:string;archived?:number;nick?:string;role?:string;title?:string;at?:number}>
|
|
167
|
+
const budget = { bytes: MAX_SNAPSHOT_BYTES }
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const list = out.get(row.root) ?? []
|
|
170
|
+
if (list.length >= perParent) continue
|
|
171
|
+
const state: ChildState = row.archived === 1 ? 'completed' : typeof row.rollout_path === 'string' ? await childState(row.rollout_path, sessionsRoot, now, budget) : 'unknown'
|
|
172
|
+
list.push({ name: String(row.nick || row.role || 'agent').slice(0, 60), task: String(row.title || '').slice(0, 160), state, at: Number(row.at) || 0 })
|
|
173
|
+
out.set(row.root, list)
|
|
174
|
+
}
|
|
175
|
+
} catch { return new Map() }
|
|
176
|
+
return out
|
|
177
|
+
}
|