@gotcos/glasses-server 6.76.1 → 6.77.0
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 +40 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting.ts +65 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): what the cleanup knows about a meeting besides its audio.
|
|
2
|
+
//
|
|
3
|
+
// TEXTUAL AWARENESS. The meeting's title and attendees, the enrolled speaker names (COS
|
|
4
|
+
// people), the owner card (meeting-ask-owner.ts), the profile vocabulary and the
|
|
5
|
+
// heard-to-written corrections (the COS dictionary, which dictation also uses), the text
|
|
6
|
+
// of files dropped on the meeting (the Meeting Ask file list), and a few local notes hits
|
|
7
|
+
// for the names the doubtful passages are near (searchMemories and searchMeetingLibrary in
|
|
8
|
+
// keyword-only mode: nothing leaves the Mac).
|
|
9
|
+
//
|
|
10
|
+
// SCREEN AWARENESS. On-screen text lines COS Control recorded during the meeting
|
|
11
|
+
// (transcript-cleanup-screen.ts), shown beside each passage by time.
|
|
12
|
+
//
|
|
13
|
+
// TERMS. Every source contributes terms: capitalised words and runs of them ("Queen
|
|
14
|
+
// Ukaoma", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
|
|
15
|
+
// may write a term from this meeting's screen or files, the model may write any term,
|
|
16
|
+
// and a term the model writes that came from the meeting (not the dictionary) becomes a
|
|
17
|
+
// dictionary SUGGESTION, never an automatic addition.
|
|
18
|
+
|
|
19
|
+
import { isCommonEnglishWord } from './fuzzy-correct.js'
|
|
20
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
21
|
+
import type { OwnerCard } from './meeting-ask-owner.js'
|
|
22
|
+
import { fold } from './transcript-cleanup-text.js'
|
|
23
|
+
import type { ScreenLine } from './transcript-cleanup-screen.js'
|
|
24
|
+
|
|
25
|
+
/** Where a term came from. vocabulary and correction are the dictionary itself; the rest are evidence. */
|
|
26
|
+
export type TermSource = 'vocabulary' | 'correction' | 'speaker' | 'owner' | 'attendee' | 'screen' | 'file' | 'notes'
|
|
27
|
+
/** Sources that make a written form a dictionary suggestion. Matches Control's evidence enum. */
|
|
28
|
+
export type EvidenceSource = 'screen' | 'file' | 'owner' | 'people' | 'notes'
|
|
29
|
+
|
|
30
|
+
export interface ContextTerm {
|
|
31
|
+
display: string
|
|
32
|
+
key: string
|
|
33
|
+
sources: Set<TermSource>
|
|
34
|
+
/** A file name, memory id or app name for the evidence row. Never transcript or OCR text. */
|
|
35
|
+
details: Map<TermSource, string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CleanupFile { name: string; text: string }
|
|
39
|
+
/** `id` is the prompt's own label (N1, N2); `ref` the memory id or meeting record id, for evidence rows. */
|
|
40
|
+
export interface CleanupNote { id: string; ref: string; kind: 'memory' | 'meeting'; title: string; snippet: string; query: string }
|
|
41
|
+
|
|
42
|
+
export interface ContextPack {
|
|
43
|
+
title: string
|
|
44
|
+
date: string
|
|
45
|
+
attendees: string[]
|
|
46
|
+
speakers: string[]
|
|
47
|
+
owner: OwnerCard | null
|
|
48
|
+
vocabulary: string[]
|
|
49
|
+
corrections: Array<{ heard: string; written: string }>
|
|
50
|
+
files: Array<{ name: string; excerpt: string }>
|
|
51
|
+
screen: ScreenLine[]
|
|
52
|
+
notes: CleanupNote[]
|
|
53
|
+
terms: Map<string, ContextTerm>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const CONTEXT_LIMITS = {
|
|
57
|
+
speakers: 150,
|
|
58
|
+
vocabulary: 300,
|
|
59
|
+
corrections: 120,
|
|
60
|
+
files: 6,
|
|
61
|
+
fileChars: 60_000,
|
|
62
|
+
fileExcerptChars: 900,
|
|
63
|
+
termsPerSource: 400,
|
|
64
|
+
notes: 6,
|
|
65
|
+
noteChars: 280,
|
|
66
|
+
noteQueries: 4,
|
|
67
|
+
} as const
|
|
68
|
+
|
|
69
|
+
const WORD = /[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu
|
|
70
|
+
|
|
71
|
+
function looksLikeName(word: string): boolean {
|
|
72
|
+
if (!/^\p{Lu}/u.test(word)) return false
|
|
73
|
+
const f = fold(word)
|
|
74
|
+
if (f.length < 4) return false
|
|
75
|
+
if (isCommonEnglishWord(f)) return false
|
|
76
|
+
// An all-caps run of five letters or fewer is an acronym or a UI label, not a name.
|
|
77
|
+
if (/^\p{Lu}+$/u.test(word) && f.length <= 5) return false
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Capitalised name-like words of a text, and runs of two or three of them. */
|
|
82
|
+
export function extractTerms(text: string, max: number = CONTEXT_LIMITS.termsPerSource): string[] {
|
|
83
|
+
const out = new Set<string>()
|
|
84
|
+
const words = [...text.matchAll(WORD)].map(m => ({ w: m[0].replace(/['’]s$/i, ''), at: m.index ?? 0, end: (m.index ?? 0) + m[0].length }))
|
|
85
|
+
for (let i = 0; i < words.length && out.size < max; i++) {
|
|
86
|
+
const w = words[i].w
|
|
87
|
+
const capital = /^\p{Lu}/u.test(w)
|
|
88
|
+
if (looksLikeName(w)) out.add(w)
|
|
89
|
+
// "Queen Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
|
|
90
|
+
if (capital && i + 1 < words.length && text.slice(words[i].end, words[i + 1].at) === ' ' && looksLikeName(words[i + 1].w)) {
|
|
91
|
+
out.add(`${w} ${words[i + 1].w}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...out].slice(0, max)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function addTerm(terms: Map<string, ContextTerm>, display: string, source: TermSource, detail?: string): void {
|
|
98
|
+
const clean = display.replace(/\s+/g, ' ').trim()
|
|
99
|
+
const key = fold(clean)
|
|
100
|
+
if (!key || key.length < 2 || clean.length > 100) return
|
|
101
|
+
const existing = terms.get(key)
|
|
102
|
+
if (existing) {
|
|
103
|
+
existing.sources.add(source)
|
|
104
|
+
if (detail && !existing.details.has(source)) existing.details.set(source, detail)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
terms.set(key, { display: clean, key, sources: new Set([source]), details: new Map(detail ? [[source, detail]] : []) })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ContextInputs {
|
|
111
|
+
title: string
|
|
112
|
+
date: string
|
|
113
|
+
attendees: string[]
|
|
114
|
+
speakers: string[]
|
|
115
|
+
owner: OwnerCard | null
|
|
116
|
+
vocabulary: string[]
|
|
117
|
+
corrections: Record<string, string>
|
|
118
|
+
files: CleanupFile[]
|
|
119
|
+
screen: ScreenLine[]
|
|
120
|
+
notes?: CleanupNote[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const SPEAKER_TOKENS = new Set(['ext', 'unknown', 'me', 'speaker'])
|
|
124
|
+
|
|
125
|
+
/** The pack, with every text bounded and passed through redactSecrets. */
|
|
126
|
+
export function buildContextPack(input: ContextInputs): ContextPack {
|
|
127
|
+
const terms = new Map<string, ContextTerm>()
|
|
128
|
+
const vocabulary = input.vocabulary.map(v => v.trim()).filter(Boolean).slice(0, CONTEXT_LIMITS.vocabulary)
|
|
129
|
+
for (const v of vocabulary) {
|
|
130
|
+
addTerm(terms, v, 'vocabulary')
|
|
131
|
+
for (const part of v.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'vocabulary')
|
|
132
|
+
}
|
|
133
|
+
const corrections = Object.entries(input.corrections).slice(0, CONTEXT_LIMITS.corrections).map(([heard, written]) => ({ heard, written }))
|
|
134
|
+
for (const c of corrections) addTerm(terms, c.written, 'correction')
|
|
135
|
+
const speakers = [...new Set(input.speakers.map(s => s.trim()).filter(s => s && !SPEAKER_TOKENS.has(s.toLowerCase()) && !/^Unidentified \d+$/.test(s)))].slice(0, CONTEXT_LIMITS.speakers)
|
|
136
|
+
for (const s of speakers) {
|
|
137
|
+
addTerm(terms, s, 'speaker')
|
|
138
|
+
for (const part of s.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'speaker')
|
|
139
|
+
}
|
|
140
|
+
const attendees = [...new Set(input.attendees.map(s => s.trim()).filter(s => s && !SPEAKER_TOKENS.has(s.toLowerCase())))].slice(0, 40)
|
|
141
|
+
for (const a of attendees) {
|
|
142
|
+
addTerm(terms, a, 'attendee')
|
|
143
|
+
for (const part of a.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'attendee')
|
|
144
|
+
}
|
|
145
|
+
if (input.owner) {
|
|
146
|
+
addTerm(terms, input.owner.name, 'owner')
|
|
147
|
+
for (const part of input.owner.name.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'owner')
|
|
148
|
+
if (input.owner.notes) for (const t of extractTerms(input.owner.notes, 60)) addTerm(terms, t, 'owner')
|
|
149
|
+
}
|
|
150
|
+
const files: ContextPack['files'] = []
|
|
151
|
+
for (const file of input.files.slice(0, CONTEXT_LIMITS.files)) {
|
|
152
|
+
const text = redactSecrets(file.text.slice(0, CONTEXT_LIMITS.fileChars))
|
|
153
|
+
const name = file.name.replace(/[\u0000-\u001f]/g, ' ').slice(0, 120)
|
|
154
|
+
for (const t of extractTerms(text)) addTerm(terms, t, 'file', name)
|
|
155
|
+
files.push({ name, excerpt: text.replace(/\s+/g, ' ').trim().slice(0, CONTEXT_LIMITS.fileExcerptChars) })
|
|
156
|
+
}
|
|
157
|
+
const screen = input.screen.map(l => ({ ...l, text: redactSecrets(l.text) }))
|
|
158
|
+
for (const line of screen.slice(0, 2_000)) for (const t of extractTerms(line.text, 20)) addTerm(terms, t, 'screen', line.app)
|
|
159
|
+
const notes = (input.notes ?? []).slice(0, CONTEXT_LIMITS.notes).map(n => ({ ...n, title: redactSecrets(n.title).slice(0, 120), snippet: redactSecrets(n.snippet).replace(/\s+/g, ' ').slice(0, CONTEXT_LIMITS.noteChars) }))
|
|
160
|
+
for (const n of notes) for (const t of extractTerms(`${n.title}. ${n.snippet}`, 40)) addTerm(terms, t, 'notes', n.ref)
|
|
161
|
+
return {
|
|
162
|
+
title: redactSecrets(input.title).slice(0, 200),
|
|
163
|
+
date: input.date.slice(0, 40),
|
|
164
|
+
attendees,
|
|
165
|
+
speakers,
|
|
166
|
+
owner: input.owner,
|
|
167
|
+
vocabulary,
|
|
168
|
+
corrections,
|
|
169
|
+
files,
|
|
170
|
+
screen,
|
|
171
|
+
notes,
|
|
172
|
+
terms,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Add notes found after the pack was built (the search is async and runs only for spans' names). */
|
|
177
|
+
export function addNotes(pack: ContextPack, notes: CleanupNote[]): void {
|
|
178
|
+
for (const n of notes.slice(0, CONTEXT_LIMITS.notes - pack.notes.length)) {
|
|
179
|
+
const note = { ...n, title: redactSecrets(n.title).slice(0, 120), snippet: redactSecrets(n.snippet).replace(/\s+/g, ' ').slice(0, CONTEXT_LIMITS.noteChars) }
|
|
180
|
+
pack.notes.push(note)
|
|
181
|
+
for (const t of extractTerms(`${note.title}. ${note.snippet}`, 40)) addTerm(pack.terms, t, 'notes', note.ref)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A term the dictionary already holds (vocabulary, a correction's written form) is never suggested. */
|
|
186
|
+
export function inDictionary(term: ContextTerm): boolean {
|
|
187
|
+
return term.sources.has('vocabulary') || term.sources.has('correction')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The evidence a term carries for a dictionary suggestion, or [] when it has none. */
|
|
191
|
+
export function evidenceSources(term: ContextTerm): Array<{ source: EvidenceSource; detail: string | null }> {
|
|
192
|
+
const out: Array<{ source: EvidenceSource; detail: string | null }> = []
|
|
193
|
+
const map: Array<[TermSource, EvidenceSource]> = [['screen', 'screen'], ['file', 'file'], ['owner', 'owner'], ['speaker', 'people'], ['attendee', 'people'], ['notes', 'notes']]
|
|
194
|
+
const seen = new Set<EvidenceSource>()
|
|
195
|
+
for (const [from, to] of map) {
|
|
196
|
+
if (!term.sources.has(from) || seen.has(to)) continue
|
|
197
|
+
seen.add(to)
|
|
198
|
+
out.push({ source: to, detail: term.details.get(from)?.slice(0, 120) ?? null })
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Name-like targets for near-name detection and deterministic correction, each with where it came from. */
|
|
204
|
+
export interface NameTarget { display: string; key: string; fromMeeting: boolean }
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Single-word name targets. `fromMeeting` marks a target ONLY this meeting's screen or
|
|
208
|
+
* files supplied: deterministic correction holds those to stricter rules (the source word
|
|
209
|
+
* must be capitalised and not an inflection of the target), because screen text carries UI
|
|
210
|
+
* labels a dictionary never would.
|
|
211
|
+
*/
|
|
212
|
+
export function nameTargets(pack: ContextPack): NameTarget[] {
|
|
213
|
+
const out: NameTarget[] = []
|
|
214
|
+
for (const term of pack.terms.values()) {
|
|
215
|
+
if (term.display.includes(' ')) continue
|
|
216
|
+
if (!/^\p{Lu}/u.test(term.display) || term.key.length < 4) continue
|
|
217
|
+
const trusted = ['vocabulary', 'correction', 'speaker', 'owner', 'attendee', 'notes'].some(s => term.sources.has(s as TermSource))
|
|
218
|
+
out.push({ display: term.display, key: term.key, fromMeeting: !trusted })
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every known spelling, single words and phrases, folded. An exact match is not doubtful. */
|
|
224
|
+
export function knownKeys(pack: ContextPack): Set<string> {
|
|
225
|
+
const keys = new Set<string>()
|
|
226
|
+
for (const term of pack.terms.values()) {
|
|
227
|
+
keys.add(term.key)
|
|
228
|
+
for (const part of term.display.split(/\s+/)) {
|
|
229
|
+
const k = fold(part)
|
|
230
|
+
if (k.length >= 3) keys.add(k)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return keys
|
|
234
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): the undoable record of every edit, and the one place that
|
|
2
|
+
// writes a cleaned transcript.
|
|
3
|
+
//
|
|
4
|
+
// Modelled on meeting-corrections.ts: the ledger is written FIRST, as intent, and the
|
|
5
|
+
// files follow. Each applied edit keeps the ORIGINAL words beside the replacement and
|
|
6
|
+
// enough of the surrounding line to find it again, so any one span can be restored later
|
|
7
|
+
// (POST /api/meeting/:id/transcript-cleanup/undo) without touching the edits around it.
|
|
8
|
+
//
|
|
9
|
+
// WRITES. Sidecar first, markdown second, as the meeting store does: the markdown is the
|
|
10
|
+
// visible commit. Both files are re-read immediately before writing and compared with the
|
|
11
|
+
// copies the cleanup started from; any difference (a human relabel, a summary, a second
|
|
12
|
+
// writer) and nothing is written: `changed_during_cleanup`. Only the `## Transcript`
|
|
13
|
+
// section and the sidecar's `batchTranscript` (kept equal to the canonical text, as the
|
|
14
|
+
// Nemotron final pass keeps it), `transcriptCleanup` summary and `lifecycleRevision` change.
|
|
15
|
+
//
|
|
16
|
+
// One ledger per meeting, `transcript-cleanup/ledger/<id>.jsonl`, deleted with the meeting
|
|
17
|
+
// (sweepTranscriptCleanupData). A torn or unknown row is counted, never trusted.
|
|
18
|
+
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
21
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
22
|
+
import { cleanupDir, sessionFile } from './transcript-cleanup-screen.js'
|
|
23
|
+
import { joinTranscriptLines, parseTranscriptLines, type TranscriptLine } from './transcript-cleanup-text.js'
|
|
24
|
+
|
|
25
|
+
export const TRANSCRIPT_SECTION = /## Transcript\n\n([\s\S]*)$/
|
|
26
|
+
export function readTranscriptSection(markdown: string): string | null {
|
|
27
|
+
const match = TRANSCRIPT_SECTION.exec(markdown)
|
|
28
|
+
return match ? match[1].replace(/\n+$/, '') : null
|
|
29
|
+
}
|
|
30
|
+
export function replaceTranscriptSection(markdown: string, transcript: string): string | null {
|
|
31
|
+
if (!TRANSCRIPT_SECTION.test(markdown)) return null
|
|
32
|
+
return markdown.replace(TRANSCRIPT_SECTION, () => `## Transcript\n\n${transcript}\n`)
|
|
33
|
+
}
|
|
34
|
+
export const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex')
|
|
35
|
+
|
|
36
|
+
export type RunPhase = 'intent' | 'done' | 'skipped' | 'failed'
|
|
37
|
+
export interface RunRow {
|
|
38
|
+
kind: 'run'
|
|
39
|
+
runId: string
|
|
40
|
+
phase: RunPhase
|
|
41
|
+
at: string
|
|
42
|
+
inputSha: string
|
|
43
|
+
outputSha?: string
|
|
44
|
+
model?: string
|
|
45
|
+
reason?: string
|
|
46
|
+
/** Why the model was not asked, while fuzzy fixes still applied (correction_skipped style). */
|
|
47
|
+
modelSkipped?: string
|
|
48
|
+
}
|
|
49
|
+
export interface EditRow {
|
|
50
|
+
kind: 'edit'
|
|
51
|
+
id: string
|
|
52
|
+
runId: string
|
|
53
|
+
at: string
|
|
54
|
+
line: number
|
|
55
|
+
/** Offset of the replacement in the cleaned line. */
|
|
56
|
+
offset: number
|
|
57
|
+
original: string
|
|
58
|
+
replacement: string
|
|
59
|
+
before: string
|
|
60
|
+
after: string
|
|
61
|
+
/** sha-256 of the whole cleaned line when the run committed: while it still matches, offsets are exact. */
|
|
62
|
+
lineSha: string
|
|
63
|
+
source: 'fuzzy' | 'model'
|
|
64
|
+
reasons?: string[]
|
|
65
|
+
}
|
|
66
|
+
export interface RevertRow { kind: 'revert'; editId: string; at: string }
|
|
67
|
+
export type LedgerRow = RunRow | EditRow | RevertRow
|
|
68
|
+
|
|
69
|
+
export function appendLedger(sessionId: string, rows: LedgerRow[]): boolean {
|
|
70
|
+
const path = sessionFile('ledger', sessionId)
|
|
71
|
+
if (!path || rows.length === 0) return false
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(cleanupDir('ledger'), { recursive: true, mode: 0o700 })
|
|
74
|
+
appendFileSync(path, rows.map(r => JSON.stringify(r)).join('\n') + '\n', { mode: 0o600 })
|
|
75
|
+
return true
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readLedger(sessionId: string): { rows: LedgerRow[]; unusable: number } {
|
|
82
|
+
const path = sessionFile('ledger', sessionId)
|
|
83
|
+
if (!path || !existsSync(path)) return { rows: [], unusable: 0 }
|
|
84
|
+
let raw = ''
|
|
85
|
+
try { raw = readFileSync(path, 'utf8') } catch { return { rows: [], unusable: 0 } }
|
|
86
|
+
const rows: LedgerRow[] = []
|
|
87
|
+
let unusable = 0
|
|
88
|
+
for (const line of raw.split('\n')) {
|
|
89
|
+
if (!line.trim()) continue
|
|
90
|
+
try {
|
|
91
|
+
const o = JSON.parse(line) as Record<string, unknown>
|
|
92
|
+
if (o.kind === 'run' && typeof o.runId === 'string' && typeof o.inputSha === 'string' && ['intent', 'done', 'skipped', 'failed'].includes(o.phase as string)) rows.push(o as unknown as RunRow)
|
|
93
|
+
else if (o.kind === 'edit' && typeof o.id === 'string' && typeof o.line === 'number' && typeof o.offset === 'number'
|
|
94
|
+
&& typeof o.original === 'string' && typeof o.replacement === 'string' && typeof o.before === 'string' && typeof o.after === 'string'
|
|
95
|
+
&& typeof o.lineSha === 'string') rows.push(o as unknown as EditRow)
|
|
96
|
+
else if (o.kind === 'revert' && typeof o.editId === 'string') rows.push(o as unknown as RevertRow)
|
|
97
|
+
else unusable++
|
|
98
|
+
} catch { unusable++ }
|
|
99
|
+
}
|
|
100
|
+
return { rows, unusable }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A completed run already produced (or started from) this exact transcript: do not run again. */
|
|
104
|
+
export function alreadyCleaned(sessionId: string, transcriptSha: string): boolean {
|
|
105
|
+
return readLedger(sessionId).rows.some(r => r.kind === 'run' && (r.phase === 'done' || r.phase === 'skipped')
|
|
106
|
+
&& (r.outputSha === transcriptSha || r.inputSha === transcriptSha))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PlannedEdit {
|
|
110
|
+
line: number
|
|
111
|
+
start: number
|
|
112
|
+
end: number
|
|
113
|
+
replacement: string
|
|
114
|
+
source: 'fuzzy' | 'model'
|
|
115
|
+
reasons?: string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CONTEXT_CHARS = 24
|
|
119
|
+
|
|
120
|
+
export type AppliedEdit = PlannedEdit & { original: string; offset: number; before: string; after: string; lineSha: string }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Apply non-overlapping edits to the lines. Returns the new lines and, per edit, where its
|
|
124
|
+
* replacement now sits and the text around it. An overlapping edit is dropped (the earlier
|
|
125
|
+
* one in the line wins), which cannot happen for spans and fixes built from distinct tokens.
|
|
126
|
+
*/
|
|
127
|
+
export function applyPlannedEdits(lines: readonly TranscriptLine[], edits: readonly PlannedEdit[]): {
|
|
128
|
+
lines: TranscriptLine[]
|
|
129
|
+
applied: AppliedEdit[]
|
|
130
|
+
} {
|
|
131
|
+
const next = lines.map(l => ({ ...l }))
|
|
132
|
+
const applied: AppliedEdit[] = []
|
|
133
|
+
const byLine = new Map<number, PlannedEdit[]>()
|
|
134
|
+
for (const e of edits) byLine.set(e.line, [...(byLine.get(e.line) ?? []), e])
|
|
135
|
+
for (const [lineIndex, list] of byLine) {
|
|
136
|
+
const line = next.find(l => l.index === lineIndex)
|
|
137
|
+
if (!line || !line.editable) continue
|
|
138
|
+
const sorted = [...list].sort((a, b) => a.start - b.start)
|
|
139
|
+
const source = line.text
|
|
140
|
+
let out = ''
|
|
141
|
+
let cursor = 0
|
|
142
|
+
const placed: Array<{ edit: PlannedEdit; offset: number }> = []
|
|
143
|
+
for (const e of sorted) {
|
|
144
|
+
if (e.start < cursor || e.end > source.length || e.start >= e.end) continue
|
|
145
|
+
out += source.slice(cursor, e.start)
|
|
146
|
+
placed.push({ edit: e, offset: out.length })
|
|
147
|
+
out += e.replacement
|
|
148
|
+
cursor = e.end
|
|
149
|
+
}
|
|
150
|
+
out += source.slice(cursor)
|
|
151
|
+
line.text = out
|
|
152
|
+
for (const { edit, offset } of placed) {
|
|
153
|
+
applied.push({
|
|
154
|
+
...edit,
|
|
155
|
+
original: source.slice(edit.start, edit.end),
|
|
156
|
+
offset,
|
|
157
|
+
before: out.slice(Math.max(0, offset - CONTEXT_CHARS), offset),
|
|
158
|
+
after: out.slice(offset + edit.replacement.length, offset + edit.replacement.length + CONTEXT_CHARS),
|
|
159
|
+
lineSha: sha256(out),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { lines: next, applied }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class CleanupWriteError extends Error {
|
|
167
|
+
constructor(readonly code: 'changed_during_cleanup' | 'no_transcript' | 'sidecar_unreadable' | 'write_failed', message?: string) { super(message ?? code) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Write a new transcript section into the meeting, if neither file changed since it was
|
|
172
|
+
* read. `summary` lands under the sidecar's `transcriptCleanup`; it must hold counts only.
|
|
173
|
+
*/
|
|
174
|
+
export function commitCleanTranscript(options: {
|
|
175
|
+
meetingPath: string
|
|
176
|
+
sidecarPath: string
|
|
177
|
+
markdown: string
|
|
178
|
+
sidecarText: string
|
|
179
|
+
transcript: string
|
|
180
|
+
summary: Record<string, unknown>
|
|
181
|
+
write?: (path: string, data: string) => void
|
|
182
|
+
}): void {
|
|
183
|
+
const write = options.write ?? ((path: string, data: string) => durableAtomicWriteFileSync(path, data, { mode: 0o600 }))
|
|
184
|
+
const oldSection = readTranscriptSection(options.markdown)
|
|
185
|
+
const nextMarkdown = replaceTranscriptSection(options.markdown, options.transcript)
|
|
186
|
+
if (oldSection === null || nextMarkdown === null) throw new CleanupWriteError('no_transcript')
|
|
187
|
+
let sidecar: Record<string, unknown>
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(options.sidecarText) as unknown
|
|
190
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
|
|
191
|
+
sidecar = parsed as Record<string, unknown>
|
|
192
|
+
} catch { throw new CleanupWriteError('sidecar_unreadable') }
|
|
193
|
+
if (typeof sidecar.batchTranscript === 'string' && sidecar.batchTranscript.replace(/\n+$/, '') === oldSection) sidecar.batchTranscript = options.transcript
|
|
194
|
+
sidecar.transcriptCleanup = options.summary
|
|
195
|
+
sidecar.lifecycleRevision = Number(sidecar.lifecycleRevision ?? 0) + 1
|
|
196
|
+
let markdownNow: string, sidecarNow: string
|
|
197
|
+
try {
|
|
198
|
+
markdownNow = readFileSync(options.meetingPath, 'utf8')
|
|
199
|
+
sidecarNow = readFileSync(options.sidecarPath, 'utf8')
|
|
200
|
+
} catch { throw new CleanupWriteError('changed_during_cleanup', 'meeting files unreadable at commit') }
|
|
201
|
+
if (markdownNow !== options.markdown || sidecarNow !== options.sidecarText) throw new CleanupWriteError('changed_during_cleanup')
|
|
202
|
+
try {
|
|
203
|
+
write(options.sidecarPath, JSON.stringify(sidecar, null, 2))
|
|
204
|
+
} catch (error) { throw new CleanupWriteError('write_failed', `sidecar: ${error instanceof Error ? error.message : String(error)}`.slice(0, 200)) }
|
|
205
|
+
try {
|
|
206
|
+
write(options.meetingPath, nextMarkdown)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
try { write(options.sidecarPath, options.sidecarText) } catch { /* the two files now disagree; say so */ }
|
|
209
|
+
throw new CleanupWriteError('write_failed', `markdown: ${error instanceof Error ? error.message : String(error)}`.slice(0, 200))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type UndoOutcome =
|
|
214
|
+
| { ok: true; reverted: string[] }
|
|
215
|
+
| { ok: false; status: number; error: string }
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Restore the original words of one edit, or of every live edit of one run. Refuses
|
|
219
|
+
* (409 stale) when the cleaned words are no longer where the ledger left them: a later
|
|
220
|
+
* edit by hand, a relabel that split the line, or a re-transcription.
|
|
221
|
+
*/
|
|
222
|
+
export function undoCleanupEdits(options: {
|
|
223
|
+
sessionId: string
|
|
224
|
+
meetingPath: string
|
|
225
|
+
sidecarPath: string
|
|
226
|
+
editId?: string
|
|
227
|
+
runId?: string
|
|
228
|
+
now?: () => Date
|
|
229
|
+
write?: (path: string, data: string) => void
|
|
230
|
+
}): UndoOutcome {
|
|
231
|
+
const { rows } = readLedger(options.sessionId)
|
|
232
|
+
const reverted = new Set(rows.filter((r): r is RevertRow => r.kind === 'revert').map(r => r.editId))
|
|
233
|
+
const edits = rows.filter((r): r is EditRow => r.kind === 'edit' && !reverted.has(r.id))
|
|
234
|
+
const targets = options.editId ? edits.filter(e => e.id === options.editId) : options.runId ? edits.filter(e => e.runId === options.runId) : []
|
|
235
|
+
if (!targets.length) {
|
|
236
|
+
const known = rows.some(r => r.kind === 'edit' && (r.id === options.editId || r.runId === options.runId))
|
|
237
|
+
return known ? { ok: false, status: 409, error: 'already_reverted' } : { ok: false, status: 404, error: 'edit_not_found' }
|
|
238
|
+
}
|
|
239
|
+
let markdown: string, sidecarText: string
|
|
240
|
+
try {
|
|
241
|
+
markdown = readFileSync(options.meetingPath, 'utf8')
|
|
242
|
+
sidecarText = readFileSync(options.sidecarPath, 'utf8')
|
|
243
|
+
} catch { return { ok: false, status: 404, error: 'meeting_missing' } }
|
|
244
|
+
const section = readTranscriptSection(markdown)
|
|
245
|
+
if (section === null) return { ok: false, status: 409, error: 'no_transcript' }
|
|
246
|
+
const lines = parseTranscriptLines(section)
|
|
247
|
+
// Later edits first inside a line, so earlier offsets stay true.
|
|
248
|
+
const ordered = [...targets].sort((a, b) => a.line - b.line || b.offset - a.offset)
|
|
249
|
+
// A line still exactly as the run left it: every offset is exact. Otherwise each edit is
|
|
250
|
+
// found again by its words and their surroundings, and must be found exactly once.
|
|
251
|
+
const untouched = new Map<number, boolean>()
|
|
252
|
+
for (const edit of ordered) {
|
|
253
|
+
const line = lines.find(l => l.index === edit.line)
|
|
254
|
+
if (!line) return { ok: false, status: 409, error: 'stale' }
|
|
255
|
+
if (!untouched.has(line.index)) untouched.set(line.index, sha256(line.text) === edit.lineSha)
|
|
256
|
+
let at = -1
|
|
257
|
+
if (untouched.get(line.index) && line.text.slice(edit.offset, edit.offset + edit.replacement.length) === edit.replacement) at = edit.offset
|
|
258
|
+
else {
|
|
259
|
+
const needle = edit.before + edit.replacement + edit.after
|
|
260
|
+
const first = line.text.indexOf(needle)
|
|
261
|
+
if (first >= 0 && line.text.indexOf(needle, first + 1) < 0) at = first + edit.before.length
|
|
262
|
+
}
|
|
263
|
+
if (at < 0) return { ok: false, status: 409, error: 'stale' }
|
|
264
|
+
line.text = line.text.slice(0, at) + edit.original + line.text.slice(at + edit.replacement.length)
|
|
265
|
+
}
|
|
266
|
+
const transcript = joinTranscriptLines(lines)
|
|
267
|
+
const now = (options.now ?? (() => new Date()))().toISOString()
|
|
268
|
+
const live = edits.length - targets.length
|
|
269
|
+
try {
|
|
270
|
+
commitCleanTranscript({
|
|
271
|
+
meetingPath: options.meetingPath, sidecarPath: options.sidecarPath, markdown, sidecarText, transcript,
|
|
272
|
+
summary: { ...currentSummary(sidecarText), liveEdits: live, revertedAt: now },
|
|
273
|
+
write: options.write,
|
|
274
|
+
})
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const code = error instanceof CleanupWriteError ? error.code : 'write_failed'
|
|
277
|
+
return { ok: false, status: code === 'changed_during_cleanup' ? 409 : 500, error: code }
|
|
278
|
+
}
|
|
279
|
+
appendLedger(options.sessionId, targets.map(t => ({ kind: 'revert' as const, editId: t.id, at: now })))
|
|
280
|
+
return { ok: true, reverted: targets.map(t => t.id) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function currentSummary(sidecarText: string): Record<string, unknown> {
|
|
284
|
+
try {
|
|
285
|
+
const s = (JSON.parse(sidecarText) as Record<string, unknown>).transcriptCleanup
|
|
286
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? s as Record<string, unknown> : {}
|
|
287
|
+
} catch { return {} }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The ledger as the review route shows it: runs, and each edit with whether it is still live. */
|
|
291
|
+
export function ledgerView(sessionId: string): { runs: RunRow[]; edits: Array<EditRow & { reverted: boolean }>; unusable: number } {
|
|
292
|
+
const { rows, unusable } = readLedger(sessionId)
|
|
293
|
+
const reverted = new Set(rows.filter((r): r is RevertRow => r.kind === 'revert').map(r => r.editId))
|
|
294
|
+
const runs = new Map<string, RunRow>()
|
|
295
|
+
for (const r of rows) if (r.kind === 'run') runs.set(r.runId, r)
|
|
296
|
+
return {
|
|
297
|
+
runs: [...runs.values()],
|
|
298
|
+
edits: rows.filter((r): r is EditRow => r.kind === 'edit').map(e => ({ ...e, reverted: reverted.has(e.id) })),
|
|
299
|
+
unusable,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const newRunId = (): string => `tc_${randomUUID().replace(/-/g, '').slice(0, 16)}`
|
|
304
|
+
export const newEditId = (): string => `te_${randomUUID().replace(/-/g, '').slice(0, 16)}`
|