@gotcos/glasses-server 6.76.1 → 6.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## 6.77.0 - Transcript cleanup: the passages the engine was unsure of, resolved with the meeting's text and screen context (default off)
2
+
3
+ Includes 6.76.2. Off until Miles turns it on. No COS Control or companion change is required; Control can add the toggle, the screen feed and the Dictionary suggestions later against the contracts below.
4
+
5
+ - Miles, 2026-10-17: after a transcription (Parakeet Ultra or Whisper), run Haiku or Sonnet over the passages where the engine was unsure, with screen and textual awareness, so word hallucinations and misheard names ("Queen Ukoma", "Yukoma", "eucoma", "U coma" for Queen Ukaoma) are resolved.
6
+ - Where it runs. Once per saved meeting, in the durable finalization job after the HQ batch text and the Nemotron relabel are written, and before the meeting is handed to COS operations or summarised, so both read the cleaned text. Orphan recovery runs it too. Never during live transcription: it is a separate module no transcription file imports (pinned by a test), and a meeting whose session is still live is skipped. It never fails a save.
7
+ - Selection, no model (`transcript-cleanup-spans.ts`): low confidence (Parakeet below 0.50, Whisper below 0.40, from the batch words in the sidecar, aligned to the saved transcript); a word near a known name when the engine was unsure of it (below 0.9: one edit past fuzzy-correct's limit for a capitalised word, or the same consonant skeleton and onset, so eucoma reads like Ukaoma); two or three short pieces that join into a name ("U coma"), never function words; the leading-y rule at any confidence ("Yukoma"); an out-of-vocabulary capital mid-sentence beside a known name; a phrase repeated three times or more. Spans stay inside one speaker turn and one clause, hold at most 6 words (12 for a repeat), carry 12 words of context each side, and are cut to 40 per meeting with names first.
8
+ - Deterministic fixes first, free (fuzzy-correct's own rule, exported as `fuzzyReplacementFor`): only capitalised words that are not already a known spelling; this meeting's own screen and file terms as a temporary per-meeting vocabulary at any confidence; the curated dictionary (vocabulary and corrections) and the owner only where the engine was unsure; never an inflection ("Setting" is never a "Settings" button). Measured read-only on 68 real meetings (38.5 hours): rewriting lowercase words, or using every enrolled name as a target, swapped everyday words and real names for other names (giant to grant, Kelsey to Kelley, Slack to Black), so neither is done; with these rules the same meetings got 27 fixes, all real (Manuj to Manoj, MarketPOS to MarktPOS).
9
+ - Context pack: meeting title, date and attendees; COS people (enrolled speaker names); the owner card (6.76.0); the profile vocabulary and heard-to-written corrections (the COS dictionary dictation uses); text of files dropped on the meeting (the Meeting Ask file list); up to 6 local notes hits for the names the spans are near (memories and earlier meetings by keyword, and the memory bridge listing when memory search finds nothing, as the Live COS Ask does; never this meeting; nothing leaves the Mac; 3 s budget); and up to 3 screen lines per span, nearest in time (45 s) or naming the span's name. Every text is bounded and passed through the secret redactor.
10
+ - The call: one per meeting, batched, through the shared Claude CLI runner (`--tools ""`, no MCP servers, no hooks, empty scratch workspace), pinned `claude-haiku-5-5` by default or `claude-sonnet-5-5`, low effort, 1,500 output tokens, 75 s timeout, 24,000-character prompt, token audit source `transcript-cleanup`. Never an SDK. The reply is strict JSON by span id (`keep` or `replace`); a fenced reply or one followed by prose still parses.
11
+ - Guards on every edit (`transcript-cleanup-model.ts`): unknown or duplicate span id, bad action, no-op, a bracket or line break (a forged speaker label), more than one word over the span or twice its length, a neighbouring context word (text outside the span), a changed number or unit, an added or dropped negation, and any word that is neither a misheard span word (close spelling or same sound) nor a term this meeting's context knows. A repeat may only shrink to a run of its own words. Edits are applied by the span's offsets, never by the model's text.
12
+ - Undoable ledger (`transcript-cleanup-ledger.ts`, modelled on `meeting-corrections.ts`): intent first, every edit with its original words, offset and surrounding text, then the files (sidecar first, markdown second, both re-read and compared first: a change while the model answered writes nothing). Only the `## Transcript` section, the sidecar's `batchTranscript` (kept equal to it), `transcriptCleanup` (counts only) and `lifecycleRevision` change. Any edit or a whole run can be restored; a line changed since refuses with 409 `stale`. A rerun on a cleaned meeting does nothing.
13
+ - Stops: its own switch (`COS_TRANSCRIPT_CLEANUP=1` forces on, `=0` forces off, else the saved setting, else off; off means no read, no write, no trace); `ANTHROPIC_API_KEY` without `COS_ALLOW_API_FALLBACK=1` never calls; no Claude CLI never calls; 30 calls a day (`COS_TRANSCRIPT_CLEANUP_DAILY`) charged before the call; 400k tokens a day (`COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS`); 40 spans a meeting (`COS_TRANSCRIPT_CLEANUP_MAX_SPANS`, at most 80); a breaker after 3 failures in a row (30 minutes, one trial call after) or one provider limit (60 minutes). Each stop is recorded as `modelSkipped` while the free fixes still apply. An unreadable or future-dated state file reads as a spent day. All six env names are forwarded by the managed runtime contract.
14
+ - Trace (`transcript-cleanup/trace.jsonl`) and one log line per meeting: spans found and sent by reason, accepted, kept, rejected by reason, fixes, suggestions, screen lines, files, notes, prompt characters, tokens, model and total milliseconds. No transcript, screen or name text.
15
+ - Dictionary suggestions: an accepted edit (fixed or model) whose written form came from this meeting's screen, files, owner card, COS people or notes, and that the dictionary lacks, becomes a suggestion with its evidence, never an automatic addition. Deduped by written form (case-insensitive), 200 kept, 5 heard forms and 5 evidence rows each; an accepted or dismissed form is never suggested again.
16
+ - Routes (token authenticated like every /api route):
17
+ - `GET /api/transcript-cleanup/settings` and `POST {enabled?, model?: "haiku"|"sonnet"}`: `{protocol: 1, enabled, source: env|setting|default, model, modelId, modelSource, limits: {dailyCalls, dailyTokens, maxSpansPerMeeting}, today: {calls, tokens, breakerOpenUntil}}`; 409 `env_forced` when env pins the value; 400 `invalid_request`.
18
+ - `POST /api/meeting/:sessionId/screen-context {lines: [{at: epoch ms or ISO, text, app?}]}`: at most 100 lines and 32 KB of text a request (413 `too_many_lines`, `request_too_large`), 400 characters a line (cut and counted), 2,000 lines and 400 KB a meeting (the rest `dropped`), duplicates within 30 s skipped, a line that reads like a password, key or token `held` and never stored, clocks more than 5 minutes outside the session refused. Answers `{ok, protocol, accepted, duplicates, held, truncated, invalid, dropped, stored}`. Accepted while the session is live, and after save until the cleanup has run (then 409 `cleanup_done`); 404 `unknown_session` otherwise. `GET` answers counts only (`lines, bytes, firstAt, lastAt`), `DELETE` answers `{deleted}`. Stored per meeting in `transcript-cleanup/screen/`, text only, and deleted with the meeting: a boot sweep removes screen and ledger files whose meeting, live session and finalization are all gone (after 72 hours), and screen text after 30 days regardless (`COS_TRANSCRIPT_SCREEN_RETENTION_DAYS`).
19
+ - `GET /api/meeting/:sessionId/transcript-cleanup`: `{protocol, runs, edits (with original and replacement words, reverted), unusable, cleanedCurrent}`. `POST .../transcript-cleanup/undo {editId}` or `{runId}`: `{reverted: [ids]}`; 404 `edit_not_found`, 409 `already_reverted` or `stale`.
20
+ - `GET /api/transcript-cleanup/suggestions?status=pending|all`: `{protocol: 1, suggestions: [{id: "sug_<16 hex>", written, heard: [], status: pending|accepted|dismissed, count, createdAt, updatedAt, inVocabulary, evidence: [{source: screen|file|owner|people|notes, sessionId, heard, at, detail}]}]}`. `POST .../:id/accept {correction?: boolean}` adds the word through the glossary's own vocabulary writer, or with `correction: true` learns heard to written through the logged, undoable glossary learn (source `meeting`); answers `{suggestion, unchanged, glossary, entry}`; 409 `suggestion_dismissed`. `POST .../:id/dismiss` answers `{suggestion}`.
21
+ - Capability: `/api/transcribe/capabilities` adds `transcriptCleanup: {protocol: 1, suggestions: 1, screenContext: 1}`; `/api/health` adds `capabilities.transcriptCleanup: {protocol: 1, enabled}` (codes only, it is public).
22
+ - Older path, for the record: the app's former server had a whole-transcript `claude -p sonnet` rewrite (`correctTranscript`, 40 KB cap, breaker after 2 failures for 30 minutes, `correction_skipped`), deleted with that fork on 2026-08-11. This server had no transcript LLM pass until now, and the COS repo's `sync_meetings.py` gates only title, summary and action extraction (`COS_MEETING_LLM`); it never rewrites transcript text. This pass is span-level, guarded and undoable instead.
23
+ - Evidence and limits: the 2026-10-13 silver eval of Haiku on doubtful Parakeet spans (no screen, owner or file context) was a WER tie with Parakeet alone. Real-model quality with this context is unmeasured. Read-only selection on 68 real meetings (38.5 h, 379,273 words, 100% aligned, no model call): 149 doubtful spans an hour found (low confidence 93%), 48.9 an hour sent, a median prompt of about 1,840 input tokens, 33 meetings at the 40-span cap, at most 333 ms to select.
24
+ - Tests: `transcript-cleanup.test.ts` (33: end to end with a fake Claude provider, including the Ukoma, Yukoma, eucoma and U coma case with the owner card and a screen line, every stop, undo, the notes lookup, the per-meeting vocabulary and suggestions), `transcript-cleanup-spans.test.ts` (26: selection, fixes, edit guards, prompt), `transcript-cleanup-provider.test.ts` (1: the real runner wiring), `routes/transcript-cleanup.test.ts` (13: auth on every route, screen bounds, settings, suggestions through the real glossary writer, undo, the sweep), `meeting.test.ts` (2 new: runs once after the relabel and before the job completes; a throwing cleanup never fails the save, the default writes nothing), `health.test.ts` (1 new) and `transcribe.test.ts` (capability). `server/scripts/transcript-cleanup-mutation-gate.mjs` (`--check` validates every target): 107 mutants over the switch, model pin, every stop, selection, the fixes, every edit guard, the ledger and undo, the screen store and route, the notes lookup, suggestions and the save wiring, each killed by a named test against a green baseline (115 tests in 7 files). Full suite in isolated TMPDIR, HOME and data dir with COS_SCRIPTS_DIR unset: 7316 passed, 13 skipped, 0 failed; typecheck clean.
25
+
26
+ ## 6.76.2 - Live cues v2: a switch COS Control can flip, the owner's own notes as memory, and SubQ or Jev for the gate
27
+
28
+ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.322. Includes 6.76.1.
29
+
30
+ - Runtime switch. `GET /api/live-cues/v2/settings` answers `{ enabled, source, capability }`: `source` is `env` (`COS_LIVE_CUES_V2=1` forces v2 on), `setting` (saved) or `default` (off). `POST /api/live-cues/v2/settings {"enabled": true|false}` saves the choice in this server's data dir (`live-cues-v2-settings.json`, atomic and durable) and answers the same shape; turning it off while the env flag forces it on is 409 `{"error": "env_forced"}` and changes nothing; a body without a boolean `enabled` is 400 `invalid_request`. Both are token authenticated like every /api route. Only the exact env value `1` forces; any other value defers to the setting, and an unreadable settings file reads as off.
31
+ - No restart needed. `capabilities.liveCuesV2` on `/api/health` and `/api/models` (and `v2` on the status route) follows the setting at once, and a v2-off server still advertises nothing, so a companion treats it as v1. Turning v2 off aborts every evaluation in flight (traced `cancelled` with `cancelReason: "v2_off"`; no cue is emitted, the lease is released) and no new evaluation starts; a cue that finishes after the switch is checked once more and withheld. Meetings v2 armed keep their v2 stop and status answers after the switch, and a saved meeting still ends them. Turning it on applies from each meeting's next evaluation.
32
+ - Notes as memory. When `cue_memory.py` is missing (any Mac but Miles's), fails or times out, the memory arm now searches the owner's own COS instead of nothing: the Live COS Ask's `searchMemories` and `searchMeetingLibrary` in their keyword-only mode, plus the memory bridge listing (`cos_api_bridge.py memory`, COS Control's Memory list). The bridge matters: the public memory runtime 0.1.0 serves the owner's notes only there (its `bot_memory.py` has no `search` command, so `searchMemories` answers `memory_parse_error` for meaning and reads only markdown memory folders for keywords; measured on a scratch install). A few salient words from the window are the queries, never the window; candidates are fused by rank (k=60, as `cue_memory.py`), dated by their local day, bounded like `cue_memory.py`, at most 4, and go to the same judge and writer rules unchanged. The whole arm is bounded by `cue_memory.py`'s 3 s dense budget. No model call and no query leaves the Mac. `cue_memory.py` stays first whenever it answers, including an empty answer, so Miles's path does not change.
33
+ - Each evaluation traces `cueMemorySource` (`cue_memory`, `notes` or `none`: whose candidates the judge saw), plus `notesMs` and `notesError` when the fallback ran. No note text or query terms are traced.
34
+ - Decision provider. The gate and the memory judge run on Jev, or on SubQ when `COS_DECISION_PROVIDER_CUES=subq` says so or SubQ is the only decision key on this Mac (`COS_DECISION_PROVIDER_CUES=jev` pins Jev; both are forwarded by the managed runtime contract). Cues ignore COS Control's saved decision provider and `COS_DECISION_PROVIDER`, so a Mac whose Work decisions run on SubQ keeps Jev cues. One provider per evaluation. Both take the same questions (three `choice` questions with keyed criteria for the gate, one for the judge), and SubQ validates its answers against them. SubQ for cues has its own daily cap (`COS_LIVE_CUES_SUBQ_DAILY_TOKENS`, default 1.5M, ledger `subq-cues-usage.json`) and its own breaker, which a newly saved SubQ key clears; Jev keeps `COS_LIVE_CUES_JEV_DAILY_TOKENS` and `jev-cues-usage.json`.
35
+ - The 0.8 gate and 0.8 judge thresholds were calibrated on Jev (jev-1.13.0) and have no SubQ replay yet.
36
+ - When SubQ serves cues, the live transcript window (up to 2,200 characters per call) and the candidate notes go to SubQ, which retains data by default.
37
+ - Capability additions (optional fields; `shared/live-cues-v2.ts` changed additively, new sha256 174728e7...): `memory` (`cue_memory`, `notes` or `none`) and `decisionProvider` (`jev` or `subq`). New reasons: `decision_not_configured` (no key for the chosen provider; `jev_not_configured` is kept for Jev chosen while only a SubQ key exists), `decision_cap_reached`, `decision_breaker_open` (SubQ) and `decision_invalid_provider`. The same decision codes stop a running meeting's cues quietly with that reason; other SubQ errors count toward the meeting breaker as Jev errors do.
38
+ - Live COS Ask finds the owner's notes too. When `searchMemories` does not answer (it fails, its meaning arm fails as on the public runtime, or it finds nothing), the Ask's COS search also reads the memory bridge listing and matches it with the same keyword scorer, the same 0.35 floor and the same confirmed-domain filter as other COS hits, fused by rank with any memory hits (at most 3 memory sources). Listed notes are cited as COS memory sources (H ids, `memoryId`), counted in the `cos_search` phase time and result count, and the notices add "COS also searched the notes in its memory list by keyword." whenever one is cited. The listing is bounded at 3 s and the Ask's own cancel; a failed listing changes nothing. A memory search that answers is used exactly as before, the same sources in the same order and key order, and the listing is never read. With nothing found in either, the notices are the existing empty wording, word for word. On the public runtime this lets the 6.76.0 person lookup ("Who is X?") answer from the owner's note. One helper serves both cues and the Ask: `server/lib/cos-bridge-notes.ts` (bridge listing, keyword scoring, local dates, rank fusion).
39
+ - Tests: `live-cues-v2.test.ts` (52, 13 new), `live-cues-v2-notes.test.ts` (11), `live-cues-v2-notes-bridge.test.ts` (2: the real searchers over a scratch bridge, a markdown note and a saved meeting), `live-cues-v2-settings.test.ts` (3), `live-cues-v2-settings-route.test.ts` (10: behind the real token gate, a module reload as the restart, and the real deps with faked network sending the gate to SubQ then Jev), `health.test.ts` (1 new: `/api/health` and `/api/models` flip with the setting), `live-cos-ask-memory-fallback.test.ts` (8), `live-cos-ask-queen-runtime.test.ts` (3: the shipped public memory runtime 0.1.0 installed in a scratch dir with one accepted note; `searchMemories` alone finds nothing, the Ask answers "Who is Delgado?" from the note). `server/scripts/live-cues-v2-notes-mutation-gate.mjs`: 85 mutants over the cue switch, the cue notes fallback, the shared helper, the decision provider selection (global setting ignored, only-key fallback, explicit override both ways) and the Ask fallback (13 Ask guards), each killed by a named test against a green baseline (160 tests in 12 files). Full suite 7240 passed, 13 skipped, 0 failed; typecheck clean.
40
+
1
41
  ## 6.76.1 - Stop a glasses meeting from COS Control
2
42
 
3
43
  Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.321. Includes 6.76.0.
@@ -27,6 +27,14 @@
27
27
  "COS_LIVE_CUES_V2_WEEKLY",
28
28
  "COS_LIVE_CUES_V2_NUMBER_GUARD",
29
29
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
30
+ "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
31
+ "COS_DECISION_PROVIDER_CUES",
32
+ "COS_TRANSCRIPT_CLEANUP",
33
+ "COS_TRANSCRIPT_CLEANUP_MODEL",
34
+ "COS_TRANSCRIPT_CLEANUP_DAILY",
35
+ "COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS",
36
+ "COS_TRANSCRIPT_CLEANUP_MAX_SPANS",
37
+ "COS_TRANSCRIPT_SCREEN_RETENTION_DAYS",
30
38
  "COS_BATCH_HQ_METAL",
31
39
  "COS_BATCH_HQ_FORCE_CPU",
32
40
  "COS_BATCH_HQ_TIMESTAMPS",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.76.1",
3
+ "version": "6.77.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -72,7 +72,8 @@ import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
72
72
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
73
73
  import { displayRouter } from './routes/display.js'
74
74
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
75
- import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
75
+ import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
76
+ import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
76
77
  import { meetingsRouter } from './routes/meetings.js'
77
78
  import { openaiCompatRouter } from './routes/openai-compat.js'
78
79
  import { openaiKeyRouter } from './routes/openai-key.js'
@@ -1086,6 +1087,8 @@ app.use('/api', createAgentSessionBindingsRouter({
1086
1087
  app.use('/api', displayRouter)
1087
1088
  app.use('/api', transcribeStreamRouter)
1088
1089
  app.use('/api', meetingRouter)
1090
+ // 6.77.0: transcript cleanup settings, screen context, the cleanup ledger and dictionary suggestions.
1091
+ app.use('/api', createTranscriptCleanupRouter(transcriptCleanupRouteDeps()))
1089
1092
  app.use('/api', meetingsRouter)
1090
1093
  app.use('/api', memoryRouter)
1091
1094
  app.use('/api', threadsRouter)
@@ -1373,6 +1376,8 @@ listenRequiredServers(listeners).then(() => {
1373
1376
  // Resume already-saved meetings whose post-response HQ/operations work
1374
1377
  // was interrupted by a prior server update or process exit.
1375
1378
  resumeMeetingFinalizationJobs()
1379
+ // 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
1380
+ scheduleTranscriptCleanupSweep()
1376
1381
 
1377
1382
  // Debris from an import killed mid-write: a temp file, or a sidecar whose
1378
1383
  // markdown never landed. Both are invisible to every reader, so nothing
@@ -0,0 +1,73 @@
1
+ // The owner's notes as the memory bridge lists them, searched by keyword, and reciprocal rank fusion (6.76.2). Shared by
2
+ // the live cues notes fallback (live-cues-v2-notes.ts) and the Live COS Ask (searchAskHistory in live-cos-ask.ts).
3
+ //
4
+ // Why the listing: the public memory runtime 0.1.0 serves the owner's notes ONLY through `cos_api_bridge.py memory` (what
5
+ // COS Control's Memory list shows). Its bot_memory.py has no `search` command, so searchMemories' meaning arm answers
6
+ // memory_parse_error, and its keyword arm reads only markdown memory folders: on such a Mac, searchMemories finds none of
7
+ // the owner's notes. Listing and scoring is local: no model call, no query leaves the Mac. Nothing here logs.
8
+
9
+ import { callPython } from './python-bridge.js'
10
+ import { scoreKeywordMatch, tokenizeMeetingQuery } from './meeting-library-search.js'
11
+
12
+ export const BRIDGE_DAYS = 3650
13
+ export const BRIDGE_LIMIT = 200
14
+ /** The listing never takes longer than cue_memory.py's dense arm may (DENSE_BUDGET_S). */
15
+ export const BRIDGE_TIMEOUT_MS = 3_000
16
+ export const RRF_K = 60
17
+
18
+ export interface BridgeMemory { id: string; content: string; summary?: string; created_at?: string; domain?: string }
19
+
20
+ /** Rows the bridge listed: an id and some content, at most BRIDGE_LIMIT. Anything else (an error object) is no rows. */
21
+ export function normalizeBridgeMemories(value: unknown): BridgeMemory[] {
22
+ if (!Array.isArray(value)) return []
23
+ const out: BridgeMemory[] = []
24
+ for (const row of value.slice(0, BRIDGE_LIMIT)) {
25
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue
26
+ const r = row as Record<string, unknown>
27
+ const id = typeof r.id === 'string' ? r.id.trim().slice(0, 170) : ''
28
+ const content = typeof r.content === 'string' ? r.content : ''
29
+ if (!id || !content.trim()) continue
30
+ out.push({ id, content,
31
+ ...(typeof r.summary === 'string' ? { summary: r.summary } : {}),
32
+ ...(typeof r.created_at === 'string' ? { created_at: r.created_at } : {}),
33
+ ...(typeof r.domain === 'string' && r.domain ? { domain: r.domain } : {}) })
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** The bridge listing. Rejects when the bridge fails or the signal aborts; callers decide what that means. */
39
+ export async function listBridgeMemories(signal?: AbortSignal, timeoutMs: number = BRIDGE_TIMEOUT_MS): Promise<BridgeMemory[]> {
40
+ return normalizeBridgeMemories(await callPython(['memory', '--days', String(BRIDGE_DAYS), '--limit', String(BRIDGE_LIMIT)], timeoutMs, undefined, signal))
41
+ }
42
+
43
+ /** Rows that match `query`, best first, scored by the library's own keyword scorer (score > 0 only). */
44
+ export function scoreBridgeMemories(rows: BridgeMemory[], query: string): Array<{ row: BridgeMemory; score: number }> {
45
+ const tokens = tokenizeMeetingQuery(query)
46
+ return rows.map(row => ({ row, score: scoreKeywordMatch(tokens, row.summary || '', `${row.summary || ''}\n${row.content}`).score }))
47
+ .filter(x => x.score > 0).sort((a, b) => b.score - a.score)
48
+ }
49
+
50
+ /** A note's LOCAL calendar day: a date bound is a local day (live cues use "tomorrow"), and a memory written at 22:00 Central
51
+ * carries a UTC timestamp dated the next day. A bare YYYY-MM-DD is already a local day. */
52
+ export function localDateOf(v: unknown): string {
53
+ if (typeof v !== 'string') return ''
54
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v
55
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(v)) return ''
56
+ const ms = Date.parse(v)
57
+ return Number.isFinite(ms) ? new Date(ms).toLocaleDateString('en-CA') : ''
58
+ }
59
+
60
+ /** Reciprocal rank fusion over ranked lists (k=60, as cue_memory.py): an item in several lists, or high in one, ranks first.
61
+ * The first item seen for a key is kept; ties keep the order items were first added. */
62
+ export class RankFusion<T> {
63
+ private readonly items = new Map<string, { item: T; score: number; order: number }>()
64
+ add(list: ReadonlyArray<{ key: string; item: T }>): void {
65
+ list.forEach(({ key, item }, rank) => {
66
+ const prev = this.items.get(key)
67
+ this.items.set(key, { item: prev?.item ?? item, score: (prev?.score ?? 0) + 1 / (RRF_K + rank), order: prev?.order ?? this.items.size })
68
+ })
69
+ }
70
+ ranked(limit = Infinity): T[] {
71
+ return [...this.items.values()].sort((a, b) => b.score - a.score || a.order - b.order).slice(0, limit).map(x => x.item)
72
+ }
73
+ }
@@ -82,6 +82,13 @@ export class SubqDecisionClient implements DecisionClient {
82
82
  private readonly cap = () => { const n = Number(process.env.COS_SUBQ_DAILY_TOKENS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : SUBQ_LIMITS.dailyTokens },
83
83
  private readonly timeoutMs: number = SUBQ_LIMITS.timeoutMs) {}
84
84
  resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0 }
85
+ /** For an honest capability (6.76.2, live cues). An unreadable ledger reads as the cap reached: it fails toward not spending. */
86
+ status(): { configured: boolean; usedToday: number; dailyCap: number; breakerOpenUntil: number | null } {
87
+ const dailyCap = this.cap()
88
+ let usedToday = dailyCap
89
+ try { usedToday = Number(this.ledger()[this.day()]) || 0 } catch { /* unreadable ledger: saturated */ }
90
+ return { configured: !!this.key(), usedToday, dailyCap, breakerOpenUntil: this.breakerUntil > this.now().getTime() ? this.breakerUntil : null }
91
+ }
85
92
  private ledger(): Record<string, number> {
86
93
  try {
87
94
  const stat = lstatSync(this.usageFile)
@@ -144,6 +151,8 @@ export class SubqDecisionClient implements DecisionClient {
144
151
 
145
152
  const configuredSubqClients = new Set<SubqDecisionClient>()
146
153
  export function resetSubqDecisionClients(): void { configuredSubqClients.forEach(client => client.resetForNewKey()) }
154
+ /** A SubQ client built outside createDecisionClient (live cues, 6.76.2) still starts clean when a new key is saved. */
155
+ export function trackSubqDecisionClient(client: SubqDecisionClient): SubqDecisionClient { configuredSubqClients.add(client); return client }
147
156
  /** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
148
157
  export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
149
158
  const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
@@ -4,7 +4,11 @@
4
4
  //
5
5
  // Used by:
6
6
  // - /api/transcribe (short-form dictation / messaging)
7
- // - /api/meeting/save (batch post-processing, Phase 3.5 — via meeting.ts)
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 owner = getOwnerSpeakerLabel()
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 lower = word.toLowerCase()
241
-
242
- // Skip common English words (primary false-positive defense)
243
- if (COMMON_ENGLISH_WORDS.has(lower)) return word
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
- export async function searchAskHistory(query: string, excludeSessionId?: string, signal?: AbortSignal, mode: CosSearchMode = 'meaning', domain?: string): Promise<{ sources: AskSource[]; notices: string[] }> {
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.forEach((result, i) => {
249
- if (result.status === 'rejected') { if(result.reason instanceof AskProcessError)throw result.reason; notices.push(`${i === 0 ? 'COS memory' : 'Earlier meeting'} search is unavailable.`); return }
250
- if (semantic && !result.value.semanticAvailable) notices.push(`${i === 0 ? 'COS memory' : 'Earlier meeting'} semantic search is unavailable; local keyword results are shown.`)
251
- result.value.hits.filter(h => (!domain || h.domain === domain) && (!excludeSessionId || !('sessionId' in h) || h.sessionId !== excludeSessionId) && Math.max(h.keywordScore, h.semanticScore) >= 0.35).slice(0, 3).forEach(h => {
252
- sources.push({ id: `H${sources.length + 1}`, kind: i === 0 ? 'memory' : 'meeting', title: redactSecrets(h.title + ('date' in h && h.date ? ' · ' + h.date : '')).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), ...('recordId' in h ? { recordId: h.recordId, sessionId: h.sessionId, domain: h.domain, month: h.month, filename: h.filename } : { memoryId: h.id, domain: h.domain }) })
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
+ }