@gotcos/glasses-server 6.98.0 → 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 +158 -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-data.ts +336 -26
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +9 -5
- 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
|
@@ -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>
|