@gotcos/glasses-server 6.79.1 → 6.80.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.
Files changed (74) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +98 -0
  3. package/README.md +20 -1
  4. package/managed-runtime-contract.json +6 -1
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/dictation-clean.ts +13 -6
  23. package/server/lib/dictation-fillers.ts +17 -10
  24. package/server/lib/dictation-polish-need.ts +48 -0
  25. package/server/lib/dictation-prewarm.ts +209 -0
  26. package/server/lib/direct-bridge.ts +853 -0
  27. package/server/lib/direct-sse.ts +262 -0
  28. package/server/lib/fork-thread.ts +11 -0
  29. package/server/lib/key-answer-fields.ts +49 -0
  30. package/server/lib/key-catalog.ts +301 -0
  31. package/server/lib/key-lanes-pricing.ts +12 -0
  32. package/server/lib/key-lanes-wiring.ts +346 -0
  33. package/server/lib/key-lanes.ts +145 -0
  34. package/server/lib/key-ledger.ts +123 -0
  35. package/server/lib/key-notices.ts +486 -0
  36. package/server/lib/key-profile-rejects.ts +25 -0
  37. package/server/lib/key-profile-routing.ts +173 -0
  38. package/server/lib/key-profiles.ts +182 -0
  39. package/server/lib/key-relay-contract.ts +262 -0
  40. package/server/lib/key-relay-errors.ts +211 -0
  41. package/server/lib/key-relay-meter.ts +303 -0
  42. package/server/lib/key-relay.ts +1018 -0
  43. package/server/lib/key-secrets-guard.ts +52 -0
  44. package/server/lib/key-store.ts +228 -0
  45. package/server/lib/keyed-sessions.ts +154 -0
  46. package/server/lib/lane-b-env.ts +169 -0
  47. package/server/lib/live-cues-v2-settings.ts +51 -13
  48. package/server/lib/live-cues-v2.ts +147 -19
  49. package/server/lib/model-router.ts +96 -7
  50. package/server/lib/morning-brief-config.ts +3 -0
  51. package/server/lib/native-head.ts +13 -1
  52. package/server/lib/occupancy-probes.ts +4 -0
  53. package/server/lib/ollama-run-ledger.ts +96 -13
  54. package/server/lib/prompt-edit.ts +2 -0
  55. package/server/lib/query-job-coordinator.ts +2 -1
  56. package/server/lib/query-job-runtime.ts +67 -15
  57. package/server/lib/query-job-store.ts +7 -1
  58. package/server/lib/query-job-types.ts +15 -1
  59. package/server/lib/session-hook-events.ts +28 -0
  60. package/server/lib/session-hook-ledger.ts +10 -0
  61. package/server/lib/thread-occupancy.ts +22 -1
  62. package/server/lib/token-audit.ts +18 -4
  63. package/server/lib/work-handoff-requests.ts +3 -0
  64. package/server/routes/agent-session-bindings.ts +15 -0
  65. package/server/routes/agent-sessions.ts +17 -5
  66. package/server/routes/handoffs.ts +22 -1
  67. package/server/routes/health.ts +5 -0
  68. package/server/routes/key-lanes.ts +442 -0
  69. package/server/routes/live-cues.ts +39 -3
  70. package/server/routes/openai-compat.ts +28 -3
  71. package/server/routes/prompt-drafts.ts +75 -8
  72. package/server/routes/query.ts +20 -6
  73. package/shared/key-preference.ts +57 -0
  74. package/shared/model-preference.ts +20 -2
@@ -13,6 +13,8 @@ import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-
13
13
  import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
14
  import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
+ import { parsePreference, profilePreference } from '../../shared/key-preference.js'
17
+ import { onKeyLanesInactive } from './key-lanes.js'
16
18
  import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
17
19
  import { secureExistingPrivateFile } from './secure-user-config.js'
18
20
  import { currentMessageEra } from './message-era.js'
@@ -20,6 +22,38 @@ import { formatReferencedSourceData } from './prompt-reference-boundary.js'
20
22
 
21
23
  export type { ModelPreference }
22
24
 
