@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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.78.0: the ONE reader for every 6.78.0 switch (P7 S3). Before it, the Sessions switches
|
|
3
|
+
* took only an exact `1` or `0` while the Messages and crash-loop switches also took
|
|
4
|
+
* `true`/`false`/`on`/`off`, so `COS_QUEUE_RECONCILE=false` read ON.
|
|
5
|
+
*
|
|
6
|
+
* `1`, `true` or `on` force a switch on; `0`, `false` or `off` force it off; case and
|
|
7
|
+
* surrounding space are ignored. Anything else (unset or empty included) is the switch's
|
|
8
|
+
* DEFAULT. Callers read it at call time, so a restart applies a change.
|
|
9
|
+
*
|
|
10
|
+
* Pure, no imports: the Pi Durable canaries load modules that use it.
|
|
11
|
+
*/
|
|
12
|
+
export function readSwitch(raw: string | undefined, fallback: boolean): boolean {
|
|
13
|
+
const value = (raw ?? '').trim().toLowerCase()
|
|
14
|
+
if (value === '1' || value === 'true' || value === 'on') return true
|
|
15
|
+
if (value === '0' || value === 'false' || value === 'off') return false
|
|
16
|
+
return fallback
|
|
17
|
+
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Used by:
|
|
6
6
|
// - /api/transcribe (short-form dictation / messaging)
|
|
7
|
-
// - /
|
|
7
|
+
// - prompt drafts (routes/prompt-drafts.ts)
|
|
8
|
+
// - the post-save transcript cleanup (6.77.0, transcript-cleanup.ts), one word at
|
|
9
|
+
// a time through fuzzyReplacementFor, with the meeting's own screen and file
|
|
10
|
+
// terms as extra targets. Not otherwise on the meeting save path: the Phase 3.5
|
|
11
|
+
// LLM pass this file once fed was deleted with the app's server fork.
|
|
8
12
|
//
|
|
9
13
|
// Zero LLM cost, ~5ms on typical dictation length.
|
|
10
14
|
//
|
|
@@ -191,6 +195,72 @@ const COMMON_ENGLISH_WORDS = new Set<string>([
|
|
|
191
195
|
'willie', 'zachary',
|
|
192
196
|
])
|
|
193
197
|
|
|
198
|
+
/** True for a word the stop list protects from ever being replaced (lowercase compare). */
|
|
199
|
+
export function isCommonEnglishWord(word: string): boolean {
|
|
200
|
+
return COMMON_ENGLISH_WORDS.has(word.toLowerCase())
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The proper-noun targets fuzzy correction may write, in first-seen order: speaker
|
|
205
|
+
* tokens (Ext, Unknown, the owner label) and entries that do not start uppercase or
|
|
206
|
+
* are shorter than 4 characters are dropped.
|
|
207
|
+
*/
|
|
208
|
+
export function canonicalFuzzyTargets(targets: string[]): string[] {
|
|
209
|
+
const owner = getOwnerSpeakerLabel()
|
|
210
|
+
return Array.from(new Set(
|
|
211
|
+
targets
|
|
212
|
+
.filter(t => {
|
|
213
|
+
if (!t || t.length < 4) return false
|
|
214
|
+
if (t === 'Ext' || t === 'Unknown' || t === owner) return false
|
|
215
|
+
return /^[A-Z]/.test(t.trim())
|
|
216
|
+
})
|
|
217
|
+
.map(t => t.trim()),
|
|
218
|
+
))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The replacement fuzzy correction would write for ONE word, or null to leave it.
|
|
223
|
+
* `canonical` comes from canonicalFuzzyTargets. Same rules as applyFuzzyCorrections,
|
|
224
|
+
* which calls this for every candidate word.
|
|
225
|
+
*/
|
|
226
|
+
export function fuzzyReplacementFor(word: string, canonical: string[], targetLowerSet?: ReadonlySet<string>): string | null {
|
|
227
|
+
if (!/^[A-Za-z][A-Za-z'-]{4,}$/.test(word)) return null
|
|
228
|
+
const lower = word.toLowerCase()
|
|
229
|
+
|
|
230
|
+
// Skip common English words (primary false-positive defense)
|
|
231
|
+
if (COMMON_ENGLISH_WORDS.has(lower)) return null
|
|
232
|
+
|
|
233
|
+
// Skip exact matches (already correct)
|
|
234
|
+
const exact = targetLowerSet ?? new Set(canonical.map(t => t.toLowerCase()))
|
|
235
|
+
if (exact.has(lower)) return null
|
|
236
|
+
|
|
237
|
+
// Length-scaled distance threshold
|
|
238
|
+
const len = word.length
|
|
239
|
+
const maxDist = len <= 8 ? 1 : len <= 11 ? 2 : 3
|
|
240
|
+
|
|
241
|
+
let bestTarget: string | null = null
|
|
242
|
+
let bestDist = maxDist + 1
|
|
243
|
+
|
|
244
|
+
for (const target of canonical) {
|
|
245
|
+
// Split multi-word targets — match on individual tokens
|
|
246
|
+
const tokens = target.split(/\s+/)
|
|
247
|
+
for (const token of tokens) {
|
|
248
|
+
if (token.length < 4) continue
|
|
249
|
+
// Length difference must be small to prevent collapses
|
|
250
|
+
if (Math.abs(token.length - len) > 2) continue
|
|
251
|
+
const dist = levenshtein(lower, token.toLowerCase())
|
|
252
|
+
if (dist > 0 && dist < bestDist && dist <= maxDist) {
|
|
253
|
+
bestDist = dist
|
|
254
|
+
bestTarget = token
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!bestTarget) return null
|
|
260
|
+
// Preserve original capitalization pattern
|
|
261
|
+
return /^[A-Z]/.test(word) ? bestTarget : bestTarget.toLowerCase()
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
/**
|
|
195
265
|
* Fuzzy-match transcript words against a canonical list of proper nouns.
|
|
196
266
|
*
|
|
@@ -218,16 +288,7 @@ export function applyFuzzyCorrections(
|
|
|
218
288
|
|
|
219
289
|
// Keep only targets that look like proper nouns.
|
|
220
290
|
// Drop speaker tokens and entries that don't start with uppercase.
|
|
221
|
-
const
|
|
222
|
-
const canonical = Array.from(new Set(
|
|
223
|
-
targets
|
|
224
|
-
.filter(t => {
|
|
225
|
-
if (!t || t.length < 4) return false
|
|
226
|
-
if (t === 'Ext' || t === 'Unknown' || t === owner) return false
|
|
227
|
-
return /^[A-Z]/.test(t.trim())
|
|
228
|
-
})
|
|
229
|
-
.map(t => t.trim()),
|
|
230
|
-
))
|
|
291
|
+
const canonical = canonicalFuzzyTargets(targets)
|
|
231
292
|
if (canonical.length === 0) return { text, replacements: 0 }
|
|
232
293
|
|
|
233
294
|
const targetLowerSet = new Set(canonical.map(t => t.toLowerCase()))
|
|
@@ -237,44 +298,11 @@ export function applyFuzzyCorrections(
|
|
|
237
298
|
// Match any 5+ char word (case-insensitive). Stop-list + strict distance
|
|
238
299
|
// thresholds are the primary defenses against false positives.
|
|
239
300
|
const result = text.replace(/\b[A-Za-z][A-Za-z'-]{4,}\b/g, (word) => {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Skip exact matches (already correct)
|
|
246
|
-
if (targetLowerSet.has(lower)) return word
|
|
247
|
-
|
|
248
|
-
// Length-scaled distance threshold
|
|
249
|
-
const len = word.length
|
|
250
|
-
const maxDist = len <= 8 ? 1 : len <= 11 ? 2 : 3
|
|
251
|
-
|
|
252
|
-
let bestTarget: string | null = null
|
|
253
|
-
let bestDist = maxDist + 1
|
|
254
|
-
|
|
255
|
-
for (const target of canonical) {
|
|
256
|
-
// Split multi-word targets — match on individual tokens
|
|
257
|
-
const tokens = target.split(/\s+/)
|
|
258
|
-
for (const token of tokens) {
|
|
259
|
-
if (token.length < 4) continue
|
|
260
|
-
// Length difference must be small to prevent collapses
|
|
261
|
-
if (Math.abs(token.length - len) > 2) continue
|
|
262
|
-
const dist = levenshtein(lower, token.toLowerCase())
|
|
263
|
-
if (dist > 0 && dist < bestDist && dist <= maxDist) {
|
|
264
|
-
bestDist = dist
|
|
265
|
-
bestTarget = token
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (bestTarget) {
|
|
271
|
-
replacements++
|
|
272
|
-
fuzzyLog.push({ from: word, to: bestTarget })
|
|
273
|
-
// Preserve original capitalization pattern
|
|
274
|
-
const wasCapitalized = /^[A-Z]/.test(word)
|
|
275
|
-
return wasCapitalized ? bestTarget : bestTarget.toLowerCase()
|
|
276
|
-
}
|
|
277
|
-
return word
|
|
301
|
+
const replacement = fuzzyReplacementFor(word, canonical, targetLowerSet)
|
|
302
|
+
if (replacement === null) return word
|
|
303
|
+
replacements++
|
|
304
|
+
fuzzyLog.push({ from: word, to: replacement })
|
|
305
|
+
return replacement
|
|
278
306
|
})
|
|
279
307
|
|
|
280
308
|
if (fuzzyLog.length > 0) {
|
|
@@ -11,6 +11,7 @@ import { createHash } from 'node:crypto'
|
|
|
11
11
|
import { runLensGistEngine, redactSecrets } from './lens-gist-engines.js'
|
|
12
12
|
import { AskProcessError } from './meeting-ask-runner.js'
|
|
13
13
|
import { searchMemories } from './context-library-search.js'
|
|
14
|
+
import { listBridgeMemories, localDateOf, RankFusion, scoreBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
|
|
14
15
|
import { searchMeetingLibrary } from './meeting-library-search.js'
|
|
15
16
|
import { MEETING_ASK_LABELS, meetingAskAnswerMarkdown, meetingAskCitations, type MeetingAskSection, type MeetingAskPhaseTiming, type MeetingAskTimings } from '../../shared/meeting-ask.js'
|
|
16
17
|
import { ABSENCE_CLAIM, matchOwnerName, ownerCardSource, personLookupQuery, personQuestion, searchedDisclosure, type OwnerCard } from './meeting-ask-owner.js'
|
|
@@ -240,18 +241,51 @@ export function sectionsFromAnswer(action: AskAction, answer: string, allowed: R
|
|
|
240
241
|
}
|
|
241
242
|
/** 'meaning' adds the embedding search (default); 'keyword' stays on local keyword matching, so no query is embedded. */
|
|
242
243
|
export type CosSearchMode = 'meaning' | 'keyword'
|
|
243
|
-
|
|
244
|
+
/** The memory bridge listing searchAskHistory falls back to (cos-bridge-notes.ts); a parameter so tests can fake it. */
|
|
245
|
+
export type AskBridgeList = (signal?: AbortSignal) => Promise<BridgeMemory[]>
|
|
246
|
+
const ASK_HIT_MIN = 0.35
|
|
247
|
+
export async function searchAskHistory(query: string, excludeSessionId?: string, signal?: AbortSignal, mode: CosSearchMode = 'meaning', domain?: string, bridge: AskBridgeList = s => listBridgeMemories(s)): Promise<{ sources: AskSource[]; notices: string[] }> {
|
|
244
248
|
const semantic = mode !== 'keyword'
|
|
245
249
|
const results = await Promise.allSettled([searchMemories({ query, limit: domain ? 50 : 3, semantic, ...(signal?{signal}:{}) }), searchMeetingLibrary({ query, limit: 3, semantic, ...(domain ? {domain} : {}), ...(signal?{signal}:{}) })])
|
|
246
250
|
const sources: AskSource[] = [], notices: string[] = semantic ? [] : ['COS search used keywords only, as set in Meeting assistant settings.']
|
|
247
251
|
if (domain) notices.push(`COS search focused on the confirmed ${domain.replace(/_/g, ' ')} domain.`)
|
|
248
|
-
results
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
for (const result of results) if (result.status === 'rejected' && result.reason instanceof AskProcessError) throw result.reason
|
|
253
|
+
const keep = (h: { domain?: string; keywordScore: number; semanticScore: number; sessionId?: string }) =>
|
|
254
|
+
(!domain || h.domain === domain) && (!excludeSessionId || !('sessionId' in h) || h.sessionId !== excludeSessionId) && Math.max(h.keywordScore, h.semanticScore) >= ASK_HIT_MIN
|
|
255
|
+
// id first: the same key order as before, so an unchanged search yields the same prompt bytes.
|
|
256
|
+
const source = (s: Omit<AskSource, 'id'>): AskSource => ({ id: `H${sources.length + 1}`, ...s })
|
|
257
|
+
// Memories. 6.76.2: when memory search did not answer (it failed, its meaning arm failed, or it found nothing), the
|
|
258
|
+
// owner's notes are also read from the memory bridge listing and matched by keyword (cos-bridge-notes.ts, shared with
|
|
259
|
+
// live cues). The public memory runtime keeps them only there. A memory search that answers is used exactly as before.
|
|
260
|
+
const memory = results[0]
|
|
261
|
+
if (memory.status === 'rejected') notices.push('COS memory search is unavailable.')
|
|
262
|
+
else if (semantic && !memory.value.semanticAvailable) notices.push('COS memory semantic search is unavailable; local keyword results are shown.')
|
|
263
|
+
const memoryRaw = memory.status === 'fulfilled' ? memory.value.hits : []
|
|
264
|
+
const memoryHits = memoryRaw.filter(keep)
|
|
265
|
+
const fused = new RankFusion<Omit<AskSource, 'id'> & { listed?: true }>()
|
|
266
|
+
fused.add(memoryHits.map(h => ({ key: `memory:${h.id}`, item: { kind: 'memory' as const, title: redactSecrets(h.title).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), memoryId: h.id, domain: h.domain } })))
|
|
267
|
+
const memoryDidNotAnswer = memoryRaw.length === 0 || (memory.status === 'fulfilled' && semantic && !memory.value.semanticAvailable)
|
|
268
|
+
if (memoryDidNotAnswer && !signal?.aborted) {
|
|
269
|
+
let listed: BridgeMemory[] = []
|
|
270
|
+
try { listed = await bridge(signal) } catch { /* no listing: the notices above stand */ }
|
|
271
|
+
fused.add(scoreBridgeMemories(listed, query).filter(x => x.score >= ASK_HIT_MIN && (!domain || x.row.domain === domain)).map(({ row }) => {
|
|
272
|
+
const date = localDateOf(row.created_at)
|
|
273
|
+
const title = row.summary || row.content.split('\n').find(l => l.trim()) || row.id
|
|
274
|
+
return { key: `memory:${row.id}`, item: { kind: 'memory' as const, title: redactSecrets(title.replace(/\s+/g, ' ').trim().slice(0, 140) + (date ? ' · ' + date : '')).slice(0, 180), text: redactSecrets(row.content).slice(0, 1500), memoryId: row.id, domain: row.domain, listed: true as const } }
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
277
|
+
const memorySources = fused.ranked(3)
|
|
278
|
+
for (const { listed, ...s } of memorySources) sources.push(source(s))
|
|
279
|
+
if (memorySources.some(s => s.listed)) notices.push('COS also searched the notes in its memory list by keyword.')
|
|
280
|
+
// Earlier meetings: unchanged.
|
|
281
|
+
const meetings = results[1]
|
|
282
|
+
if (meetings.status === 'rejected') notices.push('Earlier meeting search is unavailable.')
|
|
283
|
+
else {
|
|
284
|
+
if (semantic && !meetings.value.semanticAvailable) notices.push('Earlier meeting semantic search is unavailable; local keyword results are shown.')
|
|
285
|
+
meetings.value.hits.filter(keep).slice(0, 3).forEach(h => {
|
|
286
|
+
sources.push(source({ kind: 'meeting', title: redactSecrets(h.title + (h.date ? ' · ' + h.date : '')).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), recordId: h.recordId, sessionId: h.sessionId, domain: h.domain, month: h.month, filename: h.filename }))
|
|
253
287
|
})
|
|
254
|
-
}
|
|
288
|
+
}
|
|
255
289
|
if (!sources.length) notices.push('No relevant COS history was found.')
|
|
256
290
|
return { sources, notices }
|
|
257
291
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Live cues v2 notes fallback (6.76.2). When cue_memory.py is missing, fails or times out (any Mac but Miles's has no such
|
|
2
|
+
// script), the memory arm looks in the owner's own COS instead, with no model call and no query leaving the Mac:
|
|
3
|
+
// - searchMemories and searchMeetingLibrary, the search the Live COS Ask uses, in its keyword-only mode (semantic: false),
|
|
4
|
+
// - the memory bridge listing (`cos_api_bridge.py memory`, what COS Control's Memory list shows), where the public memory
|
|
5
|
+
// runtime 0.1.0 keeps the owner's notes (cos-bridge-notes.ts, shared with the Live COS Ask).
|
|
6
|
+
// The window is never sent as a query. A few salient words are taken from it, and the candidates are ranked by
|
|
7
|
+
// reciprocal rank fusion (k=60, as cue_memory.py does). The Jev or SubQ judge then decides relevance, exactly as for
|
|
8
|
+
// cue_memory candidates. The whole arm is bounded by the 3 s dense budget of cue_memory.py. Nothing here logs.
|
|
9
|
+
|
|
10
|
+
import { searchMemories, type ContextSearchHit } from './context-library-search.js'
|
|
11
|
+
import { searchMeetingLibrary, type MeetingSearchHit } from './meeting-library-search.js'
|
|
12
|
+
import { listBridgeMemories, localDateOf, RankFusion, scoreBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
|
|
13
|
+
export { localDateOf, normalizeBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
|
|
14
|
+
|
|
15
|
+
/** Mirrors cue_memory.py DENSE_BUDGET_S: the notes arm never takes longer than its dense arm may. */
|
|
16
|
+
export const NOTES_BUDGET_MS = 3_000
|
|
17
|
+
export const NOTES_TOP = 4
|
|
18
|
+
const NOTE_CHARS = 520
|
|
19
|
+
const TITLE_CHARS = 90
|
|
20
|
+
|
|
21
|
+
export interface NoteCandidate { id: string; date: string; title: string; notes: string; key: string }
|
|
22
|
+
export interface NotesResult { notes: NoteCandidate[]; error?: string }
|
|
23
|
+
|
|
24
|
+
export interface NotesSearchers {
|
|
25
|
+
memories(query: string, signal: AbortSignal): Promise<ContextSearchHit[]>
|
|
26
|
+
meetings(query: string, signal: AbortSignal): Promise<MeetingSearchHit[]>
|
|
27
|
+
bridge(signal: AbortSignal, timeoutMs: number): Promise<BridgeMemory[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// cue_memory.py STOP, plus conversational filler that ASR produces and that would match every note.
|
|
31
|
+
const STOP = new Set(`a an the and or but if then so of to in on at for with from by as is are was were be been being it its this that these those
|
|
32
|
+
i you he she we they me him her us them my your our their what which who whom whose when where why how all any both each few more most other
|
|
33
|
+
some such no nor not only own same than too very can will just don should now do does did doing have has had having would could might must
|
|
34
|
+
shall may yeah yes okay ok like um uh gonna wanna kind sort really right know think going get got one two also there here about into over again
|
|
35
|
+
further once out up down off say said says thing things lot gotta yep nope hmm mhm let lets thats theres dont didnt doesnt cant wont isnt im ive
|
|
36
|
+
youre were theyre hes shes its okay alright actually basically literally maybe probably pretty much well good great cool sure thank thanks
|
|
37
|
+
because cause want wanted need needs make made take took come came look looking see saw tell told talk talking mean means time today tomorrow
|
|
38
|
+
yesterday week year day back still even something someone anything everything nothing way guess feel`.split(/\s+/).filter(Boolean))
|
|
39
|
+
|
|
40
|
+
// Capitalised in every sentence position, so a capital says nothing about them being a name.
|
|
41
|
+
const CALENDAR = new Set('monday tuesday wednesday thursday friday saturday sunday january february march april may june july august september october november december'.split(' '))
|
|
42
|
+
|
|
43
|
+
/** The most salient words of the window, most salient first: frequency, a bonus for the last ~600 characters (what the
|
|
44
|
+
* room is on now, the slice cue_memory.py embeds) and for a capitalised use mid-sentence (likely a name). Tokens use the
|
|
45
|
+
* library tokenizer's alphabet ([a-z0-9]) so every term can match. */
|
|
46
|
+
export function notesQueryTerms(window: string, max = 6): string[] {
|
|
47
|
+
const recentFrom = Math.max(0, window.length - 600)
|
|
48
|
+
const stats = new Map<string, { n: number; recent: boolean; proper: boolean; last: number }>()
|
|
49
|
+
for (const m of window.matchAll(/[A-Za-z0-9]+/g)) {
|
|
50
|
+
const raw = m[0], term = raw.toLowerCase(), at = m.index ?? 0
|
|
51
|
+
if (term.length < 3 || STOP.has(term) || /^\d{1,2}$/.test(term)) continue
|
|
52
|
+
const st = stats.get(term) ?? { n: 0, recent: false, proper: false, last: 0 }
|
|
53
|
+
st.n += 1
|
|
54
|
+
st.last = at
|
|
55
|
+
if (at >= recentFrom) st.recent = true
|
|
56
|
+
const before = window.slice(Math.max(0, at - 2), at)
|
|
57
|
+
if (/^[A-Z]/.test(raw) && !/[.!?\n]\s*$/.test(before) && at > 0) st.proper = true
|
|
58
|
+
stats.set(term, st)
|
|
59
|
+
}
|
|
60
|
+
return [...stats.entries()]
|
|
61
|
+
.map(([term, st]) => ({ term, score: st.n + (st.recent ? 0.5 : 0) + (st.proper && !CALENDAR.has(term) ? 1 : 0), last: st.last }))
|
|
62
|
+
.sort((a, b) => b.score - a.score || b.last - a.last)
|
|
63
|
+
.slice(0, max)
|
|
64
|
+
.map(x => x.term)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Two keyword queries: the top four terms (a note must share two of them, or one in its title, to match) and the top
|
|
68
|
+
* two (either one matches). Distinct, in that order. */
|
|
69
|
+
export function notesQueries(terms: string[]): string[] {
|
|
70
|
+
const qs = [terms.slice(0, 4).join(' '), terms.slice(0, 2).join(' ')].filter(q => q.trim())
|
|
71
|
+
return [...new Set(qs)]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const dateOf = localDateOf
|
|
75
|
+
const flat = (v: unknown, max: number): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
|
|
76
|
+
|
|
77
|
+
function memoryCandidate(hit: ContextSearchHit): NoteCandidate | null {
|
|
78
|
+
if (!hit.id) return null
|
|
79
|
+
return { id: '', date: dateOf(hit.created_at), title: flat(hit.title || hit.id, TITLE_CHARS), notes: flat(hit.snippet || hit.title, NOTE_CHARS), key: `memory:${hit.id}` }
|
|
80
|
+
}
|
|
81
|
+
function meetingCandidate(hit: MeetingSearchHit): NoteCandidate | null {
|
|
82
|
+
if (!hit.recordId) return null
|
|
83
|
+
return { id: '', date: dateOf(hit.date), title: flat(hit.title, TITLE_CHARS), notes: flat(hit.snippet || hit.title, NOTE_CHARS), key: `meeting:${hit.recordId}` }
|
|
84
|
+
}
|
|
85
|
+
function bridgeCandidate(row: BridgeMemory): NoteCandidate {
|
|
86
|
+
const firstLine = row.content.split('\n').find(l => l.trim()) ?? row.id
|
|
87
|
+
return { id: '', date: dateOf(row.created_at), title: flat(row.summary || firstLine, TITLE_CHARS), notes: flat(row.content, NOTE_CHARS), key: `memory:${row.id}` }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Bridge rows ranked for one query, best first (the shared scorer). */
|
|
91
|
+
export function rankBridgeMemories(rows: BridgeMemory[], query: string): BridgeMemory[] {
|
|
92
|
+
return scoreBridgeMemories(rows, query).map(x => x.row)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const realNotesSearchers: NotesSearchers = {
|
|
96
|
+
memories: async (query, signal) => (await searchMemories({ query, limit: 10, semantic: false, signal })).hits,
|
|
97
|
+
meetings: async (query, signal) => (await searchMeetingLibrary({ query, limit: 10, semantic: false, signal })).hits,
|
|
98
|
+
bridge: (signal, timeoutMs) => listBridgeMemories(signal, timeoutMs),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Candidates for the judge from the owner's own COS. `before` is exclusive (YYYY-MM-DD), like cue_memory.py; a note with
|
|
103
|
+
* no date is kept. `sessionId` drops the meeting being recorded if the library already holds a copy of it. Never throws:
|
|
104
|
+
* a source that fails is skipped, and running out of budget returns what was found so far with `error: notes_timeout`.
|
|
105
|
+
*/
|
|
106
|
+
export async function notesMemory(window: string, before: string, signal: AbortSignal,
|
|
107
|
+
opts: { sessionId?: string; budgetMs?: number; searchers?: NotesSearchers; top?: number } = {}): Promise<NotesResult> {
|
|
108
|
+
const budgetMs = opts.budgetMs ?? NOTES_BUDGET_MS
|
|
109
|
+
const searchers = opts.searchers ?? realNotesSearchers
|
|
110
|
+
const top = opts.top ?? NOTES_TOP
|
|
111
|
+
const terms = notesQueryTerms(window)
|
|
112
|
+
if (!terms.length) return { notes: [], error: 'notes_no_terms' }
|
|
113
|
+
const queries = notesQueries(terms)
|
|
114
|
+
const local = new AbortController()
|
|
115
|
+
const onAbort = () => local.abort()
|
|
116
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
117
|
+
if (signal.aborted) local.abort()
|
|
118
|
+
|
|
119
|
+
const fused = new RankFusion<NoteCandidate>()
|
|
120
|
+
const failed: string[] = []
|
|
121
|
+
// The date bound applies before ranking, so a dropped note never takes a rank from one that stays.
|
|
122
|
+
const add = (list: Array<NoteCandidate | null>) => {
|
|
123
|
+
fused.add(list.filter((c): c is NoteCandidate => !!c && !(c.date && c.date >= before)).map(c => ({ key: c.key, item: c })))
|
|
124
|
+
}
|
|
125
|
+
const dropLive = (hits: MeetingSearchHit[]) => hits.filter(h => !(opts.sessionId && h.sessionId === opts.sessionId))
|
|
126
|
+
|
|
127
|
+
const work = (async () => {
|
|
128
|
+
const bridge = searchers.bridge(local.signal, budgetMs).catch(() => { failed.push('bridge'); return [] as BridgeMemory[] })
|
|
129
|
+
for (const q of queries) {
|
|
130
|
+
if (local.signal.aborted) return
|
|
131
|
+
try { add((await searchers.memories(q, local.signal)).map(memoryCandidate)) } catch { failed.push('memories') }
|
|
132
|
+
if (local.signal.aborted) return
|
|
133
|
+
try { add(dropLive(await searchers.meetings(q, local.signal)).map(meetingCandidate)) } catch { failed.push('meetings') }
|
|
134
|
+
}
|
|
135
|
+
const rows = await bridge
|
|
136
|
+
if (local.signal.aborted) return
|
|
137
|
+
for (const q of queries) add(rankBridgeMemories(rows, q).map(bridgeCandidate))
|
|
138
|
+
})()
|
|
139
|
+
|
|
140
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
141
|
+
const timedOut = await Promise.race([
|
|
142
|
+
work.then(() => false),
|
|
143
|
+
new Promise<boolean>(r => { timer = setTimeout(() => r(true), budgetMs) }),
|
|
144
|
+
])
|
|
145
|
+
clearTimeout(timer)
|
|
146
|
+
local.abort()
|
|
147
|
+
signal.removeEventListener('abort', onAbort)
|
|
148
|
+
const notes = fused.ranked(top).map((c, i) => ({ ...c, id: `M${i + 1}` }))
|
|
149
|
+
if (signal.aborted) return { notes: [] }
|
|
150
|
+
if (timedOut) return { notes, error: 'notes_timeout' }
|
|
151
|
+
return failed.length ? { notes, error: `notes_${[...new Set(failed)].sort().join('_')}_failed` } : { notes }
|
|
152
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Live cues v2 runtime switch (6.76.2). COS Control offers an on/off toggle; the choice is kept in this server's own data
|
|
2
|
+
// dir and applies without a restart. Resolution order:
|
|
3
|
+
// 1. env COS_LIVE_CUES_V2=1 forces v2 on (source 'env'); a request to turn it off is refused (409 env_forced).
|
|
4
|
+
// 2. the saved setting (source 'setting').
|
|
5
|
+
// 3. off (source 'default').
|
|
6
|
+
// Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
|
|
7
|
+
// An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
12
|
+
import { dataPath } from './data-dir.js'
|
|
13
|
+
|
|
14
|
+
export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
|
|
15
|
+
|
|
16
|
+
/** What the engine and the routes need from the store: the saved choice, or null when there is none. */
|
|
17
|
+
export interface LiveCuesV2SettingReader { stored(): boolean | null }
|
|
18
|
+
|
|
19
|
+
export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
|
|
20
|
+
/** Loaded once; only this server writes the file, and every write goes through save(). */
|
|
21
|
+
private cache: { value: boolean | null } | null = null
|
|
22
|
+
constructor(readonly file: string) {}
|
|
23
|
+
|
|
24
|
+
stored(): boolean | null {
|
|
25
|
+
if (this.cache) return this.cache.value
|
|
26
|
+
let value: boolean | null = null
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(this.file)) {
|
|
29
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
|
|
30
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
|
|
31
|
+
value = (raw as { enabled: boolean }).enabled
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch { value = null }
|
|
35
|
+
this.cache = { value }
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
|
|
40
|
+
save(enabled: boolean, now: Date = new Date()): void {
|
|
41
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
42
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
|
|
43
|
+
this.cache = { value: enabled }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function liveCuesV2EnvForced(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
48
|
+
return env.COS_LIVE_CUES_V2 === '1'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveLiveCuesV2(env: NodeJS.ProcessEnv, settings: LiveCuesV2SettingReader | null): { enabled: boolean; source: LiveCuesV2SettingSource } {
|
|
52
|
+
if (liveCuesV2EnvForced(env)) return { enabled: true, source: 'env' }
|
|
53
|
+
let stored: boolean | null = null
|
|
54
|
+
try { stored = settings ? settings.stored() : null } catch { stored = null }
|
|
55
|
+
if (stored !== null) return { enabled: stored, source: 'setting' }
|
|
56
|
+
return { enabled: false, source: 'default' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let singleton: LiveCuesV2Settings | null = null
|
|
60
|
+
export function liveCuesV2Settings(): LiveCuesV2Settings {
|
|
61
|
+
if (!singleton) singleton = new LiveCuesV2Settings(dataPath('live-cues-v2-settings.json'))
|
|
62
|
+
return singleton
|
|
63
|
+
}
|