@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.
- package/.env.example +9 -0
- package/CHANGELOG.md +98 -0
- package/README.md +20 -1
- package/managed-runtime-contract.json +6 -1
- package/package.json +9 -1
- package/server/index.ts +11 -0
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/live-cues.ts +39 -3
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/prompt-drafts.ts +75 -8
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- 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
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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?:
|
|
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 =
|
|
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?:
|
|
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 =
|
|
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:
|
|
721
|
-
):
|
|
722
|
-
return
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
* -
|
|
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,
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
74
|
-
|
|
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
|
+
}
|