@gotcos/glasses-server 6.21.33 → 6.21.34
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 +16 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/cos-context-browser.ts +230 -0
- package/server/lib/python-bridge.ts +14 -1
- package/server/routes/memory.ts +65 -0
- package/server/routes/threads.ts +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 6.21.34
|
|
2
|
+
|
|
3
|
+
- **Memory and Threads are now real production surfaces.** Authenticated
|
|
4
|
+
`/api/memory` and `/api/threads` list/detail routes expose bounded read-only
|
|
5
|
+
projections from the configured COS pipeline instead of returning 404.
|
|
6
|
+
- **Stable references, not storage internals.** Memory uses its logical
|
|
7
|
+
`mem_...` ID and Threads use their existing stable ID. Responses never expose
|
|
8
|
+
embeddings, Qdrant point IDs, raw cache files, secrets, or local paths.
|
|
9
|
+
- **Memory overview is complete.** The store total and type split scan the full
|
|
10
|
+
collection instead of silently stopping after the first 1,000 records.
|
|
11
|
+
- **Manual threads remain visible immediately.** The bridge merges the computed
|
|
12
|
+
thread cache with the durable manual-thread store without mutating either.
|
|
13
|
+
- **Standalone installs fail honestly.** Systems without a COS scripts pipeline
|
|
14
|
+
return empty/unavailable shapes while the rest of the glasses server remains
|
|
15
|
+
usable.
|
|
16
|
+
|
|
1
17
|
## 6.21.33
|
|
2
18
|
|
|
3
19
|
- **Existing meeting libraries can be selected directly.** `COS_MEETINGS_ROOT`
|
package/README.md
CHANGED
|
@@ -301,6 +301,15 @@ direct library and an operations root are both configured, the server merges
|
|
|
301
301
|
them with standalone G2 recordings and prefers the enriched writable record
|
|
302
302
|
for the same session.
|
|
303
303
|
|
|
304
|
+
Server 6.21.34 adds authenticated, read-only Memory and Threads browsing for
|
|
305
|
+
full COS installs. With `COS_SCRIPTS_DIR` configured, the companion can show the
|
|
306
|
+
complete Bot Memory count/type split, bounded recent summaries, exact logical
|
|
307
|
+
memory IDs, and existing tracked/manual threads. Exact detail requests are
|
|
308
|
+
resolved by stable ID so a spoken follow-up can carry the selected snapshot as
|
|
309
|
+
context. Embeddings, vector-store point IDs, cache files, secrets, and local
|
|
310
|
+
paths never cross the API boundary. Standalone installs report the feature as
|
|
311
|
+
unavailable without affecting messages, meetings, transcription, or agents.
|
|
312
|
+
|
|
304
313
|
The first server start downloads the real-time turbo model. True HQ additionally
|
|
305
314
|
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
306
315
|
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { promptEditRouter } from './routes/prompt-edit.js'
|
|
|
39
39
|
import { bookmarksRouter } from './routes/bookmarks.js'
|
|
40
40
|
import { welcomeContextRouter } from './routes/welcome-context.js'
|
|
41
41
|
import { liveCuesRouter } from './routes/live-cues.js'
|
|
42
|
+
import { memoryRouter } from './routes/memory.js'
|
|
43
|
+
import { threadsRouter } from './routes/threads.js'
|
|
42
44
|
import { shutdownLiveCues } from './lib/live-cues-engine.js'
|
|
43
45
|
import { prewarmContext } from './lib/context-builder.js'
|
|
44
46
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
@@ -233,6 +235,8 @@ app.use('/api', displayRouter)
|
|
|
233
235
|
app.use('/api', transcribeStreamRouter)
|
|
234
236
|
app.use('/api', meetingRouter)
|
|
235
237
|
app.use('/api', meetingsRouter)
|
|
238
|
+
app.use('/api', memoryRouter)
|
|
239
|
+
app.use('/api', threadsRouter)
|
|
236
240
|
app.use('/api', openaiKeyRouter)
|
|
237
241
|
// v6.3.0 — Message History, cross-day 'reference message N', and history
|
|
238
242
|
// recovery for public npx users (previously full-COS-server only).
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const MEMORY_ID_PATTERN = /^mem_[A-Za-z0-9_:-]{1,120}$/
|
|
2
|
+
export const THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
3
|
+
|
|
4
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
5
|
+
const ABSOLUTE_PATH = /(^|[\s("'`])(?:~\/|\/(?!\/)(?:[^/\s)"'`]+\/)+[^/\s)"'`]+\/?)/g
|
|
6
|
+
const SECRET_TOKEN = /\b(?:sk-[A-Za-z0-9_-]{12,}|(?:bearer|token|api[_ -]?key)\s*[:=]\s*[A-Za-z0-9._-]{12,})\b/gi
|
|
7
|
+
|
|
8
|
+
export interface MemoryRefGroups {
|
|
9
|
+
people?: string[]
|
|
10
|
+
files?: string[]
|
|
11
|
+
meetings?: string[]
|
|
12
|
+
threads?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryListItem {
|
|
16
|
+
id: string
|
|
17
|
+
type: string
|
|
18
|
+
summary: string
|
|
19
|
+
content: string
|
|
20
|
+
created_at: string
|
|
21
|
+
domain: string
|
|
22
|
+
refs: MemoryRefGroups
|
|
23
|
+
reference_available: true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ThreadListItem {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
domain: string
|
|
30
|
+
is_manual: boolean
|
|
31
|
+
topics: string[]
|
|
32
|
+
meeting_count: number
|
|
33
|
+
first_seen: string
|
|
34
|
+
last_seen: string
|
|
35
|
+
velocity: string
|
|
36
|
+
age_days: number
|
|
37
|
+
is_stale: boolean
|
|
38
|
+
is_resolved: boolean
|
|
39
|
+
meetings: Array<{ name: string; date: string }>
|
|
40
|
+
manual_updates: Array<{ content: string; timestamp: string; source: string }>
|
|
41
|
+
stakeholders: string[]
|
|
42
|
+
milestones: string[]
|
|
43
|
+
sources: string[]
|
|
44
|
+
target_date: string
|
|
45
|
+
serves_goal: string
|
|
46
|
+
created_at: string
|
|
47
|
+
created_by: string
|
|
48
|
+
access_count: number
|
|
49
|
+
reference_available: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cleanContextText(value: unknown, limit: number): string {
|
|
53
|
+
let text = typeof value === 'string' ? value : value == null ? '' : String(value)
|
|
54
|
+
text = text.replace(CONTROL_CHARS, '')
|
|
55
|
+
text = text.replace(ABSOLUTE_PATH, (_match, prefix: string) => `${prefix}[local path hidden]`)
|
|
56
|
+
text = text.replace(SECRET_TOKEN, '[secret hidden]')
|
|
57
|
+
return text.trim().slice(0, limit)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finiteInteger(value: unknown, fallback = 0, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
61
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
62
|
+
if (!Number.isFinite(number)) return fallback
|
|
63
|
+
return Math.max(0, Math.min(maximum, Math.trunc(number)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stringList(value: unknown, itemLimit: number, textLimit: number): string[] {
|
|
67
|
+
if (!Array.isArray(value)) return []
|
|
68
|
+
return value.slice(0, itemLimit).map(item => {
|
|
69
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
70
|
+
const record = item as Record<string, unknown>
|
|
71
|
+
const label = record.title ?? record.name ?? record.summary ?? record.content ?? record.path ?? record.url
|
|
72
|
+
return cleanContextText(label ?? '', textLimit)
|
|
73
|
+
}
|
|
74
|
+
return cleanContextText(item, textLimit)
|
|
75
|
+
}).filter(Boolean)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function memoryRefs(value: unknown): MemoryRefGroups {
|
|
79
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
80
|
+
const source = value as Record<string, unknown>
|
|
81
|
+
const result: MemoryRefGroups = {}
|
|
82
|
+
for (const key of ['people', 'files', 'meetings', 'threads'] as const) {
|
|
83
|
+
const items = stringList(source[key], 12, 160)
|
|
84
|
+
if (items.length) result[key] = items
|
|
85
|
+
}
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function normalizeMemoryList(value: unknown, limit: number): MemoryListItem[] {
|
|
90
|
+
if (!Array.isArray(value)) return []
|
|
91
|
+
const result: MemoryListItem[] = []
|
|
92
|
+
for (const row of value.slice(0, Math.max(0, Math.min(limit, 50)))) {
|
|
93
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) continue
|
|
94
|
+
const source = row as Record<string, unknown>
|
|
95
|
+
const id = cleanContextText(source.id, 128)
|
|
96
|
+
if (!MEMORY_ID_PATTERN.test(id)) continue
|
|
97
|
+
result.push({
|
|
98
|
+
id,
|
|
99
|
+
type: cleanContextText(source.type || 'unknown', 48),
|
|
100
|
+
summary: cleanContextText(source.summary || source.content, 240),
|
|
101
|
+
content: cleanContextText(source.content, 1200),
|
|
102
|
+
created_at: cleanContextText(source.created_at, 64),
|
|
103
|
+
domain: cleanContextText(source.domain, 64),
|
|
104
|
+
refs: memoryRefs(source.refs),
|
|
105
|
+
reference_available: true,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeMemoryDetail(value: unknown): (MemoryListItem & {
|
|
112
|
+
auto_generated: boolean
|
|
113
|
+
consolidated: boolean
|
|
114
|
+
}) | null {
|
|
115
|
+
const rows = normalizeMemoryList(value && typeof value === 'object' ? [value] : [], 1)
|
|
116
|
+
if (!rows[0] || !value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
117
|
+
const source = value as Record<string, unknown>
|
|
118
|
+
return {
|
|
119
|
+
...rows[0],
|
|
120
|
+
content: cleanContextText(source.content, 32_000),
|
|
121
|
+
auto_generated: source.auto_generated === true,
|
|
122
|
+
consolidated: source.consolidated === true,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeThread(value: unknown, detail: boolean): ThreadListItem | null {
|
|
127
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
128
|
+
const source = value as Record<string, unknown>
|
|
129
|
+
const id = cleanContextText(source.id, 128)
|
|
130
|
+
if (!THREAD_ID_PATTERN.test(id)) return null
|
|
131
|
+
const meetings = Array.isArray(source.meetings)
|
|
132
|
+
? source.meetings.slice(0, detail ? 50 : 12).flatMap(item => {
|
|
133
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
|
134
|
+
const meeting = item as Record<string, unknown>
|
|
135
|
+
return [{ name: cleanContextText(meeting.name, 240), date: cleanContextText(meeting.date, 32) }]
|
|
136
|
+
})
|
|
137
|
+
: []
|
|
138
|
+
const manualUpdates = Array.isArray(source.manual_updates)
|
|
139
|
+
? source.manual_updates.slice(0, detail ? 20 : 8).flatMap(item => {
|
|
140
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
|
141
|
+
const update = item as Record<string, unknown>
|
|
142
|
+
return [{
|
|
143
|
+
content: cleanContextText(update.content, detail ? 2000 : 500),
|
|
144
|
+
timestamp: cleanContextText(update.timestamp, 64),
|
|
145
|
+
source: cleanContextText(update.source, 120),
|
|
146
|
+
}]
|
|
147
|
+
})
|
|
148
|
+
: []
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
name: cleanContextText(source.name || 'Untitled thread', 160),
|
|
152
|
+
domain: cleanContextText(source.domain || 'unknown', 64),
|
|
153
|
+
is_manual: source.is_manual === true,
|
|
154
|
+
topics: stringList(source.topics, detail ? 40 : 12, 160),
|
|
155
|
+
meeting_count: finiteInteger(source.meeting_count, meetings.length, 100_000),
|
|
156
|
+
first_seen: cleanContextText(source.first_seen, 32),
|
|
157
|
+
last_seen: cleanContextText(source.last_seen, 32),
|
|
158
|
+
velocity: cleanContextText(source.velocity, 48),
|
|
159
|
+
age_days: finiteInteger(source.age_days, 0, 1_000_000),
|
|
160
|
+
is_stale: source.is_stale === true,
|
|
161
|
+
is_resolved: source.is_resolved === true,
|
|
162
|
+
meetings,
|
|
163
|
+
manual_updates: manualUpdates,
|
|
164
|
+
stakeholders: stringList(source.stakeholders, detail ? 30 : 12, 160),
|
|
165
|
+
milestones: stringList(source.milestones, detail ? 30 : 8, 500),
|
|
166
|
+
sources: stringList(source.sources, detail ? 30 : 8, 500),
|
|
167
|
+
target_date: cleanContextText(source.target_date, 32),
|
|
168
|
+
serves_goal: cleanContextText(source.serves_goal, 160),
|
|
169
|
+
created_at: cleanContextText(source.created_at, 64),
|
|
170
|
+
created_by: cleanContextText(source.created_by, 120),
|
|
171
|
+
access_count: finiteInteger(source.access_count),
|
|
172
|
+
reference_available: true,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function normalizeThreads(value: unknown, limit: number): {
|
|
177
|
+
generated_at: string
|
|
178
|
+
active_count: number
|
|
179
|
+
stale_count: number
|
|
180
|
+
resolved_count: number
|
|
181
|
+
threads: ThreadListItem[]
|
|
182
|
+
} {
|
|
183
|
+
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
184
|
+
? value as Record<string, unknown>
|
|
185
|
+
: {}
|
|
186
|
+
const rawThreads = Array.isArray(source.threads) ? source.threads : []
|
|
187
|
+
const threads = rawThreads.slice(0, Math.max(0, Math.min(limit, 50)))
|
|
188
|
+
.map(item => normalizeThread(item, false))
|
|
189
|
+
.filter((item): item is ThreadListItem => item !== null)
|
|
190
|
+
return {
|
|
191
|
+
generated_at: cleanContextText(source.generated_at, 64),
|
|
192
|
+
active_count: finiteInteger(source.active_count),
|
|
193
|
+
stale_count: finiteInteger(source.stale_count),
|
|
194
|
+
resolved_count: finiteInteger(source.resolved_count),
|
|
195
|
+
threads,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function normalizeThreadDetail(value: unknown): ThreadListItem | null {
|
|
200
|
+
return normalizeThread(value, true)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function normalizeMemoryOverview(value: unknown): {
|
|
204
|
+
available: boolean
|
|
205
|
+
collection: 'cos_memory'
|
|
206
|
+
total: number
|
|
207
|
+
by_type: Record<string, number>
|
|
208
|
+
reason?: string
|
|
209
|
+
} {
|
|
210
|
+
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
211
|
+
? value as Record<string, unknown>
|
|
212
|
+
: {}
|
|
213
|
+
const rawTypes = source.by_type && typeof source.by_type === 'object' && !Array.isArray(source.by_type)
|
|
214
|
+
? source.by_type as Record<string, unknown>
|
|
215
|
+
: {}
|
|
216
|
+
const byType: Record<string, number> = {}
|
|
217
|
+
for (const [rawKey, rawValue] of Object.entries(rawTypes).slice(0, 24)) {
|
|
218
|
+
const key = cleanContextText(rawKey, 48)
|
|
219
|
+
if (key) byType[key] = finiteInteger(rawValue)
|
|
220
|
+
}
|
|
221
|
+
const result: ReturnType<typeof normalizeMemoryOverview> = {
|
|
222
|
+
available: source.available === true,
|
|
223
|
+
collection: 'cos_memory',
|
|
224
|
+
total: finiteInteger(source.total),
|
|
225
|
+
by_type: byType,
|
|
226
|
+
}
|
|
227
|
+
const reason = cleanContextText(source.reason, 120)
|
|
228
|
+
if (reason) result.reason = reason
|
|
229
|
+
return result
|
|
230
|
+
}
|
|
@@ -31,6 +31,10 @@ const BRIDGE_SCRIPT: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR,
|
|
|
31
31
|
// have these, so callPython() degrades to a no-op.
|
|
32
32
|
const pythonAvailable = !!(COS_SCRIPTS_DIR && existsSync(PYTHON_BIN!) && existsSync(BRIDGE_SCRIPT!))
|
|
33
33
|
|
|
34
|
+
export function pythonBridgeAvailable(): boolean {
|
|
35
|
+
return pythonAvailable
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
if (pythonAvailable) {
|
|
35
39
|
console.log('[python-bridge] COS pipeline detected — sourcing live context')
|
|
36
40
|
} else if (COS_SCRIPTS_DIR) {
|
|
@@ -54,8 +58,17 @@ function standaloneNoop(args: string[]): unknown {
|
|
|
54
58
|
switch (args[0]) {
|
|
55
59
|
case 'calendar': return { events: [] }
|
|
56
60
|
case 'tasks': return {}
|
|
57
|
-
case 'threads': return []
|
|
61
|
+
case 'threads': return { threads: [], active_count: 0, stale_count: 0, resolved_count: 0 }
|
|
62
|
+
case 'thread-detail': return { error: 'cos_pipeline_not_configured' }
|
|
58
63
|
case 'memory': return []
|
|
64
|
+
case 'memory-overview': return {
|
|
65
|
+
available: false,
|
|
66
|
+
collection: 'cos_memory',
|
|
67
|
+
total: 0,
|
|
68
|
+
by_type: {},
|
|
69
|
+
reason: 'cos_pipeline_not_configured',
|
|
70
|
+
}
|
|
71
|
+
case 'memory-detail': return { error: 'cos_pipeline_not_configured' }
|
|
59
72
|
case 'badges': return {}
|
|
60
73
|
default: return {}
|
|
61
74
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { callPython } from '../lib/python-bridge.js'
|
|
3
|
+
import {
|
|
4
|
+
MEMORY_ID_PATTERN,
|
|
5
|
+
normalizeMemoryDetail,
|
|
6
|
+
normalizeMemoryList,
|
|
7
|
+
normalizeMemoryOverview,
|
|
8
|
+
} from '../lib/cos-context-browser.js'
|
|
9
|
+
|
|
10
|
+
export const memoryRouter = Router()
|
|
11
|
+
|
|
12
|
+
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
13
|
+
const parsed = Number(value)
|
|
14
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
15
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
memoryRouter.get('/memory/overview', async (_req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const data = await callPython(['memory-overview'])
|
|
21
|
+
res.json(normalizeMemoryOverview(data))
|
|
22
|
+
} catch (error) {
|
|
23
|
+
res.status(503).json({
|
|
24
|
+
available: false,
|
|
25
|
+
collection: 'cos_memory',
|
|
26
|
+
total: 0,
|
|
27
|
+
by_type: {},
|
|
28
|
+
reason: 'memory_bridge_unavailable',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
memoryRouter.get('/memory/:id', async (req, res) => {
|
|
34
|
+
if (!MEMORY_ID_PATTERN.test(req.params.id)) {
|
|
35
|
+
res.status(400).json({ error: 'invalid_memory_id' })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const data = await callPython(['memory-detail', req.params.id])
|
|
40
|
+
if (data && typeof data === 'object' && 'error' in data) {
|
|
41
|
+
res.status(404).json({ error: 'memory_not_found' })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const memory = normalizeMemoryDetail(data)
|
|
45
|
+
if (!memory) {
|
|
46
|
+
res.status(404).json({ error: 'memory_not_found' })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
res.json(memory)
|
|
50
|
+
} catch {
|
|
51
|
+
res.status(503).json({ error: 'memory_unavailable' })
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
memoryRouter.get('/memory', async (req, res) => {
|
|
56
|
+
const days = boundedInteger(req.query.days, 30, 1, 3650)
|
|
57
|
+
const limit = boundedInteger(req.query.limit, 20, 1, 50)
|
|
58
|
+
try {
|
|
59
|
+
const data = await callPython(['memory', '--days', String(days), '--limit', String(limit)])
|
|
60
|
+
// Preserve the legacy top-level array used by released companions.
|
|
61
|
+
res.json(normalizeMemoryList(data, limit))
|
|
62
|
+
} catch {
|
|
63
|
+
res.json([])
|
|
64
|
+
}
|
|
65
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { callPython } from '../lib/python-bridge.js'
|
|
3
|
+
import { THREAD_ID_PATTERN, normalizeThreadDetail, normalizeThreads } from '../lib/cos-context-browser.js'
|
|
4
|
+
|
|
5
|
+
export const threadsRouter = Router()
|
|
6
|
+
|
|
7
|
+
threadsRouter.get('/threads/:id', async (req, res) => {
|
|
8
|
+
if (!THREAD_ID_PATTERN.test(req.params.id)) {
|
|
9
|
+
res.status(400).json({ error: 'invalid_thread_id' })
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const data = await callPython(['thread-detail', req.params.id])
|
|
14
|
+
if (data && typeof data === 'object' && 'error' in data) {
|
|
15
|
+
res.status(404).json({ error: 'thread_not_found' })
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
const thread = normalizeThreadDetail(data)
|
|
19
|
+
if (!thread) {
|
|
20
|
+
res.status(404).json({ error: 'thread_not_found' })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
res.json(thread)
|
|
24
|
+
} catch {
|
|
25
|
+
res.status(503).json({ error: 'threads_unavailable' })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
threadsRouter.get('/threads', async (req, res) => {
|
|
30
|
+
const parsed = Number(req.query.limit)
|
|
31
|
+
const limit = Number.isFinite(parsed) ? Math.max(1, Math.min(50, Math.trunc(parsed))) : 30
|
|
32
|
+
try {
|
|
33
|
+
const data = await callPython(['threads'])
|
|
34
|
+
res.json(normalizeThreads(data, limit))
|
|
35
|
+
} catch {
|
|
36
|
+
res.json(normalizeThreads({}, limit))
|
|
37
|
+
}
|
|
38
|
+
})
|