@gotcos/glasses-server 6.76.1 → 6.78.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/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -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/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- 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/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): how it is switched on, which model it uses, and how it stops.
|
|
2
|
+
//
|
|
3
|
+
// SWITCH. Its own flag, nothing shared:
|
|
4
|
+
// 1. env COS_TRANSCRIPT_CLEANUP=1 forces it on, =0 forces it off (source 'env'); a
|
|
5
|
+
// request to change it then answers 409 env_forced.
|
|
6
|
+
// 2. the saved setting (transcript-cleanup/settings.json, atomic), which COS Control's
|
|
7
|
+
// toggle writes through POST /api/transcript-cleanup/settings (source 'setting').
|
|
8
|
+
// 3. off (source 'default'). Miles turns it on.
|
|
9
|
+
// An unreadable settings file reads as no setting: it fails toward off, never toward spending.
|
|
10
|
+
//
|
|
11
|
+
// MODEL. Haiku unless the setting or env COS_TRANSCRIPT_CLEANUP_MODEL says sonnet. Always a
|
|
12
|
+
// pinned model id, never the CLI default (a bare `claude -p` can resolve to Opus).
|
|
13
|
+
//
|
|
14
|
+
// STOPS (each one a recorded skip, correction_skipped style):
|
|
15
|
+
// switch_off nothing runs at all: no fuzzy pass, no file, no trace
|
|
16
|
+
// api_key_in_env ANTHROPIC_API_KEY is set and COS_ALLOW_API_FALLBACK is not 1: the CLI
|
|
17
|
+
// would bill the API key, not the plan
|
|
18
|
+
// claude_not_found no Claude CLI
|
|
19
|
+
// daily_calls COS_TRANSCRIPT_CLEANUP_DAILY calls today (default 30), charged BEFORE
|
|
20
|
+
// the call and never refunded
|
|
21
|
+
// daily_tokens COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS today (default 400k in plus out)
|
|
22
|
+
// breaker_open 3 failures in a row, or one provider limit, open it for 30 minutes
|
|
23
|
+
// (60 after a limit); the first meeting after that is one trial call
|
|
24
|
+
// Per meeting: one call, at most COS_TRANSCRIPT_CLEANUP_MAX_SPANS spans (default 40, hard
|
|
25
|
+
// 80), a 24k-character prompt and a 1,500-token answer. The day is the local calendar day.
|
|
26
|
+
// An unreadable or impossible state file reads as a spent day, so a corrupt ledger can
|
|
27
|
+
// stop spending but never start it.
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { dirname } from 'node:path'
|
|
31
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
32
|
+
import { cleanupDir } from './transcript-cleanup-screen.js'
|
|
33
|
+
import { CLEANUP_MODEL_IDS, type CleanupModelChoice } from './transcript-cleanup-model.js'
|
|
34
|
+
import { SPAN_LIMITS } from './transcript-cleanup-spans.js'
|
|
35
|
+
|
|
36
|
+
export type CleanupSettingSource = 'env' | 'setting' | 'default'
|
|
37
|
+
export interface StoredCleanupSettings { enabled: boolean | null; model: CleanupModelChoice | null }
|
|
38
|
+
|
|
39
|
+
export class TranscriptCleanupSettings {
|
|
40
|
+
private cache: StoredCleanupSettings | null = null
|
|
41
|
+
constructor(readonly file: string = cleanupDir('settings.json')) {}
|
|
42
|
+
|
|
43
|
+
stored(): StoredCleanupSettings {
|
|
44
|
+
if (this.cache) return this.cache
|
|
45
|
+
let value: StoredCleanupSettings = { enabled: null, model: null }
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(this.file)) {
|
|
48
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Record<string, unknown>
|
|
49
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
50
|
+
value = {
|
|
51
|
+
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : null,
|
|
52
|
+
model: raw.model === 'haiku' || raw.model === 'sonnet' ? raw.model : null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { value = { enabled: null, model: null } }
|
|
57
|
+
this.cache = value
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Atomic and durable; the cached value changes only after the write. */
|
|
62
|
+
save(patch: Partial<{ enabled: boolean; model: CleanupModelChoice }>, now: Date = new Date()): StoredCleanupSettings {
|
|
63
|
+
const current = this.stored()
|
|
64
|
+
const next: StoredCleanupSettings = {
|
|
65
|
+
enabled: patch.enabled ?? current.enabled,
|
|
66
|
+
model: patch.model ?? current.model,
|
|
67
|
+
}
|
|
68
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
69
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, ...next, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
|
|
70
|
+
this.cache = next
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveCleanupSwitch(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { enabled: boolean; source: CleanupSettingSource } {
|
|
76
|
+
if (env.COS_TRANSCRIPT_CLEANUP === '1') return { enabled: true, source: 'env' }
|
|
77
|
+
if (env.COS_TRANSCRIPT_CLEANUP === '0') return { enabled: false, source: 'env' }
|
|
78
|
+
let stored: boolean | null = null
|
|
79
|
+
try { stored = settings ? settings.stored().enabled : null } catch { stored = null }
|
|
80
|
+
if (stored !== null) return { enabled: stored, source: 'setting' }
|
|
81
|
+
return { enabled: false, source: 'default' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveCleanupModel(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { choice: CleanupModelChoice; id: string; source: CleanupSettingSource } {
|
|
85
|
+
const raw = (env.COS_TRANSCRIPT_CLEANUP_MODEL ?? '').trim().toLowerCase()
|
|
86
|
+
if (raw === 'haiku' || raw === 'sonnet') return { choice: raw, id: CLEANUP_MODEL_IDS[raw], source: 'env' }
|
|
87
|
+
let stored: CleanupModelChoice | null = null
|
|
88
|
+
try { stored = settings ? settings.stored().model : null } catch { stored = null }
|
|
89
|
+
if (stored) return { choice: stored, id: CLEANUP_MODEL_IDS[stored], source: 'setting' }
|
|
90
|
+
return { choice: 'haiku', id: CLEANUP_MODEL_IDS.haiku, source: 'default' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function intEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, max: number): number {
|
|
94
|
+
const raw = Number(env[name])
|
|
95
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), max) : fallback
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CleanupLimits { dailyCalls: number; dailyTokens: number; maxSpans: number; breakerFailures: number; breakerCooldownMs: number; limitCooldownMs: number }
|
|
99
|
+
export function cleanupLimits(env: NodeJS.ProcessEnv = process.env): CleanupLimits {
|
|
100
|
+
return {
|
|
101
|
+
dailyCalls: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY', 30, 500),
|
|
102
|
+
dailyTokens: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS', 400_000, 10_000_000),
|
|
103
|
+
maxSpans: Math.max(1, intEnv(env, 'COS_TRANSCRIPT_CLEANUP_MAX_SPANS', SPAN_LIMITS.defaultMaxSpans, SPAN_LIMITS.hardMaxSpans)),
|
|
104
|
+
breakerFailures: 3,
|
|
105
|
+
breakerCooldownMs: 30 * 60_000,
|
|
106
|
+
limitCooldownMs: 60 * 60_000,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BudgetState {
|
|
111
|
+
day: string
|
|
112
|
+
calls: number
|
|
113
|
+
tokens: number
|
|
114
|
+
consecutiveFailures: number
|
|
115
|
+
breakerOpenUntil: number | null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function localDay(now: Date): string {
|
|
119
|
+
return now.toLocaleDateString('en-CA')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The day's spend and the breaker. One small JSON file, rewritten atomically on every change. */
|
|
123
|
+
export class TranscriptCleanupBudget {
|
|
124
|
+
constructor(readonly file: string = cleanupDir('state.json'), private readonly now: () => Date = () => new Date()) {}
|
|
125
|
+
|
|
126
|
+
read(): BudgetState {
|
|
127
|
+
const day = localDay(this.now())
|
|
128
|
+
const spent: BudgetState = { day, calls: Number.MAX_SAFE_INTEGER, tokens: Number.MAX_SAFE_INTEGER, consecutiveFailures: 0, breakerOpenUntil: null }
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(this.file)) return { day, calls: 0, tokens: 0, consecutiveFailures: 0, breakerOpenUntil: null }
|
|
131
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<BudgetState>
|
|
132
|
+
if (!raw || typeof raw !== 'object' || typeof raw.day !== 'string') return spent
|
|
133
|
+
const failures = Number(raw.consecutiveFailures ?? 0)
|
|
134
|
+
const openUntil = raw.breakerOpenUntil === null || raw.breakerOpenUntil === undefined ? null : Number(raw.breakerOpenUntil)
|
|
135
|
+
const breaker = { consecutiveFailures: Number.isFinite(failures) && failures >= 0 ? failures : 0, breakerOpenUntil: openUntil !== null && Number.isFinite(openUntil) ? openUntil : null }
|
|
136
|
+
// A future day key or a negative count fails toward NOT spending.
|
|
137
|
+
if (raw.day > day) return { ...spent, ...breaker }
|
|
138
|
+
if (raw.day < day) return { day, calls: 0, tokens: 0, ...breaker }
|
|
139
|
+
const calls = Number(raw.calls), tokens = Number(raw.tokens)
|
|
140
|
+
if (!(calls >= 0) || !(tokens >= 0)) return { ...spent, ...breaker }
|
|
141
|
+
return { day, calls, tokens, ...breaker }
|
|
142
|
+
} catch {
|
|
143
|
+
return spent
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private write(state: BudgetState): void {
|
|
148
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
149
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify(state) + '\n', { mode: 0o600 })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Why a call may not run now, or null. Read-only. */
|
|
153
|
+
gate(limits: CleanupLimits): 'daily_calls' | 'daily_tokens' | 'breaker_open' | null {
|
|
154
|
+
const s = this.read()
|
|
155
|
+
if (s.breakerOpenUntil !== null && this.now().getTime() < s.breakerOpenUntil) return 'breaker_open'
|
|
156
|
+
if (s.calls >= limits.dailyCalls) return 'daily_calls'
|
|
157
|
+
if (s.tokens >= limits.dailyTokens) return 'daily_tokens'
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Charge one call BEFORE it runs. False when the day is spent. */
|
|
162
|
+
charge(limits: CleanupLimits): boolean {
|
|
163
|
+
const s = this.read()
|
|
164
|
+
if (s.calls >= limits.dailyCalls || s.tokens >= limits.dailyTokens) return false
|
|
165
|
+
this.write({ ...s, calls: s.calls + 1 })
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
recordUsage(tokens: number): void {
|
|
170
|
+
const s = this.read()
|
|
171
|
+
if (s.tokens === Number.MAX_SAFE_INTEGER) return
|
|
172
|
+
this.write({ ...s, tokens: s.tokens + Math.max(0, Math.round(tokens)) })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
recordSuccess(): void {
|
|
176
|
+
const s = this.read()
|
|
177
|
+
if (s.consecutiveFailures === 0 && s.breakerOpenUntil === null) return
|
|
178
|
+
this.write({ ...s, consecutiveFailures: 0, breakerOpenUntil: null })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Returns true when this failure opened the breaker. */
|
|
182
|
+
recordFailure(limits: CleanupLimits, providerLimit: boolean): boolean {
|
|
183
|
+
const s = this.read()
|
|
184
|
+
const failures = s.consecutiveFailures + 1
|
|
185
|
+
const open = providerLimit || failures >= limits.breakerFailures
|
|
186
|
+
const until = open ? this.now().getTime() + (providerLimit ? limits.limitCooldownMs : limits.breakerCooldownMs) : s.breakerOpenUntil
|
|
187
|
+
this.write({ ...s, consecutiveFailures: failures, breakerOpenUntil: until })
|
|
188
|
+
return open
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let settingsSingleton: TranscriptCleanupSettings | null = null
|
|
193
|
+
let budgetSingleton: TranscriptCleanupBudget | null = null
|
|
194
|
+
export function transcriptCleanupSettings(): TranscriptCleanupSettings {
|
|
195
|
+
if (!settingsSingleton) settingsSingleton = new TranscriptCleanupSettings()
|
|
196
|
+
return settingsSingleton
|
|
197
|
+
}
|
|
198
|
+
export function transcriptCleanupBudget(): TranscriptCleanupBudget {
|
|
199
|
+
if (!budgetSingleton) budgetSingleton = new TranscriptCleanupBudget()
|
|
200
|
+
return budgetSingleton
|
|
201
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Dictionary suggestions from transcript cleanup (6.77.0).
|
|
2
|
+
//
|
|
3
|
+
// When a cleanup edit is accepted and what it wrote is a term this meeting's CONTEXT
|
|
4
|
+
// supplied (a screen line, a dropped file, the owner card, COS people, a notes hit) and
|
|
5
|
+
// the dictionary does not already hold it, COS records a SUGGESTION: the written form,
|
|
6
|
+
// the heard forms, and where the evidence came from. Never an automatic addition. A
|
|
7
|
+
// person accepts it (the word joins the profile vocabulary through the glossary's own
|
|
8
|
+
// writer, optionally with its heard form as a logged, undoable correction) or dismisses
|
|
9
|
+
// it (that written form is never suggested again).
|
|
10
|
+
//
|
|
11
|
+
// One file, `transcript-cleanup/suggestions.json`, rewritten atomically. Deduped by the
|
|
12
|
+
// written form, case-insensitive. Bounded: 200 suggestions, 5 heard forms and 5 evidence
|
|
13
|
+
// rows each; accepted and dismissed ones are evicted before pending ones. Evidence rows
|
|
14
|
+
// carry ids, a file or app name and a time, never transcript or screen text.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
20
|
+
import { cleanupDir } from './transcript-cleanup-screen.js'
|
|
21
|
+
import type { EvidenceSource } from './transcript-cleanup-context.js'
|
|
22
|
+
|
|
23
|
+
export const SUGGESTION_LIMITS = { suggestions: 200, heard: 5, evidence: 5 } as const
|
|
24
|
+
export const SUGGESTION_ID = /^sug_[0-9a-f]{16}$/
|
|
25
|
+
|
|
26
|
+
export interface SuggestionEvidence {
|
|
27
|
+
source: EvidenceSource
|
|
28
|
+
sessionId: string
|
|
29
|
+
heard: string
|
|
30
|
+
at: string
|
|
31
|
+
detail: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DictionarySuggestion {
|
|
35
|
+
id: string
|
|
36
|
+
written: string
|
|
37
|
+
heard: string[]
|
|
38
|
+
status: 'pending' | 'accepted' | 'dismissed'
|
|
39
|
+
count: number
|
|
40
|
+
createdAt: string
|
|
41
|
+
updatedAt: string
|
|
42
|
+
evidence: SuggestionEvidence[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
46
|
+
|
|
47
|
+
export class SuggestionStore {
|
|
48
|
+
constructor(readonly file: string = cleanupDir('suggestions.json')) {}
|
|
49
|
+
|
|
50
|
+
list(): DictionarySuggestion[] {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(this.file)) return []
|
|
53
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as { suggestions?: unknown }
|
|
54
|
+
if (!raw || !Array.isArray(raw.suggestions)) return []
|
|
55
|
+
return raw.suggestions.filter((s): s is DictionarySuggestion => !!s && typeof s === 'object'
|
|
56
|
+
&& typeof (s as DictionarySuggestion).id === 'string' && typeof (s as DictionarySuggestion).written === 'string'
|
|
57
|
+
&& ['pending', 'accepted', 'dismissed'].includes((s as DictionarySuggestion).status)
|
|
58
|
+
&& Array.isArray((s as DictionarySuggestion).heard) && Array.isArray((s as DictionarySuggestion).evidence))
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private write(list: DictionarySuggestion[]): void {
|
|
65
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
66
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, suggestions: list }, null, 2) + '\n', { mode: 0o600 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get(id: string): DictionarySuggestion | null {
|
|
70
|
+
return this.list().find(s => s.id === id) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record one piece of evidence for a written form. Returns the suggestion, or null when
|
|
75
|
+
* nothing was recorded: no evidence, the form is already in the dictionary, or it was
|
|
76
|
+
* dismissed before.
|
|
77
|
+
*/
|
|
78
|
+
record(input: { written: string; heard: string; evidence: Array<{ source: EvidenceSource; detail: string | null }>; sessionId: string; at: string; inDictionary: (written: string) => boolean }): DictionarySuggestion | null {
|
|
79
|
+
const written = input.written.replace(/\s+/g, ' ').trim()
|
|
80
|
+
if (!written || written.length > 100 || !input.evidence.length) return null
|
|
81
|
+
if (input.inDictionary(written)) return null
|
|
82
|
+
const list = this.list()
|
|
83
|
+
const existing = list.find(s => same(s.written, written))
|
|
84
|
+
if (existing && existing.status !== 'pending') return null
|
|
85
|
+
const rows: SuggestionEvidence[] = input.evidence.map(e => ({ source: e.source, sessionId: input.sessionId, heard: input.heard.slice(0, 100), at: input.at, detail: e.detail }))
|
|
86
|
+
let out: DictionarySuggestion
|
|
87
|
+
if (existing) {
|
|
88
|
+
if (!existing.heard.some(h => same(h, input.heard)) && existing.heard.length < SUGGESTION_LIMITS.heard) existing.heard.push(input.heard.slice(0, 100))
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const dup = existing.evidence.some(e => e.source === row.source && e.sessionId === row.sessionId && same(e.heard, row.heard))
|
|
91
|
+
if (!dup && existing.evidence.length < SUGGESTION_LIMITS.evidence) existing.evidence.push(row)
|
|
92
|
+
}
|
|
93
|
+
existing.count += 1
|
|
94
|
+
existing.updatedAt = input.at
|
|
95
|
+
out = existing
|
|
96
|
+
} else {
|
|
97
|
+
out = { id: `sug_${randomBytes(8).toString('hex')}`, written, heard: [input.heard.slice(0, 100)], status: 'pending', count: 1, createdAt: input.at, updatedAt: input.at, evidence: rows.slice(0, SUGGESTION_LIMITS.evidence) }
|
|
98
|
+
list.push(out)
|
|
99
|
+
}
|
|
100
|
+
this.write(evict(list))
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setStatus(id: string, status: 'accepted' | 'dismissed', at: string): DictionarySuggestion | null {
|
|
105
|
+
const list = this.list()
|
|
106
|
+
const s = list.find(x => x.id === id)
|
|
107
|
+
if (!s) return null
|
|
108
|
+
s.status = status
|
|
109
|
+
s.updatedAt = at
|
|
110
|
+
this.write(list)
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function evict(list: DictionarySuggestion[]): DictionarySuggestion[] {
|
|
116
|
+
if (list.length <= SUGGESTION_LIMITS.suggestions) return list
|
|
117
|
+
const ordered = [...list].sort((a, b) => (a.status === 'pending' ? 1 : 0) - (b.status === 'pending' ? 1 : 0) || a.updatedAt.localeCompare(b.updatedAt))
|
|
118
|
+
const drop = new Set(ordered.slice(0, list.length - SUGGESTION_LIMITS.suggestions).map(s => s.id))
|
|
119
|
+
return list.filter(s => !drop.has(s.id))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let singleton: SuggestionStore | null = null
|
|
123
|
+
export function suggestionStore(): SuggestionStore {
|
|
124
|
+
if (!singleton) singleton = new SuggestionStore()
|
|
125
|
+
return singleton
|
|
126
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): the text model every other cleanup module shares.
|
|
2
|
+
//
|
|
3
|
+
// A saved meeting's `## Transcript` section is lines of `[Label]: words`. Cleanup only
|
|
4
|
+
// ever edits the WORDS of a line, never its label, never a line it cannot parse, and
|
|
5
|
+
// never across a line break: a speaker turn is the largest unit an edit can touch.
|
|
6
|
+
//
|
|
7
|
+
// Tokens are whitespace runs inside a line's text. Each carries its `core`: the token
|
|
8
|
+
// without leading and trailing punctuation, which is what is compared, flagged and
|
|
9
|
+
// replaced, so "Ukoma," becomes "Ukaoma," and the comma stays exactly where it was.
|
|
10
|
+
//
|
|
11
|
+
// No IO and no model here.
|
|
12
|
+
|
|
13
|
+
import { levenshtein } from './fuzzy-correct.js'
|
|
14
|
+
|
|
15
|
+
export interface TranscriptLine {
|
|
16
|
+
index: number
|
|
17
|
+
/** `[Label]: ` including the trailing space, or '' for a line without a label. */
|
|
18
|
+
prefix: string
|
|
19
|
+
label: string | null
|
|
20
|
+
text: string
|
|
21
|
+
/** False for blank lines and anything that is not a speaker turn. Never edited. */
|
|
22
|
+
editable: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Token {
|
|
26
|
+
/** Position in the flat token list. */
|
|
27
|
+
i: number
|
|
28
|
+
line: number
|
|
29
|
+
/** Offsets of the core inside `line.text`. */
|
|
30
|
+
start: number
|
|
31
|
+
end: number
|
|
32
|
+
raw: string
|
|
33
|
+
core: string
|
|
34
|
+
key: string
|
|
35
|
+
/** First token of its line, or after a sentence end. */
|
|
36
|
+
sentenceStart: boolean
|
|
37
|
+
probability?: number
|
|
38
|
+
/** Epoch ms of the word, when the batch words could be aligned. */
|
|
39
|
+
atMs?: number
|
|
40
|
+
engine?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LINE = /^\[([^\]\n]{1,80})\]: ?(.*)$/
|
|
44
|
+
|
|
45
|
+
export function parseTranscriptLines(section: string): TranscriptLine[] {
|
|
46
|
+
return section.split('\n').map((raw, index) => {
|
|
47
|
+
const m = LINE.exec(raw)
|
|
48
|
+
if (!m) return { index, prefix: '', label: null, text: raw, editable: false }
|
|
49
|
+
const prefix = raw.slice(0, raw.length - m[2].length)
|
|
50
|
+
return { index, prefix, label: m[1], text: m[2], editable: m[2].trim().length > 0 }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function joinTranscriptLines(lines: readonly TranscriptLine[]): string {
|
|
55
|
+
return lines.map(line => line.prefix + line.text).join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Letters and digits only, accents folded, lowercase. The comparison form of a word. */
|
|
59
|
+
export function fold(word: string): string {
|
|
60
|
+
return word.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
|
|
64
|
+
|
|
65
|
+
export function tokenize(lines: readonly TranscriptLine[]): Token[] {
|
|
66
|
+
const out: Token[] = []
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (!line.editable) continue
|
|
69
|
+
let sentenceStart = true
|
|
70
|
+
for (const m of line.text.matchAll(/\S+/g)) {
|
|
71
|
+
const raw = m[0]
|
|
72
|
+
const at = m.index ?? 0
|
|
73
|
+
const lead = /^[^\p{L}\p{N}]*/u.exec(raw)![0].length
|
|
74
|
+
const core = raw.replace(EDGE, '')
|
|
75
|
+
const start = at + lead
|
|
76
|
+
out.push({ i: out.length, line: line.index, start, end: start + core.length, raw, core, key: fold(core), sentenceStart })
|
|
77
|
+
sentenceStart = /[.!?]["')\]]*$/.test(raw)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One spoken word from the batch decode, placed on the meeting clock. */
|
|
84
|
+
export interface WordInfo {
|
|
85
|
+
word: string
|
|
86
|
+
probability?: number
|
|
87
|
+
atMs?: number
|
|
88
|
+
engine?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The batch words of a saved meeting, in transcript order, from its `.g2-chunks.json`
|
|
93
|
+
* sidecar. A segment's word times are seconds from that window's start; the window
|
|
94
|
+
* starts at `startElapsed` on the meeting clock (the same convention
|
|
95
|
+
* mapWordsToSpeakers uses), and the meeting started at `startTime` (epoch ms).
|
|
96
|
+
*/
|
|
97
|
+
export function wordInfoFromSidecar(sidecar: Record<string, unknown>): WordInfo[] {
|
|
98
|
+
const segments = Array.isArray(sidecar.batchSegments) ? sidecar.batchSegments : []
|
|
99
|
+
const startTime = typeof sidecar.startTime === 'number' && Number.isFinite(sidecar.startTime) ? sidecar.startTime : null
|
|
100
|
+
const out: WordInfo[] = []
|
|
101
|
+
for (const raw of segments) {
|
|
102
|
+
if (!raw || typeof raw !== 'object') continue
|
|
103
|
+
const segment = raw as { words?: unknown; startElapsed?: unknown; stt?: { engine?: unknown } }
|
|
104
|
+
const words = Array.isArray(segment.words) ? segment.words : []
|
|
105
|
+
const startElapsed = typeof segment.startElapsed === 'number' && Number.isFinite(segment.startElapsed) ? segment.startElapsed : null
|
|
106
|
+
const engine = typeof segment.stt?.engine === 'string' ? segment.stt.engine : undefined
|
|
107
|
+
for (const w of words) {
|
|
108
|
+
if (!w || typeof w !== 'object') continue
|
|
109
|
+
const word = typeof (w as { word?: unknown }).word === 'string' ? (w as { word: string }).word.trim() : ''
|
|
110
|
+
if (!word) continue
|
|
111
|
+
const p = (w as { probability?: unknown }).probability
|
|
112
|
+
const s = (w as { start?: unknown }).start
|
|
113
|
+
const atMs = startTime !== null && startElapsed !== null && typeof s === 'number' && Number.isFinite(s)
|
|
114
|
+
? Math.round(startTime + startElapsed + s * 1000) : undefined
|
|
115
|
+
out.push({
|
|
116
|
+
word,
|
|
117
|
+
...(typeof p === 'number' && Number.isFinite(p) ? { probability: p } : {}),
|
|
118
|
+
...(atMs !== undefined ? { atMs } : {}),
|
|
119
|
+
...(engine ? { engine } : {}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** How far ahead the aligner looks for a transcript token among the batch words. */
|
|
127
|
+
export const ALIGN_LOOKAHEAD = 40
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Attach probability, time and engine to the tokens the batch words produced.
|
|
131
|
+
*
|
|
132
|
+
* The saved transcript IS those words (selectBatchTranscriptForPersistence joins
|
|
133
|
+
* them; the hallucination line filter may drop a line; Nemotron only relabels), so
|
|
134
|
+
* a greedy forward match finds them in order. A token with no match within the
|
|
135
|
+
* lookahead stays unaligned: it simply carries no confidence, which can only make
|
|
136
|
+
* cleanup look at it less, never more.
|
|
137
|
+
*/
|
|
138
|
+
export function alignWordInfo(tokens: Token[], words: readonly WordInfo[]): number {
|
|
139
|
+
let j = 0
|
|
140
|
+
let aligned = 0
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
const limit = Math.min(words.length, j + ALIGN_LOOKAHEAD)
|
|
143
|
+
let hit = -1
|
|
144
|
+
for (let k = j; k < limit; k++) {
|
|
145
|
+
if (words[k].word === token.raw || fold(words[k].word) === token.key) { hit = k; break }
|
|
146
|
+
}
|
|
147
|
+
if (hit < 0) continue
|
|
148
|
+
const w = words[hit]
|
|
149
|
+
if (w.probability !== undefined) token.probability = w.probability
|
|
150
|
+
if (w.atMs !== undefined) token.atMs = w.atMs
|
|
151
|
+
if (w.engine) token.engine = w.engine
|
|
152
|
+
aligned++
|
|
153
|
+
j = hit + 1
|
|
154
|
+
}
|
|
155
|
+
return aligned
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A consonant skeleton: how a word sounds with its vowels unsure. Ukaoma, Yukoma,
|
|
160
|
+
* eucoma and "U coma" all read "km". Only ever used together with an onset check and
|
|
161
|
+
* a length bound, because a two-letter skeleton alone matches far too much.
|
|
162
|
+
*/
|
|
163
|
+
export function skeleton(word: string): string {
|
|
164
|
+
let s = fold(word).replace(/[0-9]/g, '')
|
|
165
|
+
s = s.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/q/g, 'k')
|
|
166
|
+
.replace(/x/g, 'ks').replace(/z/g, 's').replace(/dg/g, 'j')
|
|
167
|
+
s = s.replace(/[aeiouy]/g, '')
|
|
168
|
+
return s.replace(/(.)\1+/g, '$1')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Vowel onset, counting a leading y before a vowel ("Yukoma") as a vowel. */
|
|
172
|
+
export function vowelOnset(word: string): boolean {
|
|
173
|
+
const f = fold(word)
|
|
174
|
+
return /^[aeiou]/.test(f) || /^y[aeiou]/.test(f)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function maxFuzzyDistance(length: number): number {
|
|
178
|
+
return length <= 8 ? 1 : length <= 11 ? 2 : 3
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when two folded words sound alike under the skeleton rule. */
|
|
182
|
+
export function soundsAlike(a: string, b: string): boolean {
|
|
183
|
+
const fa = fold(a), fb = fold(b)
|
|
184
|
+
if (!fa || !fb || Math.abs(fa.length - fb.length) > 2) return false
|
|
185
|
+
const sa = skeleton(fa), sb = skeleton(fb)
|
|
186
|
+
if (sa.length < 2 || sa !== sb) return false
|
|
187
|
+
if (vowelOnset(fa) !== vowelOnset(fb)) return false
|
|
188
|
+
const d = levenshtein(fa, fb)
|
|
189
|
+
return d <= Math.max(maxFuzzyDistance(Math.max(fa.length, fb.length)) + 2, Math.ceil(Math.max(fa.length, fb.length) * 0.5))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Edit distance small enough that two words are plausibly one word misheard. */
|
|
193
|
+
export function closeSpelling(a: string, b: string): boolean {
|
|
194
|
+
const fa = fold(a), fb = fold(b)
|
|
195
|
+
if (!fa || !fb) return false
|
|
196
|
+
if (fa === fb) return true
|
|
197
|
+
const longest = Math.max(fa.length, fb.length)
|
|
198
|
+
return levenshtein(fa, fb) <= Math.max(1, Math.floor(longest / 3))
|
|
199
|
+
}
|
|
200
|
+
|