@gotcos/glasses-server 6.75.0 → 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 +63 -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/display-bus.ts +2 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +118 -21
- 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/meeting-ask-owner.ts +144 -0
- package/server/lib/meeting-ask-runtime.ts +28 -6
- package/server/lib/meeting-stop-requests.ts +123 -0
- 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-ask.ts +11 -1
- package/server/routes/meeting.ts +177 -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
- package/shared/meeting-ask.ts +23 -1
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// cue, a 45 s wall per evaluation, and no evaluation while a user's own query runs. Each evaluation writes one trace row
|
|
10
10
|
// without transcript text; a skip (paused, breaker, meeting cap, weekly budget, busy) writes one `skip` row per reason
|
|
11
11
|
// per meeting at most every 5 minutes, so the trace can tell those apart from silence.
|
|
12
|
+
//
|
|
13
|
+
// 6.76.2: the switch is also a runtime setting (live-cues-v2-settings.ts; env COS_LIVE_CUES_V2=1 still forces it on), the
|
|
14
|
+
// gate and the judge run on Jev, or on SubQ when COS_DECISION_PROVIDER_CUES=subq or SubQ is the only key (each with its
|
|
15
|
+
// own daily cap and breaker; resolveCuesDecisionProvider), and
|
|
16
|
+
// when cue_memory.py is unavailable, fails or times out the memory arm searches the owner's own COS notes and meetings
|
|
17
|
+
// instead (live-cues-v2-notes.ts; local keyword search, no model call). Each evaluation traces `cueMemorySource`.
|
|
12
18
|
|
|
13
19
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
14
20
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
@@ -17,16 +23,23 @@ import { dirname, resolve } from 'node:path'
|
|
|
17
23
|
import { emitDisplay, onDisplay } from './display-bus.js'
|
|
18
24
|
import { acquireMaintenanceWork, MaintenanceLifecycleError, maintenanceLifecycle, type MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
19
25
|
import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers } from './jev.js'
|
|
26
|
+
import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
|
|
27
|
+
import type { DecisionProvider } from './decision-settings.js'
|
|
28
|
+
import { resolveSubqKey } from './subq-key.js'
|
|
29
|
+
import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
|
|
30
|
+
import { notesMemory } from './live-cues-v2-notes.js'
|
|
31
|
+
import { meetingLibraryConfigured } from './cos-operations-meetings.js'
|
|
32
|
+
import { getMeetingStore } from './meeting-store.js'
|
|
20
33
|
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
21
34
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
22
|
-
import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
|
|
35
|
+
import { COS_SCRIPTS_DIR, PYTHON_BIN, contextSourceAvailable } from './python-bridge.js'
|
|
23
36
|
import { dataPath } from './data-dir.js'
|
|
24
37
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
25
38
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
26
39
|
import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
|
|
27
40
|
import {
|
|
28
41
|
CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, LIVE_CUES_V2_PROTOCOL, isLiveCueV2,
|
|
29
|
-
type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability,
|
|
42
|
+
type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
|
|
30
43
|
type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
|
|
31
44
|
} from '../../shared/live-cues-v2.js'
|
|
32
45
|
|
|
@@ -60,10 +73,14 @@ export const LIVE_CUES_V2_LIMITS = {
|
|
|
60
73
|
counterTtlMs: 24 * 60 * 60_000,
|
|
61
74
|
/** ~1.2k gate tokens per evaluation, at most 3 a minute: ~216k an hour, so 1.5M covers ~7 meeting hours (~$0.06). */
|
|
62
75
|
defaultJevDailyTokens: 1_500_000,
|
|
76
|
+
/** The same ceiling for SubQ, on its own ledger (subq-cues-usage.json). SubQ reserves a byte-based estimate before each
|
|
77
|
+
* call and records the input tokens it reports. */
|
|
78
|
+
defaultSubqDailyTokens: 1_500_000,
|
|
63
79
|
} as const
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
/** On when env COS_LIVE_CUES_V2=1 forces it, else when the saved runtime setting says so (6.76.2). Off by default. */
|
|
82
|
+
export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
|
|
83
|
+
return resolveLiveCuesV2(env, settings).enabled
|
|
67
84
|
}
|
|
68
85
|
export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
|
|
69
86
|
const raw = Number(env.COS_LIVE_CUES_V2_GATE)
|
|
@@ -86,6 +103,29 @@ function jevDailyCap(): number {
|
|
|
86
103
|
const raw = Number(process.env.COS_LIVE_CUES_JEV_DAILY_TOKENS)
|
|
87
104
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultJevDailyTokens
|
|
88
105
|
}
|
|
106
|
+
function subqDailyCap(): number {
|
|
107
|
+
const raw = Number(process.env.COS_LIVE_CUES_SUBQ_DAILY_TOKENS)
|
|
108
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultSubqDailyTokens
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Which classifier runs the gate and the judge. Cues do NOT follow COS Control's saved decision provider or
|
|
113
|
+
* COS_DECISION_PROVIDER (Miles 2026-10-17): the 0.8 gate and judge thresholds were calibrated on Jev, there is no SubQ
|
|
114
|
+
* replay, and SubQ would receive the live transcript window. SubQ runs cues only when env COS_DECISION_PROVIDER_CUES=subq
|
|
115
|
+
* says so, or when SubQ is the only decision key on this Mac; otherwise Jev. Any other value of that env throws
|
|
116
|
+
* decision_invalid_provider: never silently Jev.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveCuesDecisionProvider(env: NodeJS.ProcessEnv = process.env,
|
|
119
|
+
keys: { jev: () => boolean; subq: () => boolean } = { jev: () => !!resolveJevKey(), subq: () => !!resolveSubqKey() }): { provider: DecisionProvider; source: 'env' | 'only_key' | 'default' } {
|
|
120
|
+
const raw = env.COS_DECISION_PROVIDER_CUES
|
|
121
|
+
if (raw !== undefined) {
|
|
122
|
+
const v = raw.trim().toLowerCase()
|
|
123
|
+
if (v !== 'jev' && v !== 'subq') throw new DecisionProviderError('decision_invalid_provider')
|
|
124
|
+
return { provider: v, source: 'env' }
|
|
125
|
+
}
|
|
126
|
+
if (!keys.jev() && keys.subq()) return { provider: 'subq', source: 'only_key' }
|
|
127
|
+
return { provider: 'jev', source: 'default' }
|
|
128
|
+
}
|
|
89
129
|
|
|
90
130
|
// ── Python-style JSON (the replay built every prompt with json.dumps; keep byte parity) ──
|
|
91
131
|
export function pyJson(value: unknown): string {
|
|
@@ -251,17 +291,31 @@ export class LiveCuesV2Store {
|
|
|
251
291
|
// ── Dependencies (real by default, faked in tests) ──────────────────────────
|
|
252
292
|
export interface GateResult { pCue: number; kind: CueKind; lookup: Record<string, number> | null; tokens: number }
|
|
253
293
|
export interface MemoryResult { notes: MemoryNote[]; denseError?: string; indexAgeH?: number }
|
|
294
|
+
/** cueMemory() codes that mean the script did not answer (missing, failed or timed out), so the notes fallback runs. A dense
|
|
295
|
+
* error the script itself reports (its keyword arm still answered) is not one, nor is an empty answer: on Miles's Mac an
|
|
296
|
+
* empty cue_memory answer means "no related meeting", and searching his notes instead would change that path. */
|
|
297
|
+
export const CUE_MEMORY_FAILURES: ReadonlySet<string> = new Set(['no_cue_memory_script', 'memory_timeout', 'spawn_failed', 'output_limit', 'bad_output', 'memory_threw'])
|
|
298
|
+
export function cueMemoryFailed(mem: MemoryResult): boolean { return !!mem.denseError && CUE_MEMORY_FAILURES.has(mem.denseError) }
|
|
254
299
|
export interface LiveCuesV2Deps {
|
|
255
|
-
gate(title: string, window: string, signal: AbortSignal): Promise<GateResult>
|
|
300
|
+
gate(title: string, window: string, signal: AbortSignal, provider: DecisionProvider): Promise<GateResult>
|
|
301
|
+
/** cue_memory.py (Miles's COS scripts). */
|
|
256
302
|
memory(window: string, before: string, signal: AbortSignal): Promise<MemoryResult>
|
|
257
|
-
|
|
303
|
+
/** The owner's own COS notes and meetings, used only when `memory` did not answer. Local search, no model call. */
|
|
304
|
+
notes(window: string, before: string, signal: AbortSignal, sessionId: string): Promise<MemoryResult>
|
|
305
|
+
judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal, provider: DecisionProvider): Promise<{ probs: Record<string, number>; tokens: number }>
|
|
258
306
|
writer(model: string, system: string, prompt: string, signal: AbortSignal): Promise<{ text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }>
|
|
259
307
|
emit(type: 'coaching_nudge' | 'coaching_nudge_v2', data: Record<string, unknown>): void
|
|
260
308
|
lease(): MaintenanceWorkLease | null
|
|
261
309
|
/** True while the user's own query runs (Ask, a durable query): cues wait rather than compete for the provider. */
|
|
262
310
|
busy(): boolean
|
|
263
|
-
/** The
|
|
264
|
-
|
|
311
|
+
/** The configured decision provider for cues. Throws DecisionProviderError('decision_invalid_provider'). */
|
|
312
|
+
decisionProvider(): DecisionProvider
|
|
313
|
+
/** That provider's cue client: key present, daily cap, breaker. For an honest capability. */
|
|
314
|
+
decisionState(provider: DecisionProvider): { configured: boolean; breakerOpen: boolean; capReached: boolean }
|
|
315
|
+
/** Where past notes would come from now, for the capability. */
|
|
316
|
+
memorySource(): LiveCuesV2MemorySource
|
|
317
|
+
/** The saved runtime switch (null when none). */
|
|
318
|
+
settings: LiveCuesV2SettingReader
|
|
265
319
|
warmMemory(): void
|
|
266
320
|
now(): Date
|
|
267
321
|
store: LiveCuesV2Store
|
|
@@ -273,26 +327,69 @@ function cleanWindow(text: string): string {
|
|
|
273
327
|
return text.replace(/^\[\d+\]\s*/gm, '').replace(/\*\*?Speaker \d+:?\*\*?:?|Speaker \d+:/g, '')
|
|
274
328
|
}
|
|
275
329
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
330
|
+
/** The gate and the judge over any decision client. Jev and SubQ take the same request (state plus `choice` questions with
|
|
331
|
+
* keyed criteria) and answer the same shape (answers.<name>.probabilities and .choice); SubQ also validates every answer
|
|
332
|
+
* against the questions before it returns. Prompts are byte-identical on both. */
|
|
333
|
+
export function cueDecisionCalls(client: Pick<DecisionClient, 'ask'>) {
|
|
279
334
|
return {
|
|
280
|
-
async gate(title, window, signal) {
|
|
335
|
+
async gate(title: string, window: string, signal: AbortSignal): Promise<GateResult> {
|
|
281
336
|
const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
|
|
282
|
-
const res: JevAnswers = await
|
|
337
|
+
const res: JevAnswers = await client.ask(state, CUE_GATE_QUESTIONS as unknown as Record<string, unknown>, estimateJevTokens({ state, CUE_GATE_QUESTIONS }), { signal })
|
|
283
338
|
const a = res.answers ?? {}
|
|
284
339
|
const kind = a.kind?.choice === 'reinforce' ? 'reinforce' : 'challenge'
|
|
285
340
|
return { pCue: Number(a.act?.probabilities?.cue ?? 0), kind, lookup: a.lookup?.probabilities ?? null, tokens: res.inputTokens }
|
|
286
341
|
},
|
|
287
|
-
|
|
288
|
-
async judge(title, window, notes, signal) {
|
|
342
|
+
async judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal): Promise<{ probs: Record<string, number>; tokens: number }> {
|
|
289
343
|
const criteria: Record<string, string> = Object.fromEntries(notes.map(m => [m.id, `${m.date} ${m.title}: ${m.notes.slice(0, 300)}`]))
|
|
290
344
|
criteria.none = CUE_JUDGE_NONE
|
|
291
345
|
const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
|
|
292
346
|
const questions = { same: { type: 'choice', instructions: CUE_JUDGE_INSTRUCTIONS, criteria } }
|
|
293
|
-
const res = await
|
|
347
|
+
const res = await client.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
|
|
294
348
|
return { probs: res.answers?.same?.probabilities ?? {}, tokens: res.inputTokens }
|
|
295
349
|
},
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Is there anything of the owner's own to search when cue_memory.py is absent: the memory bridge or memory files, a
|
|
354
|
+
* configured meeting library, or this server's own saved recordings. */
|
|
355
|
+
function notesAvailable(): boolean {
|
|
356
|
+
try { if (contextSourceAvailable() !== null) return true } catch { /* next */ }
|
|
357
|
+
try { if (meetingLibraryConfigured()) return true } catch { /* next */ }
|
|
358
|
+
try { return getMeetingStore().listMonths().length > 0 } catch { return false }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function realDeps(): LiveCuesV2Deps {
|
|
362
|
+
const jev = new JevClient(fetch, () => new Date(), dataPath('jev-cues-usage.json'), jevDailyCap)
|
|
363
|
+
const subq = trackSubqDecisionClient(new SubqDecisionClient(fetch, () => new Date(), dataPath('subq-cues-usage.json'), () => resolveSubqKey()?.key, subqDailyCap))
|
|
364
|
+
const calls = (provider: DecisionProvider) => cueDecisionCalls(provider === 'subq' ? subq : jev)
|
|
365
|
+
const traceFile = dataPath('live-cues-trace.jsonl')
|
|
366
|
+
let memorySourceCache: { at: number; value: LiveCuesV2MemorySource } | null = null
|
|
367
|
+
return {
|
|
368
|
+
gate: (title, window, signal, provider) => calls(provider).gate(title, window, signal),
|
|
369
|
+
memory: (window, before, signal) => cueMemory(window, before, signal),
|
|
370
|
+
async notes(window, before, signal, sessionId) {
|
|
371
|
+
const r = await notesMemory(window, before, signal, { sessionId })
|
|
372
|
+
return { notes: r.notes, ...(r.error ? { denseError: r.error } : {}) }
|
|
373
|
+
},
|
|
374
|
+
judge: (title, window, notes, signal, provider) => calls(provider).judge(title, window, notes, signal),
|
|
375
|
+
decisionProvider: () => resolveCuesDecisionProvider(process.env).provider,
|
|
376
|
+
decisionState(provider) {
|
|
377
|
+
if (provider === 'subq') {
|
|
378
|
+
const st = subq.status()
|
|
379
|
+
return { configured: st.configured, breakerOpen: st.breakerOpenUntil !== null, capReached: st.usedToday >= st.dailyCap }
|
|
380
|
+
}
|
|
381
|
+
const st = jev.status()
|
|
382
|
+
return { configured: !!resolveJevKey(), breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
|
|
383
|
+
},
|
|
384
|
+
memorySource() {
|
|
385
|
+
// /api/health polls the capability: probe the disk at most once a minute.
|
|
386
|
+
const nowMs = Date.now()
|
|
387
|
+
if (memorySourceCache && nowMs - memorySourceCache.at < 60_000) return memorySourceCache.value
|
|
388
|
+
const value: LiveCuesV2MemorySource = cueMemoryPaths() ? 'cue_memory' : notesAvailable() ? 'notes' : 'none'
|
|
389
|
+
memorySourceCache = { at: nowMs, value }
|
|
390
|
+
return value
|
|
391
|
+
},
|
|
392
|
+
settings: liveCuesV2Settings(),
|
|
296
393
|
async writer(model, system, prompt, signal) {
|
|
297
394
|
const run = await runLensGistEngine('claude', model, prompt, { system, timeoutMs: LIVE_CUES_V2_LIMITS.writerTimeoutMs, source: 'live-cues-v2', maxOutputTokens: LIVE_CUES_V2_LIMITS.writerMaxOutputTokens, signal })
|
|
298
395
|
return { text: run.text, costUsd: run.usage?.costUsd, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
|
|
@@ -303,10 +400,6 @@ function realDeps(): LiveCuesV2Deps {
|
|
|
303
400
|
const active = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
|
|
304
401
|
return ['durable_query', 'legacy_query', 'openai_query', 'meeting_ask'].some(kind => (active[kind] ?? 0) > 0)
|
|
305
402
|
},
|
|
306
|
-
jevState() {
|
|
307
|
-
const st = jev.status()
|
|
308
|
-
return { breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
|
|
309
|
-
},
|
|
310
403
|
warmMemory: () => warmCueMemory(),
|
|
311
404
|
now: () => new Date(),
|
|
312
405
|
store: new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()),
|
|
@@ -392,23 +485,40 @@ function isCancel(error: unknown, signal: AbortSignal): boolean {
|
|
|
392
485
|
return signal.aborted || (error instanceof Error && error.name === 'AbortError')
|
|
393
486
|
}
|
|
394
487
|
|
|
488
|
+
/** Decision-provider codes that stop cues quietly with a reason (not a failure, so not toward the meeting breaker). The
|
|
489
|
+
* provider's own breaker and cap already bound these; counting them would also end the meeting's cues. */
|
|
490
|
+
export const QUIET_DECISION_STOPS: ReadonlySet<string> = new Set(['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured',
|
|
491
|
+
'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])
|
|
492
|
+
/** Abort reason when v2 is switched off at runtime. */
|
|
493
|
+
export const V2_OFF = 'v2_off'
|
|
494
|
+
|
|
395
495
|
export class LiveCuesV2Engine {
|
|
396
496
|
private readonly sessions = new Map<string, Session>()
|
|
397
497
|
constructor(private readonly deps: LiveCuesV2Deps) {}
|
|
398
498
|
|
|
499
|
+
private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
|
|
500
|
+
|
|
399
501
|
capability(): LiveCuesV2Capability {
|
|
400
502
|
const model = liveCuesV2Model(this.deps.env)
|
|
401
|
-
|
|
503
|
+
let memory: LiveCuesV2MemorySource = 'none'
|
|
504
|
+
try { memory = this.deps.memorySource() } catch { memory = 'none' }
|
|
505
|
+
let provider: DecisionProvider | null = null
|
|
506
|
+
try { provider = this.deps.decisionProvider() } catch { provider = null }
|
|
507
|
+
const tail = { protocol: LIVE_CUES_V2_PROTOCOL, model, memory, ...(provider ? { decisionProvider: provider } : {}) }
|
|
508
|
+
const off = (reason: string): LiveCuesV2Capability => ({ available: false, reason, ...tail })
|
|
402
509
|
try {
|
|
403
|
-
if (!
|
|
510
|
+
if (!this.enabled()) return off('master_flag_off')
|
|
404
511
|
if (this.deps.env.ANTHROPIC_API_KEY && this.deps.env.COS_ALLOW_API_FALLBACK !== '1') return off('api_key_in_env')
|
|
405
|
-
if (!
|
|
512
|
+
if (!provider) return off('decision_invalid_provider')
|
|
513
|
+
const st = this.deps.decisionState(provider)
|
|
514
|
+
// jev_not_configured keeps its 6.75 meaning (Jev chosen, no Jev key) only when a SubQ key exists; with no key for
|
|
515
|
+
// the chosen provider otherwise, the reason is decision_not_configured.
|
|
516
|
+
if (!st.configured) return off(provider === 'jev' && this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'decision_not_configured')
|
|
406
517
|
if (!resolveProviderBinary('claude').ok) return off('claude_not_found')
|
|
407
518
|
if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return off('budget_exhausted')
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
return { available: true, protocol: LIVE_CUES_V2_PROTOCOL, model }
|
|
519
|
+
if (st.capReached) return off(provider === 'jev' ? 'jev_cap_reached' : 'decision_cap_reached')
|
|
520
|
+
if (st.breakerOpen) return off(provider === 'jev' ? 'jev_breaker_open' : 'decision_breaker_open')
|
|
521
|
+
return { available: true, ...tail }
|
|
412
522
|
} catch {
|
|
413
523
|
return off('unavailable')
|
|
414
524
|
}
|
|
@@ -452,14 +562,14 @@ export class LiveCuesV2Engine {
|
|
|
452
562
|
const s = this.sessions.get(sessionId)
|
|
453
563
|
const ended = this.deps.store.isEnded(sessionId)
|
|
454
564
|
const paused = this.deps.store.isPaused(sessionId)
|
|
455
|
-
if (!s && !ended) return { protocol: 2, enabled:
|
|
565
|
+
if (!s && !ended) return { protocol: 2, enabled: this.enabled(), session: null }
|
|
456
566
|
const counters = this.deps.store.counters(sessionId)
|
|
457
567
|
const reason = s ? this.stopReasonOf(s, counters) : null
|
|
458
568
|
const session: LiveCuesV2SessionStatus = {
|
|
459
569
|
sessionId, state: ended ? 'ended' : paused ? 'paused' : reason ? 'stopped' : 'active', ...(reason && !ended && !paused ? { reason } : {}),
|
|
460
570
|
paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ? [...s.cues] : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
|
|
461
571
|
}
|
|
462
|
-
return { protocol: 2, enabled:
|
|
572
|
+
return { protocol: 2, enabled: this.enabled(), session }
|
|
463
573
|
}
|
|
464
574
|
|
|
465
575
|
private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
|
|
@@ -479,7 +589,7 @@ export class LiveCuesV2Engine {
|
|
|
479
589
|
|
|
480
590
|
/** From the ASR path: fire-and-forget, never awaited. All gates live here. */
|
|
481
591
|
async feed(sessionId: string, text: string, asrDegraded = false): Promise<void> {
|
|
482
|
-
if (!
|
|
592
|
+
if (!this.enabled()) return
|
|
483
593
|
if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
|
|
484
594
|
let s = this.sessions.get(sessionId)
|
|
485
595
|
if (!s) {
|
|
@@ -532,8 +642,11 @@ export class LiveCuesV2Engine {
|
|
|
532
642
|
wall.unref?.()
|
|
533
643
|
try {
|
|
534
644
|
try { lease = this.deps.lease() } catch (e) { if (e instanceof MaintenanceLifecycleError) { trace.outcome = 'maintenance'; return } throw e }
|
|
645
|
+
// One provider per evaluation: a settings change applies from the next one, never between the gate and the judge.
|
|
646
|
+
const provider = this.deps.decisionProvider()
|
|
647
|
+
trace.decisionProvider = provider
|
|
535
648
|
const t0 = Date.now()
|
|
536
|
-
const gate = await this.deps.gate(s.title, window, abort.signal)
|
|
649
|
+
const gate = await this.deps.gate(s.title, window, abort.signal, provider)
|
|
537
650
|
const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
|
|
538
651
|
Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
|
|
539
652
|
if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
|
|
@@ -541,12 +654,22 @@ export class LiveCuesV2Engine {
|
|
|
541
654
|
|
|
542
655
|
const t1 = Date.now()
|
|
543
656
|
const before = new Date(started + 86_400_000).toLocaleDateString('en-CA')
|
|
544
|
-
|
|
545
|
-
Object.assign(trace, { memoryMs: Date.now() - t1,
|
|
657
|
+
let mem: MemoryResult = await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
|
|
658
|
+
Object.assign(trace, { memoryMs: Date.now() - t1, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
|
|
659
|
+
let source: LiveCuesV2MemorySource = 'cue_memory'
|
|
660
|
+
if (cueMemoryFailed(mem) && !abort.signal.aborted) {
|
|
661
|
+
// cue_memory.py did not answer: search the owner's own COS (no model call), inside its own 3 s budget.
|
|
662
|
+
const tn = Date.now()
|
|
663
|
+
const found: MemoryResult = await this.deps.notes(window, before, abort.signal, s.sessionId).catch((): MemoryResult => ({ notes: [], denseError: 'notes_threw' }))
|
|
664
|
+
Object.assign(trace, { notesMs: Date.now() - tn, ...(found.denseError ? { notesError: found.denseError } : {}) })
|
|
665
|
+
mem = { notes: found.notes }
|
|
666
|
+
source = 'notes'
|
|
667
|
+
}
|
|
668
|
+
Object.assign(trace, { candidates: mem.notes.length, cueMemorySource: mem.notes.length ? source : 'none' })
|
|
546
669
|
let kept: MemoryNote[] = []
|
|
547
670
|
if (mem.notes.length && !abort.signal.aborted) {
|
|
548
671
|
const t2 = Date.now()
|
|
549
|
-
const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal)
|
|
672
|
+
const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal, provider)
|
|
550
673
|
Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
|
|
551
674
|
kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= L.judgeKeep).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
|
|
552
675
|
}
|
|
@@ -570,7 +693,7 @@ export class LiveCuesV2Engine {
|
|
|
570
693
|
if (typeof parsed === 'string') { trace.outcome = parsed === 'null' ? 'writer_null' : `withheld_${parsed}`; return }
|
|
571
694
|
const elapsed = this.deps.now().getTime() - started
|
|
572
695
|
if (s.wordsSinceEval > L.staleWords || elapsed > L.staleMs) { trace.outcome = 'stale'; return }
|
|
573
|
-
if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId)) { trace.outcome = 'cancelled'; return }
|
|
696
|
+
if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId) || !this.enabled()) { trace.outcome = 'cancelled'; return }
|
|
574
697
|
|
|
575
698
|
const sources: CueSourceRef[] = parsed.memoryIds.map(id => kept.find(m => m.id === id)).filter((m): m is MemoryNote => !!m)
|
|
576
699
|
.map(m => ({ id: sourceId(m), date: m.date, title: m.title }))
|
|
@@ -587,18 +710,19 @@ export class LiveCuesV2Engine {
|
|
|
587
710
|
} catch (error) {
|
|
588
711
|
if (wallHit) { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true; return }
|
|
589
712
|
if (isCancel(error, abort.signal)) { trace.outcome = 'cancelled'; return }
|
|
590
|
-
if (error instanceof JevError
|
|
713
|
+
if ((error instanceof JevError || error instanceof DecisionProviderError) && QUIET_DECISION_STOPS.has(error.code)) {
|
|
591
714
|
trace.outcome = error.code
|
|
592
715
|
s.stopReason = error.code as LiveCuesV2StopReason
|
|
593
716
|
return
|
|
594
717
|
}
|
|
595
718
|
trace.outcome = 'failure'
|
|
596
|
-
trace.error = error instanceof JevError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
|
|
719
|
+
trace.error = error instanceof JevError || error instanceof DecisionProviderError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
|
|
597
720
|
s.failures += 1
|
|
598
721
|
if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
|
|
599
722
|
} finally {
|
|
600
723
|
clearTimeout(wall)
|
|
601
724
|
if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
|
|
725
|
+
if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
|
|
602
726
|
s.inFlight = null
|
|
603
727
|
try { lease?.release() } catch { /* released */ }
|
|
604
728
|
trace.totalMs = this.deps.now().getTime() - started
|
|
@@ -612,6 +736,18 @@ export class LiveCuesV2Engine {
|
|
|
612
736
|
}
|
|
613
737
|
|
|
614
738
|
shutdown(): void { for (const s of this.sessions.values()) s.inFlight?.abort(); this.sessions.clear() }
|
|
739
|
+
|
|
740
|
+
/** v2 switched off at runtime (6.76.2): abort every evaluation in flight, so no half-written cue is emitted and every
|
|
741
|
+
* lease is released. Sessions are kept (no new evaluation starts while off), so switching back on resumes a running
|
|
742
|
+
* meeting from its next evaluation. */
|
|
743
|
+
suspend(): number {
|
|
744
|
+
let aborted = 0
|
|
745
|
+
for (const s of this.sessions.values()) if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }
|
|
746
|
+
return aborted
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Was this meeting armed under v2 in this process? Its stop and status stay v2 even after v2 is switched off. */
|
|
750
|
+
knows(sessionId: string): boolean { return this.sessions.has(sessionId) }
|
|
615
751
|
}
|
|
616
752
|
|
|
617
753
|
function round(n: number): number { return Math.round(n * 1000) / 1000 }
|
|
@@ -625,12 +761,36 @@ export function liveCuesV2(): LiveCuesV2Engine {
|
|
|
625
761
|
// A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
|
|
626
762
|
unsubscribe = onDisplay(event => {
|
|
627
763
|
const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
|
|
628
|
-
|
|
764
|
+
// Also when v2 was switched off mid-meeting: a meeting it armed still ends, so it never lingers as active.
|
|
765
|
+
if (typeof sid === 'string' && sid && (liveCuesV2Enabled() || engine?.knows(sid))) engine?.end(sid)
|
|
629
766
|
})
|
|
630
767
|
}
|
|
631
768
|
return engine
|
|
632
769
|
}
|
|
770
|
+
/** The engine if it exists, without creating one (a v1-only server never builds the v2 clients). */
|
|
771
|
+
export function peekLiveCuesV2(): LiveCuesV2Engine | null { return engine }
|
|
633
772
|
export function liveCuesV2Capability(): LiveCuesV2Capability { return liveCuesV2().capability() }
|
|
773
|
+
|
|
774
|
+
/** GET/POST /api/live-cues/v2/settings (COS Control's toggle). */
|
|
775
|
+
export interface LiveCuesV2SettingsResponse { enabled: boolean; source: LiveCuesV2SettingSource; capability: LiveCuesV2Capability }
|
|
776
|
+
export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponse {
|
|
777
|
+
const r = resolveLiveCuesV2(env, liveCuesV2Settings())
|
|
778
|
+
return { enabled: r.enabled, source: r.source, capability: liveCuesV2Capability() }
|
|
779
|
+
}
|
|
780
|
+
/** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
|
|
781
|
+
* meetings at once; turning it on applies from each meeting's next evaluation. */
|
|
782
|
+
export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
|
|
783
|
+
if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
|
|
784
|
+
const wasOn = liveCuesV2Enabled(env)
|
|
785
|
+
liveCuesV2Settings().save(enabled)
|
|
786
|
+
if (wasOn && !liveCuesV2Enabled(env)) {
|
|
787
|
+
const aborted = engine?.suspend() ?? 0
|
|
788
|
+
console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
|
|
789
|
+
} else if (!wasOn && liveCuesV2Enabled(env)) {
|
|
790
|
+
console.log('[live-cues-v2] switched on by setting')
|
|
791
|
+
}
|
|
792
|
+
return { ok: true, view: liveCuesV2SettingsView(env) }
|
|
793
|
+
}
|
|
634
794
|
/** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
|
|
635
795
|
* companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
|
|
636
796
|
export function liveCuesV2Advertised(env: NodeJS.ProcessEnv = process.env): LiveCuesV2Capability | undefined {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// 6.76.0: who owns this COS, for Live COS Ask. Read from the profile the server already keeps
|
|
2
|
+
// (owner_name, owner_speaker_label, home_city, system_prompt_context). No new store, no addresses.
|
|
3
|
+
import { getOwnerName, getOwnerSpeakerLabel, getOwnerHomeArea, getSystemContext } from './profile.js'
|
|
4
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
5
|
+
import type { MeetingAskSource } from '../../shared/meeting-ask.js'
|
|
6
|
+
|
|
7
|
+
export interface OwnerCard {
|
|
8
|
+
name: string
|
|
9
|
+
speakerLabel: string | null
|
|
10
|
+
homeArea: string | null
|
|
11
|
+
/** The owner's own profile notes (system_prompt_context), address-like sentences removed. */
|
|
12
|
+
notes: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PLACEHOLDER_LABELS = new Set(['', 'me', 'you', 'speaker', 'wearer', 'owner', 'user'])
|
|
16
|
+
/** A sentence that carries a street address, zip, phone number or email is dropped from the card. */
|
|
17
|
+
const ADDRESS_LIKE = /\b\d+\s+(?:[\p{L}'-]+\s+){0,3}(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|way|ct|court|pl|place|pkwy|parkway|hwy|highway|cir|circle|trl|trail|ter|terrace)\b\.?|\b\d{5}(?:-\d{4})?\b|\bp\.?\s?o\.?\s+box\b|\b(?:apt|apartment|suite|unit)\s*#?\s*\d+|[\w.+-]+@[\w-]+\.[\w.]+|\+?\d[\d\s().-]{8,}\d/iu
|
|
18
|
+
export const OWNER_NOTES_MAX = 600
|
|
19
|
+
|
|
20
|
+
/** Profile notes, one sentence at a time, without address, phone or email sentences. */
|
|
21
|
+
export function ownerNotes(raw: string): string | null {
|
|
22
|
+
const kept = redactSecrets(raw).replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+/).filter(s => s && !ADDRESS_LIKE.test(s))
|
|
23
|
+
let out = ''
|
|
24
|
+
for (const sentence of kept) {
|
|
25
|
+
if ((out ? out.length + 1 : 0) + sentence.length > OWNER_NOTES_MAX) break
|
|
26
|
+
out = out ? `${out} ${sentence}` : sentence
|
|
27
|
+
}
|
|
28
|
+
return out || null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The owner card, or null when the profile has no real owner name (then the Ask is unchanged). */
|
|
32
|
+
export function ownerCardFromProfile(): OwnerCard | null {
|
|
33
|
+
try {
|
|
34
|
+
const name = getOwnerName().trim()
|
|
35
|
+
if (!name || name === 'User') return null
|
|
36
|
+
const label = (() => { try { return getOwnerSpeakerLabel().trim() } catch { return '' } })()
|
|
37
|
+
const homeArea = (() => { try { return getOwnerHomeArea() } catch { return null } })()
|
|
38
|
+
const notes = (() => { try { return ownerNotes(getSystemContext()) } catch { return null } })()
|
|
39
|
+
return { name: name.slice(0, 120), speakerLabel: PLACEHOLDER_LABELS.has(label.toLowerCase()) ? null : label.slice(0, 60), homeArea, notes }
|
|
40
|
+
} catch { return null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The card as an Ask source (kind 'profile'), so an answer about the owner can cite it like any COS source. */
|
|
44
|
+
export function ownerCardSource(card: OwnerCard, id: string): MeetingAskSource {
|
|
45
|
+
const parts = [`This COS belongs to ${card.name}. The glasses wearer is normally ${card.name}.`]
|
|
46
|
+
if (card.speakerLabel) parts.push(`${card.name} appears in transcripts as "${card.speakerLabel}".`)
|
|
47
|
+
if (card.homeArea) parts.push(`Home area: ${card.homeArea}.`)
|
|
48
|
+
if (card.notes) parts.push(`${card.name}'s profile notes: ${card.notes}`)
|
|
49
|
+
return { id, kind: 'profile', title: `COS profile · ${card.name}`, text: parts.join(' ') }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fold = (word: string) => word.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
|
|
53
|
+
function distance(a: string, b: string): number {
|
|
54
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
55
|
+
for (let i = 1; i <= a.length; i++) {
|
|
56
|
+
let diagonal = row[0]
|
|
57
|
+
row[0] = i
|
|
58
|
+
for (let j = 1; j <= b.length; j++) {
|
|
59
|
+
const above = row[j]
|
|
60
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
61
|
+
diagonal = above
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return row[b.length]
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One heard word against one name word. Short words must match exactly. Otherwise one edit (two from
|
|
68
|
+
* seven letters), or one edit after a leading y the recognizer added before a vowel: Ukoma and Yukoma
|
|
69
|
+
* both read as Ukaoma.
|
|
70
|
+
*/
|
|
71
|
+
export function nameWordMatches(heard: string, name: string): boolean {
|
|
72
|
+
const a = fold(heard), b = fold(name)
|
|
73
|
+
if (!a || !b) return false
|
|
74
|
+
if (a === b) return true
|
|
75
|
+
if (Math.min(a.length, b.length) < 4) return false
|
|
76
|
+
const allowed = Math.max(a.length, b.length) >= 7 ? 2 : 1
|
|
77
|
+
if (distance(a, b) <= allowed) return true
|
|
78
|
+
return /^y[aeiou]/.test(a) && !/^y/.test(b) && distance(a.slice(1), b) <= 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LEAD_IN = new Set(['is', 'was', 'about', 'with', 'for', 'from', 'to', 'of', 'and', 'mr', 'mrs', 'ms', 'miss', 'dr', 'doctor', 'named', 'called'])
|
|
82
|
+
export interface OwnerMatch { heard: string; owner: string }
|
|
83
|
+
/**
|
|
84
|
+
* Whether the question names the owner, allowing ASR spellings. Full name: first and last words, in
|
|
85
|
+
* order and adjacent. Surname alone: only after a lead-in word ("who is Ukoma"), never after another
|
|
86
|
+
* first name ("Isaiah Ukaoma" is someone else). First name alone: only when it is the whole subject of
|
|
87
|
+
* a who question, compared exactly.
|
|
88
|
+
*/
|
|
89
|
+
export function matchOwnerName(question: string, ownerName: string | null | undefined): OwnerMatch | null {
|
|
90
|
+
const owner = (ownerName ?? '').trim()
|
|
91
|
+
const parts = owner.split(/\s+/).map(fold).filter(Boolean)
|
|
92
|
+
if (!owner || !parts.length) return null
|
|
93
|
+
const words = [...question.matchAll(/[\p{L}\p{N}'’-]+/gu)].map(m => ({ raw: m[0].replace(/['’]s$/i, ''), at: m.index! }))
|
|
94
|
+
const first = parts[0], last = parts.at(-1)!
|
|
95
|
+
for (let i = 0; i < words.length; i++) {
|
|
96
|
+
if (parts.length >= 2 && i + 1 < words.length && nameWordMatches(words[i].raw, first) && nameWordMatches(words[i + 1].raw, last)) {
|
|
97
|
+
return { heard: question.slice(words[i].at, words[i + 1].at + words[i + 1].raw.length), owner }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (parts.length >= 2) {
|
|
101
|
+
for (let i = 0; i < words.length; i++) {
|
|
102
|
+
if (!nameWordMatches(words[i].raw, last)) continue
|
|
103
|
+
const before = i > 0 ? fold(words[i - 1].raw) : ''
|
|
104
|
+
if (i === 0 || LEAD_IN.has(before)) return { heard: words[i].raw, owner }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const subject = personQuestion(question)
|
|
108
|
+
if (subject?.kind === 'who' && subject.subject && fold(subject.subject) === first) return { heard: subject.subject, owner }
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const NOT_A_PERSON = new Set(['going', 'speaking', 'talking', 'this', 'that', 'it', 'here', 'there', 'on', 'in', 'at', 'responsible', 'leading', 'next', 'doing', 'supposed', 'the', 'a', 'an', 'we', 'they', 'he', 'she', 'you', 'i', 'someone', 'somebody', 'everyone', 'anyone', 'everybody', 'anybody', 'these', 'those', 'our', 'your', 'their', 'his', 'her', 'its', 'my', 'calling', 'asking', 'saying', 'paying', 'presenting', 'running', 'handling', 'in', 'up', 'out', 'coming', 'joining', 'missing', 'left', 'right', 'correct', 'wrong', 'better', 'best', 'more', 'most', 'not', 'still', 'also', 'currently', 'now', 'all', 'both', 'each', 'which', 'what', 'who', 'whose', 'how'])
|
|
113
|
+
const WHO = /\b(?:who(?:'s|’s|\s+is|\s+was|\s+were|\s+are)|tell\s+me\s+(?:more\s+)?about|what\s+(?:do|does)\s+(?:you|we|i|cos)\s+know\s+about)\s+([^?.!,;:]{1,80})/i
|
|
114
|
+
const SELF = /\b(?:who\s+am\s+i|about\s+(?:me|myself)\b|(?:going\s+on|happening|happened)\s+(?:with|to|for)\s+me\b|when\s+i\s+was\b|back\s+when\s+i\b|my\s+(?:pregnancy|history|life|past|childhood)\b|what\s+(?:was|were)\s+i\s+(?:doing|going\s+through|dealing\s+with)\b)/i
|
|
115
|
+
export type PersonQuestion = { kind: 'who'; subject: string } | { kind: 'self' }
|
|
116
|
+
/** "Who is X", "tell me about X", or the owner's own history ("what was going on with me when..."). */
|
|
117
|
+
export function personQuestion(question: string): PersonQuestion | null {
|
|
118
|
+
if (SELF.test(question)) return { kind: 'self' }
|
|
119
|
+
const m = WHO.exec(question)
|
|
120
|
+
if (!m) return null
|
|
121
|
+
const subject = m[1].replace(/\b(?:again|now|exactly|really)\s*$/i, '').replace(/^(?:mr|mrs|ms|miss|dr)\.?\s+/i, '').trim()
|
|
122
|
+
const words = subject.split(/\s+/).filter(Boolean)
|
|
123
|
+
if (!words.length || words.length > 5 || !/\p{L}/u.test(subject)) return null
|
|
124
|
+
if (NOT_A_PERSON.has(fold(words[0]))) return null
|
|
125
|
+
if (/^(?:me|myself)$/i.test(subject)) return { kind: 'self' }
|
|
126
|
+
return { kind: 'who', subject }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const QUERY_STOP = new Set(['what', 'was', 'were', 'is', 'are', 'going', 'on', 'with', 'me', 'my', 'myself', 'i', 'when', 'back', 'the', 'a', 'an', 'to', 'for', 'of', 'and', 'or', 'about', 'tell', 'happening', 'happened', 'doing', 'through', 'dealing', 'who', 'am', 'did', 'do', 'does', 'how', 'that', 'this', 'it', 'in', 'at', 'so', 'then', 'there', 'can', 'you', 'please', 'know', 'cos'])
|
|
130
|
+
/** The COS search phrase for a person question. The owner is searched by the profile spelling, not the heard one. */
|
|
131
|
+
export function personLookupQuery(question: string, person: PersonQuestion, owner: OwnerMatch | null, ownerName: string | null): string {
|
|
132
|
+
if (owner) return owner.owner.slice(0, 240)
|
|
133
|
+
if (person.kind === 'who') return person.subject.slice(0, 240)
|
|
134
|
+
const words = (question.match(/[\p{L}\p{N}'’-]+/gu) ?? []).filter(w => !QUERY_STOP.has(fold(w)))
|
|
135
|
+
return (words.length ? words.join(' ') : ownerName ?? '').slice(0, 240)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Wording that reads an empty lookup as absence ("these sources say nothing about X"). */
|
|
139
|
+
export const ABSENCE_CLAIM = /\b(?:say|says|said|mention|mentions|contain|contains|show|shows)\s+nothing\b|\bno\s+(?:information|mention|record|records|details|context)\b|\bnothing\s+(?:about|on|regarding)\b|\b(?:do|does|did)\s*(?:n['’]t|\s+not)\s+(?:say|mention|identify|establish|describe|include)\b/i
|
|
140
|
+
/** The disclosure an answer gets when it reads as absence after an empty COS lookup. */
|
|
141
|
+
export function searchedDisclosure(queries: readonly string[]): string {
|
|
142
|
+
const list = [...new Set(queries)].slice(0, 2).map(q => `"${q}"`).join(' and ')
|
|
143
|
+
return `COS searched memories and earlier meetings for ${list} and found no match, which does not mean COS has nothing on it.`
|
|
144
|
+
}
|