@gotcos/glasses-server 6.27.7 → 6.27.9
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 +44 -0
- package/package.json +1 -1
- package/server/lib/agent-session-store.ts +200 -3
- package/server/routes/agent-sessions.ts +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.27.9
|
|
4
|
+
- **Large sessions open instead of 413ing.** `GET /api/agent-sessions/:provider/:id`
|
|
5
|
+
answered `413 Session too large to open` for any transcript over 32 MiB, so the
|
|
6
|
+
biggest sessions — the ones most worth reviewing before a follow-up — returned
|
|
7
|
+
nothing at all. A 67 MB transcript is not exotic; this repo's own 2026-08-13 session
|
|
8
|
+
is 70 MB. Oversized files are now read as a bounded **head (256 KiB) + tail
|
|
9
|
+
(768 KiB)**. That 70 MB session parses in **7 ms** and yields a 1,286-char digest
|
|
10
|
+
with both the opening ask and the most recent turns.
|
|
11
|
+
- Slicing at an arbitrary byte offset is safe because `parseJsonLine` returns null for
|
|
12
|
+
the fragmentary first line of the tail window and the loop skips it.
|
|
13
|
+
- **`truncated` is reported, and the counts stop pretending.** On a partial read the
|
|
14
|
+
message counts are counts of what was READ. The digest therefore prints
|
|
15
|
+
`… middle of a large session not read …` instead of a turn number it cannot know —
|
|
16
|
+
a confidently wrong "… 12 earlier turns …" on a 4,000-turn session is the same
|
|
17
|
+
dishonesty as a silent cap.
|
|
18
|
+
- **Slash-command scaffolding no longer eats the two best slots.** `collectTurn` now
|
|
19
|
+
applies the existing `isWrapperPrompt` filter and `<user_query>` stripping, so a
|
|
20
|
+
digest opens with the real ask rather than `<command-message>…`.
|
|
21
|
+
- Window sizes are injectable. With production defaults any quick-to-build fixture is
|
|
22
|
+
smaller than head+tail combined, so a test would read the whole file and pass
|
|
23
|
+
identically with windowing REMOVED — it did, until a mutation caught it. The test
|
|
24
|
+
now pins the read count and fails when windowing is dropped.
|
|
25
|
+
|
|
26
|
+
## 6.27.8
|
|
27
|
+
- **Session bodies get a real digest.** `GET /api/agent-sessions/:provider/:id` adds
|
|
28
|
+
`discussion_digest`: up to **2000 chars** of what actually happened — the opening
|
|
29
|
+
ask, the most recent user turns in order, and where the assistant left off.
|
|
30
|
+
- **The list row is untouched at 180.** Miles: "it should be in the body not the
|
|
31
|
+
title, the row should be no more than the 180 characters." `discussion_summary`
|
|
32
|
+
keeps its 180-char budget for the single-line row; the digest is a separate field
|
|
33
|
+
the detail page reads. One shared field could not serve both — a 2000-char gist
|
|
34
|
+
appended to a row destroys it.
|
|
35
|
+
- **No LLM, no extra reads.** `parseAgentSession` already streams every line of the
|
|
36
|
+
transcript to count turns; it was discarding the middle. The digest is assembled
|
|
37
|
+
from turns it is already parsing, so it costs no tokens and no additional I/O.
|
|
38
|
+
- **Elision is stated, never silent.** The store keeps the opening turns plus a
|
|
39
|
+
60-turn recent window so a 900-turn session cannot balloon memory, and passes the
|
|
40
|
+
TRUE turn count so the `… N earlier turns …` line reports what was really dropped
|
|
41
|
+
rather than what the buffer happened to hold.
|
|
42
|
+
- Opening turns are reserved BEFORE recency. Filling from the end first starved the
|
|
43
|
+
original ask out of a 40-turn session entirely — caught by its own test.
|
|
44
|
+
- Older clients ignore the field; older servers omit it and the glasses fall back to
|
|
45
|
+
the 180-char summary.
|
|
46
|
+
|
|
3
47
|
## 6.27.7
|
|
4
48
|
- **`GET /api/agent-sessions` ships in the public package.** Claude Code, Codex,
|
|
5
49
|
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.9",
|
|
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,
|