@gotcos/glasses-server 6.2.1 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -7
- package/CHANGELOG.md +116 -0
- package/README.md +26 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +65 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +27 -9
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/archive.ts +79 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +202 -0
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +299 -0
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Internal model-input shape for image attachments (Release A).
|
|
2
|
+
// The bridges receive resolved server-owned FILE PATHS — never base64 —
|
|
3
|
+
// plus the public ref and an explicit deletion contract:
|
|
4
|
+
// deleteAfterRun: false → durable media-store asset; the store's lifecycle
|
|
5
|
+
// (GC/retention) owns the file. Bridges must not
|
|
6
|
+
// delete it, even on failed/cancelled runs.
|
|
7
|
+
// deleteAfterRun: true → truly ephemeral request-temp file; the bridge
|
|
8
|
+
// deletes it exactly once when the run settles.
|
|
9
|
+
|
|
10
|
+
import { unlinkSync } from 'node:fs'
|
|
11
|
+
import type { MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
12
|
+
|
|
13
|
+
export interface ModelImageInput {
|
|
14
|
+
/** Absolute path to a server-owned normalized image file. */
|
|
15
|
+
path: string
|
|
16
|
+
attachment: MediaAttachmentRef
|
|
17
|
+
deleteAfterRun: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Terminal cleanup used by both bridges: delete ONLY inputs explicitly
|
|
21
|
+
* marked ephemeral. Idempotent — safe if a bridge settles twice. */
|
|
22
|
+
export function cleanupModelImageInputs(inputs: ModelImageInput[]): void {
|
|
23
|
+
for (const input of inputs) {
|
|
24
|
+
if (!input.deleteAfterRun) continue
|
|
25
|
+
try { unlinkSync(input.path) } catch { /* already gone — fine */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -8,21 +8,51 @@ import {
|
|
|
8
8
|
type PromptReference,
|
|
9
9
|
} from './conversation.js'
|
|
10
10
|
import { DEFAULT_MODEL, isCodexModel, isClaudeModel, normalizeModelPreference } from '../../shared/model-preference.js'
|
|
11
|
+
import type { ModelImageInput } from './model-image-input.js'
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// Bridges return as soon as their subprocess is spawned, while completion is
|
|
14
|
+
// delivered later through callbacks. This keyed tail queue therefore releases
|
|
15
|
+
// only on a terminal callback (or an early throw), preventing two turns from
|
|
16
|
+
// mutating the same conversation/CLI session concurrently.
|
|
17
|
+
const sessionRunTails = new Map<string, Promise<void>>()
|
|
18
|
+
|
|
19
|
+
async function acquireSessionRunLock(sessionId: string): Promise<() => void> {
|
|
20
|
+
const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
|
|
21
|
+
let openGate!: () => void
|
|
22
|
+
const gate = new Promise<void>(resolve => { openGate = resolve })
|
|
23
|
+
const tail = previous.catch(() => {}).then(() => gate)
|
|
24
|
+
sessionRunTails.set(sessionId, tail)
|
|
25
|
+
await previous.catch(() => {})
|
|
26
|
+
|
|
27
|
+
let released = false
|
|
28
|
+
return () => {
|
|
29
|
+
if (released) return
|
|
30
|
+
released = true
|
|
31
|
+
openGate()
|
|
32
|
+
if (sessionRunTails.get(sessionId) === tail) sessionRunTails.delete(sessionId)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Chat routes to the user's local Claude Code CLI or stable Codex live-catalog
|
|
37
|
+
// slots. Any unknown preference falls back to the Claude default
|
|
14
38
|
// so chat always works on a stock install.
|
|
15
39
|
export async function callModelStreaming(
|
|
16
40
|
query: string,
|
|
17
41
|
sessionId: string | undefined,
|
|
18
42
|
callbacks: StreamCallbacks,
|
|
19
43
|
model?: ModelPreference,
|
|
20
|
-
images?:
|
|
44
|
+
images?: ModelImageInput[],
|
|
21
45
|
reference?: PromptReference,
|
|
22
46
|
globalMsgNum?: number,
|
|
23
47
|
options?: CallOptions,
|
|
24
48
|
): Promise<string> {
|
|
25
49
|
const sid = getOrCreateSession(sessionId)
|
|
50
|
+
const release = await acquireSessionRunLock(sid)
|
|
51
|
+
if (options?.abortSignal?.aborted) {
|
|
52
|
+
release()
|
|
53
|
+
throw new Error('model-router: request aborted before the model run started.')
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
const sessionModel = getSessionModel(sid)
|
|
27
57
|
// COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
|
|
28
58
|
// it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
|
|
@@ -32,11 +62,40 @@ export async function callModelStreaming(
|
|
|
32
62
|
|
|
33
63
|
setSessionModel(sid, resolvedModel)
|
|
34
64
|
|
|
35
|
-
|
|
36
|
-
|
|
65
|
+
let terminal = false
|
|
66
|
+
const releaseTerminal = () => {
|
|
67
|
+
if (terminal) return
|
|
68
|
+
terminal = true
|
|
69
|
+
release()
|
|
37
70
|
}
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
const lockedCallbacks: StreamCallbacks = {
|
|
72
|
+
...callbacks,
|
|
73
|
+
onDone: (fullText, completedModel, cliSessionId, metadata) => {
|
|
74
|
+
try {
|
|
75
|
+
callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
|
|
76
|
+
} finally {
|
|
77
|
+
releaseTerminal()
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onError: (error) => {
|
|
81
|
+
try {
|
|
82
|
+
callbacks.onError(error)
|
|
83
|
+
} finally {
|
|
84
|
+
releaseTerminal()
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (isCodexModel(resolvedModel)) {
|
|
91
|
+
return await callCodexStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
|
|
92
|
+
}
|
|
93
|
+
if (isClaudeModel(resolvedModel)) {
|
|
94
|
+
return await callClaudeStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
|
|
95
|
+
}
|
|
96
|
+
return await callClaudeStreaming(query, sid, lockedCallbacks, DEFAULT_MODEL, images, reference, globalMsgNum, options)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
releaseTerminal()
|
|
99
|
+
throw err
|
|
40
100
|
}
|
|
41
|
-
return callClaudeStreaming(query, sid, callbacks, DEFAULT_MODEL, images, reference, globalMsgNum, options)
|
|
42
101
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Query-side attachment resolution (Release A). Converts an /api/query
|
|
2
|
+
// request body's image inputs into ModelImageInput[]:
|
|
3
|
+
//
|
|
4
|
+
// attachmentIds[] — durable media-store assets uploaded via /api/media.
|
|
5
|
+
// Validated against lifecycle + the reservation's
|
|
6
|
+
// clientQueueItemId (an id alone is never bearer auth).
|
|
7
|
+
// images[]/image — legacy base64. NOT a bypass: bytes are ingested
|
|
8
|
+
// through the same media store (validation,
|
|
9
|
+
// normalization, lifecycle tracking, limits) and then
|
|
10
|
+
// resolved exactly like uploaded attachments.
|
|
11
|
+
//
|
|
12
|
+
// Every resolved input is durable (deleteAfterRun: false) — the store's
|
|
13
|
+
// retention owns the files; model runs never delete them.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
MAX_ATTACHMENTS_PER_PROMPT,
|
|
17
|
+
isValidMediaId,
|
|
18
|
+
parseMediaIdList,
|
|
19
|
+
type MediaAttachmentRef,
|
|
20
|
+
} from '../../shared/media-attachment.js'
|
|
21
|
+
import { getMediaStore, MediaStoreError } from './media-store.js'
|
|
22
|
+
import { strictBase64Decode, ImageSafetyError } from './image-safety.js'
|
|
23
|
+
import type { ModelImageInput } from './model-image-input.js'
|
|
24
|
+
|
|
25
|
+
export interface ResolvedQueryAttachments {
|
|
26
|
+
inputs: ModelImageInput[]
|
|
27
|
+
refs: MediaAttachmentRef[]
|
|
28
|
+
/** Ids to associate with the final message when the run completes. */
|
|
29
|
+
ids: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class QueryAttachmentError extends Error {
|
|
33
|
+
readonly status: number
|
|
34
|
+
readonly code: string
|
|
35
|
+
constructor(status: number, code: string, message: string) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.status = status
|
|
38
|
+
this.code = code
|
|
39
|
+
this.name = 'QueryAttachmentError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MEDIA_ERROR_STATUS: Record<string, number> = {
|
|
44
|
+
media_not_found: 404,
|
|
45
|
+
media_expired: 410,
|
|
46
|
+
media_unavailable: 503,
|
|
47
|
+
media_conflict: 409,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SAFETY_ERROR_STATUS: Record<string, number> = {
|
|
51
|
+
invalid_base64: 400,
|
|
52
|
+
unsupported_format: 400,
|
|
53
|
+
image_too_large: 400,
|
|
54
|
+
dimensions_too_large: 400,
|
|
55
|
+
corrupt_image: 400,
|
|
56
|
+
media_processing_unavailable: 503,
|
|
57
|
+
normalization_failed: 500,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function asQueryAttachmentError(err: unknown): QueryAttachmentError {
|
|
61
|
+
if (err instanceof MediaStoreError) {
|
|
62
|
+
return new QueryAttachmentError(MEDIA_ERROR_STATUS[err.code] ?? 500, err.code, err.message)
|
|
63
|
+
}
|
|
64
|
+
if (err instanceof ImageSafetyError) {
|
|
65
|
+
return new QueryAttachmentError(SAFETY_ERROR_STATUS[err.code] ?? 500, err.code, err.message)
|
|
66
|
+
}
|
|
67
|
+
return new QueryAttachmentError(500, 'attachment_resolution_failed', err instanceof Error ? err.message : String(err))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface QueryImageBody {
|
|
71
|
+
attachmentIds?: unknown
|
|
72
|
+
clientQueueItemId?: unknown
|
|
73
|
+
images?: unknown
|
|
74
|
+
image?: unknown
|
|
75
|
+
sessionId?: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve all image inputs for one query. Throws QueryAttachmentError with
|
|
79
|
+
* an HTTP status + typed code — callers respond BEFORE opening the SSE
|
|
80
|
+
* stream. Returns empty inputs when the request carries no images. */
|
|
81
|
+
export async function resolveQueryAttachments(body: QueryImageBody): Promise<ResolvedQueryAttachments> {
|
|
82
|
+
try {
|
|
83
|
+
const store = getMediaStore()
|
|
84
|
+
const clientQueueItemId = typeof body.clientQueueItemId === 'string' && body.clientQueueItemId.trim()
|
|
85
|
+
? body.clientQueueItemId.trim().slice(0, 120)
|
|
86
|
+
: undefined
|
|
87
|
+
const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim()
|
|
88
|
+
? body.sessionId.trim().slice(0, 64)
|
|
89
|
+
: undefined
|
|
90
|
+
|
|
91
|
+
const inputs: ModelImageInput[] = []
|
|
92
|
+
|
|
93
|
+
// Reject over-limit requests BEFORE the shared parser caps them. Silent
|
|
94
|
+
// truncation would run a successful vision query while dropping a user's
|
|
95
|
+
// sixth image, which is materially worse than a typed 400.
|
|
96
|
+
const validUniqueIds = Array.isArray(body.attachmentIds)
|
|
97
|
+
? [...new Set(body.attachmentIds.filter(isValidMediaId))]
|
|
98
|
+
: []
|
|
99
|
+
const legacyRaw: unknown[] = Array.isArray(body.images)
|
|
100
|
+
? body.images
|
|
101
|
+
: typeof body.image === 'string' && body.image.length > 0 ? [body.image] : []
|
|
102
|
+
const validLegacyCount = legacyRaw.filter((raw) => typeof raw === 'string' && raw.length > 0).length
|
|
103
|
+
if (validUniqueIds.length + validLegacyCount > MAX_ATTACHMENTS_PER_PROMPT) {
|
|
104
|
+
throw new QueryAttachmentError(400, 'too_many_images', `max ${MAX_ATTACHMENTS_PER_PROMPT} images per prompt`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 1. Durable attachment ids.
|
|
108
|
+
const ids = parseMediaIdList(body.attachmentIds)
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const { record, path } = store.resolveUsable(id, clientQueueItemId)
|
|
111
|
+
inputs.push({ path, attachment: record.ref, deleteAfterRun: false })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Legacy base64 — ingested through the SAME store (no bypass).
|
|
115
|
+
for (const raw of legacyRaw) {
|
|
116
|
+
if (typeof raw !== 'string' || raw.length === 0) continue
|
|
117
|
+
const bytes = strictBase64Decode(raw)
|
|
118
|
+
const ref = await store.ingestImage({ bytes, kind: 'user_photo', sessionId })
|
|
119
|
+
const { path } = store.resolveUsable(ref.id)
|
|
120
|
+
inputs.push({ path, attachment: ref, deleteAfterRun: false })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
inputs,
|
|
125
|
+
refs: inputs.map((i) => i.attachment),
|
|
126
|
+
ids: inputs.map((i) => i.attachment.id),
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof QueryAttachmentError) throw err
|
|
130
|
+
throw asQueryAttachmentError(err)
|
|
131
|
+
}
|
|
132
|
+
}
|