@gotcos/glasses-server 6.75.0 → 6.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/display-bus.ts +2 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +118 -21
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/meeting-ask-owner.ts +144 -0
- package/server/lib/meeting-ask-runtime.ts +28 -6
- package/server/lib/meeting-stop-requests.ts +123 -0
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting-ask.ts +11 -1
- package/server/routes/meeting.ts +177 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
- package/shared/meeting-ask.ts +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
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
|
+
|
|
41
|
+
## 6.76.1 - Stop a glasses meeting from COS Control
|
|
42
|
+
|
|
43
|
+
Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.321. Includes 6.76.0.
|
|
44
|
+
|
|
45
|
+
- New routes so COS Control can stop a live G2 meeting without saving it from the Mac. A Mac-side `POST /api/meeting/save` on a live G2 session closes it while the phone keeps recording (lens still REC, later chunks 410 `session_closed`, local-first files the tail as a separate recovery meeting), so Control asks the glasses instead.
|
|
46
|
+
- `POST /api/meeting/sessions/:sessionId/stop-request` records a request and emits the display event `meeting_stop_requested` `{sessionId, requestId, source, claimWindowMs}`; answers 202 `pending`, 200 `saved` (with `saveReceipt`) or `finalizing`, 409 `not_live`, 400 `mac_capture` or `invalid_session_id`. A double click reuses the pending request. It never saves or closes the session itself.
|
|
47
|
+
- `POST .../stop-request/:requestId/claim` `{decision: 'accept'}` or `{decision: 'refuse', reason: not_recording | other_session | stop_in_progress}`: the companion (6.10.644) claims before it stops. A claim is accepted only within 10 s of the request on this server's clock (`STOP_REQUEST_CLAIM_WINDOW_MS`); later, 409 `expired`, so a late phone or a replayed event never stops a meeting Control already reported as not stopped.
|
|
48
|
+
- `GET .../stop-request/:requestId`: Control's poll. `saved` with the receipt once the meeting is saved, else `pending`, `claimed` (`finalizing` while the save runs), `refused` with its reason, or `expired`. Requests are in memory for 10 minutes; a restart forgets them (404 `unknown_request`), which stops nothing.
|
|
49
|
+
- `meeting_stop_requested` is withheld from ticketless display subscribers, like every type but `recording_stop`.
|
|
50
|
+
- Tests: `server/routes/meeting-stop-request.test.ts` (9) over a real router; seven mutants (window ignored, window off by one, a refused request claimable, a not-live session asked, a Mac capture asked, no display event, a request id from another session) each killed.
|
|
51
|
+
## 6.76.0 - Live COS Ask knows who owns this COS, looks in COS for person questions, and times every phase
|
|
52
|
+
|
|
53
|
+
- Field report (Queen, 2026-10-17): on her own COS, "Who is Queen Ukaoma?" (heard as "Queen Ukoma") was answered "These sources say nothing about who Queen Ukaoma is" from one transcript passage.
|
|
54
|
+
- Owner card. A typed question gets a `profile` source built from the profile the server already keeps: `owner_name`, `owner_speaker_label`, `home_city`, and the owner's own `system_prompt_context` notes with any sentence holding a street address, zip, phone number or email removed. It is cited with an H id like other COS history, returned in `sources` when cited, and named "COS profile" on the lens From line. No profile name: no card, and the prompt is byte-for-byte as before. Presets never get it.
|
|
55
|
+
- ASR spellings of the owner's name read as the owner: one edit for names under seven letters (two from seven), one edit after a recognizer-added leading y (Ukoma and Yukoma both read as Ukaoma). Full name, a surname after a lead-in word ("who is Ukoma"), or an exact first name as the whole subject. A different first name with the same surname ("Isaiah Ukaoma") is not the owner. The answer's notices say how the question was read.
|
|
56
|
+
- Person and self-history questions always look in COS first. "Who is X", "tell me about X" and the owner's own history ("what was going on with me when I was pregnant with Isaiah?") run one COS lookup (memories and earlier meetings, every domain, not only the confirmed meeting domain) before the first model call, whatever the planner would choose. It takes one of the two lookups and saves the planner hop. It never goes to the web: web search stays gated exactly as before, so a public "who is" question still reaches the gated web through the planner after an empty COS lookup.
|
|
57
|
+
- An empty COS lookup is never proof of absence. The prompt lists each COS lookup with its result count, the notices say what was searched, and an answer that reads as "nothing about X" after an empty lookup gets one sentence saying what COS searched and that this does not mean COS has nothing on it.
|
|
58
|
+
- Latency, measured on Miles's Mac before changing anything: "through 210666 ms" and "288457 ms" in the report are `throughMs`, the meeting position when the question was asked (3:30 and 4:48 into the recording), not how long the answer took. The 98 completed Asks in the journal took 3.2 s to 24.3 s (median 6.7 s), and the durable runtime stops any Ask at 30 s. Per model call (the live server's token audit since 2026-10-10): Sonnet median 4.6 s, max 9.4 s (n=121); Opus median 6.1 s, max 12.3 s (n=11); Haiku median 3.8 s, max 7.0 s (n=10); web search median 9.1 s, max 10.5 s (n=10). No search timing existed before this release.
|
|
59
|
+
- Per-phase timing. Each exchange carries `timings` (`totalMs` and ordered `phases`: plan, answer, review, cos_search, web_search, public_topic, fact_check, with `forced`, `failed` and result counts), kept on failed and timed-out Asks too, and each Ask logs one line `[meeting-ask] timing <requestId> <action> <outcome> total=...ms <phase>=<ms>[/<results>] ...` with no question, answer or query text.
|
|
60
|
+
- The Markdown export now says "Transcript through: 3:30 into the meeting" and, when known, "Answer time: 6.4 s (cos search 0.9 s, answer 5.5 s)", so the meeting position is no longer read as a wait.
|
|
61
|
+
- Tests: `meeting-ask-owner.test.ts` (24); 33 guards mutation-tested against a proven green baseline, each mutation killed. No new LLM caller and no new model call: the forced lookup is a local COS search.
|
|
62
|
+
- Includes 6.75.0. Pair with companion 6.10.643. No public publication is implied.
|
|
63
|
+
|
|
1
64
|
## 6.75.0 - Live cues v2 MVP: Jev gate, rank-fusion memory, one writer (candidate, default off)
|
|
2
65
|
|
|
3
66
|
- New live cues pipeline behind `COS_LIVE_CUES_V2=1` (default off; independent of the v1 `COS_LIVE_CUES` flag, so switching v2 off never re-enables the v1 Composer path): a Jev gate decides whether a moment deserves a cue (P(cue) >= `COS_LIVE_CUES_V2_GATE`, default 0.8), `cue_memory.py` in COS scripts ranks past meetings by rank fusion (BM25 first, then Qdrant dense inside a 3 s budget; no LLM), a Jev judge keeps at most two notes about the same client or project (P >= 0.8), and ONE tools-off Claude call writes the cue (`COS_LIVE_CUES_V2_MODEL`, default Sonnet 5.5; writer rules v3, `COS_LIVE_CUES_V2_PROMPT=v2` restores v2). Prompt strings are generated from the replay sources (`cue_rrf_replay/generate_server_prompts.py`) and pinned by sha256 in the tests.
|
|
@@ -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>> = {}
|
|
@@ -9,6 +9,8 @@ bus.setMaxListeners(20) // Multiple glasses clients
|
|
|
9
9
|
|
|
10
10
|
export interface DisplayEvent {
|
|
11
11
|
type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge' | 'coaching_nudge_v2'
|
|
12
|
+
// Control asked the glasses to stop a G2 meeting (routes/meeting.ts stop-request).
|
|
13
|
+
| 'meeting_stop_requested'
|
|
12
14
|
data: Record<string, unknown>
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -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) {
|