@gotcos/glasses-server 6.98.1 → 6.99.12
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 +149 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop it router (plan section 2): where one drop goes.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic first, with no Jev call: "remind me" is a reminder, "remember" is a memory, and text ending in "?"
|
|
5
|
+
* or starting with who, what, when, where, why or how is a question for the existing Ask job. Everything else is
|
|
6
|
+
* ONE Jev request with up to six questions (dest, domain, card, thread, session, clear). It files on its own at 0.80
|
|
7
|
+
* with a 0.25 lead (memory needs 0.90); otherwise it asks one question with the top two readings and "Just a note".
|
|
8
|
+
* When Jev cannot run (no key, cap, breaker, switch off, unreachable, a bad answer) the drop is saved as a plain note.
|
|
9
|
+
*
|
|
10
|
+
* Cost controls of its own, copying lib/work-search.ts: a daily input-token cap (COS_DROP_DAILY_TOKENS, default
|
|
11
|
+
* 300,000), its own usage ledger (data/jev-drop-usage.json), its own breaker (jev.ts: 3 failures, then 1 hour), and a
|
|
12
|
+
* kill switch (COS_DROP_ROUTER=0 turns Jev routing off; drops are still saved). Token-audit caller `drop`.
|
|
13
|
+
* Pinned to Jev for P1: the global decision provider setting (SubQ) does not apply, and there is no `drop` decision
|
|
14
|
+
* use case, so no settings file can carry a drop override that 6.99.4 and earlier would refuse to read.
|
|
15
|
+
*
|
|
16
|
+
* Privacy (plan section 8): Jev reads cleaned text only (credentials, URLs, email addresses and opaque ids removed),
|
|
17
|
+
* and a drop whose words trip the secret redactor is never sent at all: it stays a note. Nothing here logs words.
|
|
18
|
+
* There is no claude -p and no generative model anywhere on this path.
|
|
19
|
+
*/
|
|
20
|
+
import { dataPath } from './data-dir.js'
|
|
21
|
+
import { estimateJevTokens, JevClient, JevError } from './jev.js'
|
|
22
|
+
import { DecisionProviderError, type DecisionClient } from './decision-provider.js'
|
|
23
|
+
import { boundForRedaction, redactSecretText } from './activity-preview.js'
|
|
24
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
25
|
+
import { searchOptionText } from './work-search.js'
|
|
26
|
+
import { logTokenAudit } from './token-audit.js'
|
|
27
|
+
import type { DropChoice, DropDest } from './drop-store.js'
|
|
28
|
+
|
|
29
|
+
export const DROP_ROUTER_LIMITS = {
|
|
30
|
+
autoFile: 0.80, lead: 0.25, memory: 0.90, clear: 0.5, target: 0.80,
|
|
31
|
+
cardCandidates: 30, threadCandidates: 15, stateChars: 600, scanChars: 2_400,
|
|
32
|
+
defaultDailyTokens: 300_000,
|
|
33
|
+
} as const
|
|
34
|
+
export const DROP_USAGE_FILE = dataPath('jev-drop-usage.json')
|
|
35
|
+
|
|
36
|
+
/** The Jev-routed destinations. Reminder and ask are rule-routed only. */
|
|
37
|
+
export const JEV_DESTS = ['new_card', 'existing_card', 'thread', 'memory', 'session', 'note'] as const
|
|
38
|
+
export type JevDest = typeof JEV_DESTS[number]
|
|
39
|
+
export const DEST_CRITERIA: Record<JevDest, string> = {
|
|
40
|
+
new_card: 'A new task the user means to do: a new card on their Work board.',
|
|
41
|
+
existing_card: 'More detail or an update for one of the user\'s existing Work cards.',
|
|
42
|
+
thread: 'A note that belongs on one of the user\'s ongoing topic threads.',
|
|
43
|
+
memory: 'A lasting fact or preference the assistant should remember for later answers.',
|
|
44
|
+
session: 'A follow-up instruction for the AI work session the user just had open.',
|
|
45
|
+
note: 'A loose note or idea to keep, with nothing else to do.',
|
|
46
|
+
}
|
|
47
|
+
export const DROP_INSTRUCTIONS = {
|
|
48
|
+
dest: 'The user typed `drop` into a quick capture box. Where should it go?',
|
|
49
|
+
domain: 'Which area of the user\'s work does `drop` belong to?',
|
|
50
|
+
card: 'Which of the user\'s existing Work cards is `drop` about? None if no card fits.',
|
|
51
|
+
thread: 'Which of the user\'s topic threads is `drop` about? None if no thread fits.',
|
|
52
|
+
session: 'Is `drop` an instruction for the AI work session named in `session`?',
|
|
53
|
+
clear: 'Is `drop` a clear, specific statement rather than a rough idea?',
|
|
54
|
+
} as const
|
|
55
|
+
export const DROP_NONE = { card: 'None of these cards.', thread: 'None of these threads.' } as const
|
|
56
|
+
|
|
57
|
+
export type DropFallbackReason = 'router_off' | 'redacted' | 'jev_not_configured' | 'jev_key_rejected' | 'jev_cap_reached'
|
|
58
|
+
| 'jev_breaker_open' | 'jev_request_rejected' | 'jev_unavailable' | 'jev_bad_answer' | DecisionProviderError['code']
|
|
59
|
+
|
|
60
|
+
/** The daily input-token cap for drop routing alone. The ledger day is UTC. */
|
|
61
|
+
export function dropDailyCap(env: NodeJS.ProcessEnv = process.env): number {
|
|
62
|
+
const raw = Number(env.COS_DROP_DAILY_TOKENS)
|
|
63
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DROP_ROUTER_LIMITS.defaultDailyTokens
|
|
64
|
+
}
|
|
65
|
+
/** On unless COS_DROP_ROUTER says off (0, false, no, off). Read from the server's environment at each drop. */
|
|
66
|
+
export function dropRouterEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
67
|
+
return !['0', 'false', 'no', 'off'].includes((env.COS_DROP_ROUTER ?? '').trim().toLowerCase())
|
|
68
|
+
}
|
|
69
|
+
export function createDropJevClient(fetchImpl: typeof fetch = fetch, now: () => Date = () => new Date(), usageFile = DROP_USAGE_FILE): JevClient {
|
|
70
|
+
return new JevClient(fetchImpl, now, usageFile, () => dropDailyCap())
|
|
71
|
+
}
|
|
72
|
+
let sharedClient: JevClient | null = null
|
|
73
|
+
export function sharedDropJevClient(): JevClient { return sharedClient ??= createDropJevClient() }
|
|
74
|
+
let sharedDecision: DecisionClient | null = null
|
|
75
|
+
/** Jev only, whatever the decision provider setting says: a drop never goes to another classifier in P1. */
|
|
76
|
+
export function jevOnlyDropClient(jev: Pick<JevClient, 'ask'>): DecisionClient {
|
|
77
|
+
return {
|
|
78
|
+
provider: 'jev',
|
|
79
|
+
async ask(state, questions, estimate, options) {
|
|
80
|
+
if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
|
|
81
|
+
return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function sharedDropDecisionClient(): DecisionClient {
|
|
86
|
+
return sharedDecision ??= jevOnlyDropClient(sharedDropJevClient())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Rules ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const REMIND = /^\s*(?:please\s+|hey\s+cos[,\s]+)?remind\s+me\b/i
|
|
92
|
+
const REMEMBER = /^\s*(?:please\s+|hey\s+cos[,\s]+)?remember\b/i
|
|
93
|
+
const QUESTION_START = /^\s*(?:who|what|when|where|why|how)\b/i
|
|
94
|
+
|
|
95
|
+
export type RuleDest = 'reminder' | 'memory' | 'ask'
|
|
96
|
+
/** The deterministic routes. Order matters: "remind me what…?" is a reminder, not a question. */
|
|
97
|
+
export function ruleRoute(text: string): RuleDest | null {
|
|
98
|
+
if (REMIND.test(text)) return 'reminder'
|
|
99
|
+
if (REMEMBER.test(text)) return 'memory'
|
|
100
|
+
if (/\?\s*$/.test(text) || QUESTION_START.test(text)) return 'ask'
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── What Jev reads ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The drop as Jev reads it. `tripped` is true when the server's secret redaction changed the words: such a drop is
|
|
108
|
+
* never sent (plan section 8), it stays a note. URLs, email addresses and opaque ids are removed without tripping:
|
|
109
|
+
* they add nothing to routing.
|
|
110
|
+
*/
|
|
111
|
+
export function cleanDropForJev(text: string): { text: string; tripped: boolean } {
|
|
112
|
+
const head = boundForRedaction(text, DROP_ROUTER_LIMITS.scanChars).head
|
|
113
|
+
const tripped = redactSecrets(redactSecretText(head)) !== head
|
|
114
|
+
return { text: searchOptionText(head).slice(0, DROP_ROUTER_LIMITS.stateChars), tripped }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const STOP = new Set(('the a an and or but for to of in on at by with from into about this that these those it its is are was were be '
|
|
118
|
+
+ 'been do does did done have has had will would should could can may might must not no yes our your my me we you they them '
|
|
119
|
+
+ 'i he she his her their there here then than so if as up out over just also very more most some any all each new next last '
|
|
120
|
+
+ 'get got make made send sent call ask let lets need needs want wants try think idea note today tomorrow week').split(' '))
|
|
121
|
+
export function words(text: string): Set<string> {
|
|
122
|
+
return new Set(text.normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 3 && !STOP.has(w)))
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The candidates worth a token: rows sharing at least one meaningful word with the drop, most shared words first,
|
|
126
|
+
* then the caller's order. A drop that shares no word with any card offers no existing card, and Jev is not asked.
|
|
127
|
+
*/
|
|
128
|
+
export function rankByOverlap<Row>(drop: string, rows: readonly Row[], text: (row: Row) => string, max: number): Row[] {
|
|
129
|
+
const mine = words(drop)
|
|
130
|
+
if (!mine.size) return []
|
|
131
|
+
return rows.map((row, i) => {
|
|
132
|
+
let score = 0
|
|
133
|
+
for (const w of words(text(row))) if (mine.has(w)) score++
|
|
134
|
+
return { row, i, score }
|
|
135
|
+
}).filter(r => r.score > 0).sort((a, b) => b.score - a.score || a.i - b.i).slice(0, max).map(r => r.row)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface DropCard { id: string; workIdentity: string; domain: string; text: string }
|
|
139
|
+
export interface DropThread { id: string; name: string }
|
|
140
|
+
export interface RouteCandidates {
|
|
141
|
+
domains: Array<{ name: string; label: string }>
|
|
142
|
+
cards: DropCard[]
|
|
143
|
+
threads: DropThread[]
|
|
144
|
+
session?: { id: string; label: string }
|
|
145
|
+
}
|
|
146
|
+
export interface DropRequest {
|
|
147
|
+
state: Record<string, string>
|
|
148
|
+
questions: Record<string, { type: 'choice' | 'noul'; instructions: string; criteria?: Record<string, string> }>
|
|
149
|
+
dests: JevDest[]
|
|
150
|
+
cards: DropCard[]
|
|
151
|
+
threads: DropThread[]
|
|
152
|
+
domains: RouteCandidates['domains']
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One request: only the questions that have something to choose. */
|
|
156
|
+
export function buildDropRequest(cleaned: string, candidates: RouteCandidates): DropRequest {
|
|
157
|
+
const cards = candidates.cards.slice(0, DROP_ROUTER_LIMITS.cardCandidates)
|
|
158
|
+
const threads = candidates.threads.slice(0, DROP_ROUTER_LIMITS.threadCandidates)
|
|
159
|
+
const dests = JEV_DESTS.filter(d => (d !== 'new_card' || candidates.domains.length > 0) && (d !== 'existing_card' || cards.length > 0)
|
|
160
|
+
&& (d !== 'thread' || threads.length > 0) && (d !== 'session' || !!candidates.session))
|
|
161
|
+
const state: Record<string, string> = { drop: cleaned }
|
|
162
|
+
const questions: DropRequest['questions'] = {
|
|
163
|
+
dest: { type: 'choice', instructions: DROP_INSTRUCTIONS.dest, criteria: Object.fromEntries(dests.map(d => [d, DEST_CRITERIA[d]])) },
|
|
164
|
+
}
|
|
165
|
+
if (candidates.domains.length >= 2) {
|
|
166
|
+
questions.domain = { type: 'choice', instructions: DROP_INSTRUCTIONS.domain,
|
|
167
|
+
criteria: Object.fromEntries(candidates.domains.map((d, i) => [`d${i}`, searchOptionText(d.label) || `Area ${i + 1}`])) }
|
|
168
|
+
}
|
|
169
|
+
if (cards.length) {
|
|
170
|
+
questions.card = { type: 'choice', instructions: DROP_INSTRUCTIONS.card,
|
|
171
|
+
criteria: { ...Object.fromEntries(cards.map((c, i) => [`c${i}`, searchOptionText(c.text) || `Card ${i + 1}`])), none: DROP_NONE.card } }
|
|
172
|
+
}
|
|
173
|
+
if (threads.length) {
|
|
174
|
+
questions.thread = { type: 'choice', instructions: DROP_INSTRUCTIONS.thread,
|
|
175
|
+
criteria: { ...Object.fromEntries(threads.map((t, i) => [`h${i}`, searchOptionText(t.name) || `Thread ${i + 1}`])), none: DROP_NONE.thread } }
|
|
176
|
+
}
|
|
177
|
+
if (candidates.session) {
|
|
178
|
+
state.session = searchOptionText(candidates.session.label) || 'the open session'
|
|
179
|
+
questions.session = { type: 'noul', instructions: DROP_INSTRUCTIONS.session }
|
|
180
|
+
}
|
|
181
|
+
questions.clear = { type: 'noul', instructions: DROP_INSTRUCTIONS.clear }
|
|
182
|
+
return { state, questions, dests, cards, threads, domains: candidates.domains }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Deciding ───────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export interface DropTarget { id: string; label: string; domain?: string }
|
|
188
|
+
export type DropDecision =
|
|
189
|
+
| { kind: 'file'; dest: JevDest; target?: DropTarget; p: number; clear: number }
|
|
190
|
+
| { kind: 'ask'; choices: DropChoice[]; p: number; clear: number }
|
|
191
|
+
|
|
192
|
+
type Answers = Record<string, { probabilities?: Record<string, number>; noul?: number } | undefined>
|
|
193
|
+
const probs = (answers: Answers, q: string): Array<[string, number]> => {
|
|
194
|
+
const raw = answers[q]?.probabilities
|
|
195
|
+
if (!raw || typeof raw !== 'object') return []
|
|
196
|
+
return Object.entries(raw).filter(([, p]) => typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1).sort((a, b) => b[1] - a[1])
|
|
197
|
+
}
|
|
198
|
+
const noul = (answers: Answers, q: string): number | null => {
|
|
199
|
+
const v = answers[q]?.noul
|
|
200
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : null
|
|
201
|
+
}
|
|
202
|
+
const short = (text: string, max = 60): string => {
|
|
203
|
+
const one = text.replace(/\s+/g, ' ').trim()
|
|
204
|
+
return one.length <= max ? one : one.slice(0, max - 1).trimEnd() + '…'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Turn Jev's answer into a decision. Thresholds (plan section 2): the top destination files on its own at 0.80 with a
|
|
209
|
+
* 0.25 lead over the next (memory needs 0.90), and only when its target is just as sure (a domain, card or thread
|
|
210
|
+
* at 0.80, the session at 0.80). Otherwise one question: the top two readings that have a target, and Just a note.
|
|
211
|
+
*/
|
|
212
|
+
export function decideDrop(answers: Answers, request: DropRequest): DropDecision {
|
|
213
|
+
const dest = probs(answers, 'dest').filter(([d]) => (request.dests as string[]).includes(d)) as Array<[JevDest, number]>
|
|
214
|
+
if (!dest.length) throw new JevError('jev_bad_answer', 'Jev returned no destination')
|
|
215
|
+
const clear = noul(answers, 'clear') ?? 0.5
|
|
216
|
+
const targetOf = (d: JevDest, strict: boolean): DropTarget | null | undefined => {
|
|
217
|
+
const need = strict ? DROP_ROUTER_LIMITS.target : 0
|
|
218
|
+
if (d === 'new_card') {
|
|
219
|
+
if (request.domains.length === 1) return { id: request.domains[0].name, label: request.domains[0].label, domain: request.domains[0].name }
|
|
220
|
+
const [top] = probs(answers, 'domain')
|
|
221
|
+
const index = top && /^d\d+$/.test(top[0]) ? Number(top[0].slice(1)) : -1
|
|
222
|
+
return top && top[1] >= need && index >= 0 && index < request.domains.length
|
|
223
|
+
? { id: request.domains[index].name, label: request.domains[index].label, domain: request.domains[index].name } : null
|
|
224
|
+
}
|
|
225
|
+
if (d === 'existing_card' || d === 'thread') {
|
|
226
|
+
const [top] = probs(answers, d === 'existing_card' ? 'card' : 'thread')
|
|
227
|
+
if (!top || top[0] === 'none' || top[1] < need) return null
|
|
228
|
+
const index = Number(top[0].slice(1))
|
|
229
|
+
if (d === 'existing_card') {
|
|
230
|
+
const card = /^c\d+$/.test(top[0]) ? request.cards[index] : undefined
|
|
231
|
+
return card ? { id: card.workIdentity, label: short(card.text), domain: card.domain } : null
|
|
232
|
+
}
|
|
233
|
+
const thread = /^h\d+$/.test(top[0]) ? request.threads[index] : undefined
|
|
234
|
+
return thread ? { id: thread.id, label: short(thread.name) } : null
|
|
235
|
+
}
|
|
236
|
+
if (d === 'session') {
|
|
237
|
+
const p = noul(answers, 'session')
|
|
238
|
+
return p !== null && p >= need ? { id: '', label: '' } : null
|
|
239
|
+
}
|
|
240
|
+
return undefined // memory and note need no target
|
|
241
|
+
}
|
|
242
|
+
const [d1, p1] = dest[0], p2 = dest[1]?.[1] ?? 0
|
|
243
|
+
const sure = p1 >= DROP_ROUTER_LIMITS.autoFile && p1 - p2 >= DROP_ROUTER_LIMITS.lead && (d1 !== 'memory' || p1 >= DROP_ROUTER_LIMITS.memory)
|
|
244
|
+
const strictTarget = targetOf(d1, true)
|
|
245
|
+
if (sure && strictTarget !== null) return { kind: 'file', dest: d1, ...(strictTarget && strictTarget.id ? { target: strictTarget } : {}), p: p1, clear }
|
|
246
|
+
const choices: DropChoice[] = []
|
|
247
|
+
for (const [d] of dest) {
|
|
248
|
+
if (d === 'note' || choices.length === 2) continue
|
|
249
|
+
const target = targetOf(d, false)
|
|
250
|
+
if (target === null) continue
|
|
251
|
+
choices.push(choiceFor(d, target && target.id ? target : undefined, `o${choices.length + 1}`))
|
|
252
|
+
}
|
|
253
|
+
if (!choices.length) return { kind: 'file', dest: 'note', p: dest.find(([d]) => d === 'note')?.[1] ?? 0, clear }
|
|
254
|
+
choices.push(choiceFor('note', undefined, 'note'))
|
|
255
|
+
return { kind: 'ask', choices, p: p1, clear }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The words of one reading, as the mock's question shows it. */
|
|
259
|
+
export function choiceFor(dest: DropDest, target: DropTarget | undefined, id: string): DropChoice {
|
|
260
|
+
const t = target ? { target: { id: target.id, label: target.label, ...(target.domain ? { domain: target.domain } : {}) } } : {}
|
|
261
|
+
switch (dest) {
|
|
262
|
+
case 'new_card': return { id, dest, label: 'A task for me', detail: target ? `A card in Work, ${target.label}.` : 'A card in Work.', ...t }
|
|
263
|
+
case 'existing_card': return { id, dest, label: target ? `An update to ${target.label}` : 'An update to a card', detail: 'Linked to that card. No new card.', ...t }
|
|
264
|
+
case 'thread': return { id, dest, label: target ? `A note on ${target.label}` : 'A note on a thread', detail: 'Added to the thread for the next prep. No card.', ...t }
|
|
265
|
+
case 'memory': return { id, dest, label: 'Something to remember', detail: 'Kept in memories, in To review.', ...t }
|
|
266
|
+
case 'session': return { id, dest, label: 'A follow-up for the session', detail: 'Held until you send it to the session.', ...t }
|
|
267
|
+
case 'reminder': return { id, dest, label: 'A reminder', detail: 'COS reminds you.', ...t }
|
|
268
|
+
case 'ask': return { id, dest, label: 'A question', detail: 'COS answers it here.', ...t }
|
|
269
|
+
default: return { id, dest: 'note', label: 'Just a note', detail: 'Kept, nothing else happens.' }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── The router ─────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
export type RouteOutcome =
|
|
276
|
+
| { by: 'jev'; decision: DropDecision; tokens: number; provider: string }
|
|
277
|
+
| { by: 'fallback'; reason: DropFallbackReason }
|
|
278
|
+
|
|
279
|
+
export interface DropRouterDeps {
|
|
280
|
+
client: DecisionClient
|
|
281
|
+
enabled: () => boolean
|
|
282
|
+
audit?: (entry: { inputChars: number; tokens: number; durationMs: number; outcome: string }) => void
|
|
283
|
+
now?: () => number
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const PASSED: ReadonlySet<string> = new Set(['jev_not_configured', 'jev_key_rejected', 'jev_cap_reached', 'jev_breaker_open', 'jev_request_rejected', 'jev_bad_answer'])
|
|
287
|
+
|
|
288
|
+
export class DropRouter {
|
|
289
|
+
constructor(private readonly deps: DropRouterDeps) {}
|
|
290
|
+
|
|
291
|
+
/** One Jev request for one drop. Never throws for a Jev failure: the caller saves a note with the reason. */
|
|
292
|
+
async route(text: string, candidates: RouteCandidates): Promise<RouteOutcome> {
|
|
293
|
+
if (!this.deps.enabled()) return { by: 'fallback', reason: 'router_off' }
|
|
294
|
+
const cleaned = cleanDropForJev(text)
|
|
295
|
+
if (cleaned.tripped || !cleaned.text) return { by: 'fallback', reason: 'redacted' }
|
|
296
|
+
const request = buildDropRequest(cleaned.text, candidates)
|
|
297
|
+
const started = (this.deps.now ?? Date.now)()
|
|
298
|
+
const inputChars = JSON.stringify({ state: request.state, questions: request.questions }).length
|
|
299
|
+
let client: DecisionClient
|
|
300
|
+
try { client = this.deps.client.snapshot?.() ?? this.deps.client } catch (e) {
|
|
301
|
+
if (e instanceof DecisionProviderError) return { by: 'fallback', reason: e.code }
|
|
302
|
+
throw e
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const reply = await client.ask(request.state, request.questions, estimateJevTokens({ state: request.state, questions: request.questions }))
|
|
306
|
+
const decision = decideDrop(reply.answers, request)
|
|
307
|
+
this.deps.audit?.({ inputChars, tokens: reply.inputTokens, durationMs: (this.deps.now ?? Date.now)() - started, outcome: decision.kind })
|
|
308
|
+
return { by: 'jev', decision, tokens: reply.inputTokens, provider: reply.provider ?? 'jev' }
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if (e instanceof DecisionProviderError) return { by: 'fallback', reason: e.code }
|
|
311
|
+
if (!(e instanceof JevError)) throw e
|
|
312
|
+
return { by: 'fallback', reason: PASSED.has(e.code) ? e.code as DropFallbackReason : 'jev_unavailable' }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The token-audit row for one routed drop: caller `drop`, sizes and timing only, never words. */
|
|
318
|
+
export function auditDropRoute(entry: { inputChars: number; tokens: number; durationMs: number; outcome: string }): void {
|
|
319
|
+
logTokenAudit({ source: 'drop-router', model: 'jev', inputChars: entry.inputChars, outputChars: 0, durationMs: entry.durationMs, caller: 'drop',
|
|
320
|
+
...(entry.tokens > 0 ? { realUsage: { inputTokens: entry.tokens, outputTokens: 0 } } : {}) })
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function createDropRouter(overrides: Partial<DropRouterDeps> = {}): DropRouter {
|
|
324
|
+
return new DropRouter({ client: sharedDropDecisionClient(), enabled: () => dropRouterEnabled(), audit: auditDropRoute, ...overrides })
|
|
325
|
+
}
|