25
+ /**
26
+ * Key lanes K1 (plan v4 5.2, W2): what a session or an exchange may be bound to. A model slot,
27
+ * exactly as before, or a key profile as `profile:<id>`. Persistence accepts a profile by SYNTAX
28
+ * only: whether that profile still exists, is enabled and has a key is decided at resolve time
29
+ * (model-router), which refuses with a lens line. Dropping the binding here would make the next
30
+ * turn run on the subscription without a word, which is the bug this type exists to prevent.
31
+ */
32
+ export type KeyProfileBinding = `profile:${string}`
33
+ export type SessionModelBinding = ModelPreference | KeyProfileBinding
34
+
35
+ /**
36
+ * The persistence normalizer (plan 5.2 Accept by syntax). For every input that is not a well
37
+ * formed `profile:<id>` this returns exactly what normalizeModelPreference returns, so a store
38
+ * with no profile in it reads and writes byte for byte as 6.79.0 did.
39
+ */
40
+ export function normalizeSessionBinding(value: unknown): SessionModelBinding | undefined {
41
+ const parsed = parsePreference(value)
42
+ if (!parsed) return undefined
43
+ return parsed.kind === 'model' ? parsed.value : profilePreference(parsed.id) as KeyProfileBinding
44
+ }
45
+
46
+ /** The profile id of a `profile:<id>` binding, else undefined. */
47
+ export function bindingProfileId(value: unknown): string | undefined {
48
+ const parsed = parsePreference(value)
49
+ return parsed?.kind === 'profile' ? parsed.id : undefined
50
+ }
51
+
52
+ /** The closed view of a binding: the model slot, or null for a profile (or nothing). */
53
+ function closedSessionModel(value: SessionModelBinding | null | undefined): ModelPreference | null {
54
+ return normalizeModelPreference(value) ?? null
55
+ }
56
+
23
57
  export interface Exchange {
24
58
  role: 'user' | 'assistant'
25
59
  content: string
@@ -31,8 +65,9 @@ export interface Exchange {
31
65
  /** Short-number namespace. Missing means the original legacy era. */
32
66
  messageEra?: string
33
67
  /** Per-exchange model badge. Prefer this over session.modelPreference when
34
- * rendering history so Cursor/Codex turns do not inherit an Opus default. */
35
- modelPreference?: ModelPreference
68
+ * rendering history so Cursor/Codex turns do not inherit an Opus default.
69
+ * K1: a keyed turn is stamped `profile:<id>` (accepted by syntax). */
70
+ modelPreference?: SessionModelBinding
36
71
  /** Public attachment refs may live on either half of a Q&A pair: request
37
72
  * photos on the user exchange, model-published images on the assistant
38
73
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
@@ -75,7 +110,7 @@ interface Session {
75
110
  exchanges: Exchange[]
76
111
  lastActivity: number
77
112
  createdAt: number
78
- modelPreference: ModelPreference | null
113
+ modelPreference: SessionModelBinding | null
79
114
  contextBreaks: number[] // timestamps where user said "new chat" — prompt history only sees exchanges after last break
80
115
  }
81
116
 
@@ -160,12 +195,12 @@ function loadFromDisk(): void {
160
195
  let loaded = 0
161
196
  for (const [id, session] of Object.entries(result.data.sessions)) {
162
197
  if (!session.contextBreaks) session.contextBreaks = []
163
- session.modelPreference = normalizeModelPreference(session.modelPreference) ?? null
198
+ session.modelPreference = normalizeSessionBinding(session.modelPreference) ?? null
164
199
  // Persistence boundary: malformed refs are dropped without sacrificing
165
200
  // the surrounding exchange or the rest of the recovered session.
166
201
  for (const exchange of session.exchanges) {
167
202
  validateLoadedExchangeProvenance(exchange)
168
- const exchangeModel = normalizeModelPreference(exchange.modelPreference)
203
+ const exchangeModel = normalizeSessionBinding(exchange.modelPreference)
169
204
  if (exchangeModel) exchange.modelPreference = exchangeModel
170
205
  else delete exchange.modelPreference
171
206
  if ('attachments' in exchange && exchange.attachments !== undefined) {
@@ -479,7 +514,7 @@ export function addExchange(
479
514
  globalMsgNum?: number,
480
515
  attachments?: MediaAttachmentRef[],
481
516
  provenance?: ExchangeJobProvenance,
482
- modelPreference?: ModelPreference | null,
517
+ modelPreference?: SessionModelBinding | null,
483
518
  ): Exchange {
484
519
  let session = sessions.get(sessionId)
485
520
  if (!session) {
@@ -487,7 +522,7 @@ export function addExchange(
487
522
  sessions.set(sessionId, session)
488
523
  }
489
524
 
490
- const stampedModel = normalizeModelPreference(modelPreference)
525
+ const stampedModel = normalizeSessionBinding(modelPreference)
491
526
  const exchange: Exchange = {
492
527
  role,
493
528
  content,
@@ -593,7 +628,7 @@ export function reconcileExchangeByJobIdentity(
593
628
  globalMsgNum?: number,
594
629
  attachments?: MediaAttachmentRef[],
595
630
  messageEra?: string,
596
- modelPreference?: ModelPreference | null,
631
+ modelPreference?: SessionModelBinding | null,
597
632
  origin?: ExchangeOriginStamp,
598
633
  ): ReconciledExchange {
599
634
  if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
@@ -625,7 +660,7 @@ export function reconcileExchangeByJobIdentity(
625
660
  && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(messageEra)
626
661
  ? messageEra
627
662
  : currentMessageEra()
628
- const stampedModel = normalizeModelPreference(modelPreference)
663
+ const stampedModel = normalizeSessionBinding(modelPreference)
629
664
  let exchange: Exchange
630
665
  const created = matchingIndexes.length === 0
631
666
  if (created) {
@@ -709,17 +744,57 @@ export async function clearSession(sessionId: string): Promise<void> {
709
744
  scheduleCacheUpdate()
710
745
  }
711
746
 
747
+ /**
748
+ * The session's model slot. CLOSED (plan 5): a session bound to a key profile reads null here,
749
+ * so no caller that predates key lanes can ever mistake `profile:<id>` for a model and fall
750
+ * through to Claude. Routing reads getSessionBinding instead.
751
+ */
712
752
  export function getSessionModel(sessionId: string): ModelPreference | null {
753
+ return closedSessionModel(sessions.get(sessionId)?.modelPreference)
754
+ }
755
+
756
+ /** The session's binding, a model slot or `profile:<id>`. Only model-router and the job runtime read it. */
757
+ export function getSessionBinding(sessionId: string): SessionModelBinding | null {
713
758
  return sessions.get(sessionId)?.modelPreference ?? null
714
759
  }
715
760
 
761
+ /**
762
+ * Plan 12 (Remove all keys): every session bound to a profile (or only to `profileIds`) loses the
763
+ * binding and gets a context break, so a later subscription turn starts a fresh CLI session and
764
+ * never resumes a keyed transcript. Exchange stamps stay: history still says which answers ran
765
+ * on a key. Returns the session ids it changed.
766
+ */
767
+ export function clearProfileBindings(profileIds?: readonly string[]): string[] {
768
+ const only = profileIds ? new Set(profileIds) : null
769
+ const changed: string[] = []
770
+ const now = Date.now()
771
+ for (const session of sessions.values()) {
772
+ const id = bindingProfileId(session.modelPreference)
773
+ if (id === undefined || (only && !only.has(id))) continue
774
+ session.modelPreference = null
775
+ session.contextBreaks.push(now)
776
+ changed.push(session.id)
777
+ }
778
+ if (changed.length > 0) {
779
+ scheduleSave()
780
+ scheduleCacheUpdate()
781
+ }
782
+ return changed
783
+ }
784
+
785
+ // Plan 12: Remove all keys clears every profile binding. A named Map entry; nothing runs until
786
+ // the remove-all route fires it.
787
+ onKeyLanesInactive('p6-session-bindings', reason => {
788
+ if (reason === 'remove_all') clearProfileBindings()
789
+ })
790
+
716
791
  /** Prefer per-exchange stamps; fall back to session model for pre-stamp rows. */
717
792
  export function resolveExchangePairModel(
718
793
  user: Pick<Exchange, 'modelPreference'> | undefined,
719
794
  assistant: Pick<Exchange, 'modelPreference'> | undefined,
720
- sessionModel: ModelPreference | null | undefined,
721
- ): ModelPreference | undefined {
722
- return normalizeModelPreference(
795
+ sessionModel: SessionModelBinding | null | undefined,
796
+ ): SessionModelBinding | undefined {
797
+ return normalizeSessionBinding(
723
798
  assistant?.modelPreference
724
799
  ?? user?.modelPreference
725
800
  ?? sessionModel
@@ -727,7 +802,7 @@ export function resolveExchangePairModel(
727
802
  )
728
803
  }
729
804
 
730
- export function setSessionModel(sessionId: string, model: ModelPreference | null): void {
805
+ export function setSessionModel(sessionId: string, model: SessionModelBinding | null): void {
731
806
  const session = sessions.get(sessionId)
732
807
  if (session) {
733
808
  session.modelPreference = model
@@ -809,7 +884,7 @@ export interface SessionSummary {
809
884
  exchangeCount: number
810
885
  lastActivity: number
811
886
  createdAt: number
812
- modelPreference: ModelPreference | null
887
+ modelPreference: SessionModelBinding | null
813
888
  lastQuery: string | null
814
889
  }
815
890
 
@@ -948,7 +1023,9 @@ export function getActiveSessions(): SessionToArchive[] {
948
1023
  contextBreaks: session.contextBreaks,
949
1024
  createdAt: session.createdAt,
950
1025
  lastActivity: session.lastActivity,
951
- modelPreference: session.modelPreference,
1026
+ // Archive rows carry their own stamps (a keyed answer is stamped profile:<id>); the
1027
+ // session-level fallback stays a closed model slot, as archive.ts types it.
1028
+ modelPreference: closedSessionModel(session.modelPreference),
952
1029
  })
953
1030
  }
954
1031
  return result
@@ -83,6 +83,18 @@ export function dictationChildEnv(binary: string): NodeJS.ProcessEnv {
83
83
  return env
84
84
  }
85
85
 
86
+ /** The Claude polish process: no tools, MCP, hooks or session file. `stream` keeps stdin open
87
+ * for exactly one stream-json user message (the 6.79.2 prewarm); `text` reads the prompt from stdin. */
88
+ export function claudePolishArgs(model: string, version: DictationPolishPromptVersion, io: 'text' | 'stream'): string[] {
89
+ return [
90
+ '-p', '--model', model, '--effort', 'low',
91
+ ...(io === 'text' ? ['--output-format', 'text'] : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']),
92
+ '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
93
+ '--tools', '', '--settings', '{"disableAllHooks":true}', '--permission-mode', 'dontAsk',
94
+ '--system-prompt', DICTATION_POLISH_SYSTEM_PROMPTS[version],
95
+ ]
96
+ }
97
+
86
98
  /** A fresh workspace and no MCP. Claude has no tools or hooks; Cursor's ask
87
99
  * mode retains read tools. Neither runner is given the COS working directory.
88
100
  * Failure rejects so callers preserve the deterministic transcript. */
@@ -128,12 +140,7 @@ export async function autoCleanDictation(
128
140
  const args = cursor ? [
129
141
  '-p', '--mode', 'ask', '--model', LUNA_DICTATION_CLI_MODEL,
130
142
  '--output-format', 'json', '--trust', '--workspace', workspace,
131
- ] : [
132
- '-p', '--model', model, '--effort', 'low', '--output-format', 'text',
133
- '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
134
- '--tools', '', '--settings', '{"disableAllHooks":true}', '--permission-mode', 'dontAsk',
135
- '--system-prompt', DICTATION_POLISH_SYSTEM_PROMPTS[version],
136
- ]
143
+ ] : claudePolishArgs(model, version, 'text')
137
144
  return await new Promise<string>((resolve, reject) => {
138
145
  const proc = spawn(binary.path, args, { stdio: ['pipe', 'pipe', 'pipe'], env, cwd: workspace, detached: true })
139
146
  let stdout = ''
@@ -16,7 +16,8 @@
16
16
  * ellipsis follows it ("I think, Um, we"). An abbreviation's period ("Mr.", "e.g.", "5 p.m.",
17
17
  * "U.S.", an initial) and an ellipsis are not sentence ends, so they neither start a sentence
18
18
  * nor trigger the capital hand-off.
19
- * - any token right after a title ("Mr. Ah", "Dr. Er") and any quoted mention ('"um"').
19
+ * - a Titlecase token right after a title or abbreviation ("Mr. Ah", "Dr. Er"; "Mr. uh Jones"
20
+ * still loses "uh"), and any quoted mention ('"um"').
20
21
  * - "uh huh", "uh oh" and "mm hmm" written without a hyphen: answers, not hesitations.
21
22
  * - "like", "you know", "I mean": filler only sometimes, so the model decides, not a regex.
22
23
  * A filler inside a longer quote ("um, no") is removed like any other.
@@ -30,23 +31,28 @@ const TRAILING_PAUSE = /^[ \t]*(?:,|\.{2,}|…|--|—|–)?[ \t]*/
30
31
  const OWN_SENTENCE_END = /^[ \t]*[.?!]+[ \t]*/
31
32
  const CLOSES_CLAUSE = /^(?:[.?!;:)\]}"”’]|$|\n)/
32
33
 
33
- /** A period that ends an abbreviation, not a sentence: "Mr.", "e.g.", "5 p.m.", "U.S.", "J.". */
34
- const ABBREVIATION = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st|vs|etc|e\.g|i\.e|a\.m|p\.m|u\.s)\.["”’)]*$/i
34
+ /** A period that ends an abbreviation, not a sentence: "Mr.", "Jr.", "e.g.", "5 p.m.", "U.S.", "J.". */
35
+ const ABBREVIATION = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st|jr|sr|vs|etc|e\.g|i\.e|a\.m|p\.m|u\.s)\.["”’)]*$/i
35
36
  const INITIAL = /(?:^|[\s(“"‘'])[A-Z]\.["”’)]*$/
36
- /** A title right before a token makes the token a name ("Mr. Ah", "Dr. Er"). */
37
- const TITLE = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st)\.[ \t]*$/i
38
37
  const QUOTE_OPEN = /["“”'‘’]$/
39
38
  const QUOTE_CLOSE = /^["“”'‘’]/
40
39
  /** A pause mark right after the token: a Titlecase filler set off this way is a hesitation. */
41
40
  const PAUSE_AFTER = /^[ \t]*(?:,|\.{2,}|…)/
42
41
 
42
+ function afterAbbreviation(before: string): boolean {
43
+ const trimmed = before.replace(/[ \t]+$/, '')
44
+ return ABBREVIATION.test(trimmed) || INITIAL.test(trimmed)
45
+ }
46
+
43
47
  function sentenceStart(before: string): boolean {
44
48
  const trimmed = before.replace(/[ \t]+$/, '')
45
49
  if (trimmed === '' || trimmed.endsWith('\n')) return true
46
50
  if (!/[.?!]["”’)]*$/.test(trimmed)) return false
47
- // An ellipsis is a pause, and an abbreviation's period is not a sentence end.
48
- if (/(?:\.{2,}|…)["”’)]*$/.test(trimmed)) return false
49
- return !(ABBREVIATION.test(trimmed) || INITIAL.test(trimmed))
51
+ // An ellipsis is a pause, not a sentence end. An abbreviation's period needs no case here:
52
+ // the only thing a sentence start changes is the capital hand-off from a Titlecase filler,
53
+ // and a Titlecase token after an abbreviation is kept as a name before this is consulted
54
+ // (the 6.79.1 mutation gate showed a check here was unreachable).
55
+ return !/(?:\.{2,}|…)["”’)]*$/.test(trimmed)
50
56
  }
51
57
 
52
58
  export function fillerPassEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -70,8 +76,9 @@ export function stripDisfluencies(text: string): string {
70
76
  // A capital away from a sentence start is a name ("Ask Ah Kow"), unless a pause mark
71
77
  // sets it off as a hesitation ("I think, Um, we should").
72
78
  || (!atStart && /^[A-Z]/.test(token) && !PAUSE_AFTER.test(after))
73
- // Right after a title it is a name ("Mr. Ah", "Dr. Er").
74
- || TITLE.test(out + before)
79
+ // Titlecase right after a title or abbreviation is a name ("Mr. Ah", "Dr. Er, the surgeon").
80
+ // A lowercase filler there still goes ("Mr. uh Jones").
81
+ || (/^[A-Z]/.test(token) && afterAbbreviation(out + before))
75
82
  // A quoted mention is the word itself ('"um" is a filler word').
76
83
  || (QUOTE_OPEN.test(out + before) && QUOTE_CLOSE.test(after))
77
84
  // Two-word answers said without a hyphen: "uh huh", "uh oh", "mm hmm".
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Skip the polish model when the text has nothing for it to fix.
3
+ *
4
+ * Runs after the deterministic filler pass. The model is called only when one of these
5
+ * is present; anything else goes out as the filler-passed text with polishSkipped "clean":
6
+ * - a backtrack or self-correction marker ("actually", "no wait", "I mean", "scratch that", "sorry")
7
+ * - a repeated word or stutter ("the the", "I I"), or a clause restarted on its first word
8
+ * ("we should, we need to")
9
+ * - a filler-like the deterministic pass leaves to the model ("like", "you know", "kind of"),
10
+ * or a sentence that opens on a tic ("So", "Well", "Okay")
11
+ * - a filler sound still present (a meeting line, or one the pass kept)
12
+ * - spoken formatting or punctuation ("new line", "bullet point", "comma", "question mark")
13
+ * - text that does not read as finished: no capital at the start or no closing punctuation
14
+ * - more than MAX_WORDS words
15
+ * Each check errs toward calling the model: a false "needed" costs a model call, a false
16
+ * "clean" costs a fix. What it cannot see is a mis-heard name ("ask preeya" for Priya): the
17
+ * 6.79.2 eval found those are the fixes skipping loses, so the switch defaults OFF.
18
+ */
19
+ export const MAX_WORDS = 40
20
+
21
+ export type PolishNeed =
22
+ | 'backtrack' | 'repeat' | 'false_start' | 'filler_like' | 'filler' | 'formatting' | 'unfinished' | 'long'
23
+
24
+ const BACKTRACK = /\b(actually|no,? wait|wait,? no|i mean|scratch that|sorry|or rather|correction|let me rephrase|i meant|no no)\b|,\s*no\s*,/i
25
+ const REPEAT = /(?<![\p{L}\p{N}'])([\p{L}\p{N}']+)[\s,]+\1(?![\p{L}\p{N}'])/iu
26
+ /** A clause restarted on the same word: "we should, we need to". */
27
+ const FALSE_START = /(?<![\p{L}\p{N}'])([\p{L}']+)\s+[\p{L}']+(?:\s+[\p{L}']+)?,\s+\1(?![\p{L}\p{N}'])/iu
28
+ const FILLER_LIKE = /\b(like|you know|kind of|kinda|sort of|sorta|basically|literally|i guess)\b|^["“(]?(so|well|okay|ok|alright|anyway)\b/i
29
+ const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/iu
30
+ const FORMATTING = /\b(new ?line|new paragraph|next line|bullet( point)?|numbered|number (one|two|three|1|2|3)|comma|period|full stop|question mark|exclamation (point|mark)|colon|semicolon|open (quote|paren)|close (quote|paren)|dash)\b/i
31
+
32
+ export function polishNeed(text: string): PolishNeed | null {
33
+ const trimmed = text.trim()
34
+ if (BACKTRACK.test(trimmed)) return 'backtrack'
35
+ if (REPEAT.test(trimmed)) return 'repeat'
36
+ if (FALSE_START.test(trimmed)) return 'false_start'
37
+ if (FILLER_LIKE.test(trimmed)) return 'filler_like'
38
+ if (FILLER.test(trimmed)) return 'filler'
39
+ if (FORMATTING.test(trimmed)) return 'formatting'
40
+ if (!/^["“'‘(]?[\p{Lu}\p{N}]/u.test(trimmed) || !/[.?!]["”’)]*$/.test(trimmed)) return 'unfinished'
41
+ if (trimmed.split(/\s+/).length > MAX_WORDS) return 'long'
42
+ return null
43
+ }
44
+
45
+ /** Off unless COS_DICTATION_POLISH_SKIP_CLEAN is 1, true or on. */
46
+ export function skipCleanEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
47
+ return ['1', 'true', 'on'].includes((env.COS_DICTATION_POLISH_SKIP_CLEAN ?? '').trim().toLowerCase())
48
+ }
@@ -0,0 +1,209 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { resolveProviderBinary } from './provider-binary.js'
7
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
8
+ import {
9
+ buildDictationPolishPrompt, claudePolishArgs, dictationChildEnv, dictationPolishPromptVersion,
10
+ type DictationPolishPromptVersion,
11
+ } from './dictation-clean.js'
12
+
13
+ /**
14
+ * 6.79.2: start the polish Claude process when dictation starts, not when it ends.
15
+ *
16
+ * About 70% of a polish is the CLI's own start (measured on Miles's Mac: cold polish 1.8 to
17
+ * 1.9 s; the same process started 3 s earlier answered 1.0 to 1.3 s after the text arrived).
18
+ * A warm process is the exact polish process (same args, no tools, MCP, hooks or session
19
+ * file, its own empty workspace) in stream-json input mode, waiting for ONE user message.
20
+ * It gets that message, answers, and its stdin is closed at once, so it exits: no history
21
+ * ever carries into another dictation.
22
+ *
23
+ * Bounded: at most PREWARM_MAX live (a new one evicts the oldest), each killed after
24
+ * PREWARM_TTL_MS unused, killed when finalize decides not to call the model, and killed
25
+ * with the server. A warm process costs no model call until a message is written, so the
26
+ * daily polish budget is spent only when it is used.
27
+ */
28
+ export const PREWARM_TTL_MS = 150_000
29
+ export const PREWARM_MAX = 2
30
+ export const PREWARM_MODELS = ['haiku', 'sonnet'] as const
31
+ export type PrewarmModel = typeof PREWARM_MODELS[number]
32
+
33
+ export class PrewarmUnavailableError extends Error {}
34
+
35
+ interface WarmPolish {
36
+ token: string
37
+ model: PrewarmModel
38
+ version: DictationPolishPromptVersion
39
+ proc: ChildProcess
40
+ scratch: string
41
+ exited: boolean
42
+ timer: NodeJS.Timeout
43
+ onResult: ((event: any) => void) | null
44
+ onExit: (() => void) | null
45
+ }
46
+
47
+ /** Unclaimed warm processes, by token. */
48
+ const warm = new Map<string, WarmPolish>()
49
+ /** Every process not yet exited, claimed or not: what the server kills on its way out. */
50
+ const alive = new Set<WarmPolish>()
51
+
52
+ export function prewarmEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
53
+ return !['0', 'false', 'off'].includes((env.COS_DICTATION_PREWARM ?? '').trim().toLowerCase())
54
+ }
55
+
56
+ export function isPrewarmModel(model: unknown): model is PrewarmModel {
57
+ return typeof model === 'string' && (PREWARM_MODELS as readonly string[]).includes(model)
58
+ }
59
+
60
+ export function livePrewarmCount(): number {
61
+ return warm.size
62
+ }
63
+
64
+ function retire(entry: WarmPolish): void {
65
+ warm.delete(entry.token)
66
+ clearTimeout(entry.timer)
67
+ if (!entry.exited) void terminateProviderProcess(entry.proc).catch(() => {})
68
+ }
69
+
70
+ export function startPolishPrewarm(model: PrewarmModel): { token: string; expiresMs: number } {
71
+ const binary = resolveProviderBinary('claude')
72
+ if (!binary.ok) throw new PrewarmUnavailableError('Dictation cleanup Claude CLI unavailable')
73
+ while (warm.size >= PREWARM_MAX) retire(warm.values().next().value!)
74
+ const version = dictationPolishPromptVersion()
75
+ const scratch = mkdtempSync(join(tmpdir(), 'cos-dictation-prewarm-'))
76
+ const workspace = join(scratch, 'workspace')
77
+ mkdirSync(workspace, { mode: 0o700 })
78
+ const proc = spawn(binary.path, claudePolishArgs(model, version, 'stream'), {
79
+ stdio: ['pipe', 'pipe', 'pipe'], env: dictationChildEnv(binary.path), cwd: workspace, detached: true,
80
+ })
81
+ const token = randomUUID()
82
+ const entry: WarmPolish = {
83
+ token, model, version, proc, scratch, exited: false, onResult: null, onExit: null,
84
+ timer: setTimeout(() => retire(entry), PREWARM_TTL_MS),
85
+ }
86
+ entry.timer.unref?.()
87
+ let buffer = ''
88
+ proc.stdout?.on('data', (chunk: Buffer) => {
89
+ buffer += chunk.toString()
90
+ if (buffer.length > 256_000) { retire(entry); return }
91
+ let newline: number
92
+ while ((newline = buffer.indexOf('\n')) >= 0) {
93
+ const line = buffer.slice(0, newline).trim()
94
+ buffer = buffer.slice(newline + 1)
95
+ if (!line) continue
96
+ let event: any
97
+ try { event = JSON.parse(line) } catch { continue }
98
+ if (event?.type === 'result') entry.onResult?.(event)
99
+ }
100
+ })
101
+ // Do not log provider stderr: it can contain login or dictation data.
102
+ proc.stderr?.resume()
103
+ proc.stdin?.on('error', () => {})
104
+ const exited = () => {
105
+ if (entry.exited) return
106
+ entry.exited = true
107
+ warm.delete(token)
108
+ alive.delete(entry)
109
+ clearTimeout(entry.timer)
110
+ rmSync(scratch, { recursive: true, force: true })
111
+ entry.onExit?.()
112
+ }
113
+ proc.on('error', exited)
114
+ proc.on('close', exited)
115
+ warm.set(token, entry)
116
+ alive.add(entry)
117
+ return { token, expiresMs: PREWARM_TTL_MS }
118
+ }
119
+
120
+ /** Kill a warm process nobody will use (finalize skipped the model, or the press was cancelled). */
121
+ export function discardPolishPrewarm(token: string | undefined): boolean {
122
+ const entry = token ? warm.get(token) : undefined
123
+ if (!entry) return false
124
+ retire(entry)
125
+ return true
126
+ }
127
+
128
+ /** One use: the entry leaves the pool whether or not it matches. A model mismatch kills it. */
129
+ export function takePolishPrewarm(token: string | undefined, model: string): WarmPolish | null {
130
+ const entry = token ? warm.get(token) : undefined
131
+ if (!entry) return null
132
+ warm.delete(entry.token)
133
+ if (entry.model !== model || entry.exited) { retire(entry); return null }
134
+ return entry
135
+ }
136
+
137
+ /**
138
+ * Write the one message and read the one result. PrewarmUnavailableError means nothing was
139
+ * sent (the process had already gone), so the caller may spawn cold. Any later failure means
140
+ * the model may have run, so it is a real polish failure.
141
+ */
142
+ export async function runWarmPolish(
143
+ entry: WarmPolish,
144
+ text: string,
145
+ terms: string[],
146
+ opts: { signal?: AbortSignal } = {},
147
+ ): Promise<string> {
148
+ if (entry.exited || !entry.proc.stdin || entry.proc.stdin.destroyed) {
149
+ retire(entry)
150
+ throw new PrewarmUnavailableError('Warm polish process already exited')
151
+ }
152
+ if (opts.signal?.aborted) { retire(entry); throw new Error('Auto-clean aborted') }
153
+ clearTimeout(entry.timer)
154
+ const configuredTimeout = Number.parseInt(process.env.COS_DICTATION_AUTOCLEAN_TIMEOUT_MS || '20000', 10)
155
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 20_000
156
+ return await new Promise<string>((resolve, reject) => {
157
+ let settled = false
158
+ const finish = (error?: Error, output?: string) => {
159
+ if (settled) return
160
+ settled = true
161
+ clearTimeout(timer)
162
+ opts.signal?.removeEventListener('abort', abort)
163
+ entry.onResult = null
164
+ entry.onExit = null
165
+ // Exactly one message: once it is answered (or abandoned) the process must go.
166
+ retire(entry)
167
+ if (error) reject(error); else resolve(output!)
168
+ }
169
+ const abort = () => finish(new Error('Auto-clean aborted'))
170
+ const timer = setTimeout(() => finish(new Error(`Auto-clean timed out (${timeoutMs}ms)`)), timeoutMs)
171
+ entry.onResult = (event) => {
172
+ if (event.subtype !== 'success' || event.is_error || typeof event.result !== 'string') {
173
+ return finish(new Error('Warm polish returned an unsuccessful result'))
174
+ }
175
+ const output = event.result.trim()
176
+ if (!output) return finish(new Error('Auto-clean returned empty text'))
177
+ finish(undefined, output)
178
+ }
179
+ entry.onExit = () => finish(new Error('Warm polish process exited without a result'))
180
+ opts.signal?.addEventListener('abort', abort, { once: true })
181
+ const message = JSON.stringify({
182
+ type: 'user',
183
+ message: { role: 'user', content: buildDictationPolishPrompt(text, terms, entry.version) },
184
+ })
185
+ try {
186
+ entry.proc.stdin!.write(message + '\n')
187
+ entry.proc.stdin!.end()
188
+ } catch {
189
+ finish(new Error('Auto-clean input failed'))
190
+ }
191
+ })
192
+ }
193
+
194
+ /** The server is going away: a detached warm process would outlive it. */
195
+ export function killAllPrewarmsSync(): void {
196
+ for (const entry of alive) {
197
+ try { if (entry.proc.pid) process.kill(-entry.proc.pid, 'SIGTERM') } catch {}
198
+ // No close event will run before the server is gone, so its workspace goes now.
199
+ try { rmSync(entry.scratch, { recursive: true, force: true }) } catch {}
200
+ }
201
+ warm.clear()
202
+ alive.clear()
203
+ }
204
+ process.once('exit', killAllPrewarmsSync)
205
+
206
+ export function _resetPrewarmsForTest(): void {
207
+ for (const entry of [...alive]) retire(entry)
208
+ warm.clear()
209
+ }