@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-settings.ts +2 -1
  22. package/server/lib/live-cues-v2.ts +6 -4
  23. package/server/lib/meeting-consolidation-run.ts +169 -0
  24. package/server/lib/meeting-store.ts +61 -0
  25. package/server/lib/meeting-title-steer.ts +200 -0
  26. package/server/lib/network-policy.ts +84 -6
  27. package/server/lib/permission-broker.ts +11 -0
  28. package/server/lib/ping-budget.ts +149 -0
  29. package/server/lib/ping-engine.ts +245 -0
  30. package/server/lib/ping-feed-sink.ts +33 -0
  31. package/server/lib/ping-sources.ts +192 -0
  32. package/server/lib/query-job-coordinator.ts +5 -2
  33. package/server/lib/query-job-runtime.ts +45 -4
  34. package/server/lib/query-job-types.ts +10 -1
  35. package/server/lib/reminder-scheduler.ts +153 -0
  36. package/server/lib/tailnet-self-name.ts +103 -0
  37. package/server/lib/tailscale-nudge.ts +40 -2
  38. package/server/lib/task-store.ts +32 -0
  39. package/server/lib/web-ask-admission.ts +56 -0
  40. package/server/lib/web-live-chunks.ts +273 -0
  41. package/server/lib/web-memories-bridge.ts +323 -0
  42. package/server/lib/web-session-auth.ts +492 -0
  43. package/server/lib/web-usage-bridge.ts +154 -0
  44. package/server/lib/work-search.ts +14 -1
  45. package/server/lib/your-move-count.ts +58 -0
  46. package/server/routes/display.ts +11 -1
  47. package/server/routes/drops.ts +260 -0
  48. package/server/routes/health.ts +16 -0
  49. package/server/routes/live-cues.ts +5 -4
  50. package/server/routes/meeting.ts +61 -5
  51. package/server/routes/permission-broker.ts +23 -3
  52. package/server/routes/transcribe-stream.ts +34 -3
  53. package/server/routes/web-control.ts +569 -0
  54. package/server/routes/welcome-context.ts +2 -1
  55. package/server/web-control/app.css +519 -0
  56. package/server/web-control/app.js +3643 -0
  57. package/server/web-control/control.css +312 -0
  58. package/server/web-control/fonts/DMSans.ttf +0 -0
  59. package/server/web-control/fonts/Fraunces.ttf +0 -0
  60. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  61. package/server/web-control/index.html +275 -0
  62. package/server/web-control/mark.svg +1 -0
  63. package/server/web-control/memories/d3.min.js +2 -0
  64. package/server/web-control/memories/graph-explorer.css +200 -0
  65. package/server/web-control/memories/graph-explorer.js +748 -0
  66. package/server/web-control/memories/memories-app.js +1561 -0
  67. package/server/web-control/memories/memories-theme.css +73 -0
  68. package/server/web-control/memories/memories.html +72 -0
  69. package/server/web-control/memories/memory-stewardship.js +63 -0
  70. package/server/web-control/memories/memory-workspace.js +216 -0
  71. package/server/web-control/panes/cos-chat.css +157 -0
  72. package/server/web-control/panes/cos-chat.js +973 -0
  73. package/server/web-control/panes/dictation.js +173 -0
  74. package/server/web-control/panes/meetings.css +257 -0
  75. package/server/web-control/panes/meetings.js +2264 -0
  76. package/server/web-control/panes/messages.js +711 -0
  77. package/server/web-control/panes/questions.css +32 -0
  78. package/server/web-control/panes/questions.js +275 -0
  79. package/server/web-control/panes/sessions.css +452 -0
  80. package/server/web-control/panes/settings.js +292 -0
  81. package/server/web-control/panes/speakers.css +269 -0
  82. package/server/web-control/panes/speakers.js +2755 -0
  83. package/server/web-control/panes/threads.js +200 -0
  84. package/server/web-control/panes/work.css +261 -0
  85. package/server/web-control/panes/work.js +1667 -0
  86. package/server/web-control/shell.js +294 -0
  87. package/server/web-control/theme.js +25 -0
  88. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -722,8 +722,16 @@ export async function callCodexStreaming(
722
722
  callbacks.onChunk(text)
723
723
  }
724
724
 
725
+ let maintenanceClosed = false
725
726
  async function finalize(text: string) {
726
727
  if (finalized) return
728
+ // A next maintenance pass may start only after the entire provider tree
729
+ // has closed. Normal chat retains its existing result-event behavior.
730
+ if (options?.maintenancePass && !maintenanceClosed) {
731
+ if (terminationRequested) return
732
+ await terminateForTerminal('maintenance checkpoint', () => finalize(text))
733
+ return
734
+ }
727
735
  const responseAuthFailure = terminalProviderAuthFailure('codex', text)
728
736
  const authFailure = responseAuthFailure
729
737
  ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('codex', stderr) : null)
@@ -861,6 +869,11 @@ export async function callCodexStreaming(
861
869
 
862
870
  async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
863
871
  if (finalized) return
872
+ if (options?.maintenancePass && !maintenanceClosed) {
873
+ if (terminationRequested) return
874
+ await terminateForTerminal('maintenance error', () => finalizeError(msg, exitCode, status))
875
+ return
876
+ }
864
877
  finalized = true
865
878
  // Canary R14: a Codex child keeps retrying after the bridge ends the turn; its nonce dies now.
866
879
  keyTurn?.revoke('cancel')
@@ -876,7 +889,7 @@ export async function callCodexStreaming(
876
889
  exitCode,
877
890
  })
