@gotcos/glasses-server 6.21.32 → 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/.env.example +8 -5
- package/CHANGELOG.md +31 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/cos-context-browser.ts +230 -0
- package/server/lib/cos-operations-meetings.ts +230 -12
- package/server/lib/meeting-store.ts +8 -1
- package/server/lib/python-bridge.ts +14 -1
- package/server/routes/health.ts +7 -0
- package/server/routes/meeting.ts +92 -13
- package/server/routes/meetings.ts +91 -4
- package/server/routes/memory.ts +65 -0
- package/server/routes/threads.ts +38 -0
package/.env.example
CHANGED
|
@@ -124,12 +124,15 @@ BIND_HOST=0.0.0.0
|
|
|
124
124
|
# pipeline to inherit live tasks/calendar/people context. Omit for standalone.
|
|
125
125
|
# COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
|
|
126
126
|
#
|
|
127
|
-
# G2 "Review Meetings"
|
|
128
|
-
# {
|
|
129
|
-
#
|
|
130
|
-
#
|
|
127
|
+
# G2 "Review Meetings" accepts either layout:
|
|
128
|
+
# {COS_MEETINGS_ROOT}/YYYY-MM/*.md # one read-only library
|
|
129
|
+
# {COS_OPERATIONS_DIR}/{domain}/meetings/YYYY-MM/*.md # multi-domain pipeline
|
|
130
|
+
# Keep COS_OPERATIONS_DIR for enrichment and writes. COS_MEETINGS_ROOT is a
|
|
131
|
+
# browse override; legacy values that contain a multi-domain operations tree
|
|
132
|
+
# retain their old alias behavior. If both are unset, the server tries
|
|
133
|
+
# COS_SCRIPTS_DIR/.. and then local G2 recordings.
|
|
131
134
|
# COS_OPERATIONS_DIR=/path/to/your/cos/operations
|
|
132
|
-
# COS_MEETINGS_ROOT=/path/to/your/
|
|
135
|
+
# COS_MEETINGS_ROOT=/path/to/your/existing/meetings
|
|
133
136
|
|
|
134
137
|
# Telegram session/activity notifications remain OFF even if the COS scripts
|
|
135
138
|
# directory contains .telegram_config.json. Enable export explicitly:
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
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
|
+
|
|
17
|
+
## 6.21.33
|
|
18
|
+
|
|
19
|
+
- **Existing meeting libraries can be selected directly.** `COS_MEETINGS_ROOT`
|
|
20
|
+
now accepts `meetings/YYYY-MM/*.md` as a read-only library, while
|
|
21
|
+
`COS_OPERATIONS_DIR` continues to own multi-domain enrichment and writes.
|
|
22
|
+
- **Mixed libraries stay coherent.** Review Meetings merges direct, enriched
|
|
23
|
+
operations, and standalone G2 records, dedupes by session identity, and
|
|
24
|
+
prefers the writable enriched copy when one exists.
|
|
25
|
+
- **Upgrades remain compatible.** A legacy multi-domain
|
|
26
|
+
`COS_MEETINGS_ROOT` keeps its prior operations-root meaning. Invalid explicit
|
|
27
|
+
roots report a degraded state instead of silently switching libraries.
|
|
28
|
+
- **Read-only means read-only.** Direct-library speaker mutations return a
|
|
29
|
+
typed conflict, paths and symlinks are contained, scans are bounded, and
|
|
30
|
+
public health never exposes the selected filesystem path.
|
|
31
|
+
|
|
1
32
|
## 6.21.32
|
|
2
33
|
|
|
3
34
|
- **Adaptive meeting-audio cleanup is a default-off, replay-only canary.** When
|
package/README.md
CHANGED
|
@@ -292,6 +292,24 @@ optional cleanup process cannot contend with live transcription. Cleanup uses
|
|
|
292
292
|
one global worker, serves raw while that worker is busy, and preempts within
|
|
293
293
|
100 ms if a meeting starts after a replay request was admitted.
|
|
294
294
|
|
|
295
|
+
Server 6.21.33 lets Review Meetings browse an existing single-library tree such
|
|
296
|
+
as `meetings/YYYY-MM/*.md`. Set `COS_MEETINGS_ROOT` to the folder that directly
|
|
297
|
+
contains the month folders. It is intentionally read-only. For the full COS
|
|
298
|
+
sync and enrichment pipeline, keep using `COS_OPERATIONS_DIR` with
|
|
299
|
+
`<domain>/meetings/YYYY-MM/*.md`; arbitrary domain names are supported. When a
|
|
300
|
+
direct library and an operations root are both configured, the server merges
|
|
301
|
+
them with standalone G2 recordings and prefers the enriched writable record
|
|
302
|
+
for the same session.
|
|
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
|
+
|
|
295
313
|
The first server start downloads the real-time turbo model. True HQ additionally
|
|
296
314
|
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
297
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
|
+
}
|
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* When configured, G2 "Review Meetings" reads markdown from a COS-style tree:
|
|
5
5
|
* {operationsDir}/{domain}/meetings/YYYY-MM/*.md
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 3. COS_SCRIPTS_DIR/.. — classic Starter Kit layout (operations/scripts → operations)
|
|
7
|
+
* Read resolution accepts COS_MEETINGS_ROOT as a direct YYYY-MM library.
|
|
8
|
+
* Write/enrichment resolution remains COS_OPERATIONS_DIR, a legacy
|
|
9
|
+
* multi-domain COS_MEETINGS_ROOT, then COS_SCRIPTS_DIR/...
|
|
11
10
|
*
|
|
12
11
|
* Standalone installs leave all of these unset and keep using MeetingStore
|
|
13
12
|
* (~/.cos-glasses/data/recordings).
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
17
|
-
import {
|
|
15
|
+
import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
18
18
|
import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
|
|
19
19
|
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
20
20
|
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
@@ -41,6 +41,103 @@ const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
|
41
41
|
|
|
42
42
|
export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
|
|
43
43
|
|
|
44
|
+
export type MeetingLibraryLayout = 'direct' | 'multi_domain' | 'standalone' | 'invalid_explicit_root'
|
|
45
|
+
|
|
46
|
+
export interface MeetingLibraryInspection {
|
|
47
|
+
layout: MeetingLibraryLayout
|
|
48
|
+
root: string | null
|
|
49
|
+
rootFingerprint: string | null
|
|
50
|
+
meetingCount: number
|
|
51
|
+
warnings: string[]
|
|
52
|
+
domains: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
56
|
+
const MAX_LIST_CANDIDATES = 2_000
|
|
57
|
+
const MAX_LIST_FILE_BYTES = 100_000
|
|
58
|
+
const MAX_DETAIL_FILE_BYTES = 10 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
function rootFingerprint(root: string): string {
|
|
61
|
+
return createHash('sha256').update(root).digest('hex').slice(0, 16)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeRoot(path: string): string | null {
|
|
65
|
+
try {
|
|
66
|
+
const stat = lstatSync(path)
|
|
67
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
68
|
+
return realpathSync(path)
|
|
69
|
+
} catch { return null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeChildDirectory(parentReal: string, path: string): string | null {
|
|
73
|
+
try {
|
|
74
|
+
const stat = lstatSync(path)
|
|
75
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
76
|
+
const real = realpathSync(path)
|
|
77
|
+
return dirname(real) === parentReal ? real : null
|
|
78
|
+
} catch { return null }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeRegularFile(parentReal: string, path: string, maxBytes = MAX_LIST_FILE_BYTES): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const stat = lstatSync(path)
|
|
84
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > maxBytes) return null
|
|
85
|
+
const real = realpathSync(path)
|
|
86
|
+
if (dirname(real) !== parentReal) return null
|
|
87
|
+
return readFileSync(real, 'utf8')
|
|
88
|
+
} catch { return null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeBoundedRegularFile(parentReal: string, path: string, maxReadBytes: number): { content: string; truncated: boolean } | null {
|
|
92
|
+
let fd: number | null = null
|
|
93
|
+
try {
|
|
94
|
+
const stat = lstatSync(path)
|
|
95
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_DETAIL_FILE_BYTES) return null
|
|
96
|
+
const real = realpathSync(path)
|
|
97
|
+
if (dirname(real) !== parentReal) return null
|
|
98
|
+
fd = openSync(real, 'r')
|
|
99
|
+
const buffer = Buffer.alloc(Math.min(maxReadBytes, stat.size))
|
|
100
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
101
|
+
return { content: buffer.subarray(0, read).toString('utf8'), truncated: stat.size > read }
|
|
102
|
+
} catch { return null }
|
|
103
|
+
finally {
|
|
104
|
+
if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function inspectMeetingLibraryPath(path: string): MeetingLibraryInspection {
|
|
109
|
+
const requested = resolve(path)
|
|
110
|
+
const root = safeRoot(requested)
|
|
111
|
+
if (!root) {
|
|
112
|
+
return { layout: 'invalid_explicit_root', root: null, rootFingerprint: null, meetingCount: 0, warnings: ['folder is missing, unreadable, or unsafe'], domains: [] }
|
|
113
|
+
}
|
|
114
|
+
const entries = (() => { try { return readdirSync(root) } catch { return [] } })()
|
|
115
|
+
const directMonths = entries.filter(name => MONTH_PATTERN.test(name) && safeChildDirectory(root, join(root, name))).sort()
|
|
116
|
+
const domains = entries.filter(safeName).filter(name => {
|
|
117
|
+
const domainReal = safeChildDirectory(root, join(root, name))
|
|
118
|
+
if (!domainReal) return false
|
|
119
|
+
return safeChildDirectory(domainReal, join(domainReal, 'meetings')) != null
|
|
120
|
+
}).sort()
|
|
121
|
+
const warnings: string[] = []
|
|
122
|
+
if (directMonths.length > 0 && domains.length > 0) {
|
|
123
|
+
return { layout: 'invalid_explicit_root', root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings: ['folder mixes direct months and domain/meetings trees'], domains }
|
|
124
|
+
}
|
|
125
|
+
const layout: MeetingLibraryLayout = directMonths.length > 0 ? 'direct' : domains.length > 0 ? 'multi_domain' : 'invalid_explicit_root'
|
|
126
|
+
if (layout === 'invalid_explicit_root') {
|
|
127
|
+
const nearMisses = entries.filter(safeName).filter(name => {
|
|
128
|
+
const child = safeChildDirectory(root, join(root, name))
|
|
129
|
+
if (!child) return false
|
|
130
|
+
try {
|
|
131
|
+
return readdirSync(child).some(entry => MONTH_PATTERN.test(entry) && safeChildDirectory(child, join(child, entry)))
|
|
132
|
+
} catch { return false }
|
|
133
|
+
})
|
|
134
|
+
warnings.push(nearMisses.length > 0
|
|
135
|
+
? `missing meetings/ inside: ${nearMisses.slice(0, 6).join(', ')}`
|
|
136
|
+
: 'expected YYYY-MM folders or <domain>/meetings/YYYY-MM')
|
|
137
|
+
}
|
|
138
|
+
return { layout, root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings, domains }
|
|
139
|
+
}
|
|
140
|
+
|
|
44
141
|
/** Enough to clear the sidecar's leading metadata keys whatever their order. */
|
|
45
142
|
const SIDECAR_HEAD_BYTES = 4096
|
|
46
143
|
|
|
@@ -60,8 +157,11 @@ function sidecarSessionId(monthDir: string, meetingFilename: string): string | u
|
|
|
60
157
|
const path = join(monthDir, sidecarName)
|
|
61
158
|
let fd: number | null = null
|
|
62
159
|
try {
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
160
|
+
const linkStat = lstatSync(path)
|
|
161
|
+
if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
|
|
162
|
+
const real = realpathSync(path)
|
|
163
|
+
if (dirname(real) !== realpathSync(monthDir)) return undefined
|
|
164
|
+
const stat = statSync(real)
|
|
65
165
|
fd = openSync(path, 'r')
|
|
66
166
|
const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
|
|
67
167
|
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
@@ -84,12 +184,17 @@ function envPath(name: string): string | null {
|
|
|
84
184
|
/** Resolve the COS operations directory, or null in standalone mode.
|
|
85
185
|
* Reads process.env on each call so tests and Control env updates stay live. */
|
|
86
186
|
export function resolveCosOperationsDir(): string | null {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
187
|
+
const operations = envPath('COS_OPERATIONS_DIR')
|
|
188
|
+
if (operations && inspectMeetingLibraryPath(operations).layout === 'multi_domain') return safeRoot(operations)
|
|
189
|
+
// Backward compatibility: before 6.21.33 COS_MEETINGS_ROOT was documented as
|
|
190
|
+
// an alias for COS_OPERATIONS_DIR. Preserve that meaning only for a real
|
|
191
|
+
// multi-domain tree; a direct library is never a write destination.
|
|
192
|
+
const legacy = envPath('COS_MEETINGS_ROOT')
|
|
193
|
+
if (legacy && inspectMeetingLibraryPath(legacy).layout === 'multi_domain') return safeRoot(legacy)
|
|
89
194
|
const scriptsDir = envPath('COS_SCRIPTS_DIR')
|
|
90
195
|
if (scriptsDir) {
|
|
91
196
|
const inferred = resolve(scriptsDir, '..')
|
|
92
|
-
if (
|
|
197
|
+
if (inspectMeetingLibraryPath(inferred).layout === 'multi_domain') return safeRoot(inferred)
|
|
93
198
|
}
|
|
94
199
|
return null
|
|
95
200
|
}
|
|
@@ -98,6 +203,19 @@ export function cosOperationsMeetingsConfigured(): boolean {
|
|
|
98
203
|
return resolveCosOperationsDir() != null
|
|
99
204
|
}
|
|
100
205
|
|
|
206
|
+
export function resolveMeetingLibrary(): MeetingLibraryInspection {
|
|
207
|
+
const explicit = envPath('COS_MEETINGS_ROOT')
|
|
208
|
+
if (explicit) return inspectMeetingLibraryPath(explicit)
|
|
209
|
+
const operations = resolveCosOperationsDir()
|
|
210
|
+
if (operations) return inspectMeetingLibraryPath(operations)
|
|
211
|
+
return { layout: 'standalone', root: null, rootFingerprint: null, meetingCount: 0, warnings: [], domains: [] }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function meetingLibraryConfigured(): boolean {
|
|
215
|
+
const layout = resolveMeetingLibrary().layout
|
|
216
|
+
return layout === 'direct' || layout === 'multi_domain'
|
|
217
|
+
}
|
|
218
|
+
|
|
101
219
|
function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
|
|
102
220
|
const bytes = Buffer.from(content, 'utf8')
|
|
103
221
|
if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
|
|
@@ -364,6 +482,41 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
364
482
|
return null
|
|
365
483
|
}
|
|
366
484
|
|
|
485
|
+
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
486
|
+
recordId?: string
|
|
487
|
+
librarySource?: 'direct_library' | 'cos_operations'
|
|
488
|
+
mutable?: boolean
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Read resolver for direct libraries. Mutations must require recordId and
|
|
492
|
+
* mutable=true; the legacy operations resolver above remains writable. */
|
|
493
|
+
export function findDirectLibraryMeetingBySessionId(sessionId: string): MeetingLibraryRecord | null {
|
|
494
|
+
const inspection = resolveMeetingLibrary()
|
|
495
|
+
if (inspection.layout !== 'direct' || !inspection.root) return null
|
|
496
|
+
const root = inspection.root
|
|
497
|
+
const months = readdirSync(root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()
|
|
498
|
+
for (const month of months) {
|
|
499
|
+
const monthDir = safeChildDirectory(root, join(root, month))
|
|
500
|
+
if (!monthDir) continue
|
|
501
|
+
for (const sidecarName of readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')).sort().reverse()) {
|
|
502
|
+
const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
503
|
+
if (sidecarSessionId(monthDir, filename) !== sessionId) continue
|
|
504
|
+
const meetingPath = join(monthDir, filename)
|
|
505
|
+
const bounded = safeBoundedRegularFile(monthDir, meetingPath, MEETING_SOURCE_MAX_BYTES)
|
|
506
|
+
if (bounded == null) continue
|
|
507
|
+
const title = bounded.content.match(/^#\s+(.+)$/m)?.[1]?.trim() || filename.replace(/\.md$/, '')
|
|
508
|
+
return {
|
|
509
|
+
sidecarPath: join(monthDir, sidecarName), meetingPath, filename,
|
|
510
|
+
domain: 'library', month, title,
|
|
511
|
+
recordId: `direct:${month}:${filename}`,
|
|
512
|
+
librarySource: 'direct_library',
|
|
513
|
+
mutable: false,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
|
|
367
520
|
export function listCosOperationsMeetings(options: {
|
|
368
521
|
limit?: number
|
|
369
522
|
domain?: string
|
|
@@ -387,7 +540,6 @@ export function listCosOperationsMeetings(options: {
|
|
|
387
540
|
.filter(d => /^\d{4}-\d{2}$/.test(d))
|
|
388
541
|
.sort()
|
|
389
542
|
.reverse()
|
|
390
|
-
.slice(0, 3)
|
|
391
543
|
|
|
392
544
|
for (const month of months) {
|
|
393
545
|
const monthDir = join(meetingsBase, month)
|
|
@@ -403,6 +555,10 @@ export function listCosOperationsMeetings(options: {
|
|
|
403
555
|
const content = readFileSync(filepath, 'utf-8')
|
|
404
556
|
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
|
|
405
557
|
meta.month = month
|
|
558
|
+
meta.librarySource = 'cos_operations'
|
|
559
|
+
meta.recordId = `ops:${domain}:${month}:${file}`
|
|
560
|
+
meta.mutable = true
|
|
561
|
+
meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
|
|
406
562
|
const sessionId = sidecarSessionId(monthDir, file)
|
|
407
563
|
if (sessionId) meta.sessionId = sessionId
|
|
408
564
|
allMeetings.push(meta)
|
|
@@ -417,6 +573,35 @@ export function listCosOperationsMeetings(options: {
|
|
|
417
573
|
return allMeetings.slice(0, limit)
|
|
418
574
|
}
|
|
419
575
|
|
|
576
|
+
export function listDirectLibraryMeetings(options: { limit?: number } = {}): CosOperationsMeetingMeta[] {
|
|
577
|
+
const inspection = resolveMeetingLibrary()
|
|
578
|
+
if (inspection.layout !== 'direct' || !inspection.root) return []
|
|
579
|
+
const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
|
|
580
|
+
const all: CosOperationsMeetingMeta[] = []
|
|
581
|
+
let candidates = 0
|
|
582
|
+
for (const month of readdirSync(inspection.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
583
|
+
const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
|
|
584
|
+
if (!monthDir) continue
|
|
585
|
+
for (const file of readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()) {
|
|
586
|
+
if (++candidates > MAX_LIST_CANDIDATES) break
|
|
587
|
+
const bounded = safeBoundedRegularFile(monthDir, join(monthDir, file), MAX_LIST_FILE_BYTES)
|
|
588
|
+
if (bounded == null) continue
|
|
589
|
+
const content = bounded.content
|
|
590
|
+
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, 'library'), content)
|
|
591
|
+
meta.month = month
|
|
592
|
+
meta.librarySource = 'direct_library'
|
|
593
|
+
meta.recordId = `direct:${month}:${file}`
|
|
594
|
+
meta.mutable = false
|
|
595
|
+
const sessionId = sidecarSessionId(monthDir, file)
|
|
596
|
+
if (sessionId) meta.sessionId = sessionId
|
|
597
|
+
all.push(meta)
|
|
598
|
+
}
|
|
599
|
+
if (candidates > MAX_LIST_CANDIDATES) break
|
|
600
|
+
}
|
|
601
|
+
all.sort(compareMeetingsNewestFirst)
|
|
602
|
+
return all.slice(0, limit)
|
|
603
|
+
}
|
|
604
|
+
|
|
420
605
|
export function getCosOperationsMeetingDetail(
|
|
421
606
|
domain: string,
|
|
422
607
|
month: string,
|
|
@@ -473,3 +658,36 @@ export function getCosOperationsMeetingDetail(
|
|
|
473
658
|
...source,
|
|
474
659
|
}
|
|
475
660
|
}
|
|
661
|
+
|
|
662
|
+
export function getDirectLibraryMeetingDetail(month: string, filename: string): MeetingDetail | null {
|
|
663
|
+
const inspection = resolveMeetingLibrary()
|
|
664
|
+
if (inspection.layout !== 'direct' || !inspection.root) return null
|
|
665
|
+
if (!MONTH_PATTERN.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) return null
|
|
666
|
+
const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
|
|
667
|
+
if (!monthDir) return null
|
|
668
|
+
let resolvedFilename = filename
|
|
669
|
+
let bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
|
|
670
|
+
if (bounded == null) {
|
|
671
|
+
const candidates = readdirSync(monthDir).filter(name => name.endsWith('.md')).slice(0, MAX_LIST_CANDIDATES)
|
|
672
|
+
.map(name => ({ filename: name, content: safeRegularFile(monthDir, join(monthDir, name), 4_000) ?? '' }))
|
|
673
|
+
const renamed = matchRenamedMeetingFilename(filename, candidates)
|
|
674
|
+
if (!renamed) return null
|
|
675
|
+
resolvedFilename = renamed
|
|
676
|
+
bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
|
|
677
|
+
if (bounded == null) return null
|
|
678
|
+
}
|
|
679
|
+
const content = bounded.content
|
|
680
|
+
const meta = parseMeetingMeta(content, resolvedFilename, 'library')
|
|
681
|
+
meta.month = month
|
|
682
|
+
meta.librarySource = 'direct_library'
|
|
683
|
+
meta.recordId = `direct:${month}:${resolvedFilename}`
|
|
684
|
+
meta.mutable = false
|
|
685
|
+
const sessionId = sidecarSessionId(monthDir, resolvedFilename)
|
|
686
|
+
if (sessionId) meta.sessionId = sessionId
|
|
687
|
+
const source = { sourceContent: content, sourceTruncated: bounded.truncated }
|
|
688
|
+
return {
|
|
689
|
+
...meta,
|
|
690
|
+
summary: extractSummary(content), topics: extractTopics(content), decisions: extractDecisions(content),
|
|
691
|
+
actionItems: extractActionItems(content), attendees: extractAttendees(content), transcript: content, ...source,
|
|
692
|
+
}
|
|
693
|
+
}
|
|
@@ -52,6 +52,7 @@ export interface MeetingMeta {
|
|
|
52
52
|
sessionId?: string
|
|
53
53
|
title: string
|
|
54
54
|
date: string
|
|
55
|
+
time?: string
|
|
55
56
|
domain: string
|
|
56
57
|
domainAbbr: string
|
|
57
58
|
source: string
|
|
@@ -64,6 +65,12 @@ export interface MeetingMeta {
|
|
|
64
65
|
decisionCount?: number
|
|
65
66
|
actionCount?: number
|
|
66
67
|
attendeeCount?: number
|
|
68
|
+
/** Additive archive identity. Older companions ignore these fields. */
|
|
69
|
+
librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings'
|
|
70
|
+
recordId?: string
|
|
71
|
+
mutable?: boolean
|
|
72
|
+
/** Present only when the server can state a truthful local record. */
|
|
73
|
+
canonicalRecord?: string
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface MeetingActionItem {
|
|
@@ -437,7 +444,7 @@ export class MeetingStore {
|
|
|
437
444
|
// When COS ops is configured, use the private-app pipeline markers so
|
|
438
445
|
// sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
|
|
439
446
|
// "Standalone recording" summaries are treated as already-final and skipped.
|
|
440
|
-
...(process.env.COS_SCRIPTS_DIR
|
|
447
|
+
...(process.env.COS_SCRIPTS_DIR
|
|
441
448
|
? [
|
|
442
449
|
'<!-- g2-needs-domain-review -->',
|
|
443
450
|
'',
|
|
@@ -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
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { getHealthStaticProbes } from '../lib/health-static-probes.js'
|
|
|
47
47
|
import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
|
|
48
48
|
import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
|
|
49
49
|
import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
|
|
50
|
+
import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
|
|
50
51
|
|
|
51
52
|
export const healthRouter = Router()
|
|
52
53
|
|
|
@@ -234,6 +235,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
234
235
|
recovered: item.recovered,
|
|
235
236
|
})),
|
|
236
237
|
}
|
|
238
|
+
const meetingLibrary = resolveMeetingLibrary()
|
|
237
239
|
res.json({
|
|
238
240
|
...checks,
|
|
239
241
|
server_version: managedServerVersion(),
|
|
@@ -249,6 +251,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
249
251
|
codex_models,
|
|
250
252
|
cursor_models,
|
|
251
253
|
meeting_sync,
|
|
254
|
+
meeting_library: {
|
|
255
|
+
layout: meetingLibrary.layout,
|
|
256
|
+
ready: meetingLibrary.layout !== 'invalid_explicit_root',
|
|
257
|
+
warningCount: meetingLibrary.warnings.length,
|
|
258
|
+
},
|
|
252
259
|
unsaved_captures,
|
|
253
260
|
chunk_embeddings: chunkEmbeddings,
|
|
254
261
|
speaker_corrections: speakerCorrections,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -112,6 +112,7 @@ import {
|
|
|
112
112
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
113
113
|
import {
|
|
114
114
|
cosOperationsMeetingsConfigured,
|
|
115
|
+
findDirectLibraryMeetingBySessionId,
|
|
115
116
|
findCosOperationsMeetingBySessionId,
|
|
116
117
|
resolveCosOperationsDir,
|
|
117
118
|
} from '../lib/cos-operations-meetings.js'
|
|
@@ -156,6 +157,10 @@ function cosOpsPipelineConfigured(): boolean {
|
|
|
156
157
|
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
161
|
+
return requested == null || requested === '' || requested === expected
|
|
162
|
+
}
|
|
163
|
+
|
|
159
164
|
interface MeetingSessionSource {
|
|
160
165
|
getTranscript(sessionId: string): string | null
|
|
161
166
|
getStartTime(sessionId: string): number | null
|
|
@@ -712,15 +717,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
712
717
|
const operations = cosOperationsMeetingsConfigured()
|
|
713
718
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
714
719
|
: null
|
|
715
|
-
const
|
|
716
|
-
|
|
720
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
721
|
+
const saved = operations || direct ? null : store.findBySessionId(sessionId)
|
|
722
|
+
if (!operations && !direct && !saved) {
|
|
717
723
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
718
724
|
return
|
|
719
725
|
}
|
|
720
|
-
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
721
|
-
const title = operations?.title ?? saved!.title
|
|
722
|
-
const domain = operations?.domain ?? saved!.domain
|
|
723
|
-
const filename = operations?.filename ?? saved!.filename
|
|
726
|
+
const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
|
|
727
|
+
const title = operations?.title ?? direct?.title ?? saved!.title
|
|
728
|
+
const domain = operations?.domain ?? direct?.domain ?? saved!.domain
|
|
729
|
+
const filename = operations?.filename ?? direct?.filename ?? saved!.filename
|
|
724
730
|
|
|
725
731
|
let chunks: unknown
|
|
726
732
|
try {
|
|
@@ -770,7 +776,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
770
776
|
title,
|
|
771
777
|
domain,
|
|
772
778
|
filename,
|
|
773
|
-
source: operations ? 'cos_operations' : 'standalone_recordings',
|
|
779
|
+
source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
|
|
780
|
+
recordId: operations
|
|
781
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
782
|
+
: direct?.recordId ?? `standalone:${sessionId}`,
|
|
783
|
+
mutable: direct == null,
|
|
774
784
|
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
775
785
|
...review,
|
|
776
786
|
})
|
|
@@ -812,14 +822,15 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
812
822
|
const operations = cosOperationsMeetingsConfigured()
|
|
813
823
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
814
824
|
: null
|
|
815
|
-
const
|
|
816
|
-
|
|
825
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
826
|
+
const saved = operations || direct ? null : store.findBySessionId(sessionId)
|
|
827
|
+
if (!operations && !direct && !saved) {
|
|
817
828
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
818
829
|
return
|
|
819
830
|
}
|
|
820
|
-
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
821
|
-
const title = operations?.title ?? saved!.title
|
|
822
|
-
const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
831
|
+
const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
|
|
832
|
+
const title = operations?.title ?? direct?.title ?? saved!.title
|
|
833
|
+
const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
823
834
|
|
|
824
835
|
let sidecar: Record<string, unknown>
|
|
825
836
|
try {
|
|
@@ -909,7 +920,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
909
920
|
// Which business this is. The sibling /speakers route has always carried
|
|
910
921
|
// this and this one dropped it, so a personal 1:1 about someone's
|
|
911
922
|
// compensation was byte-identical to a marketing sync.
|
|
912
|
-
domain: operations?.domain ?? saved?.domain ?? '',
|
|
923
|
+
domain: operations?.domain ?? direct?.domain ?? saved?.domain ?? '',
|
|
913
924
|
// Transcript actually captured, whether or not a write-up exists yet. 140
|
|
914
925
|
// of 399 real sidecars have no .md, and the fallback text claimed there was
|
|
915
926
|
// no transcript while holding one.
|
|
@@ -943,6 +954,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
943
954
|
// no write-up yet.
|
|
944
955
|
capturedChars: clip.capturedChars,
|
|
945
956
|
domain: clip.domain,
|
|
957
|
+
source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
|
|
958
|
+
recordId: operations
|
|
959
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
960
|
+
: direct?.recordId ?? `standalone:${sessionId}`,
|
|
961
|
+
mutable: direct == null,
|
|
946
962
|
// So the panel can warn above the write-up, not just the clipboard.
|
|
947
963
|
removedNames: clip.removed,
|
|
948
964
|
coverage,
|
|
@@ -990,11 +1006,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
990
1006
|
const operations = cosOperationsMeetingsConfigured()
|
|
991
1007
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
992
1008
|
: null
|
|
1009
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
1010
|
+
if (direct) {
|
|
1011
|
+
if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
|
|
1012
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
res.status(409).json({
|
|
1016
|
+
error: 'This meeting comes from a read-only library',
|
|
1017
|
+
reason: 'direct_library_read_only',
|
|
1018
|
+
recordId: direct.recordId,
|
|
1019
|
+
mutable: false,
|
|
1020
|
+
})
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
993
1023
|
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
994
1024
|
if (!operations && !saved) {
|
|
995
1025
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
996
1026
|
return
|
|
997
1027
|
}
|
|
1028
|
+
const selectedRecordId = operations
|
|
1029
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
1030
|
+
: `standalone:${sessionId}`
|
|
1031
|
+
if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
|
|
1032
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
998
1035
|
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
999
1036
|
const meetingPath = operations?.meetingPath ?? saved!.filepath
|
|
1000
1037
|
const title = operations?.title ?? saved!.title
|
|
@@ -1164,11 +1201,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1164
1201
|
const operations = cosOperationsMeetingsConfigured()
|
|
1165
1202
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
1166
1203
|
: null
|
|
1204
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
1205
|
+
if (direct) {
|
|
1206
|
+
if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
|
|
1207
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1208
|
+
return
|
|
1209
|
+
}
|
|
1210
|
+
res.status(409).json({
|
|
1211
|
+
error: 'This meeting comes from a read-only library',
|
|
1212
|
+
reason: 'direct_library_read_only',
|
|
1213
|
+
recordId: direct.recordId,
|
|
1214
|
+
mutable: false,
|
|
1215
|
+
})
|
|
1216
|
+
return
|
|
1217
|
+
}
|
|
1167
1218
|
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
1168
1219
|
if (!operations && !saved) {
|
|
1169
1220
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
1170
1221
|
return
|
|
1171
1222
|
}
|
|
1223
|
+
const selectedRecordId = operations
|
|
1224
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
1225
|
+
: `standalone:${sessionId}`
|
|
1226
|
+
if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
|
|
1227
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1228
|
+
return
|
|
1229
|
+
}
|
|
1172
1230
|
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
1173
1231
|
|
|
1174
1232
|
// Refuse to confirm a label the meeting does not actually carry. Otherwise
|
|
@@ -1242,11 +1300,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1242
1300
|
const operations = cosOperationsMeetingsConfigured()
|
|
1243
1301
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
1244
1302
|
: null
|
|
1303
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
1304
|
+
if (direct) {
|
|
1305
|
+
if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
|
|
1306
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
res.status(409).json({
|
|
1310
|
+
error: 'This meeting comes from a read-only library',
|
|
1311
|
+
reason: 'direct_library_read_only',
|
|
1312
|
+
recordId: direct.recordId,
|
|
1313
|
+
mutable: false,
|
|
1314
|
+
})
|
|
1315
|
+
return
|
|
1316
|
+
}
|
|
1245
1317
|
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
1246
1318
|
if (!operations && !saved) {
|
|
1247
1319
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
1248
1320
|
return
|
|
1249
1321
|
}
|
|
1322
|
+
const selectedRecordId = operations
|
|
1323
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
1324
|
+
: `standalone:${sessionId}`
|
|
1325
|
+
if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
|
|
1326
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1250
1329
|
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
1251
1330
|
const meetingPath = operations?.meetingPath ?? saved!.filepath
|
|
1252
1331
|
const title = operations?.title ?? saved!.title
|
|
@@ -4,10 +4,44 @@ import { Router } from 'express'
|
|
|
4
4
|
import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
|
|
5
5
|
import {
|
|
6
6
|
cosOperationsMeetingsConfigured,
|
|
7
|
+
getDirectLibraryMeetingDetail,
|
|
7
8
|
getCosOperationsMeetingDetail,
|
|
9
|
+
listDirectLibraryMeetings,
|
|
8
10
|
listCosOperationsMeetings,
|
|
9
|
-
|
|
11
|
+
resolveMeetingLibrary,
|
|
10
12
|
} from '../lib/cos-operations-meetings.js'
|
|
13
|
+
import type { MeetingMeta } from '../lib/meeting-store.js'
|
|
14
|
+
|
|
15
|
+
function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
|
|
16
|
+
return {
|
|
17
|
+
...meeting,
|
|
18
|
+
librarySource: 'standalone_recordings',
|
|
19
|
+
recordId: `standalone:${meeting.sessionId || `${meeting.domain}:${meeting.month}:${meeting.filename}`}`,
|
|
20
|
+
mutable: true,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mergeMeetingSources(groups: MeetingMeta[][], limit: number): MeetingMeta[] {
|
|
25
|
+
const seenSessions = new Set<string>()
|
|
26
|
+
const seenExact = new Set<string>()
|
|
27
|
+
const merged: MeetingMeta[] = []
|
|
28
|
+
for (const group of groups) {
|
|
29
|
+
for (const meeting of group) {
|
|
30
|
+
if (meeting.sessionId) {
|
|
31
|
+
if (seenSessions.has(meeting.sessionId)) continue
|
|
32
|
+
seenSessions.add(meeting.sessionId)
|
|
33
|
+
} else {
|
|
34
|
+
const exact = `${meeting.librarySource || ''}:${meeting.domain}:${meeting.month}:${meeting.filename}`
|
|
35
|
+
if (seenExact.has(exact)) continue
|
|
36
|
+
seenExact.add(exact)
|
|
37
|
+
}
|
|
38
|
+
merged.push(meeting)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
merged.sort((a, b) => `${b.date}T${b.time || '00:00'}`.localeCompare(`${a.date}T${a.time || '00:00'}`)
|
|
42
|
+
|| b.filename.localeCompare(a.filename))
|
|
43
|
+
return merged.slice(0, Math.min(Math.max(limit, 1), 50))
|
|
44
|
+
}
|
|
11
45
|
|
|
12
46
|
export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
|
|
13
47
|
const router = Router()
|
|
@@ -20,17 +54,54 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
20
54
|
const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
|
|
21
55
|
res.set('Cache-Control', 'private, no-store')
|
|
22
56
|
|
|
23
|
-
|
|
57
|
+
const library = resolveMeetingLibrary()
|
|
58
|
+
if (library.layout === 'invalid_explicit_root') {
|
|
59
|
+
res.status(409).json({
|
|
60
|
+
error: 'Configured meetings library is unavailable or malformed',
|
|
61
|
+
reason: 'invalid_explicit_root',
|
|
62
|
+
layout: library.layout,
|
|
63
|
+
warnings: library.warnings,
|
|
64
|
+
})
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (library.layout === 'direct') {
|
|
69
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
70
|
+
? listCosOperationsMeetings({ limit: 50, domain })
|
|
71
|
+
: []
|
|
72
|
+
const direct = domain === 'all' || domain === 'library'
|
|
73
|
+
? listDirectLibraryMeetings({ limit: 50 })
|
|
74
|
+
: []
|
|
75
|
+
const standalone = store.list({ limit: 50, domain }).map(withStandaloneIdentity)
|
|
76
|
+
const meetings = mergeMeetingSources([operations, direct, standalone], limit)
|
|
77
|
+
res.json({
|
|
78
|
+
meetings,
|
|
79
|
+
source: operations.length > 0 ? 'mixed_library' : 'direct_library',
|
|
80
|
+
layout: 'direct',
|
|
81
|
+
root: library.root,
|
|
82
|
+
rootFingerprint: library.rootFingerprint,
|
|
83
|
+
meetingCount: meetings.length,
|
|
84
|
+
warnings: library.warnings,
|
|
85
|
+
})
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (library.layout === 'multi_domain') {
|
|
24
90
|
const meetings = listCosOperationsMeetings({ limit, domain })
|
|
25
91
|
res.json({
|
|
26
92
|
meetings,
|
|
27
93
|
source: 'cos_operations',
|
|
28
|
-
|
|
94
|
+
layout: 'multi_domain',
|
|
95
|
+
root: library.root,
|
|
96
|
+
rootFingerprint: library.rootFingerprint,
|
|
97
|
+
meetingCount: meetings.length,
|
|
98
|
+
warnings: library.warnings,
|
|
29
99
|
})
|
|
30
100
|
return
|
|
31
101
|
}
|
|
32
102
|
|
|
33
|
-
|
|
103
|
+
const meetings = store.list({ limit, domain }).map(withStandaloneIdentity)
|
|
104
|
+
res.json({ meetings, source: 'standalone_recordings', layout: 'standalone', meetingCount: meetings.length })
|
|
34
105
|
} catch (error) {
|
|
35
106
|
sendMeetingStoreError(res, error)
|
|
36
107
|
}
|
|
@@ -49,6 +120,14 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
49
120
|
}
|
|
50
121
|
res.set('Cache-Control', 'private, no-store')
|
|
51
122
|
|
|
123
|
+
if (domain === 'library') {
|
|
124
|
+
const detail = getDirectLibraryMeetingDetail(month, filename)
|
|
125
|
+
if (detail) {
|
|
126
|
+
res.json(detail)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
52
131
|
if (cosOperationsMeetingsConfigured()) {
|
|
53
132
|
const detail = getCosOperationsMeetingDetail(domain, month, filename)
|
|
54
133
|
if (detail) {
|
|
@@ -71,6 +150,14 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
|
|
|
71
150
|
try {
|
|
72
151
|
res.set('Cache-Control', 'private, no-store')
|
|
73
152
|
|
|
153
|
+
if (req.params.domain === 'library') {
|
|
154
|
+
const detail = getDirectLibraryMeetingDetail(req.params.month, req.params.filename)
|
|
155
|
+
if (detail) {
|
|
156
|
+
res.json(detail)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
74
161
|
if (cosOperationsMeetingsConfigured()) {
|
|
75
162
|
const detail = getCosOperationsMeetingDetail(
|
|
76
163
|
req.params.domain,
|
|
@@ -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
|
+
})
|