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