878
891
  try {
879
- await callbacks.onError(safeCodexUserError(msg))
892
+ await callbacks.onError(options?.maintenancePass && msg === 'maintenance_pass_timeout' ? msg : safeCodexUserError(msg))
880
893
  } catch (error) {
881
894
  console.error('[codex-bridge] terminal error callback failed:', error)
882
895
  }
@@ -910,6 +923,7 @@ export async function callCodexStreaming(
910
923
  console.error(`[codex-bridge] provider did not close after SIGKILL (${reason}); retaining lifecycle ownership`)
911
924
  return
912
925
  }
926
+ maintenanceClosed = true
913
927
  await onClosed(result)
914
928
  }
915
929
 
@@ -949,6 +963,7 @@ export async function callCodexStreaming(
949
963
 
950
964
  const wallTimer = setTimeout(() => {
951
965
  void terminateForTerminal('wall timeout', result => {
966
+ if (options?.maintenancePass) return finalizeError('maintenance_pass_timeout', result.code)
952
967
  if (fullText) return finalize(fullText)
953
968
  return finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`, result.code)
954
969
  })
@@ -1046,7 +1061,11 @@ export async function callCodexStreaming(
1046
1061
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
1047
1062
  }
1048
1063
  runUsage.settle()
1049
- if (finalized) return
1064
+ if (finalized || terminationRequested) return
1065
+ if (options?.maintenancePass) {
1066
+ finalizeError('maintenance_pass_incomplete_exit', code)
1067
+ return
1068
+ }
1050
1069
  if (fullText) {
1051
1070
  void finalize(fullText)
1052
1071
  return
@@ -11,6 +11,9 @@ export interface DisplayEvent {
11
11
  type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge' | 'coaching_nudge_v2'
12
12
  // Control asked the glasses to stop a G2 meeting (routes/meeting.ts stop-request).
13
13
  | 'meeting_stop_requested' | 'mac_speaker_live_revision'
14
+ // Drop it P2: a reminder he set fired; the lens shows it (plan section 5). Not in the ticketless projections, so a
15
+ // stream without a ticket never receives it; shipped clients do not listen for it and ignore it.
16
+ | 'cos_ping'
14
17
  data: Record<string, unknown>
15
18
  }
16
19
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Drop it destinations (plan section 3): the writes a filed drop makes, through the existing COS bridge.
3
+ *
4
+ * | Destination | Write | Undo |
5
+ * | New card | `task-capture-drop` (answers the id) | `task-archive-drop` while untouched |
6
+ * | Existing card | a link on the drop only (no tasks.md write) | the link is removed |
7
+ * | Thread | `thread-source --id <thread>` | `thread-unsource --id --source-id` |
8
+ * | Memory | `memory-capture` into To review (the bridge embeds | `memory-uncapture`: quarantine (reversible), |
9
+ * | | it through the embedding provider, as any memory) | refused once the memory was reviewed |
10
+ * | Session, note, reminder: the drop store only. |
11
+ *
12
+ * A bridge that does not know a verb answers `cos_pipeline_not_configured` (or the portable runtime's
13
+ * `task_runtime_unavailable`), which becomes DropDestinationError('destination_unavailable'): the caller keeps a plain
14
+ * note and says why. Every destination is local; nothing here sends anything anywhere.
15
+ */
16
+ import { callPython, contextSourceAvailable } from './python-bridge.js'
17
+ import { archiveDropCard, captureDropCard, listBoard, taskDomains, TaskBridgeError, TaskRunError } from './task-store.js'
18
+ import { domainLabel } from './domain-label.js'
19
+ import { normalizeThreads, THREAD_ID_PATTERN } from './cos-context-browser.js'
20
+ import { DROP_ROUTER_LIMITS, rankByOverlap, type RouteCandidates } from './drop-router.js'
21
+
22
+ export class DropDestinationError extends Error {
23
+ constructor(readonly code: 'destination_unavailable' | 'destination_failed' | 'memory_too_short') { super(code) }
24
+ }
25
+
26
+ export interface DropWriters {
27
+ captureCard(domain: string, text: string, dropId: string): Promise<{ id: string; workIdentity: string; created: boolean }>
28
+ archiveCard(domain: string, id: string, dropId: string, text: string): Promise<'archived' | 'touched' | 'gone'>
29
+ /** `attempt` is the filing's number: each filing is a new capture, so a replay never answers a set-aside row. */
30
+ captureMemory(text: string, dropId: string, attempt: string): Promise<{ id: string; created: boolean }>
31
+ /** pruned | touched (reviewed since, or not this drop's) | gone. */
32
+ pruneMemory(id: string, dropId: string): Promise<'pruned' | 'touched' | 'gone'>
33
+ sourceThread(threadId: string, text: string, dropId: string): Promise<{ sourceId: string; created: boolean }>
34
+ unsourceThread(threadId: string, sourceId: string, dropId: string): Promise<'removed' | 'touched' | 'gone'>
35
+ /** What the router may choose from. Any part that cannot be read is simply left out. */
36
+ candidates(text: string, session?: { id: string; label: string }): Promise<RouteCandidates>
37
+ /** Card stages for History status, keyed by Work identity. */
38
+ cardStates(): Promise<Map<string, { workStage: string; checked: boolean }>>
39
+ }
40
+
41
+ const MISSING = new Set(['cos_pipeline_not_configured', 'task_runtime_unavailable', 'unknown_command'])
42
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
43
+
44
+ /** A bridge answer as an error code, or null. The COS bridge answers both `{error:{code}}` and `{error:"…"}`. */
45
+ export function bridgeErrorCode(answer: unknown): string | null {
46
+ if (!answer || typeof answer !== 'object') return 'empty_answer'
47
+ const error = (answer as { error?: unknown }).error
48
+ if (error === undefined) return null
49
+ if (typeof error === 'string') return /unknown command/i.test(error) ? 'unknown_command' : 'bridge_error'
50
+ const code = (error as { code?: unknown })?.code
51
+ return typeof code === 'string' ? code : 'bridge_error'
52
+ }
53
+ function fail(code: string | null): never {
54
+ if (code === 'memory_rejected') throw new DropDestinationError('memory_too_short')
55
+ throw new DropDestinationError(code && MISSING.has(code) ? 'destination_unavailable' : 'destination_failed')
56
+ }
57
+ async function python(args: string[], input?: string): Promise<Record<string, unknown>> {
58
+ // The file tier (no venv) answers `{}` for every verb it does not know: that is a missing verb, not a failure.
59
+ if (contextSourceAvailable() !== 'bridge') fail('cos_pipeline_not_configured')
60
+ let answer: unknown
61
+ try { answer = await callPython(args, 20_000, input) } catch { fail('bridge_error') }
62
+ const code = bridgeErrorCode(answer)
63
+ if (code) fail(code)
64
+ return answer as Record<string, unknown>
65
+ }
66
+ const taskCode = (e: unknown): string | null => e instanceof TaskBridgeError || e instanceof TaskRunError ? e.code : null
67
+
68
+ export const CANDIDATE_MS = 6_000
69
+ /** The value, or null when it failed or took longer than `ms`. */
70
+ export async function within<T>(ms: number, fn: () => Promise<T>): Promise<T | null> {
71
+ let timer: NodeJS.Timeout | undefined
72
+ try {
73
+ return await Promise.race([fn().catch(() => null), new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), ms) })])
74
+ } finally { if (timer) clearTimeout(timer) }
75
+ }
76
+
77
+ export function createBridgeDropWriters(): DropWriters {
78
+ return {
79
+ async captureCard(domain, text, dropId) {
80
+ try { return await captureDropCard(domain, text, dropId) } catch (e) { fail(taskCode(e)) }
81
+ },
82
+ async archiveCard(domain, id, dropId, text) {
83
+ try { return await archiveDropCard(domain, id, dropId, text) } catch (e) { fail(taskCode(e)) }
84
+ },
85
+ async captureMemory(text, dropId, attempt) {
86
+ const out = await python(['memory-capture'], JSON.stringify({ text, dropId, attempt }))
87
+ if (out.ok !== true || typeof out.id !== 'string' || !ID.test(out.id)) fail('bridge_error')
88
+ return { id: out.id as string, created: out.created !== false }
89
+ },
90
+ async pruneMemory(id, dropId) {
91
+ const out = await python(['memory-uncapture', '--id', id], JSON.stringify({ dropId }))
92
+ if (out.ok === true && (out.result === 'pruned' || out.result === 'touched' || out.result === 'gone')) return out.result
93
+ fail('bridge_error')
94
+ },
95
+ async sourceThread(threadId, text, dropId) {
96
+ if (!THREAD_ID_PATTERN.test(threadId)) fail('invalid_thread')
97
+ const out = await python(['thread-source', '--id', threadId], JSON.stringify({ text, dropId }))
98
+ if (out.ok !== true || typeof out.sourceId !== 'string' || !ID.test(out.sourceId)) fail('bridge_error')
99
+ return { sourceId: out.sourceId as string, created: out.created !== false }
100
+ },
101
+ async unsourceThread(threadId, sourceId, dropId) {
102
+ const out = await python(['thread-unsource', '--id', threadId, '--source-id', sourceId], JSON.stringify({ dropId }))
103
+ if (out.ok === true && (out.result === 'removed' || out.result === 'touched' || out.result === 'gone')) return out.result
104
+ fail('bridge_error')
105
+ },
106
+ async candidates(text, session) {
107
+ // The board and the threads are read side by side, each bounded: POST /api/drops holds the API mutation lease,
108
+ // so its worst case (candidates 6 s, Jev 20 s, a card write with lock retries about 37 s) stays under Control's
109
+ // 90 s drain. A part that is slow or unreadable is simply not offered.
110
+ const out: RouteCandidates = { domains: [], cards: [], threads: [], ...(session ? { session } : {}) }
111
+ try { out.domains = taskDomains().map(name => ({ name, label: domainLabel(name) })) } catch { /* no domains: no new card */ }
112
+ const [cards, threads] = await Promise.all([
113
+ within(CANDIDATE_MS, async () => {
114
+ const open = (await listBoard()).filter(row => !row.checked)
115
+ return rankByOverlap(text, open, row => row.text, DROP_ROUTER_LIMITS.cardCandidates)
116
+ .map(row => ({ id: row.id, workIdentity: row.workIdentity ?? row.id, domain: row.domain, text: row.text }))
117
+ }),
118
+ within(CANDIDATE_MS, async () => {
119
+ if (!contextSourceAvailable()) return []
120
+ const list = normalizeThreads(await callPython(['threads', '--limit', '50'], CANDIDATE_MS), 50).threads.filter(t => !t.is_resolved)
121
+ return rankByOverlap(text, list, t => `${t.name} ${t.topics.join(' ')}`, DROP_ROUTER_LIMITS.threadCandidates).map(t => ({ id: t.id, name: t.name }))
122
+ }),
123
+ ])
124
+ out.cards = cards ?? []
125
+ out.threads = threads ?? []
126
+ return out
127
+ },
128
+ async cardStates() {
129
+ const map = new Map<string, { workStage: string; checked: boolean }>()
130
+ for (const row of await listBoard()) map.set(row.workIdentity ?? row.id, { workStage: row.workStage ?? 'planned', checked: row.checked })
131
+ return map
132
+ },
133
+ }
134
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 6): History search by meaning.
3
+ *
4
+ * The chat's History searches its drops by words at once, on the client. After a pause it asks this for the drop he
5
+ * MEANS: one Jev Choice over the drops' cleaned words and link labels, with `none` as an option, on the work-search
6
+ * pattern (lib/work-search.ts): the same option cleaning, the same ranking, a 10-minute cache and one request per query.
7
+ *
8
+ * Cost and privacy:
9
+ * - Charged to the drop router's own budget (COS_DROP_DAILY_TOKENS, its usage ledger and breaker), through the client
10
+ * the caller passes in; P1 pins drops to Jev, so the decision provider setting does not apply here either.
11
+ * - Off with the router (COS_DROP_ROUTER=0).
12
+ * - A drop whose words trip the secret check (cleanDropForJev) is never offered: it stays a note, found by words only.
13
+ * - A Choice holds 254 options plus `none`. History can be longer, so the newest 254 are searched and the answer says
14
+ * `partial` (word search still covers everything) instead of refusing.
15
+ * - Logs hold a count and an error code, never words.
16
+ */
17
+ import { createHash } from 'node:crypto'
18
+ import type { DecisionClient } from './decision-provider.js'
19
+ import { DecisionProviderError } from './decision-provider.js'
20
+ import { estimateJevTokens, JevError } from './jev.js'
21
+ import { cleanDropForJev, dropRouterEnabled } from './drop-router.js'
22
+ import type { DropRecord } from './drop-store.js'
23
+ import { normalizeQuery, queryLength, rankHits, searchOptionText, unavailable, WORK_SEARCH_LIMITS, type WorkSearchHit, type WorkSearchReason } from './work-search.js'
24
+
25
+ export const DROP_SEARCH_INSTRUCTIONS = 'The user typed `query` into the search box of the history of things they jotted down '
26
+ + '(ideas, tasks, notes, reminders). Which entry are they trying to find? Pick the one they mean even when it uses different '
27
+ + 'words, or none if no entry fits.'
28
+ export const DROP_SEARCH_NONE = 'None of these entries is what the user is looking for.'
29
+
30
+ export type DropSearchResult =
31
+ | { available: true; results: WorkSearchHit[]; none: number | null; cached: boolean; tokens: number; searched: number; partial: boolean }
32
+ | { available: false; reason: WorkSearchReason | 'query_invalid'; message: string }
33
+
34
+ /** What Jev reads for one drop: its words and its link labels, cleaned as work search cleans a card. */
35
+ export function dropOptionText(drop: Pick<DropRecord, 'text' | 'links'>): string {
36
+ const labels = drop.links.map(l => l.label).filter(Boolean).join('; ')
37
+ return searchOptionText(labels ? `${drop.text} (${labels})` : drop.text)
38
+ }
39
+
40
+ /** The drops a search covers, newest first: never one that tripped the secret check, never one with no words left. */
41
+ export function searchableDrops(drops: readonly DropRecord[]): { rows: Array<{ id: string; text: string }>; total: number } {
42
+ const rows = [...drops]
43
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
44
+ .filter(d => d.route?.reason !== 'redacted' && !cleanDropForJev(d.text).tripped)
45
+ .map(d => ({ id: d.id, text: dropOptionText(d) }))
46
+ .filter(r => r.text.length > 0)
47
+ return { rows: rows.slice(0, WORK_SEARCH_LIMITS.maxCandidates), total: rows.length }
48
+ }
49
+
50
+ const PASSED_THROUGH = new Set<string>(['jev_not_configured', 'jev_key_rejected', 'jev_cap_reached', 'jev_breaker_open', 'jev_request_rejected'])
51
+
52
+ export class DropHistorySearcher {
53
+ private cache = new Map<string, { at: number; results: WorkSearchHit[]; none: number | null; searched: number; partial: boolean }>()
54
+ private inflight = new Map<string, Promise<DropSearchResult>>()
55
+ constructor(private readonly client: DecisionClient, private readonly now: () => number = () => Date.now(), private readonly env: NodeJS.ProcessEnv = process.env) {}
56
+
57
+ /** Never throws for a Jev failure: it answers `available: false` with one line, and word search carries on. */
58
+ async search(query: unknown, drops: readonly DropRecord[]): Promise<DropSearchResult> {
59
+ if (typeof query !== 'string' || queryLength(query.trim()) < WORK_SEARCH_LIMITS.queryMin || queryLength(query) > WORK_SEARCH_LIMITS.queryMax) {
60
+ return { available: false, reason: 'query_invalid', message: `Type ${WORK_SEARCH_LIMITS.queryMin} to ${WORK_SEARCH_LIMITS.queryMax} characters to search by meaning.` }
61
+ }
62
+ if (!dropRouterEnabled(this.env)) return unavailable('search_off')
63
+ const { rows, total } = searchableDrops(drops)
64
+ const partial = total > rows.length
65
+ if (!rows.length) return { available: true, results: [], none: 1, cached: false, tokens: 0, searched: 0, partial: false }
66
+ const set = createHash('sha256').update(JSON.stringify(rows.map(r => [r.id, r.text]))).digest('hex')
67
+ const key = createHash('sha256').update(normalizeQuery(query) + '\0' + set).digest('hex')
68
+ const hit = this.cache.get(key)
69
+ if (hit && this.now() - hit.at < WORK_SEARCH_LIMITS.cacheMs) return { available: true, results: hit.results, none: hit.none, cached: true, tokens: 0, searched: hit.searched, partial: hit.partial }
70
+ if (hit) this.cache.delete(key)
71
+ const running = this.inflight.get(key)
72
+ if (running) return running
73
+ const work = this.ask(query, rows, key, partial)
74
+ this.inflight.set(key, work)
75
+ try { return await work } finally { this.inflight.delete(key) }
76
+ }
77
+
78
+ private async ask(query: string, rows: Array<{ id: string; text: string }>, key: string, partial: boolean): Promise<DropSearchResult> {
79
+ const state = { query: query.trim() }
80
+ const criteria: Record<string, string> = Object.fromEntries(rows.map((r, i) => [`t${i}`, r.text]))
81
+ criteria.none = DROP_SEARCH_NONE
82
+ const questions = { c: { type: 'choice', instructions: DROP_SEARCH_INSTRUCTIONS, criteria } }
83
+ try {
84
+ const reply = await this.client.ask(state, questions, estimateJevTokens({ state, questions }))
85
+ const { results, none } = rankHits(reply.answers.c?.probabilities, rows.map(r => r.id))
86
+ if (this.cache.size >= WORK_SEARCH_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
87
+ this.cache.set(key, { at: this.now(), results, none, searched: rows.length, partial })
88
+ return { available: true, results, none, cached: false, tokens: reply.inputTokens, searched: rows.length, partial }
89
+ } catch (e) {
90
+ if (e instanceof DecisionProviderError) return unavailable(e.code)
91
+ if (!(e instanceof JevError)) throw e
92
+ if (PASSED_THROUGH.has(e.code)) return unavailable(e.code as WorkSearchReason)
93
+ console.warn('[drop-search] Jev failed:', e.code)
94
+ return unavailable('jev_unavailable')
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Drop it P2: when the pings, reminders and follow-ups run. Only while Drop it is on, and they must run without a
3
+ * request (a reminder fires on its own), so this checks the switch at boot and once a minute: on, it opens the drop
4
+ * service (the same stores the routes use) and starts the runtime; off, it stops the runtime and closes its state.
5
+ *
6
+ * Off (the default for every install) opens nothing and writes nothing, exactly as P1.
7
+ */
8
+ import type { DropService } from './drop-service.js'
9
+ import { PingStateStore } from './ping-engine.js'
10
+ import type { DropP2Runtime } from './drop-p2-runtime.js'
11
+
12
+ export const DROP_P2_CHECK_MS = 60_000
13
+
14
+ export interface DropP2BootDeps {
15
+ enabled: () => boolean
16
+ /** The routes' lazy service: opening it here and in a request gives the same instance. */
17
+ service: () => DropService | null
18
+ pingRoot: string
19
+ make: (stores: ReturnType<DropService['p2Stores']>, pingState: PingStateStore) => DropP2Runtime
20
+ openPingState?: (root: string) => PingStateStore
21
+ checkMs?: number
22
+ }
23
+
24
+ export function createDropP2Boot(deps: DropP2BootDeps) {
25
+ let runtime: DropP2Runtime | null = null
26
+ let state: PingStateStore | null = null
27
+ let timer: ReturnType<typeof setInterval> | null = null
28
+
29
+ function down() {
30
+ runtime?.stop(); runtime = null
31
+ try { state?.close() } catch { /* already closed */ }
32
+ state = null
33
+ }
34
+ /** Bring the runtime in line with the switch. Never throws: a store that cannot open leaves the pings off. */
35
+ function check(): DropP2Runtime | null {
36
+ let on = false
37
+ try { on = deps.enabled() } catch { on = false }
38
+ if (!on) { if (runtime) { down(); console.log('[pings] stopped: Drop it is off') } return null }
39
+ if (runtime) return runtime
40
+ const service = deps.service()
41
+ if (!service) return null
42
+ try {
43
+ state = (deps.openPingState ?? (root => new PingStateStore(root)))(deps.pingRoot)
44
+ runtime = deps.make(service.p2Stores(), state)
45
+ runtime.start()
46
+ console.log('[pings] started')
47
+ } catch (e) {
48
+ console.error('[pings] could not start:', e instanceof Error ? e.message.slice(0, 80) : 'error')
49
+ down()
50
+ }
51
+ return runtime
52
+ }
53
+ return {
54
+ /** The running runtime, or null. The routes read it for banners. */
55
+ runtime: (): DropP2Runtime | null => runtime,
56
+ check,
57
+ start() { if (timer) return; check(); timer = setInterval(check, deps.checkMs ?? DROP_P2_CHECK_MS); timer.unref?.() },
58
+ stop() { if (timer) clearInterval(timer); timer = null; down() },
59
+ }
60
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Drop it P2: the parts running together. One object the server starts beside the P1 drop service.
3
+ *
4
+ * - The ping engine polls its sources every minute (prep needs a minute's grain for its 15-minute window).
5
+ * - The reminder tick runs every 30 seconds.
6
+ * - The resolution poll reads the board, the Work journal and the thread cache every 2 minutes; the server's own card
7
+ * writes and session turn ends call in at once (`onCardWrite`, `onSessionTurn`).
8
+ * - Banners go to an outbox Control reads (`banners(after)`), each once per boot cursor; a reminder banner also goes to
9
+ * the lens as a `cos_ping` display event.
10
+ *
11
+ * Every reader is injected, so a test runs it on fixtures and a fake clock, and a reader that throws only quiets its
12
+ * source for that tick. Each loop is single flight: a slow board read never stacks a second one. Timers never hold the
13
+ * process open. Logs are counts only.
14
+ */
15
+ import { existsSync, lstatSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { randomBytes } from 'node:crypto'
18
+ import type { DropRecord, InboxFeedStore } from './drop-store.js'
19
+ import { PingEngine, type PingBanner, type PingSnapshot, type PingStateStore } from './ping-engine.js'
20
+ import { DEFAULT_BANNER_SOURCES, type PingSource } from './ping-budget.js'
21
+ import { P2_FOLLOWUP_KINDS, timeMinutes, type P2Settings } from './drop-p2-settings.js'
22
+ import type { IntakeRow, ThreadRow, SignalRow, PingCandidate } from './ping-sources.js'
23
+ import { feedSink } from './ping-feed-sink.js'
24
+ import { createReminderScheduler, pendingReminders, applyReminderAction, type ReminderAction, type ReminderStore } from './reminder-scheduler.js'
25
+ import { cardWriteResolutions, pollResolutions, RESOLUTION_POLL_MS, sessionTurnResolutions, type CardSnapshot, type HandoffSnapshot, type SessionState, type ThreadSnapshot } from './drop-resolution.js'
26
+
27
+ export const P2_TIMING = { pingTickMs: 60_000, reminderTickMs: 30_000, resolutionPollMs: RESOLUTION_POLL_MS, outbox: 50, kickDelayMs: 3_000 } as const
28
+ /** The COS caches are a few hundred KB; anything far larger is not the cache this reads. */
29
+ const CACHE_MAX_BYTES = 8 * 1024 * 1024
30
+
31
+ export interface BoardRowLite { id: string; domain: string; workIdentity?: string; workStage?: string; checked?: boolean }
32
+ export interface JournalReceiptLite { id: string; workID?: string | null; status: string; result?: string | null }
33
+
34
+ export interface DropP2Deps {
35
+ drops: ReminderStore & { list(): DropRecord[] }
36
+ feed: Pick<InboxFeedStore, 'byKey' | 'insert'>
37
+ pingState: Pick<PingStateStore, 'read' | 'write'>
38
+ timezone: () => string
39
+ now?: () => number
40
+ /** The next calendar event; undefined when the calendar could not be read. */
41
+ nextEvent?: () => Promise<{ title?: unknown; time?: unknown } | null | undefined>
42
+ /** Open Work intake items; undefined when the store is not available. */
43
+ intakeOpen?: () => IntakeRow[] | undefined
44
+ /** operations/scripts, for the thread and signal caches (read only; no script is run). */
45
+ scriptsDir?: string | null
46
+ /** The board; throwing means it could not be read, and the poll infers nothing from it. */
47
+ board?: () => Promise<BoardRowLite[]>
48
+ /** Work journal receipts; undefined when the journal is not available. */
49
+ journal?: () => JournalReceiptLite[] | undefined
50
+ /** Publishes a lens event (display bus). */
51
+ emitLens?: (event: { type: 'cos_ping'; data: Record<string, unknown> }) => void
52
+ enabled?: () => readonly PingSource[]
53
+ bootId?: string
54
+ /** True while the server drains for an update or restart: every loop skips its turn (a reminder fires next tick, late). */
55
+ paused?: () => boolean
56
+ /** 6.99.11 settings (drop-p2-settings.ts). Absent: the defaults, exactly as before. */
57
+ settings?: () => P2Settings
58
+ }
59
+
60
+ /** Follow-ups allowed by the settings: the switch, then the kind (the link kind in the key `dropId:kind:id:state`). */
61
+ export function followUpAllowed(c: PingCandidate, s: P2Settings | null): boolean {
62
+ if (!s) return true
63
+ if (!s.followUps.on) return false
64
+ const kind = c.key.split(':')[1] as (typeof P2_FOLLOWUP_KINDS)[number]
65
+ return (P2_FOLLOWUP_KINDS as readonly string[]).includes(kind) ? s.followUps.kinds[kind] : true
66
+ }
67
+
68
+ export interface OutboxBanner extends PingBanner { seq: number; at: string }
69
+
70
+ /** The journal's validated raw receipts (work-activity readWorkJournal) as the few fields the poll reads. */
71
+ export function journalReceiptsLite(records: ReadonlyArray<Record<string, unknown>>): JournalReceiptLite[] {
72
+ const out: JournalReceiptLite[] = []
73
+ for (const r of records) {
74
+ if (typeof r?.id !== 'string' || typeof r.status !== 'string') continue
75
+ out.push({ id: r.id, status: r.status, workID: typeof r.workID === 'string' ? r.workID : null, result: typeof r.result === 'string' ? r.result : null })
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** Read one array out of a COS cache file, or undefined when the file is missing, unsafe, too large or unreadable. */
81
+ export function readCacheArray(dir: string | null | undefined, file: string, key: string): unknown[] | undefined {
82
+ if (!dir) return undefined
83
+ const path = join(dir, file)
84
+ try {
85
+ if (!existsSync(path)) return undefined
86
+ const st = lstatSync(path)
87
+ if (!st.isFile() || st.isSymbolicLink() || st.size > CACHE_MAX_BYTES) return undefined
88
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
89
+ const list = parsed?.[key]
90
+ return Array.isArray(list) ? list : undefined
91
+ } catch { return undefined }
92
+ }
93
+
94
+ export function createDropP2Runtime(deps: DropP2Deps) {
95
+ const now = deps.now ?? (() => Date.now())
96
+ const bootId = deps.bootId ?? randomBytes(8).toString('hex')
97
+ const outbox: OutboxBanner[] = []
98
+ let seq = 0
99
+
100
+ const settingsNow = (): P2Settings | null => { try { return deps.settings?.() ?? null } catch { return null } }
101
+ const followUps = (list: PingCandidate[]) => { const s = settingsNow(); return list.filter(c => followUpAllowed(c, s)) }
102
+ const banner = (b: PingBanner) => {
103
+ outbox.push({ ...b, seq: ++seq, at: new Date(now()).toISOString() })
104
+ if (outbox.length > P2_TIMING.outbox) outbox.shift()
105
+ if (b.exempt && b.sources.includes('reminder') && (settingsNow()?.reminders.lensPush ?? true)) {
106
+ try { deps.emitLens?.({ type: 'cos_ping', data: { title: b.title, line: b.line, feedIds: b.feedIds, ...(b.notifyId ? { notifyId: b.notifyId } : {}) } }) }
107
+ catch { /* the lens is a second screen: the chat and the Mac banner already have it */ }
108
+ }
109
+ }
110
+
111
+ const threadsNow = () => readCacheArray(deps.scriptsDir, '.threads_cache.json', 'threads') as ThreadRow[] | undefined
112
+ const signalsNow = () => readCacheArray(deps.scriptsDir, '.signals_cache.json', 'signals') as SignalRow[] | undefined
113
+
114
+ const engine = new PingEngine({
115
+ state: deps.pingState,
116
+ feed: feedSink(deps.feed, now),
117
+ banner,
118
+ now,
119
+ timezone: deps.timezone,
120
+ enabled: deps.enabled ?? (() => DEFAULT_BANNER_SOURCES),
121
+ ...(deps.settings ? { settings: deps.settings } : {}),
122
+ read: async (): Promise<PingSnapshot> => {
123
+ const snap: PingSnapshot = {}
124
+ try { const e = await deps.nextEvent?.(); if (e !== undefined) snap.nextEvent = e } catch { /* calendar unread */ }
125
+ try { const i = deps.intakeOpen?.(); if (i !== undefined) snap.intakeOpen = i } catch { /* intake unread */ }
126
+ const threads = threadsNow(), signals = signalsNow()
127
+ // Each cache on its own: a missing signals cache must not quiet stale threads. The engine counts the stale source
128
+ // as read (and so may expire a held item) only when both were read.
129
+ if (threads !== undefined) snap.threads = threads
130
+ if (signals !== undefined) snap.signals = signals
131
+ return snap
132
+ },
133
+ })
134
+
135
+ const isPaused = () => { try { return deps.paused?.() === true } catch { return true } }
136
+ const reminderStore = {
137
+ // While paused, the scheduler sees nothing due, so nothing is written during a drain.
138
+ select: (m: Parameters<typeof deps.drops.select>[0]) => isPaused() ? [] : deps.drops.select(m),
139
+ update: deps.drops.update.bind(deps.drops),
140
+ }
141
+ const reminders = createReminderScheduler({ store: reminderStore, deliver: c => engine.push(c), now, intervalMs: P2_TIMING.reminderTickMs,
142
+ snoozeMinutes: () => settingsNow()?.reminders.snoozeMinutes ?? 60 })
143
+
144
+ async function resolutionSnapshot() {
145
+ const snap: { cards?: Map<string, CardSnapshot>; handoffs?: Map<string, HandoffSnapshot[]>; threads?: Map<string, ThreadSnapshot> } = {}
146
+ try {
147
+ const rows = await deps.board?.()
148
+ if (rows) snap.cards = new Map(rows.map(r => [`${r.domain}:${r.workIdentity ?? r.id}`, { workStage: r.workStage, checked: r.checked }]))
149
+ } catch { /* board unread: nothing is inferred from it */ }
150
+ try {
151
+ const receipts = deps.journal?.()
152
+ if (receipts) {
153
+ snap.handoffs = new Map()
154
+ for (const r of receipts) {
155
+ const m = typeof r.workID === 'string' ? /^task:([^:]+):(.+)$/.exec(r.workID) : null
156
+ if (!m) continue
157
+ const key = `${m[1]}:${m[2]}`
158
+ snap.handoffs.set(key, [...(snap.handoffs.get(key) ?? []), { id: r.id, status: r.status, result: r.result ?? null }])
159
+ }
160
+ }
161
+ } catch { /* journal unread */ }
162
+ const threads = threadsNow()
163
+ if (threads) snap.threads = new Map(threads.filter(t => typeof t?.id === 'string').map(t => [t.id as string, { is_resolved: t.is_resolved === true, is_stale: t.is_stale === true }]))
164
+ return snap
165
+ }
166
+
167
+ let polling = false, ticking = false
168
+ async function pollOnce(): Promise<number> {
169
+ if (polling || isPaused()) return 0
170
+ polling = true
171
+ try {
172
+ const drops = deps.drops.list()
173
+ if (!drops.length) return 0
174
+ const found = followUps(pollResolutions(drops, await resolutionSnapshot(), now()))
175
+ return found.length ? engine.push(found).posted : 0
176
+ } catch { return 0 } finally { polling = false }
177
+ }
178
+ async function pingOnce() {
179
+ if (ticking || isPaused()) return
180
+ ticking = true
181
+ try { await engine.tick() } catch { /* never throws by contract; belt and braces */ } finally { ticking = false }
182
+ }
183
+
184
+ const timers: Array<ReturnType<typeof setInterval>> = []
185
+ let kickTimer: ReturnType<typeof setTimeout> | null = null
186
+ return {
187
+ bootId,
188
+ engine,
189
+ start() {
190
+ if (timers.length) return
191
+ reminders.start()
192
+ void pingOnce(); void pollOnce()
193
+ for (const [fn, ms] of [[pingOnce, P2_TIMING.pingTickMs], [pollOnce, P2_TIMING.resolutionPollMs]] as const) {
194
+ const t = setInterval(() => { void fn() }, ms); t.unref?.(); timers.push(t)
195
+ }
196
+ },
197
+ stop() { reminders.stop(); for (const t of timers.splice(0)) clearInterval(t); if (kickTimer) clearTimeout(kickTimer); kickTimer = null },
198
+ /**
199
+ * A card was written through this server (work-board, tasks): poll the board shortly, instead of within 2 minutes.
200
+ * Several writes in a row make one poll. The write invalidated the shared board reader, so the poll sees it.
201
+ */
202
+ kickPoll() {
203
+ if (kickTimer) return
204
+ kickTimer = setTimeout(() => { kickTimer = null; void pollOnce() }, P2_TIMING.kickDelayMs)
205
+ kickTimer.unref?.()
206
+ },
207
+ tickReminders: () => reminders.tick(),
208
+ pingOnce,
209
+ pollOnce,
210
+ /** The server's own card write (work-board, tasks routes): the same rule as the poll, at once. */
211
+ onCardWrite(domain: string, workIdentity: string, row: CardSnapshot | null) {
212
+ try { const c = followUps(cardWriteResolutions(deps.drops.list(), domain, workIdentity, row, now())); if (c.length) engine.push(c) } catch { /* the poll will see it */ }
213
+ },
214
+ onSessionTurn(sessionId: string, outcome: SessionState, endedAtMs?: number) {
215
+ // Every session's every Stop lands here: copy only the drops held for a session and already sent.
216
+ try {
217
+ const held = deps.drops.select(d => d.state !== 'undone' && d.dest === 'session' && !!d.hold?.sentAt)
218
+ if (!held.length) return
219
+ const c = followUps(sessionTurnResolutions(held, sessionId, outcome, now(), endedAtMs ?? now()))
220
+ if (c.length) engine.push(c)
221
+ } catch { /* nothing else watches a held session; it stays open in History */ }
222
+ },
223
+ pushAgent: (ev: Parameters<PingEngine['pushAgent']>[0]) => engine.pushAgent(ev),
224
+ push: (c: PingCandidate[]) => engine.push(c),
225
+ /** Banners since a cursor. A cursor from another boot starts over from what this boot holds. */
226
+ banners(after: { bootId?: string; seq?: number } = {}): { bootId: string; seq: number; banners: OutboxBanner[] } {
227
+ const from = after.bootId === bootId && Number.isSafeInteger(after.seq) ? after.seq! : 0
228
+ return { bootId, seq, banners: outbox.filter(b => b.seq > from) }
229
+ },
230
+ pendingReminders: () => pendingReminders(deps.drops),
231
+ reminderAction: (dropId: string, action: ReminderAction) => {
232
+ const s = settingsNow()
233
+ return applyReminderAction(deps.drops, dropId, action, now(), s ? { snoozeMs: s.reminders.snoozeMinutes * 60_000, dayMinutes: timeMinutes(s.reminders.defaultTime) } : undefined)
234
+ },
235
+ }
236
+ }
237
+ export type DropP2Runtime = ReturnType<typeof createDropP2Runtime>