@gotcos/glasses-server 6.44.16 → 6.45.1
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 +77 -51
- package/README.md +9 -0
- package/bin/memory-setup.cjs +55 -0
- package/memory-runtime/cos-memory-runtime-0.1.0.tar.gz +0 -0
- package/memory-runtime/runtime.json +7 -0
- package/package.json +7 -4
- package/server/index.ts +4 -4
- package/server/lib/adaptive-playback-audio.ts +1 -1
- package/server/lib/agent-session-store.ts +139 -28
- package/server/lib/archive-search.ts +1 -1
- package/server/lib/attached-provider-adapter.ts +1 -1
- package/server/lib/attached-workspace.ts +3 -3
- package/server/lib/chunk-embedding-store.ts +1 -1
- package/server/lib/context-files.ts +1 -1
- package/server/lib/cos-context-browser.ts +27 -8
- package/server/lib/cos-operations-meetings.ts +1 -1
- package/server/lib/domains.ts +2 -2
- package/server/lib/maintenance-lifecycle.ts +1 -1
- package/server/lib/meeting-audio-archive.ts +3 -3
- package/server/lib/meeting-corrections.ts +3 -3
- package/server/lib/meeting-relabel-enrolment.ts +1 -1
- package/server/lib/meeting-relabel.ts +2 -2
- package/server/lib/meeting-scribe-content.ts +7 -7
- package/server/lib/meeting-speaker-review.ts +3 -3
- package/server/lib/meeting-summary.ts +1 -1
- package/server/lib/message-era-reset.ts +1 -1
- package/server/lib/message-era.ts +1 -1
- package/server/lib/morning-brief-config.ts +1 -1
- package/server/lib/morning-brief-coverage.ts +1 -1
- package/server/lib/occupancy-probes.ts +7 -4
- package/server/lib/ollama-tools.ts +1 -1
- package/server/lib/profile.ts +29 -5
- package/server/lib/python-bridge.ts +6 -4
- package/server/lib/query-job-types.ts +1 -1
- package/server/lib/session-stream-events.ts +3 -3
- package/server/lib/session-transcript-watcher.ts +1 -1
- package/server/lib/speaker-calibration-log.ts +1 -1
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/thread-occupancy.ts +1 -1
- package/server/lib/thread-turn-queue-deliver.ts +1 -1
- package/server/lib/thread-turn-queue.ts +3 -3
- package/server/lib/video-upload-v2.ts +1 -1
- package/server/lib/voice-profile-store.ts +2 -2
- package/server/lib/whisper-metal-gate.ts +1 -1
- package/server/routes/agent-session-bindings.ts +2 -2
- package/server/routes/agent-session-stream.ts +1 -1
- package/server/routes/agent-sessions.ts +2 -2
- package/server/routes/claude-sessions.ts +1 -1
- package/server/routes/health.ts +3 -1
- package/server/routes/meeting.ts +8 -8
- package/server/routes/memory.ts +66 -7
- package/server/routes/prompt-drafts.ts +1 -1
- package/server/routes/session-index.ts +1 -1
- package/server/routes/thread-turn-queue.ts +10 -4
- package/server/routes/transcribe-stream.ts +15 -4
- package/server/routes/voice.ts +4 -4
|
@@ -111,7 +111,7 @@ export function isScratchCursorProject(folder: string): boolean {
|
|
|
111
111
|
export const isSkippedCursorFolder = isScratchCursorProject
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* Titles that belong to a MACHINE, not to a conversation
|
|
114
|
+
* Titles that belong to a MACHINE, not to a conversation the user had.
|
|
115
115
|
*
|
|
116
116
|
* Measured 2026-08-18 by driving the real collector over the 1,296 Claude transcripts on
|
|
117
117
|
* this machine: of the 41 Claude docs it indexed, 28 were machine prompts -- 22 Slack
|
|
@@ -141,7 +141,7 @@ export function isKeepWarmSessionTitle(title: string): boolean {
|
|
|
141
141
|
/**
|
|
142
142
|
* The compaction preamble, which the harness writes as a USER turn.
|
|
143
143
|
*
|
|
144
|
-
*
|
|
144
|
+
* the user's 2026-08-17 screenshot had this sitting in the DISCUSSION list as though he
|
|
145
145
|
* had asked it: "This session is being continued from a previous conversation… Summary:
|
|
146
146
|
* ## 1. Primary Request and Intent". It is scaffolding, and it lands in the digest at
|
|
147
147
|
* full length because it IS recent -- compaction happens mid-session -- so recency
|
|
@@ -251,7 +251,7 @@ export function proseSnippet(text: string, max = 160): string {
|
|
|
251
251
|
* swipes of deliberate reading; 21,757 would be 109. Bounded on purpose: this
|
|
252
252
|
* crosses a phone radio and renders on a 576x288 HUD.
|
|
253
253
|
*
|
|
254
|
-
* WHY NOT LOWER. 2,000 would still cut 7.5% of replies, and
|
|
254
|
+
* WHY NOT LOWER. 2,000 would still cut 7.5% of replies, and the user's 1,821-char reply
|
|
255
255
|
* — the one that started this — sits inside the band that a 2,000 cap leaves with no
|
|
256
256
|
* headroom at all.
|
|
257
257
|
*/
|
|
@@ -311,7 +311,7 @@ const DIGEST_TURN_MAX = 220
|
|
|
311
311
|
* "frames everything after it" -- see the head-first block in composeDiscussionDigest,
|
|
312
312
|
* which was itself a fix for a digest that spent everything on the tail.
|
|
313
313
|
*
|
|
314
|
-
*
|
|
314
|
+
* the user overruled it from hardware, 2026-08-17, looking at a digest whose first two
|
|
315
315
|
* bullets were a question from the previous day and a compaction preamble: "It needs to
|
|
316
316
|
* refresh to the most recent response inside of the thread. The discussion should show
|
|
317
317
|
* the questions that we're asking and a summary of those most recent things, not
|
|
@@ -322,7 +322,7 @@ const DIGEST_TURN_MAX = 220
|
|
|
322
322
|
* occupying the two most valuable slots in a list about what is happening NOW.
|
|
323
323
|
*
|
|
324
324
|
* Left as a constant rather than deleting the head path: this is a judgement call about
|
|
325
|
-
* emphasis, and one number is the whole reversal if
|
|
325
|
+
* emphasis, and one number is the whole reversal if the user wants some framing back.
|
|
326
326
|
*/
|
|
327
327
|
const DIGEST_HEAD_TURNS = 0
|
|
328
328
|
|
|
@@ -641,14 +641,96 @@ export function normalizeClaudeSessionId(raw: string): string {
|
|
|
641
641
|
return id
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
644
|
+
/** Read completed top-level fields from a bounded JSON prefix. Nested transcript
|
|
645
|
+
* strings are not metadata. Repeated keys and non-string values fail closed. */
|
|
646
|
+
export function desktopMetadataPrefix(text: string): { fields: Map<string, string | null>; complete: boolean } {
|
|
647
|
+
const fields = new Map<string, string | null>()
|
|
648
|
+
let i = 0
|
|
649
|
+
const space = () => { while (/\s/.test(text[i] || '') && i < text.length) i++ }
|
|
650
|
+
const string = (): string | undefined => {
|
|
651
|
+
const start = i
|
|
652
|
+
if (text[i++] !== '"') return undefined
|
|
653
|
+
let escaped = false
|
|
654
|
+
while (i < text.length) {
|
|
655
|
+
const ch = text[i++]
|
|
656
|
+
if (!escaped && ch === '"') {
|
|
657
|
+
try { return JSON.parse(text.slice(start, i)) as string } catch { return undefined }
|
|
658
|
+
}
|
|
659
|
+
if (!escaped && ch === '\\') escaped = true
|
|
660
|
+
else escaped = false
|
|
661
|
+
}
|
|
662
|
+
return undefined
|
|
663
|
+
}
|
|
664
|
+
space()
|
|
665
|
+
if (text[i++] !== '{') return { fields, complete: false }
|
|
666
|
+
while (i < text.length) {
|
|
667
|
+
space()
|
|
668
|
+
if (text[i] === '}') return { fields, complete: true }
|
|
669
|
+
const key = string(); if (key === undefined) break
|
|
670
|
+
space(); if (text[i++] !== ':') break
|
|
671
|
+
space()
|
|
672
|
+
let value: string | null = null
|
|
673
|
+
if (text[i] === '"') { const parsed = string(); if (parsed === undefined) break; value = parsed }
|
|
674
|
+
else {
|
|
675
|
+
let depth = 0, inString = false, escaped = false
|
|
676
|
+
while (i < text.length) {
|
|
677
|
+
const ch = text[i]
|
|
678
|
+
if (!inString && depth === 0 && (ch === ',' || ch === '}')) break
|
|
679
|
+
i++
|
|
680
|
+
if (inString) {
|
|
681
|
+
if (!escaped && ch === '"') inString = false
|
|
682
|
+
if (!escaped && ch === '\\') escaped = true; else escaped = false
|
|
683
|
+
} else if (ch === '"') inString = true
|
|
684
|
+
else if (ch === '{' || ch === '[') depth++
|
|
685
|
+
else if (ch === '}' || ch === ']') depth--
|
|
686
|
+
}
|
|
687
|
+
if (i === text.length) break
|
|
688
|
+
}
|
|
689
|
+
fields.set(key, fields.has(key) ? null : value)
|
|
690
|
+
space()
|
|
691
|
+
if (text[i] === '}') return { fields, complete: true }
|
|
692
|
+
if (text[i++] !== ',') break
|
|
693
|
+
}
|
|
694
|
+
return { fields, complete: false }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function peekClaudeDesktopHead(text: string): { title: string; cwd: string; cliSessionId: string } {
|
|
698
|
+
const { fields, complete } = desktopMetadataPrefix(text)
|
|
699
|
+
const cli = fields.get('cliSessionId')
|
|
700
|
+
const normalized = typeof cli === 'string' ? normalizeClaudeSessionId(cli) : ''
|
|
648
701
|
return {
|
|
649
|
-
title: title
|
|
650
|
-
cwd:
|
|
702
|
+
title: (fields.get('title') || '').slice(0, 120),
|
|
703
|
+
cwd: fields.get('cwd') || '',
|
|
704
|
+
cliSessionId: fields.has('cliSessionId')
|
|
705
|
+
? (CLAUDE_UUID_JSONL.test(normalized + '.jsonl') ? normalized : '!invalid')
|
|
706
|
+
: (complete ? '' : '!unreadable'),
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
type ClaudeDesktopHead = ReturnType<typeof peekClaudeDesktopHead> & { file: string; mtimeMs: number }
|
|
711
|
+
|
|
712
|
+
/** Explicit Desktop→CLI aliases only. A conflicting alias cannot select a transcript. */
|
|
713
|
+
export async function loadClaudeDesktopAliases(root: string): Promise<Map<string, ClaudeDesktopHead | null>> {
|
|
714
|
+
const aliases = new Map<string, ClaudeDesktopHead | null>()
|
|
715
|
+
if (!root) return aliases
|
|
716
|
+
for (const account of await dirents(root)) {
|
|
717
|
+
for (const workspace of await dirents(join(root, account))) {
|
|
718
|
+
const dir = join(root, account, workspace)
|
|
719
|
+
for (const name of await dirents(dir)) {
|
|
720
|
+
if (!name.startsWith('local_') || !name.endsWith('.json')) continue
|
|
721
|
+
const id = normalizeClaudeSessionId(name.slice(0, -5))
|
|
722
|
+
if (!CLAUDE_UUID_JSONL.test(id + '.jsonl')) continue
|
|
723
|
+
const file = join(dir, name), st = await fileStat(file)
|
|
724
|
+
if (!st?.isFile) continue
|
|
725
|
+
const head = { ...peekClaudeDesktopHead(await readWindow(file, false)), file, mtimeMs: st.mtimeMs }
|
|
726
|
+
const previous = aliases.get(id)
|
|
727
|
+
if (previous === null) continue
|
|
728
|
+
if (previous && previous.cliSessionId !== head.cliSessionId) aliases.set(id, null)
|
|
729
|
+
else if (!previous || head.mtimeMs >= previous.mtimeMs) aliases.set(id, head)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
651
732
|
}
|
|
733
|
+
return aliases
|
|
652
734
|
}
|
|
653
735
|
|
|
654
736
|
export async function loadClaudeStarredIds(configPath: string): Promise<Set<string>> {
|
|
@@ -851,6 +933,16 @@ export async function listClaudeSessions(
|
|
|
851
933
|
desktopSessionsRoot = '',
|
|
852
934
|
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
853
935
|
): Promise<AgentSessionRow[]> {
|
|
936
|
+
const aliases = await loadClaudeDesktopAliases(desktopSessionsRoot)
|
|
937
|
+
const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
|
|
938
|
+
const canonicalStars = new Set([...starredIds].map(canonical))
|
|
939
|
+
const canonicalLive = new Set([...liveIds].map(canonical))
|
|
940
|
+
const desktopByCli = new Map<string, ClaudeDesktopHead>()
|
|
941
|
+
for (const [id, head] of aliases) {
|
|
942
|
+
if (!head) continue
|
|
943
|
+
const key = canonical(id), prev = desktopByCli.get(key)
|
|
944
|
+
if (!prev || head.mtimeMs >= prev.mtimeMs) desktopByCli.set(key, head)
|
|
945
|
+
}
|
|
854
946
|
const seen = new Set<string>()
|
|
855
947
|
const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
856
948
|
const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
@@ -862,11 +954,11 @@ export async function listClaudeSessions(
|
|
|
862
954
|
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
863
955
|
const native = name.slice(0, -6)
|
|
864
956
|
const shortId = native.slice(0, 8)
|
|
865
|
-
if (
|
|
957
|
+
if (canonicalLive.has(shortId) || canonicalLive.has(native)) continue
|
|
866
958
|
const file = join(dir, name)
|
|
867
959
|
const st = await fileStat(file)
|
|
868
960
|
if (!st?.isFile) continue
|
|
869
|
-
const pinned =
|
|
961
|
+
const pinned = canonicalStars.has(native.toLowerCase())
|
|
870
962
|
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
871
963
|
if (!pinned && !fresh) {
|
|
872
964
|
dropped.age += 1
|
|
@@ -878,15 +970,16 @@ export async function listClaudeSessions(
|
|
|
878
970
|
project: workspaceLabel(folder),
|
|
879
971
|
mtimeMs: st.mtimeMs,
|
|
880
972
|
birthtimeMs: st.birthtimeMs,
|
|
973
|
+
desktop: desktopByCli.get(native)?.file,
|
|
881
974
|
}
|
|
882
975
|
seen.add(native.toLowerCase())
|
|
883
976
|
if (pinned) pinnedCandidates.push(candidate)
|
|
884
977
|
else recentCandidates.push(candidate)
|
|
885
978
|
}
|
|
886
979
|
}
|
|
887
|
-
for (const starred of
|
|
888
|
-
if (seen.has(starred) ||
|
|
889
|
-
const desktop =
|
|
980
|
+
for (const starred of canonicalStars) {
|
|
981
|
+
if (seen.has(starred) || canonicalLive.has(starred) || canonicalLive.has(starred.slice(0, 8))) continue
|
|
982
|
+
const desktop = desktopByCli.get(starred)?.file
|
|
890
983
|
if (!desktop) continue
|
|
891
984
|
const st = await fileStat(desktop)
|
|
892
985
|
if (!st?.isFile) continue
|
|
@@ -921,11 +1014,11 @@ export async function listClaudeSessions(
|
|
|
921
1014
|
title = peek.customTitle ?? firstPrompt
|
|
922
1015
|
latestAssistant = peek.latestAssistant
|
|
923
1016
|
}
|
|
924
|
-
if (
|
|
1017
|
+
if (candidate.desktop || !title || !project) {
|
|
925
1018
|
const desktop = candidate.desktop || await findClaudeDesktopFile(desktopSessionsRoot, candidate.native)
|
|
926
1019
|
if (desktop) {
|
|
927
1020
|
const head = peekClaudeDesktopHead(await readWindow(desktop, false))
|
|
928
|
-
if (
|
|
1021
|
+
if (head.title) title = head.title
|
|
929
1022
|
if (!project && head.cwd) project = workspaceLabel(head.cwd)
|
|
930
1023
|
}
|
|
931
1024
|
}
|
|
@@ -939,7 +1032,7 @@ export async function listClaudeSessions(
|
|
|
939
1032
|
modified: isoFromMtime(candidate.mtimeMs),
|
|
940
1033
|
created: isoFromMtime(candidate.birthtimeMs),
|
|
941
1034
|
alive: false,
|
|
942
|
-
pinned:
|
|
1035
|
+
pinned: canonicalStars.has(candidate.native.toLowerCase()),
|
|
943
1036
|
...discussionFields(title, firstPrompt, latestAssistant),
|
|
944
1037
|
}))
|
|
945
1038
|
}
|
|
@@ -997,9 +1090,9 @@ export async function listCodexSessions(
|
|
|
997
1090
|
// `session_meta` record wholesale, so the new rollout carries the PARENT's id
|
|
998
1091
|
// while its filename carries its own.
|
|
999
1092
|
//
|
|
1000
|
-
//
|
|
1093
|
+
// the user forked "Example project 2.0 build" into "Example project 3.0 build" and the fork
|
|
1001
1094
|
// never appeared in the session list. It had not failed to index -- it was indexed
|
|
1002
|
-
// AS its parent, so the list showed one `
|
|
1095
|
+
// AS its parent, so the list showed one `Example project 2.0 build` row whose modified
|
|
1003
1096
|
// time was the fork's activity. Two conversations, one identity, and the newer one
|
|
1004
1097
|
// invisible.
|
|
1005
1098
|
//
|
|
@@ -1183,7 +1276,11 @@ export async function listAgentSessions(
|
|
|
1183
1276
|
sort: AgentSessionSort = 'updated',
|
|
1184
1277
|
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
1185
1278
|
): Promise<AgentSessionRow[]> {
|
|
1186
|
-
const
|
|
1279
|
+
const originalStars = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
1280
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
1281
|
+
const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
|
|
1282
|
+
const starredIds = new Set([...originalStars].map(canonical))
|
|
1283
|
+
live = live.map(row => row.provider === 'claude' ? { ...row, session_id: canonical(normalizeClaudeSessionId(row.session_id)) } : row)
|
|
1187
1284
|
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
1188
1285
|
const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
|
|
1189
1286
|
.filter(entry => !isKeepWarmSessionTitle(entry.display_label))
|
|
@@ -1233,15 +1330,29 @@ export async function findAgentSessionFile(
|
|
|
1233
1330
|
if (!isSafeSessionId(sessionId)) return null
|
|
1234
1331
|
const needle = sessionId.trim().toLowerCase()
|
|
1235
1332
|
if (provider === 'claude') {
|
|
1333
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
1334
|
+
const files = new Map<string, string[]>()
|
|
1236
1335
|
for (const folder of await dirents(roots.claudeProjects)) {
|
|
1237
1336
|
const dir = join(roots.claudeProjects, folder)
|
|
1238
1337
|
for (const name of await dirents(dir)) {
|
|
1239
|
-
if (!
|
|
1338
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
1339
|
+
const file = join(dir, name)
|
|
1340
|
+
if (!(await fileStat(file))?.isFile) continue
|
|
1240
1341
|
const id = name.slice(0, -6).toLowerCase()
|
|
1241
|
-
|
|
1342
|
+
files.set(id, [...(files.get(id) || []), file])
|
|
1242
1343
|
}
|
|
1243
1344
|
}
|
|
1244
|
-
|
|
1345
|
+
const exact = aliases.has(needle) || files.has(needle)
|
|
1346
|
+
const ids = new Set<string>()
|
|
1347
|
+
for (const [id, head] of aliases) {
|
|
1348
|
+
if (!(exact ? id === needle : id.startsWith(needle))) continue
|
|
1349
|
+
if (!head) return null
|
|
1350
|
+
ids.add(head.cliSessionId || id)
|
|
1351
|
+
}
|
|
1352
|
+
for (const id of files.keys()) if (exact ? id === needle : id.startsWith(needle)) ids.add(id)
|
|
1353
|
+
if (ids.size !== 1) return null
|
|
1354
|
+
const matches = files.get([...ids][0]) || []
|
|
1355
|
+
return matches.length === 1 ? matches[0] : null
|
|
1245
1356
|
}
|
|
1246
1357
|
if (provider === 'codex') {
|
|
1247
1358
|
for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
|
|
@@ -1469,18 +1580,18 @@ export async function parseAgentSession(
|
|
|
1469
1580
|
/**
|
|
1470
1581
|
* Add a user turn to the recency digest -- or decline to.
|
|
1471
1582
|
*
|
|
1472
|
-
* TWO DECLINES, both found by parsing
|
|
1583
|
+
* TWO DECLINES, both found by parsing the user's own 94 MiB session rather than by
|
|
1473
1584
|
* reasoning about the code.
|
|
1474
1585
|
*
|
|
1475
1586
|
* `injected`: STRUCTURAL, not a heuristic. Claude marks a slash-command body with
|
|
1476
1587
|
* `isMeta: true` and a compaction preamble with `isCompactSummary: true`. Both are
|
|
1477
|
-
* written as USER rows, so both rendered in the digest as things
|
|
1588
|
+
* written as USER rows, so both rendered in the digest as things the user had asked --
|
|
1478
1589
|
* his screenshot led with "This session is being continued from a previous
|
|
1479
1590
|
* conversation… Summary: ## 1. Primary Request and Intent". Neither is an ask, and
|
|
1480
1591
|
* both are recent, so recency ordering alone leaves them exactly where they were.
|
|
1481
1592
|
*
|
|
1482
1593
|
* `fromTail` on a TRUNCATED read: the head window IS the session opening. Feeding it
|
|
1483
|
-
* into a recency list is the very thing
|
|
1594
|
+
* into a recency list is the very thing the user objected to -- "not something that's
|
|
1484
1595
|
* the 'first' message" -- and on a large session the 60-turn window never fills, so
|
|
1485
1596
|
* those opening turns survive to the top of the digest forever. Measured on his
|
|
1486
1597
|
* session: the two leading bullets were both head-window turns from the previous day.
|
|
@@ -72,7 +72,7 @@ function cleanSnippet(raw: string): string {
|
|
|
72
72
|
* Count matches in `hayLower` but cut snippets from `hayOriginal`.
|
|
73
73
|
*
|
|
74
74
|
* Matching has to happen on the lowercased text for case-insensitivity, but a
|
|
75
|
-
* snippet cut from that text renders as all-lowercase prose -- "
|
|
75
|
+
* snippet cut from that text renders as all-lowercase prose -- "casey owes the
|
|
76
76
|
* sales-vs-education split" -- which reads like broken data in a UI. The offsets
|
|
77
77
|
* line up because toLowerCase() is length-preserving for effectively all real
|
|
78
78
|
* archive text; where it is NOT (a handful of exotic codepoints expand), the
|
|
@@ -405,7 +405,7 @@ const STRIPPED_ENV_KEYS: readonly string[] = ['CLAUDECODE', 'COS_API_TOKEN']
|
|
|
405
405
|
* the prompt is the user's private text; stdin also matches what both ordinary
|
|
406
406
|
* bridges already do.
|
|
407
407
|
*
|
|
408
|
-
* THE TURN INHERITS THE SESSION'S OWN PERMISSIONS (
|
|
408
|
+
* THE TURN INHERITS THE SESSION'S OWN PERMISSIONS (the user, 2026-08-16, explicit).
|
|
409
409
|
*
|
|
410
410
|
* This used to force two independent read-only layers: `--permission-mode plan`
|
|
411
411
|
* plus an empty `--tools`/`--allowedTools` pair. The result was a Continue that
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// WHY NOT DECODE THE PROJECT SLUG. Claude files transcripts under
|
|
9
9
|
// `~/.claude/projects/<slug>/<id>.jsonl` where the slug is the cwd with separators
|
|
10
10
|
// replaced. Decoding it back is LOSSY and wrong on this very machine:
|
|
11
|
-
// slug -Users-
|
|
12
|
-
// naive /Users/
|
|
13
|
-
// real /Users/
|
|
11
|
+
// slug -Users-example-Documents-GitHub-Example-Chief-Of-Staff-MU-Chief-Staff
|
|
12
|
+
// naive /Users/example/Documents/GitHub/Example/Chief/Of/Staff/MU/Chief/Staff
|
|
13
|
+
// real /Users/example/Documents/GitHub/Example Chief Of Staff/MU-Chief-Staff
|
|
14
14
|
// The real path contains spaces AND hyphens, so the mapping is not invertible.
|
|
15
15
|
// Resuming in the wrong cwd is not a harmless error: Claude associates a session
|
|
16
16
|
// with its project, so a mismatched cwd risks writing a NEW session instead of
|
|
@@ -38,7 +38,7 @@ export const EXPECTED_EMBEDDING_DIM = 192
|
|
|
38
38
|
* non-invertible timbre vectors, so the privacy argument that motivates a short
|
|
39
39
|
* audio window does not carry across unchanged.
|
|
40
40
|
*
|
|
41
|
-
* Flagged for
|
|
41
|
+
* Flagged for the user rather than assumed: if he wants these on the 8-hour clock
|
|
42
42
|
* too, set the env var to a fraction and the correction loop becomes same-day
|
|
43
43
|
* only.
|
|
44
44
|
*/
|
|
@@ -97,7 +97,7 @@ const NOTE_EXTENSIONS = ['.md', '.markdown', '.txt']
|
|
|
97
97
|
*
|
|
98
98
|
* SYMLINKS ARE FIRST-CLASS, and that is a correction. A `Dirent` reports a symlink
|
|
99
99
|
* as `isSymbolicLink()`, NOT as `isDirectory()` or `isFile()`, so the first version
|
|
100
|
-
* of this walk silently skipped every linked file and linked subfolder.
|
|
100
|
+
* of this walk silently skipped every linked file and linked subfolder. Sam hit
|
|
101
101
|
* exactly that on 2026-08-09: she wired `operations/memory` at her real note store
|
|
102
102
|
* with a symlink, which worked only because `findDir` uses `statSync` and follows
|
|
103
103
|
* links — anything linked one level deeper would have vanished with no error.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// one id space, disjoint prefixes — so a reference is never ambiguous about which
|
|
3
3
|
// one it means. `.` is permitted because a file id carries its extension; it
|
|
4
4
|
// cannot traverse, since `/` is excluded and a bare `..` cannot match the prefix.
|
|
5
|
-
export const MEMORY_ID_PATTERN = /^(?:mem|file)_[A-Za-z0-9._:-]{1,
|
|
5
|
+
export const MEMORY_ID_PATTERN = /^(?:mem|file|point)_[A-Za-z0-9._:-]{1,160}$/
|
|
6
6
|
export const THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
7
7
|
|
|
8
8
|
const CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
@@ -33,7 +33,7 @@ const UNC_PATH = /\\\\[A-Za-z0-9._-]+\\(?:[^\\\r\n]+\\?)*/g
|
|
|
33
33
|
/**
|
|
34
34
|
* Trim a greedy path match back to its last plausible component.
|
|
35
35
|
*
|
|
36
|
-
* Keeps `…/
|
|
36
|
+
* Keeps `…/Example Chief Of Staff/MU/ops/comp.md` whole (every space is followed by
|
|
37
37
|
* more path) while not swallowing the trailing prose in `/Users/me/x.md here`.
|
|
38
38
|
* A component qualifies if it is followed by a `/` or contains a dot.
|
|
39
39
|
*/
|
|
@@ -208,7 +208,7 @@ export function normalizeMemoryList(value: unknown, limit: number): MemoryListIt
|
|
|
208
208
|
for (const row of value.slice(0, Math.max(0, Math.min(limit, 50)))) {
|
|
209
209
|
if (!row || typeof row !== 'object' || Array.isArray(row)) continue
|
|
210
210
|
const source = row as Record<string, unknown>
|
|
211
|
-
const id = cleanContextText(source.id,
|
|
211
|
+
const id = cleanContextText(source.id, 170)
|
|
212
212
|
if (!MEMORY_ID_PATTERN.test(id)) continue
|
|
213
213
|
result.push({
|
|
214
214
|
id,
|
|
@@ -242,7 +242,7 @@ export function normalizeMemoryDetail(value: unknown): (MemoryListItem & {
|
|
|
242
242
|
function normalizeThread(value: unknown, detail: boolean): ThreadListItem | null {
|
|
243
243
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
244
244
|
const source = value as Record<string, unknown>
|
|
245
|
-
const id = cleanContextText(source.id,
|
|
245
|
+
const id = cleanContextText(source.id, 170)
|
|
246
246
|
if (!THREAD_ID_PATTERN.test(id)) return null
|
|
247
247
|
const rawMeetings = Array.isArray(source.meetings) && source.meetings.length
|
|
248
248
|
? source.meetings
|
|
@@ -663,6 +663,7 @@ export function normalizeGraphStatus(value: unknown): Record<string, unknown> {
|
|
|
663
663
|
const processor = asRecord(source.processor) ?? {}
|
|
664
664
|
const indexState = stringOrAbsent(source.index_state, 32)
|
|
665
665
|
return {
|
|
666
|
+
engine: stringOrAbsent(source.engine, 48) ?? 'lightrag',
|
|
666
667
|
entities: integerOrAbsent(source.entities) ?? null,
|
|
667
668
|
relationships: integerOrAbsent(source.relationships) ?? null,
|
|
668
669
|
source_updated_at: isoOrAbsent(source.source_updated_at) ?? null,
|
|
@@ -913,7 +914,16 @@ export function normalizeExtractionBlock(value: unknown): Record<string, unknown
|
|
|
913
914
|
const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 16) : undefined
|
|
914
915
|
return id ? [{ id, label: stringOrAbsent(r!.label, 40) ?? id, detail: stringOrAbsent(r!.detail, 200) ?? '', selected: r!.selected === true }] : []
|
|
915
916
|
})
|
|
916
|
-
|
|
917
|
+
const run = asRecord(x.execution)
|
|
918
|
+
const execution = run ? {
|
|
919
|
+
provider: stringOrAbsent(run.provider, 40) ?? null,
|
|
920
|
+
requested_model: stringOrAbsent(run.requested_model, 120) ?? null,
|
|
921
|
+
reasoning_effort: stringOrAbsent(run.reasoning_effort, 20) ?? null,
|
|
922
|
+
actual_identity: stringOrAbsent(run.actual_identity, 120) ?? 'unverified',
|
|
923
|
+
state: stringOrAbsent(run.state, 40) ?? 'unknown',
|
|
924
|
+
error: stringOrAbsent(run.error, 160) ?? null,
|
|
925
|
+
} : null
|
|
926
|
+
return { tier: stringOrAbsent(x.tier, 16) ?? null, label: stringOrAbsent(x.label, 40) ?? null, detail: stringOrAbsent(x.detail, 200) ?? null, tiers, execution }
|
|
917
927
|
}
|
|
918
928
|
export interface KnowledgeSetupSource { path: string; enabled: boolean; exists: boolean; files: number | null; added_at: string | null }
|
|
919
929
|
|
|
@@ -980,11 +990,19 @@ export function normalizeSampleKickoff(value: unknown): Record<string, unknown>
|
|
|
980
990
|
}
|
|
981
991
|
|
|
982
992
|
/** `graph-ask`: one answer from the graph, bounded. */
|
|
983
|
-
export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null } | null {
|
|
993
|
+
export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null; investigation?: Record<string, unknown> } | null {
|
|
984
994
|
const s = asRecord(value) ?? {}
|
|
985
995
|
const answer = typeof s.answer === 'string' ? s.answer.slice(0, 20_000) : null
|
|
986
996
|
if (answer === null) return null
|
|
987
|
-
|
|
997
|
+
const candidate = asRecord(s.investigation)
|
|
998
|
+
let investigation: Record<string, unknown> | undefined
|
|
999
|
+
if (candidate?.status === 'unavailable') investigation = { status: 'unavailable', message: stringOrAbsent(candidate.message, 500) ?? 'No verified graph plan is available.' }
|
|
1000
|
+
if (candidate?.status === 'ready' && Array.isArray(candidate.nodes) && candidate.nodes.length <= 200 && Array.isArray(candidate.links) && candidate.links.length <= 500 && JSON.stringify(candidate).length <= 500_000) {
|
|
1001
|
+
// Python validates this read-only plan against the policy-filtered graph and generation.
|
|
1002
|
+
// Forward only the canvas contract; it never becomes an arbitrary workspace request.
|
|
1003
|
+
investigation = Object.fromEntries(['status','message','nodes','links','anchors','waypoints','filters','paths','generation','truncated','truncation_reason','scope'].filter(key => candidate[key] !== undefined).map(key => [key, candidate[key]]))
|
|
1004
|
+
}
|
|
1005
|
+
return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null, ...(investigation ? { investigation } : {}) }
|
|
988
1006
|
}
|
|
989
1007
|
|
|
990
1008
|
export interface IngestProgressItem { id: string; outcome: 'indexed' | 'failed' | 'unknown'; seconds: number | null; reason: string | null }
|
|
@@ -1032,7 +1050,8 @@ export function normalizeGuardrails(value: unknown): Record<string, unknown> | n
|
|
|
1032
1050
|
max_repeat_ratio: typeof g.max_repeat_ratio === 'number' && Number.isFinite(g.max_repeat_ratio) ? g.max_repeat_ratio : 0.5,
|
|
1033
1051
|
banned_patterns: (Array.isArray(g.banned_patterns) ? g.banned_patterns : []).filter((p): p is string => typeof p === 'string').slice(0, 50).map(p => p.slice(0, 200)),
|
|
1034
1052
|
llm_review: {
|
|
1035
|
-
|
|
1053
|
+
...(typeof llm.supported === 'boolean' ? { supported: llm.supported } : {}),
|
|
1054
|
+
enabled: llm.supported !== false && llm.enabled === true,
|
|
1036
1055
|
model: (GUARDRAIL_LLM_TIERS as readonly string[]).includes(String(llm.model)) ? String(llm.model) : 'haiku',
|
|
1037
1056
|
max_per_run: integerOrAbsent(llm.max_per_run) ?? 20,
|
|
1038
1057
|
},
|
|
@@ -574,7 +574,7 @@ export function listCosOperationsMeetings(options: {
|
|
|
574
574
|
meta.mutable = true
|
|
575
575
|
meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
|
|
576
576
|
// The sidecar is gitignored in the operations tree, so a G2 recording
|
|
577
|
-
// whose markdown arrived through git or iCloud (three of
|
|
577
|
+
// whose markdown arrived through git or iCloud (three of the user's KC-week
|
|
578
578
|
// recordings, 2026-09-08) sits there without one and the row loses its
|
|
579
579
|
// session id, which reads as "cannot be reviewed". The server that saved
|
|
580
580
|
// it still holds the sidecar under its own recordings root by the same
|
package/server/lib/domains.ts
CHANGED
|
@@ -160,7 +160,7 @@ export function discoveredTaskDomains(operationsDir: string | null): string[] {
|
|
|
160
160
|
/**
|
|
161
161
|
* Task domains: configured first, then any directory with a `tasks.md`.
|
|
162
162
|
*
|
|
163
|
-
* Same union rule as `resolveDomains` and for the same reason —
|
|
163
|
+
* Same union rule as `resolveDomains` and for the same reason — the user has no
|
|
164
164
|
* `domains` in his profile, discovery finds his four, and the union is exactly
|
|
165
165
|
* his four, so switching the task store onto this changes nothing about his
|
|
166
166
|
* install while removing one user's business units from everyone else's build.
|
|
@@ -182,7 +182,7 @@ export function taskDomainNames(operationsDir: string | null): string[] {
|
|
|
182
182
|
* ROUTED before any folder exists.
|
|
183
183
|
* - On disk but not configured: still listed, so a folder someone made by hand
|
|
184
184
|
* never becomes invisible. This is also what protects an existing install —
|
|
185
|
-
*
|
|
185
|
+
* the user has no `domains` in his profile, discovery finds his four, and the
|
|
186
186
|
* union is exactly his four. Nothing is written to his profile and nothing
|
|
187
187
|
* about his setup changes.
|
|
188
188
|
* - Neither: the DEFAULTS. Only a genuinely fresh COS ever sees them, which is
|
|
@@ -60,7 +60,7 @@ export type MaintenanceWorkKind =
|
|
|
60
60
|
// Two `task_dispatch` leases (dispatch + reconcile) are expected —
|
|
61
61
|
// acquire() is a concurrent Map, not a per-kind mutex.
|
|
62
62
|
| 'task_dispatch'
|
|
63
|
-
//
|
|
63
|
+
// the user-triggered recovery of a quarantined unsaved capture (6.19.0):
|
|
64
64
|
// batch-transcribes retained WAVs into a durable scribe. Held for the whole
|
|
65
65
|
// background run so an Update Server drain waits for it like any batch.
|
|
66
66
|
// Long runs are made visible: the active-recovery registry renders a
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// review panel could show a phrase but never let anyone HEAR it, and its own
|
|
7
7
|
// copy said "naming this needs its audio, which is no longer held."
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// the user's decision: keep a week, so review can happen on a weekend rather than
|
|
10
10
|
// only within hours of the meeting, and stay under 8 GB.
|
|
11
11
|
//
|
|
12
12
|
// SIZING IS MEASURED, NOT GUESSED. Real recording volume over the 14 days to
|
|
@@ -30,14 +30,14 @@ import { dataPath } from './data-dir.js'
|
|
|
30
30
|
|
|
31
31
|
export const MEETING_AUDIO_DIR = 'meeting-audio'
|
|
32
32
|
|
|
33
|
-
/** One week, per
|
|
33
|
+
/** One week, per the user: review should survive until a weekend. */
|
|
34
34
|
export function meetingAudioTtlMs(): number {
|
|
35
35
|
const raw = Number(process.env.COS_MEETING_AUDIO_RETENTION_DAYS)
|
|
36
36
|
const days = Number.isFinite(raw) && raw > 0 ? raw : 7
|
|
37
37
|
return days * 24 * 60 * 60 * 1000
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Total budget for retained meeting audio.
|
|
40
|
+
/** Total budget for retained meeting audio. the user: stay under 8 GB. */
|
|
41
41
|
export function meetingAudioMaxBytes(): number {
|
|
42
42
|
const raw = Number(process.env.COS_MEETING_AUDIO_MAX_BYTES)
|
|
43
43
|
return Number.isFinite(raw) && raw > 0 ? raw : 8 * 1024 * 1024 * 1024
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
// WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
|
|
20
20
|
// Decisions and Action Items are LLM prose that refers to people by BARE FIRST
|
|
21
21
|
// NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
|
|
22
|
-
// real scribe: 6 of 12 speakers appear that way. This org has two
|
|
23
|
-
//
|
|
22
|
+
// real scribe: 6 of 12 speakers appear that way. This org has two Alexes, two
|
|
23
|
+
// Jordans and two Chrises, so a find/replace on a first name in narrative text
|
|
24
24
|
// would silently rewrite a sentence about a different person. Prose is left
|
|
25
25
|
// alone and flagged stale instead — `proseStale` on the applied row.
|
|
26
26
|
|
|
@@ -41,7 +41,7 @@ export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
|
|
|
41
41
|
* demoted.
|
|
42
42
|
*
|
|
43
43
|
* Distinct from a rename, and it has to be: `relabelSidecarJson` rejects
|
|
44
|
-
* `from === to`, so "yes, this really is
|
|
44
|
+
* `from === to`, so "yes, this really is Sam Example" cannot be expressed as a
|
|
45
45
|
* correction at all. The floor exists because a 0.56 match is not evidence — but
|
|
46
46
|
* a person who was in the room IS evidence, and there was no way to record it.
|
|
47
47
|
* The panel demoted the row, told the reviewer to name it, and then offered a
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
//
|
|
30
30
|
// Consequence, measured on `meeting_1786628481833_eagkaz`: naming one voice
|
|
31
31
|
// enrolled 73 of 103 rows belonging to OTHER PEOPLE — 22 chunks of MU (the device
|
|
32
|
-
// owner), 16
|
|
32
|
+
// owner), 16 Riley, 10 Taylor, 8 Casey, 7 Morgan, 6 Chris, 3 Jordan, 1 Jamie, and
|
|
33
33
|
// only 30 that were actually the named voice. It reported SUCCESS, because rows do
|
|
34
34
|
// come back from the store; they were simply the wrong rows.
|
|
35
35
|
//
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
|
|
9
9
|
// while the markdown carries 70 turns — and they disagree on who spoke, with the
|
|
10
10
|
// markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
|
|
11
|
-
// says
|
|
11
|
+
// says Riley Example. The transcript was rendered from a different
|
|
12
12
|
// segmentation pass. So:
|
|
13
13
|
//
|
|
14
14
|
// * There is NO mapping from a chunk index to a transcript turn.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
//
|
|
23
23
|
// AND WHAT IS NEVER REWRITTEN: the Summary / Topics / Decisions / Action Items
|
|
24
24
|
// prose. It refers to people by bare first name — 6 of 12 speakers did so on the
|
|
25
|
-
// meeting above — and this org has two
|
|
25
|
+
// meeting above — and this org has two Alexes, two Jordans and two Chrises, so
|
|
26
26
|
// a first-name substitution in narrative text would rewrite sentences about
|
|
27
27
|
// somebody else. Reported as `proseStale` instead.
|
|
28
28
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// v1 floored the attendee block and shipped every other section verbatim. On the
|
|
6
6
|
// 2026-08-06 IJO Post-Mortem — where the review asserts exactly ONE voice — all
|
|
7
|
-
// 14 unasserted names still reached the clipboard, including one
|
|
7
|
+
// 14 unasserted names still reached the clipboard, including one the user had
|
|
8
8
|
// already confirmed was never in the room, as `[Name]:` transcript labels. The
|
|
9
9
|
// stated contract, "only asserted voices are named", was false for the largest
|
|
10
10
|
// output.
|
|
@@ -69,7 +69,7 @@ export interface ParsedScribe {
|
|
|
69
69
|
* Sections that are none of the above, in document order.
|
|
70
70
|
*
|
|
71
71
|
* v1 discarded these, losing 116,820 characters across the corpus including
|
|
72
|
-
* `Granola Structured Notes (canonical)` —
|
|
72
|
+
* `Granola Structured Notes (canonical)` — the user's own write-up — and
|
|
73
73
|
* `Fathom Action Items (with exact timestamps)`. "Copy the meeting" has to
|
|
74
74
|
* copy the meeting.
|
|
75
75
|
*/
|
|
@@ -322,7 +322,7 @@ export interface AttendeeRenderOptions {
|
|
|
322
322
|
*
|
|
323
323
|
* Gating on `voicedMs` instead tripped on 16 of 23 real meetings, because any
|
|
324
324
|
* overlap at all exceeds the union of voiced time — at 70% the line stops being
|
|
325
|
-
* a signal and becomes boilerplate on something
|
|
325
|
+
* a signal and becomes boilerplate on something the user pastes into Slack. Rows
|
|
326
326
|
* adding to more than the meeting LENGTH is the genuinely confusing case (71m
|
|
327
327
|
* of rows inside a 66-minute meeting), and that is ~10% of meetings.
|
|
328
328
|
*/
|
|
@@ -380,10 +380,10 @@ export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions
|
|
|
380
380
|
* written sentence mangles grammar and can hit the wrong person. So the applied
|
|
381
381
|
* correction row records `proseStale: true` and, until now, NOTHING read it.
|
|
382
382
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* label sites were rewritten correctly; the LLM summary still opened "
|
|
386
|
-
*
|
|
383
|
+
* Regression example (synthetic names): the user removed "Morgan Example" from a personal call that
|
|
384
|
+
* had only two participants (a third participant's voice matched a similar profile). All 8
|
|
385
|
+
* label sites were rewritten correctly; the LLM summary still opened "the user,
|
|
386
|
+
* Sam, and Morgan talk through..." and the payload said nothing. An allowlist
|
|
387
387
|
* ("this name was not confirmed") is far too weak here — he did not fail to
|
|
388
388
|
* confirm this person, he explicitly said they were not in the room.
|
|
389
389
|
*/
|
|
@@ -54,7 +54,7 @@ export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker
|
|
|
54
54
|
* distinct.
|
|
55
55
|
*
|
|
56
56
|
* De-attributing to a single shared `Ext` folded every corrected voice into one
|
|
57
|
-
* row: on the 2026-08-06 Ditto meeting
|
|
57
|
+
* row: on the 2026-08-06 Ditto meeting the user named five wrong attributions, and
|
|
58
58
|
* collapsing them would have destroyed his ability to tell those five voices
|
|
59
59
|
* apart afterwards — which is exactly what he then needs playback for. Numbering
|
|
60
60
|
* keeps them separable while asserting no identity.
|
|
@@ -80,9 +80,9 @@ export const CONFIDENT_SIMILARITY = 0.65
|
|
|
80
80
|
*
|
|
81
81
|
* The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
|
|
82
82
|
* scoring 0.55 currently arrives in the panel wearing somebody's full name. On
|
|
83
|
-
*
|
|
83
|
+
* the user's 2026-08-06 Ditto meeting that produced Riley Example (1 segment,
|
|
84
84
|
* 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
|
|
85
|
-
*
|
|
85
|
+
* Jamie Sharif (3 segments, 0.58) — and he confirmed none of them were in the
|
|
86
86
|
* room. Presenting those as names is the defect; the reviewer then has to undo
|
|
87
87
|
* an assertion the system should never have made.
|
|
88
88
|
*
|