@gotcos/glasses-server 6.27.7 → 6.27.10
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 +68 -0
- package/package.json +1 -1
- package/server/lib/agent-session-store.ts +200 -3
- package/server/routes/agent-sessions.ts +16 -5
- package/server/routes/meeting.ts +48 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.27.10
|
|
4
|
+
- **Naming an unidentified voice now creates a real profile.** `POST /api/meeting/:id/relabel`
|
|
5
|
+
was TEXT ONLY: it rewrote `speaker` strings in the sidecar and never touched the
|
|
6
|
+
voice store. Saving "Kirstyn Blum" over 109 segments labelled that one meeting and
|
|
7
|
+
taught the system nothing — she never appeared in `/api/voice/profiles`, the review
|
|
8
|
+
panel still offered her as `new name` inside the SAME meeting, no later meeting
|
|
9
|
+
could match her, and there was no profile to add further chunks against.
|
|
10
|
+
Verified on the live store: 77 profiles, no Kirstyn Blum, while both sidecars
|
|
11
|
+
carried her name.
|
|
12
|
+
- The server already had `/api/voice/enroll-ext` for exactly this and the naming flow
|
|
13
|
+
never called it. Relabel now enrols the embeddings of the chunks it actually
|
|
14
|
+
changed, tagged `source: meeting-relabel` so a bad batch can be retracted wholesale.
|
|
15
|
+
- **Scoped deliberately.** Enrolment runs ONLY when a placeholder (`Ext`,
|
|
16
|
+
`Unidentified N`, `Speaker N`, `Unknown`) becomes a real name. Correcting one real
|
|
17
|
+
name to another is left alone: moving a voice between existing people is
|
|
18
|
+
`merge-profiles`, which is explicit and confirmation-gated. A cross-roster sweep of
|
|
19
|
+
this store put two DISTINCT people at 0.85 similarity, so implicit re-pointing would
|
|
20
|
+
poison profiles.
|
|
21
|
+
- Enrolment happens after the sidecar and ledger are durable, and a throwing voice
|
|
22
|
+
store cannot undo the rename the user asked for. `enrolledEmbeddings` is returned so
|
|
23
|
+
the panel can confirm a profile was created.
|
|
24
|
+
- Four tests, both directions mutation-checked: reverting to text-only fails the
|
|
25
|
+
enrolment test; dropping the placeholder guard fails both safety tests.
|
|
26
|
+
|
|
27
|
+
## 6.27.9
|
|
28
|
+
- **Large sessions open instead of 413ing.** `GET /api/agent-sessions/:provider/:id`
|
|
29
|
+
answered `413 Session too large to open` for any transcript over 32 MiB, so the
|
|
30
|
+
biggest sessions — the ones most worth reviewing before a follow-up — returned
|
|
31
|
+
nothing at all. A 67 MB transcript is not exotic; this repo's own 2026-08-13 session
|
|
32
|
+
is 70 MB. Oversized files are now read as a bounded **head (256 KiB) + tail
|
|
33
|
+
(768 KiB)**. That 70 MB session parses in **7 ms** and yields a 1,286-char digest
|
|
34
|
+
with both the opening ask and the most recent turns.
|
|
35
|
+
- Slicing at an arbitrary byte offset is safe because `parseJsonLine` returns null for
|
|
36
|
+
the fragmentary first line of the tail window and the loop skips it.
|
|
37
|
+
- **`truncated` is reported, and the counts stop pretending.** On a partial read the
|
|
38
|
+
message counts are counts of what was READ. The digest therefore prints
|
|
39
|
+
`… middle of a large session not read …` instead of a turn number it cannot know —
|
|
40
|
+
a confidently wrong "… 12 earlier turns …" on a 4,000-turn session is the same
|
|
41
|
+
dishonesty as a silent cap.
|
|
42
|
+
- **Slash-command scaffolding no longer eats the two best slots.** `collectTurn` now
|
|
43
|
+
applies the existing `isWrapperPrompt` filter and `<user_query>` stripping, so a
|
|
44
|
+
digest opens with the real ask rather than `<command-message>…`.
|
|
45
|
+
- Window sizes are injectable. With production defaults any quick-to-build fixture is
|
|
46
|
+
smaller than head+tail combined, so a test would read the whole file and pass
|
|
47
|
+
identically with windowing REMOVED — it did, until a mutation caught it. The test
|
|
48
|
+
now pins the read count and fails when windowing is dropped.
|
|
49
|
+
|
|
50
|
+
## 6.27.8
|
|
51
|
+
- **Session bodies get a real digest.** `GET /api/agent-sessions/:provider/:id` adds
|
|
52
|
+
`discussion_digest`: up to **2000 chars** of what actually happened — the opening
|
|
53
|
+
ask, the most recent user turns in order, and where the assistant left off.
|
|
54
|
+
- **The list row is untouched at 180.** Miles: "it should be in the body not the
|
|
55
|
+
title, the row should be no more than the 180 characters." `discussion_summary`
|
|
56
|
+
keeps its 180-char budget for the single-line row; the digest is a separate field
|
|
57
|
+
the detail page reads. One shared field could not serve both — a 2000-char gist
|
|
58
|
+
appended to a row destroys it.
|
|
59
|
+
- **No LLM, no extra reads.** `parseAgentSession` already streams every line of the
|
|
60
|
+
transcript to count turns; it was discarding the middle. The digest is assembled
|
|
61
|
+
from turns it is already parsing, so it costs no tokens and no additional I/O.
|
|
62
|
+
- **Elision is stated, never silent.** The store keeps the opening turns plus a
|
|
63
|
+
60-turn recent window so a 900-turn session cannot balloon memory, and passes the
|
|
64
|
+
TRUE turn count so the `… N earlier turns …` line reports what was really dropped
|
|
65
|
+
rather than what the buffer happened to hold.
|
|
66
|
+
- Opening turns are reserved BEFORE recency. Filling from the end first starved the
|
|
67
|
+
original ask out of a 40-turn session entirely — caught by its own test.
|
|
68
|
+
- Older clients ignore the field; older servers omit it and the glasses fall back to
|
|
69
|
+
the 180-char summary.
|
|
70
|
+
|
|
3
71
|
## 6.27.7
|
|
4
72
|
- **`GET /api/agent-sessions` ships in the public package.** Claude Code, Codex,
|
|
5
73
|
and Cursor transcripts from this Mac, last 7 days of writes. Glasses 6.8.360
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.27.
|
|
3
|
+
"version": "6.27.10",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -148,6 +148,102 @@ export function composeDiscussionSummary(input: {
|
|
|
148
148
|
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** Deep digest budget. The glasses detail page paginates at 200 chars, so 2000 is
|
|
152
|
+
* ~10 swipes — long by G2 standards, and deliberately so: this exists to be READ
|
|
153
|
+
* before deciding whether to follow up on a session, the one moment depth beats
|
|
154
|
+
* brevity. The LIST row keeps the short `discussion_summary`; a 2000-char gist
|
|
155
|
+
* appended to a single row would destroy it. Two fields, two jobs. */
|
|
156
|
+
export const DISCUSSION_DIGEST_MAX = 2000
|
|
157
|
+
|
|
158
|
+
/** Per-turn cap, so one enormous paste cannot eat the whole budget. */
|
|
159
|
+
const DIGEST_TURN_MAX = 220
|
|
160
|
+
|
|
161
|
+
/** Turns kept from the START. The opening ask frames everything after it. */
|
|
162
|
+
const DIGEST_HEAD_TURNS = 2
|
|
163
|
+
|
|
164
|
+
/** Recent turns retained while streaming. Comfortably more than 2000 chars can
|
|
165
|
+
* render (~20-25 at typical length), so the budget and not the buffer decides
|
|
166
|
+
* what appears. */
|
|
167
|
+
const DIGEST_RECENT_WINDOW = 60
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A readable account of what actually happened in a session.
|
|
171
|
+
*
|
|
172
|
+
* `composeDiscussionSummary` is the opening prompt plus the last assistant snippet at
|
|
173
|
+
* 180 chars — it says where a session started and where it stopped, and nothing about
|
|
174
|
+
* the turns between, which is exactly what you need to decide whether to reopen it.
|
|
175
|
+
*
|
|
176
|
+
* Shape: the opening ask, then the MOST RECENT user turns in chronological order, then
|
|
177
|
+
* where the assistant left off. Recency is weighted because a follow-up continues from
|
|
178
|
+
* the end, not the beginning.
|
|
179
|
+
*
|
|
180
|
+
* NO LLM, by design — assembled from turns `parseAgentSession` already streams, so a
|
|
181
|
+
* digest costs no tokens and no extra file reads.
|
|
182
|
+
*
|
|
183
|
+
* Elision is STATED, never silent: dropping 40 turns and rendering the rest as clean
|
|
184
|
+
* prose reads like the whole story. `… N earlier turns …` says otherwise.
|
|
185
|
+
*/
|
|
186
|
+
export function composeDiscussionDigest(input: {
|
|
187
|
+
userTurns: string[]
|
|
188
|
+
latestAssistant?: string
|
|
189
|
+
max?: number
|
|
190
|
+
/** True number of user turns in the session, when `userTurns` is a bounded sample.
|
|
191
|
+
* The caller keeps only head + a recent window so a 900-turn session cannot balloon
|
|
192
|
+
* memory, and without this the elision line would report only the turns it can see
|
|
193
|
+
* and quietly under-count the rest — a silent cap wearing an honest label. */
|
|
194
|
+
totalTurns?: number
|
|
195
|
+
/** The transcript was read as head+tail, so the middle was never seen and the true
|
|
196
|
+
* turn count is unknown. Print elision WITHOUT a number rather than a wrong one. */
|
|
197
|
+
truncated?: boolean
|
|
198
|
+
}): string {
|
|
199
|
+
const max = input.max ?? DISCUSSION_DIGEST_MAX
|
|
200
|
+
const clean = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
201
|
+
const cap = (s: string): string => (s.length <= DIGEST_TURN_MAX ? s : `${s.slice(0, DIGEST_TURN_MAX - 1)}…`)
|
|
202
|
+
|
|
203
|
+
const turns = input.userTurns.map(clean).filter(Boolean).map(cap)
|
|
204
|
+
const latest = clean(input.latestAssistant ?? '')
|
|
205
|
+
if (turns.length === 0 && !latest) return ''
|
|
206
|
+
const total = Math.max(input.totalTurns ?? turns.length, turns.length)
|
|
207
|
+
|
|
208
|
+
// Reserve room for the closing state before spending budget on asks.
|
|
209
|
+
const tail = latest ? `\n\nLatest: ${cap(latest)}` : ''
|
|
210
|
+
let budget = max - tail.length
|
|
211
|
+
|
|
212
|
+
const head = turns.slice(0, DIGEST_HEAD_TURNS)
|
|
213
|
+
const rest = turns.slice(DIGEST_HEAD_TURNS)
|
|
214
|
+
|
|
215
|
+
// HEAD FIRST. Claiming the opening ask "frames everything after it" and then
|
|
216
|
+
// spending the budget from the end left no room for it — a 40-turn session rendered
|
|
217
|
+
// as "… 22 earlier turns …" with the original request nowhere on the page. Reserve
|
|
218
|
+
// the framing, then spend what is left on recency. Caught by its own test.
|
|
219
|
+
const headKept: string[] = []
|
|
220
|
+
for (const turn of head) {
|
|
221
|
+
const cost = turn.length + 3
|
|
222
|
+
if (cost > budget) break
|
|
223
|
+
headKept.push(turn)
|
|
224
|
+
budget -= cost
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Then fill from the END backwards — the turns nearest a follow-up.
|
|
228
|
+
const kept: string[] = []
|
|
229
|
+
for (let i = rest.length - 1; i >= 0; i--) {
|
|
230
|
+
const cost = rest[i].length + 3
|
|
231
|
+
if (cost > budget) break
|
|
232
|
+
kept.unshift(rest[i])
|
|
233
|
+
budget -= cost
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const dropped = total - headKept.length - kept.length
|
|
237
|
+
const lines: string[] = []
|
|
238
|
+
for (const t of headKept) lines.push(`• ${t}`)
|
|
239
|
+
if (input.truncated) lines.push('… middle of a large session not read …')
|
|
240
|
+
else if (dropped > 0) lines.push(`… ${dropped} earlier turn${dropped === 1 ? '' : 's'} …`)
|
|
241
|
+
for (const t of kept) lines.push(`• ${t}`)
|
|
242
|
+
|
|
243
|
+
const body = lines.join('\n') + tail
|
|
244
|
+
return body.length <= max ? body : `${body.slice(0, max - 1)}…`
|
|
245
|
+
}
|
|
246
|
+
|
|
151
247
|
export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
|
|
152
248
|
if (obj.type === 'assistant') {
|
|
153
249
|
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
@@ -931,6 +1027,54 @@ export async function findAgentSessionFile(
|
|
|
931
1027
|
return best?.file ?? null
|
|
932
1028
|
}
|
|
933
1029
|
|
|
1030
|
+
/** Bytes read from the START of an oversized transcript — enough for the opening ask
|
|
1031
|
+
* and the session_meta record that carries cwd/branch. */
|
|
1032
|
+
export const PARTIAL_HEAD_BYTES = 256 * 1024
|
|
1033
|
+
/** Bytes read from the END. Larger than the head because the recent turns are what a
|
|
1034
|
+
* follow-up continues from. */
|
|
1035
|
+
export const PARTIAL_TAIL_BYTES = 768 * 1024
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Lines of a transcript, reading the WHOLE file when it fits and head+tail when it
|
|
1039
|
+
* does not.
|
|
1040
|
+
*
|
|
1041
|
+
* WHY. `GET /api/agent-sessions/:provider/:id` used to answer 413 "Session too large
|
|
1042
|
+
* to open" for anything over 32 MiB, so the biggest sessions — the ones most worth
|
|
1043
|
+
* summarizing before a follow-up — returned nothing at all. A 67 MB transcript is
|
|
1044
|
+
* real; today's own session is one. Refusing was never necessary: the detail page
|
|
1045
|
+
* needs the opening turns, the recent turns, and the counts, and two bounded windows
|
|
1046
|
+
* carry all three without loading 67 MB into memory.
|
|
1047
|
+
*
|
|
1048
|
+
* The first line of the tail window is almost always a fragment of a JSON record.
|
|
1049
|
+
* `parseJsonLine` returns null for it and the caller's loop skips it, which is why
|
|
1050
|
+
* this can slice at an arbitrary byte offset and stay correct.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function* agentSessionLines(
|
|
1053
|
+
path: string,
|
|
1054
|
+
size: number,
|
|
1055
|
+
maxBytes: number,
|
|
1056
|
+
// Window sizes are injectable so a test can prove ELISION without writing a
|
|
1057
|
+
// multi-megabyte fixture. With the production 256 KiB + 768 KiB defaults, any
|
|
1058
|
+
// fixture small enough to build quickly is fully covered by the two windows — so
|
|
1059
|
+
// the test would read the whole file, see every turn, and pass identically with
|
|
1060
|
+
// windowing removed. It did exactly that until a mutation caught it.
|
|
1061
|
+
headBytes = PARTIAL_HEAD_BYTES,
|
|
1062
|
+
tailBytes = PARTIAL_TAIL_BYTES,
|
|
1063
|
+
): AsyncGenerator<string> {
|
|
1064
|
+
if (size <= maxBytes) {
|
|
1065
|
+
yield* createInterface({ input: createReadStream(path), crlfDelay: Infinity })
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
yield* createInterface({
|
|
1069
|
+
input: createReadStream(path, { start: 0, end: headBytes - 1 }),
|
|
1070
|
+
crlfDelay: Infinity,
|
|
1071
|
+
})
|
|
1072
|
+
yield* createInterface({
|
|
1073
|
+
input: createReadStream(path, { start: Math.max(headBytes, size - tailBytes) }),
|
|
1074
|
+
crlfDelay: Infinity,
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
934
1078
|
export interface AgentSessionDetail {
|
|
935
1079
|
session_id: string
|
|
936
1080
|
provider: AgentProvider
|
|
@@ -939,14 +1083,27 @@ export interface AgentSessionDetail {
|
|
|
939
1083
|
git_branch: string
|
|
940
1084
|
first_prompt: string
|
|
941
1085
|
discussion_summary: string
|
|
1086
|
+
/** Deep, paginated body text for the detail page — up to 2000 chars. The list row
|
|
1087
|
+
* keeps `discussion_summary` at 180. Older clients ignore this field. */
|
|
1088
|
+
discussion_digest: string
|
|
942
1089
|
user_message_count: number
|
|
943
1090
|
assistant_message_count: number
|
|
944
1091
|
omitted_tools: number
|
|
945
1092
|
file_size_bytes: number
|
|
1093
|
+
/** True when the transcript exceeded the read ceiling and only head+tail were
|
|
1094
|
+
* parsed. The message counts are then counts of what was READ, not of the
|
|
1095
|
+
* session — the client must not present them as exact. */
|
|
1096
|
+
truncated: boolean
|
|
946
1097
|
}
|
|
947
1098
|
|
|
948
|
-
export async function parseAgentSession(
|
|
1099
|
+
export async function parseAgentSession(
|
|
1100
|
+
provider: AgentProvider,
|
|
1101
|
+
path: string,
|
|
1102
|
+
opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
|
|
1103
|
+
): Promise<AgentSessionDetail> {
|
|
949
1104
|
const st = await stat(path)
|
|
1105
|
+
const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
|
|
1106
|
+
const truncated = st.size > maxBytes
|
|
950
1107
|
let title = ''
|
|
951
1108
|
let project = ''
|
|
952
1109
|
let gitBranch = ''
|
|
@@ -954,11 +1111,38 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
|
|
|
954
1111
|
let firstPrompt = ''
|
|
955
1112
|
let latestAssistant = ''
|
|
956
1113
|
let userCount = 0
|
|
1114
|
+
// Bounded sample for the deep digest: the opening turns plus a recent window.
|
|
1115
|
+
// The composer only ever renders head + as many recent as fit 2000 chars, so
|
|
1116
|
+
// retaining the whole conversation would be memory held for nothing — a 900-turn
|
|
1117
|
+
// session is real. `userCount` still carries the TRUE total so the elision line
|
|
1118
|
+
// reports what was actually dropped rather than what this buffer happens to hold.
|
|
1119
|
+
const digestHead: string[] = []
|
|
1120
|
+
const digestRecent: string[] = []
|
|
1121
|
+
const collectTurn = (text: string): void => {
|
|
1122
|
+
// Reuse the wrapper filter the rest of this module already trusts. Without it the
|
|
1123
|
+
// opening turns of a slash-command session render as
|
|
1124
|
+
// "<command-message>cos-glasses</command-message>…" — scaffolding, not the ask,
|
|
1125
|
+
// and it lands in the two most valuable slots in the digest.
|
|
1126
|
+
const raw = (text ?? '').trim()
|
|
1127
|
+
if (!raw || isWrapperPrompt(raw)) return
|
|
1128
|
+
// Same tag-stripping firstLineTitle does, so an inline <user_query> wrapper does
|
|
1129
|
+
// not leak angle brackets into the body.
|
|
1130
|
+
const unwrapped = (() => {
|
|
1131
|
+
const start = raw.indexOf('<user_query>')
|
|
1132
|
+
const end = raw.indexOf('</user_query>')
|
|
1133
|
+
const inner = start >= 0 && end > start ? raw.slice(start + '<user_query>'.length, end) : raw
|
|
1134
|
+
return inner.replace(/<[^>]+>/g, ' ')
|
|
1135
|
+
})()
|
|
1136
|
+
const line = unwrapped.replace(/\s+/g, ' ').trim()
|
|
1137
|
+
if (!line) return
|
|
1138
|
+
if (digestHead.length < DIGEST_HEAD_TURNS) { digestHead.push(line.slice(0, DIGEST_TURN_MAX)); return }
|
|
1139
|
+
digestRecent.push(line.slice(0, DIGEST_TURN_MAX))
|
|
1140
|
+
if (digestRecent.length > DIGEST_RECENT_WINDOW) digestRecent.shift()
|
|
1141
|
+
}
|
|
957
1142
|
let assistantCount = 0
|
|
958
1143
|
let omittedTools = 0
|
|
959
1144
|
|
|
960
|
-
|
|
961
|
-
for await (const line of rl) {
|
|
1145
|
+
for await (const line of agentSessionLines(path, st.size, maxBytes, opts.headBytes, opts.tailBytes)) {
|
|
962
1146
|
const obj = parseJsonLine(line)
|
|
963
1147
|
if (!obj) continue
|
|
964
1148
|
if (provider === 'claude') {
|
|
@@ -980,6 +1164,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
|
|
|
980
1164
|
}
|
|
981
1165
|
if (obj.type === 'user') {
|
|
982
1166
|
userCount += 1
|
|
1167
|
+
collectTurn(text)
|
|
983
1168
|
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
984
1169
|
if (!title) title = firstLineTitle(text)
|
|
985
1170
|
} else {
|
|
@@ -1012,6 +1197,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
|
|
|
1012
1197
|
if (snippet) latestAssistant = snippet
|
|
1013
1198
|
} else {
|
|
1014
1199
|
userCount += 1
|
|
1200
|
+
collectTurn(text)
|
|
1015
1201
|
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
1016
1202
|
if (!title) title = firstLineTitle(text)
|
|
1017
1203
|
}
|
|
@@ -1026,6 +1212,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
|
|
|
1026
1212
|
if (obj.role === 'user') {
|
|
1027
1213
|
userCount += 1
|
|
1028
1214
|
const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
|
|
1215
|
+
collectTurn(query ?? text)
|
|
1029
1216
|
if (query) {
|
|
1030
1217
|
title = query
|
|
1031
1218
|
if (!firstPrompt) firstPrompt = query
|
|
@@ -1057,9 +1244,19 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
|
|
|
1057
1244
|
firstPrompt: firstPrompt || title,
|
|
1058
1245
|
latestAssistant,
|
|
1059
1246
|
}),
|
|
1247
|
+
discussion_digest: composeDiscussionDigest({
|
|
1248
|
+
userTurns: [...digestHead, ...digestRecent],
|
|
1249
|
+
latestAssistant,
|
|
1250
|
+
// On a truncated read `userCount` counts only the sampled windows, so passing
|
|
1251
|
+
// it as the total would print a confidently WRONG "… 12 earlier turns …" for a
|
|
1252
|
+
// session with hundreds. The digest drops the number instead of inventing one.
|
|
1253
|
+
totalTurns: truncated ? undefined : userCount,
|
|
1254
|
+
truncated,
|
|
1255
|
+
}),
|
|
1060
1256
|
user_message_count: userCount,
|
|
1061
1257
|
assistant_message_count: assistantCount,
|
|
1062
1258
|
omitted_tools: omittedTools,
|
|
1063
1259
|
file_size_bytes: st.size,
|
|
1260
|
+
truncated,
|
|
1064
1261
|
}
|
|
1065
1262
|
}
|
|
@@ -19,7 +19,6 @@ import { stat } from 'node:fs/promises'
|
|
|
19
19
|
import {
|
|
20
20
|
AGENT_SESSION_LIST_LIMIT,
|
|
21
21
|
AGENT_SESSION_LIST_MAX,
|
|
22
|
-
AGENT_SESSION_MAX_FILE_BYTES,
|
|
23
22
|
AGENT_SESSION_WINDOW_HOURS,
|
|
24
23
|
agentSessionRoots,
|
|
25
24
|
findAgentSessionFile,
|
|
@@ -156,10 +155,17 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
156
155
|
return
|
|
157
156
|
}
|
|
158
157
|
const st = await stat(found)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
// Oversized transcripts are READ IN PART, not refused.
|
|
159
|
+
//
|
|
160
|
+
// This used to answer 413 "Session too large to open" above 32 MiB, which made the
|
|
161
|
+
// biggest sessions — the ones most worth reviewing before a follow-up — completely
|
|
162
|
+
// unopenable on the glasses. A 67 MB transcript is not exotic; this repo's own
|
|
163
|
+
// 2026-08-13 session is one. The detail page needs the opening turns, the recent
|
|
164
|
+
// turns, and stats, and a bounded head+tail carries all three.
|
|
165
|
+
//
|
|
166
|
+
// `parsed.truncated` says so out loud, and the counts are then counts of what was
|
|
167
|
+
// READ. Presenting a partial count as the session total would be the same
|
|
168
|
+
// dishonesty as a silent cap, so the digest omits the number entirely instead.
|
|
163
169
|
const parsed = await parseAgentSession(provider, found)
|
|
164
170
|
if (provider === 'cursor') {
|
|
165
171
|
const names = await loadCursorComposerNames(agentSessionRoots().cursorComposerDb)
|
|
@@ -175,6 +181,11 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
175
181
|
display_label: parsed.display_label,
|
|
176
182
|
first_prompt: parsed.first_prompt,
|
|
177
183
|
discussion_summary: parsed.discussion_summary || '',
|
|
184
|
+
// Detail only. The list row (line 73) deliberately stays on the 180-char
|
|
185
|
+
// summary — Miles: "it should be in the body not the title, the row should
|
|
186
|
+
// be no more than the 180 characters."
|
|
187
|
+
discussion_digest: parsed.discussion_digest || '',
|
|
188
|
+
truncated: parsed.truncated,
|
|
178
189
|
project: parsed.project,
|
|
179
190
|
created: modified,
|
|
180
191
|
modified,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
meetingAudioChunkPath,
|
|
24
24
|
meetingAudioRetentionDays,
|
|
25
25
|
} from '../lib/meeting-audio-archive.js'
|
|
26
|
-
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
26
|
+
import { enrollEmbedding, readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
27
|
+
import { chunkEmbeddingsForIndices } from '../lib/chunk-embedding-store.js'
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The label a de-attributed voice takes, numbered within its meeting.
|
|
@@ -974,6 +975,46 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
974
975
|
})
|
|
975
976
|
})
|
|
976
977
|
|
|
978
|
+
/** Labels the diariser invents when it does not know who is speaking. Naming one
|
|
979
|
+
* of these is a FIRST TRAINING RUN for a new person, not a correction. */
|
|
980
|
+
const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
|
|
981
|
+
const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Turn a named placeholder into a real voice profile.
|
|
985
|
+
*
|
|
986
|
+
* WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
|
|
987
|
+
* meeting sidecar and never touched the voice store. So naming an unidentified
|
|
988
|
+
* voice "Kirstyn Blum" labelled that one meeting and taught the system nothing —
|
|
989
|
+
* she never appeared in `/api/voice/profiles`, the review panel still offered her
|
|
990
|
+
* as `new name` inside the SAME meeting, no later meeting could match her, and
|
|
991
|
+
* there was no profile to accumulate further chunks against. The server already
|
|
992
|
+
* had `/api/voice/enroll-ext` for exactly this and the naming flow never called it.
|
|
993
|
+
*
|
|
994
|
+
* Enrolment is ADDITIVE and scoped: it runs only when a placeholder becomes a real
|
|
995
|
+
* name. Correcting one real name to another is left alone deliberately — moving a
|
|
996
|
+
* voice between existing people is `merge-profiles`, which is explicit and
|
|
997
|
+
* confirmation-gated, and doing it implicitly here would poison profiles.
|
|
998
|
+
*
|
|
999
|
+
* `enrollEmbedding` owns the diversity gate and the FIFO cap, so feeding it the
|
|
1000
|
+
* relabelled chunks cannot bloat a profile.
|
|
1001
|
+
*/
|
|
1002
|
+
const enrolNamedVoice = (sessionId: string, from: string, to: string, changed: number[]): number => {
|
|
1003
|
+
if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return 0
|
|
1004
|
+
let enrolled = 0
|
|
1005
|
+
try {
|
|
1006
|
+
for (const row of chunkEmbeddingsForIndices(sessionId, changed)) {
|
|
1007
|
+
if (!row.embedding) continue
|
|
1008
|
+
if (enrollEmbedding(to, row.embedding, 'meeting-relabel').success) enrolled += 1
|
|
1009
|
+
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
// Enrolment is a bonus on top of the relabel. A voice store that refuses must
|
|
1012
|
+
// not fail the rename the user actually asked for.
|
|
1013
|
+
return enrolled
|
|
1014
|
+
}
|
|
1015
|
+
return enrolled
|
|
1016
|
+
}
|
|
1017
|
+
|
|
977
1018
|
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
978
1019
|
res.set('Cache-Control', 'private, no-store')
|
|
979
1020
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -1150,7 +1191,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1150
1191
|
proseStale: preview.proseStale,
|
|
1151
1192
|
})
|
|
1152
1193
|
|
|
1153
|
-
|
|
1194
|
+
// Enrol AFTER the sidecar and ledger are durable: the rename is the thing the
|
|
1195
|
+
// user asked for, and a voice store that refuses must not undo it. Reported so
|
|
1196
|
+
// the panel can say a profile was created rather than leaving the user to
|
|
1197
|
+
// discover, in another meeting, that it was not.
|
|
1198
|
+
const enrolled = enrolNamedVoice(sessionId, from, to, plan.value.changed)
|
|
1199
|
+
res.json({ ok: true, correctionId: id, enrolledEmbeddings: enrolled, ...preview })
|
|
1154
1200
|
})
|
|
1155
1201
|
|
|
1156
1202
|
|