@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
@@ -0,0 +1,739 @@
1
+ /**
2
+ * Drop it P1 (plan sections 1 to 3 and 6): one drop in, filed or asked about, with a receipt in the pinned COS chat.
3
+ *
4
+ * Order of writes, so a drop is never lost: the drop and its chat line are saved first (state `routing`), then it is
5
+ * routed, then the destination write, then the receipt. A restart that finds a drop still `routing` keeps it as a note.
6
+ * A repeated dropId answers the first result; two concurrent posts of one dropId share one run.
7
+ *
8
+ * Undo lasts 24 hours and refuses with a reason when the item was edited since. Move is Undo, then filing where the
9
+ * user picked, with no Jev call. Nothing is deleted: a new card is archived, a memory quarantined, a thread source
10
+ * removed by its id, and the drop itself stays in History as undone.
11
+ *
12
+ * Logs carry ids, codes and lengths only (plan section 8). Nothing here imports the Messages store, so a drop never adds
13
+ * a Messages row or moves the lens message counter (canary 11).
14
+ */
15
+ import { randomBytes } from 'node:crypto'
16
+ import { DROP_DESTS, DROP_ID, DROP_LIMITS, DropStoreError, parseLink, type DropChoice, type DropDest, type DropKind, type DropLink, type DropRecord,
17
+ type DropSource, type DropFileRef, type FeedAction, type FeedItem, type DropStore, type InboxFeedStore } from './drop-store.js'
18
+ import { choiceFor, cleanDropForJev, DROP_ROUTER_LIMITS, ruleRoute, type DropRouter, type DropTarget, type RouteOutcome } from './drop-router.js'
19
+ import { DropDestinationError, type DropWriters } from './drop-destinations.js'
20
+ import { parseWhen, whenLabel, whenSuggestions, WHEN_MAX_AHEAD_MS } from './drop-when.js'
21
+ import { applyReminderAction, reminderAbout, REMINDER_ACTIONS, type ReminderAction } from './reminder-scheduler.js'
22
+ import { timeMinutes, type P2Settings } from './drop-p2-settings.js'
23
+
24
+ export class DropError extends Error {
25
+ constructor(readonly code: string, readonly status: number, message?: string) { super(message ?? code) }
26
+ }
27
+
28
+ export interface DropInput {
29
+ dropId: string
30
+ text: string
31
+ source: DropSource
32
+ hint?: { sessionId: string; label?: string }
33
+ file?: DropFileRef
34
+ }
35
+ export interface DropView {
36
+ id: string; text: string; source: DropSource; createdAt: string; updatedAt: string
37
+ state: DropRecord['state']; dest: DropDest | null; kind: DropKind; status: HistoryStatus
38
+ links: DropLink[]; choices?: DropChoice[]; route: DropRecord['route']; undoUntil: string | null
39
+ canUndo: boolean; hold?: DropRecord['hold']; remind?: DropRecord['remind']; file?: DropFileRef
40
+ }
41
+ export interface DropAnswer { drop: DropView; items: FeedItem[]; replayed: boolean; next?: { kind: 'ask'; prompt: string } }
42
+ export type HistoryStatus = 'open' | 'in_progress' | 'done' | 'undone'
43
+ export interface AskLookup { status: string; response?: string; sessionId?: string }
44
+
45
+ export interface DropServiceDeps {
46
+ drops: DropStore
47
+ feed: InboxFeedStore
48
+ router: Pick<DropRouter, 'route'>
49
+ writers: DropWriters
50
+ /** The existing Ask job, read by its client identity, for posting the answer into the chat. */
51
+ askLookup?: (clientJobId: string, generation: number) => Promise<AskLookup | undefined>
52
+ now?: () => Date
53
+ timezone?: () => string
54
+ /** 6.99.11 settings: the reminders' default time and snooze length. Absent: 9:00 and an hour. */
55
+ p2Settings?: () => P2Settings
56
+ }
57
+
58
+ const TEXT_MAX = DROP_LIMITS.text
59
+ const ASK_WINDOW_MS = 6 * 60 * 60_000
60
+ const ANSWER_CHARS = 1_200
61
+ const short = (text: string, max = 60): string => {
62
+ const one = text.replace(/\s+/g, ' ').trim()
63
+ return one.length <= max ? one : one.slice(0, max - 1).trimEnd() + '…'
64
+ }
65
+ const feedId = () => `fi_${randomBytes(16).toString('hex')}`
66
+ /** One item that waits on an answer: a live question, or a live item marked needsReply (6.99.10). */
67
+ export const isWaiting = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
68
+ const A = {
69
+ undo: { id: 'undo', label: 'Undo' }, move: { id: 'move', label: 'Move' },
70
+ openCard: { id: 'open_card', label: 'Open card' }, openThread: { id: 'open_thread', label: 'Open thread' },
71
+ openMemory: { id: 'open_memory', label: 'Open memory' }, openSession: { id: 'open_session', label: 'Open session' },
72
+ send: { id: 'send_to_session', label: 'Send to session' }, openMessages: { id: 'open_messages', label: 'Open in Messages' },
73
+ } as const
74
+
75
+ /** Why a drop is a note, in one line the chat shows. Codes stay in `route.reason`. */
76
+ export const NOTE_REASONS: Record<string, string> = {
77
+ router_off: 'Automatic filing is off on this server.',
78
+ redacted: 'It looked like it held a password or key, so it was not sent for filing.',
79
+ jev_not_configured: 'Filing needs a TypeSafe key in Settings.',
80
+ jev_key_rejected: 'TypeSafe did not accept the saved key. Check it in Settings.',
81
+ jev_cap_reached: 'Filing has used today\'s budget. It resets at midnight UTC.',
82
+ jev_breaker_open: 'Filing is paused for an hour after repeated failures.',
83
+ jev_request_rejected: 'Jev refused this one.',
84
+ jev_unavailable: 'COS could not reach Jev to file it.',
85
+ jev_bad_answer: 'Jev gave an answer COS could not read.',
86
+ destination_unavailable: 'Filing there needs a COS update on this Mac.',
87
+ destination_failed: 'The place it was going did not accept it. Move it to try again.',
88
+ memory_too_short: 'Memories need a full sentence, so this one is kept as a note.',
89
+ interrupted: 'The server restarted while filing it.',
90
+ }
91
+
92
+ export class DropService {
93
+ private readonly inflight = new Map<string, Promise<DropAnswer>>()
94
+ private readonly busy = new Set<string>()
95
+ private readonly now: () => Date
96
+ constructor(private readonly deps: DropServiceDeps) {
97
+ this.now = deps.now ?? (() => new Date())
98
+ this.recover()
99
+ this.purgeOrphans()
100
+ }
101
+
102
+ // ── Intake ─────────────────────────────────────────────────────────
103
+
104
+ /** Runs synchronously up to the first await, so the in-flight entry exists before a second post can look. */
105
+ async submit(input: DropInput): Promise<DropAnswer> {
106
+ const parsed = validateInput(input)
107
+ const existing = this.deps.drops.get(parsed.dropId)
108
+ if (existing && !this.inflight.has(parsed.dropId)) return this.answer(existing, true)
109
+ const running = this.inflight.get(parsed.dropId)
110
+ if (running) return running.then(a => ({ ...a, replayed: true }))
111
+ const work = this.run(parsed).finally(() => this.inflight.delete(parsed.dropId))
112
+ this.inflight.set(parsed.dropId, work)
113
+ return work
114
+ }
115
+
116
+ private async run(input: DropInput): Promise<DropAnswer> {
117
+ const at = this.now().toISOString()
118
+ const fresh: DropRecord = {
119
+ id: input.dropId, text: input.text, source: input.source, createdAt: at, updatedAt: at, state: 'routing', dest: null,
120
+ kind: input.file ? 'file' : 'note', links: [], route: null, undoUntil: null, timeline: [{ at, state: 'routing' }],
121
+ ...(input.hint ? { hint: { sessionId: input.hint.sessionId } } : {}), ...(input.file ? { file: input.file } : {}),
122
+ }
123
+ this.deps.drops.insert(fresh)
124
+ this.deps.feed.insert([{ id: feedId(), kind: 'drop', threadId: fresh.id, createdAt: at, readAt: at, source: input.source,
125
+ line: input.text, links: [], actions: [], state: 'live', key: `${fresh.id}:drop` }])
126
+ // Before any rule (plan section 8): a drop that looks like it holds a password or key goes nowhere but the drop
127
+ // store. Not to memory (the bridge embeds it), not to the Ask job, not to Jev.
128
+ if (cleanDropForJev(input.text).tripped) return this.file(fresh, 'note', undefined, { by: 'fallback', reason: 'redacted' })
129
+ const rule = ruleRoute(input.text)
130
+ if (rule === 'ask') return this.asked(fresh)
131
+ if (rule === 'reminder') return this.file(fresh, 'reminder', undefined, { by: 'rule' })
132
+ if (rule === 'memory') return this.file(fresh, 'memory', undefined, { by: 'rule' })
133
+ const session = input.hint ? { id: input.hint.sessionId, label: (input.hint.label ?? '').trim() || 'the open session' } : undefined
134
+ let outcome: RouteOutcome
135
+ try {
136
+ const candidates = await this.deps.writers.candidates(input.text, session)
137
+ outcome = await this.deps.router.route(input.text, candidates)
138
+ } catch {
139
+ outcome = { by: 'fallback', reason: 'jev_unavailable' }
140
+ }
141
+ if (outcome.by === 'fallback') return this.file(fresh, 'note', undefined, { by: 'fallback', reason: outcome.reason })
142
+ const route = { by: 'jev' as const, p: round(outcome.decision.p), tokens: outcome.tokens, provider: outcome.provider }
143
+ const clear = outcome.decision.clear
144
+ if (outcome.decision.kind === 'ask') {
145
+ const choices = outcome.decision.choices.map(c => c.dest === 'session' && session ? { ...c, target: { id: session.id, label: session.label } } : c)
146
+ return this.question(fresh, choices, route, clear)
147
+ }
148
+ const decision = outcome.decision
149
+ const target: DropTarget | undefined = decision.dest === 'session' && session ? { id: session.id, label: session.label } : decision.target
150
+ return this.file(fresh, decision.dest, target, route, clear)
151
+ }
152
+
153
+ /** A question for the existing Ask job: the client submits it there and links the job back here. */
154
+ private asked(drop: DropRecord): DropAnswer {
155
+ const at = this.now().toISOString()
156
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, state: 'asked', dest: 'ask', kind: 'ask', route: { by: 'rule' },
157
+ undoUntil: null, timeline: [...d.timeline, { at, state: 'asked', dest: 'ask' }] }))
158
+ this.receipt(saved, 'receipt', 'Asking COS', 'The answer lands here when it is ready.', [], [], 1)
159
+ return { ...this.answer(saved, false), next: { kind: 'ask', prompt: saved.text } }
160
+ }
161
+
162
+ private question(drop: DropRecord, choices: DropChoice[], route: DropRecord['route'], clear: number): DropAnswer {
163
+ const at = this.now().toISOString()
164
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, state: 'asking', dest: null, kind: clear >= DROP_ROUTER_LIMITS.clear ? 'note' : 'idea',
165
+ route, choices, undoUntil: new Date(Date.parse(at) + DROP_LIMITS.undoMs).toISOString(), timeline: [...d.timeline, { at, state: 'asking' }] }))
166
+ const options = choices.filter(c => c.dest !== 'note')
167
+ const title = options.length >= 2 ? `Is this ${reading(options[0])}, or ${reading(options[1])}?` : `Is this ${reading(options[0])}, or just a note?`
168
+ const actions: FeedAction[] = choices.map(c => ({ id: `choose:${c.id}`, label: c.label }))
169
+ this.receipt(saved, 'question', title, 'COS is not sure where this goes, so it is asking instead of guessing.', [], actions, 1)
170
+ return this.answer(this.deps.drops.get(saved.id)!, false)
171
+ }
172
+
173
+ /**
174
+ * File to one destination. A destination that cannot take it (a missing verb, a refusal) keeps a note with the
175
+ * reason, never an error: the drop is already saved, and Move can try again.
176
+ */
177
+ private async file(drop: DropRecord, dest: DropDest, target: DropTarget | undefined, route: NonNullable<DropRecord['route']>,
178
+ clear = 0.5, generation = 1, movedFrom?: DropDest): Promise<DropAnswer> {
179
+ let links: DropLink[] = []
180
+ let hold: DropRecord['hold']
181
+ let remind: DropRecord['remind']
182
+ let actualDest = dest
183
+ let reason = route.reason
184
+ // A label can come from a card's own words (Jev's pick): control characters become spaces, and it is cut to the
185
+ // store's limit, so the record written after the destination write can never be refused (an unlinked write).
186
+ if (target) target = { ...target, label: target.label.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, DROP_LIMITS.label) }
187
+ try {
188
+ if (target) checkTarget(target)
189
+ switch (dest) {
190
+ case 'new_card': {
191
+ if (!target?.domain) throw new DropDestinationError('destination_failed')
192
+ const card = await this.deps.writers.captureCard(target.domain, drop.text, drop.id)
193
+ links = [{ kind: 'card', id: card.workIdentity, label: target.label, domain: target.domain, created: card.created }]
194
+ break
195
+ }
196
+ case 'existing_card':
197
+ if (!target?.id || !target.domain) throw new DropDestinationError('destination_failed')
198
+ links = [{ kind: 'card', id: target.id, label: target.label, domain: target.domain }]
199
+ break
200
+ case 'thread': {
201
+ if (!target?.id) throw new DropDestinationError('destination_failed')
202
+ const out = await this.deps.writers.sourceThread(target.id, drop.text, drop.id)
203
+ links = [{ kind: 'thread', id: target.id, label: target.label, sourceId: out.sourceId, created: out.created }]
204
+ break
205
+ }
206
+ case 'memory': {
207
+ // A new capture key per filing, so a replay never answers with a row an earlier Undo set aside.
208
+ const out = await this.deps.writers.captureMemory(drop.text, drop.id, String(generation))
209
+ links = [{ kind: 'memory', id: out.id, label: 'To review', created: out.created }]
210
+ break
211
+ }
212
+ case 'session':
213
+ if (!target?.id) throw new DropDestinationError('destination_failed')
214
+ hold = { sessionId: target.id, label: target.label }
215
+ links = [{ kind: 'session', id: target.id, label: target.label }]
216
+ break
217
+ case 'reminder': {
218
+ // P2 (plan section 7): the time is read from his words at filing. None found: the receipt asks once.
219
+ const tz = this.deps.timezone?.() ?? 'UTC'
220
+ const when = parseWhen(drop.text, this.now().getTime(), tz, this.whenOptions())
221
+ remind = { at: when.at === null ? null : new Date(when.at).toISOString(), tz, firedAt: null, ackAt: null, snoozes: 0 }
222
+ links = [{ kind: 'reminder', id: drop.id, label: 'Reminder' }]
223
+ break
224
+ }
225
+ case 'note':
226
+ break
227
+ default:
228
+ throw new DropDestinationError('destination_failed')
229
+ }
230
+ } catch (caught) {
231
+ // A target that still fails the store's rule is refused before any write, as a failed destination.
232
+ const e = caught instanceof DropError && caught.code === 'invalid_target' ? new DropDestinationError('destination_failed') : caught
233
+ if (!(e instanceof DropDestinationError)) throw e
234
+ console.warn(`[drops] ${drop.id} ${dest} write failed: ${e.code}`)
235
+ actualDest = 'note'; links = []; hold = undefined; remind = undefined
236
+ route = { ...route, reason: e.code }; reason = e.code
237
+ }
238
+ const at = this.now().toISOString()
239
+ const kind: DropKind = drop.file ? 'file' : kindFor(actualDest, clear)
240
+ const saved = this.deps.drops.update(drop.id, d => {
241
+ const next: DropRecord = { ...d, state: actualDest === 'session' ? 'held' : 'filed', dest: actualDest, kind, links, route,
242
+ undoUntil: new Date(Date.parse(at) + DROP_LIMITS.undoMs).toISOString(),
243
+ timeline: [...d.timeline, { at, state: actualDest === 'session' ? 'held' : 'filed', dest: actualDest, ...(reason ? { note: reason } : {}) }] }
244
+ delete next.choices; delete next.hold; delete next.remind
245
+ if (hold) next.hold = hold
246
+ if (remind) next.remind = remind
247
+ return next
248
+ })
249
+ console.log(`[drops] ${saved.id} filed dest=${actualDest} by=${route.by}${reason ? ` reason=${reason}` : ''} chars=${saved.text.length}`)
250
+ const words = receiptWords(saved, actualDest, reason, movedFrom, this.now().getTime(), this.whenOptions())
251
+ this.receipt(saved, 'receipt', words.title, words.line, saved.links, words.actions, generation, words.needsReply)
252
+ return this.answer(this.deps.drops.get(saved.id)!, false)
253
+ }
254
+
255
+ /**
256
+ * Retention across the two stores (plan section 6): when the drop store lets a drop go (a year, or 30 days after
257
+ * Undo), its chat lines go too, so its words never outlive the drop.
258
+ */
259
+ private purgeOrphans(): void {
260
+ const ids = this.deps.drops.ids()
261
+ this.deps.feed.purge(item => DROP_ID.test(item.threadId) && !ids.has(item.threadId))
262
+ }
263
+
264
+ private receipt(drop: DropRecord, kind: 'receipt' | 'question' | 'followup', title: string, line: string, links: DropLink[], actions: FeedAction[], generation: number, needsReply = false): FeedItem {
265
+ const at = this.now().toISOString()
266
+ const [item] = this.deps.feed.insert([{ id: feedId(), kind, threadId: drop.id, createdAt: at,
267
+ // Receipts are born read (plan section 1); a question and a follow-up count as unread on the pinned row.
268
+ // A receipt that asks (a reminder with no time) is waiting on him until he picks one (P2).
269
+ readAt: kind === 'receipt' ? at : null, source: 'cos', title, line, links, actions, state: 'live', ...(needsReply ? { needsReply: true } : {}),
270
+ ...(kind === 'receipt' && drop.undoUntil ? { undoUntil: drop.undoUntil } : {}), key: `${drop.id}:${kind}:${generation}` }])
271
+ this.purgeOrphans()
272
+ return item
273
+ }
274
+
275
+ /** The earlier live receipts and questions of a drop lose their buttons: only the newest one acts. */
276
+ private retire(dropId: string): void {
277
+ const ids = new Set(this.deps.feed.list().filter(i => i.threadId === dropId && i.kind !== 'drop' && i.state === 'live').map(i => i.id))
278
+ if (ids.size) this.deps.feed.update(ids, item => ({ ...item, state: 'done', actions: [] }))
279
+ }
280
+ private nextGeneration(dropId: string): number {
281
+ return this.deps.feed.list().filter(i => i.threadId === dropId && (i.kind === 'receipt' || i.kind === 'question')).length + 1
282
+ }
283
+
284
+ // ── Answering a question ───────────────────────────────────────────
285
+
286
+ async choose(itemId: string, choiceId: string): Promise<DropAnswer> {
287
+ const item = this.deps.feed.get(itemId)
288
+ if (!item || item.kind !== 'question') throw new DropError('question_not_found', 404)
289
+ return this.locked(item.threadId, async () => {
290
+ const drop = this.mustGet(item.threadId)
291
+ if (drop.state !== 'asking' || item.state !== 'live') return this.answer(drop, true) // answered already: idempotent
292
+ const choice = drop.choices?.find(c => c.id === choiceId)
293
+ if (!choice) throw new DropError('invalid_choice', 400)
294
+ const generation = this.nextGeneration(drop.id)
295
+ this.retire(drop.id)
296
+ const at = this.now().toISOString()
297
+ this.deps.feed.insert([{ id: feedId(), kind: 'answer', threadId: drop.id, createdAt: at, readAt: at, source: drop.source,
298
+ line: choice.label, links: [], actions: [], state: 'done', key: `${drop.id}:answer:${generation}` }])
299
+ const target = choice.target ? { id: choice.target.id, label: choice.target.label, ...(choice.target.domain ? { domain: choice.target.domain } : {}) } : undefined
300
+ const sessionTarget = choice.dest === 'session' && drop.hint && !target ? { id: drop.hint.sessionId, label: 'the open session' } : target
301
+ return this.file(drop, choice.dest, sessionTarget, { by: 'user' }, drop.kind === 'idea' ? 0 : 1, generation)
302
+ })
303
+ }
304
+
305
+ // ── P2: reminder times and actions ──────────────────────────────────
306
+
307
+ /**
308
+ * `when:tomorrow_9`, `when:monday_9` or `when:<ISO instant>` on a reminder's receipt: set (or change) its time.
309
+ * Refused for an undone drop, a reminder already done, and a time in the past or over a year out.
310
+ */
311
+ async setReminderTime(itemId: string, choice: string): Promise<DropAnswer> {
312
+ const item = this.deps.feed.get(itemId)
313
+ if (!item || item.kind !== 'receipt') throw new DropError('receipt_not_found', 404)
314
+ return this.locked(item.threadId, async () => {
315
+ const drop = this.mustGet(item.threadId)
316
+ if (drop.state === 'undone' || drop.dest !== 'reminder' || !drop.remind || drop.remind.ackAt) throw new DropError('not_a_live_reminder', 409)
317
+ const nowMs = this.now().getTime()
318
+ const suggested = whenSuggestions(nowMs, drop.remind.tz, this.whenOptions()).find(w => w.id === choice)
319
+ const atMs = suggested ? suggested.at : /^\d{4}-\d{2}-\d{2}T/.test(choice) ? Date.parse(choice) : Number.NaN
320
+ if (!Number.isFinite(atMs) || atMs <= nowMs || atMs - nowMs > WHEN_MAX_AHEAD_MS) throw new DropError('invalid_time', 400)
321
+ const generation = this.nextGeneration(drop.id)
322
+ this.retire(drop.id)
323
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, remind: { ...d.remind!, at: new Date(atMs).toISOString(), firedAt: null } }))
324
+ const words = receiptWords(saved, 'reminder', undefined, undefined, nowMs, this.whenOptions())
325
+ this.receipt(saved, 'receipt', words.title, words.line, saved.links, words.actions, generation)
326
+ return this.answer(this.deps.drops.get(saved.id)!, false)
327
+ })
328
+ }
329
+
330
+ /** Done, Snooze 1 hour or Tomorrow 9 on a fired reminder's ping. The ping's buttons go; a snooze fires again later. */
331
+ reminderAction(itemId: string, action: string): { item: FeedItem; drop: DropView } {
332
+ const item = this.deps.feed.get(itemId)
333
+ const reminder = item?.links.find(l => l.kind === 'reminder')
334
+ if (!item || item.kind !== 'ping' || !reminder) throw new DropError('reminder_not_found', 404)
335
+ if (!REMINDER_ACTIONS.some(a => a.id === action)) throw new DropError('invalid_action', 400)
336
+ const o = this.whenOptions()
337
+ const drop = applyReminderAction(this.deps.drops, reminder.id, action as ReminderAction, this.now().getTime(), { dayMinutes: o.dayMinutes, snoozeMs: o.snoozeMinutes === undefined ? undefined : o.snoozeMinutes * 60_000 })
338
+ if (!drop) throw new DropError('not_a_live_reminder', 409)
339
+ const [done] = this.deps.feed.update(new Set([item.id]), i => ({ ...i, state: 'done', actions: [] }))
340
+ return { item: done, drop: this.view(drop, null) }
341
+ }
342
+
343
+ private whenOptions(): { dayMinutes?: number; snoozeMinutes?: number } {
344
+ let s: P2Settings | undefined
345
+ try { s = this.deps.p2Settings?.() } catch { s = undefined }
346
+ return s ? { dayMinutes: timeMinutes(s.reminders.defaultTime), snoozeMinutes: s.reminders.snoozeMinutes } : {}
347
+ }
348
+
349
+ /** The two stores, for the P2 runtime (pings, reminders, follow-ups) that writes beside this service. */
350
+ p2Stores(): { drops: DropStore; feed: InboxFeedStore } { return { drops: this.deps.drops, feed: this.deps.feed } }
351
+
352
+ // ── Undo and Move ──────────────────────────────────────────────────
353
+
354
+ async undo(dropId: string): Promise<DropAnswer> {
355
+ return this.locked(dropId, async () => {
356
+ const drop = this.mustGet(dropId)
357
+ if (drop.state === 'undone') return this.answer(drop, true)
358
+ const generation = this.nextGeneration(drop.id)
359
+ await this.reverse(drop)
360
+ const at = this.now().toISOString()
361
+ const saved = this.deps.drops.update(drop.id, d => {
362
+ const next: DropRecord = { ...d, state: 'undone', links: [], undoUntil: null, timeline: [...d.timeline, { at, state: 'undone' }] }
363
+ delete next.hold; delete next.remind; delete next.choices
364
+ return next
365
+ })
366
+ this.retire(drop.id)
367
+ this.receipt(saved, 'receipt', 'Undone', undoLine(drop), [], [], generation)
368
+ console.log(`[drops] ${drop.id} undone from=${drop.dest ?? drop.state}`)
369
+ return this.answer(this.deps.drops.get(drop.id)!, false)
370
+ })
371
+ }
372
+
373
+ /** Move: Undo, then file where the user picked, with no Jev call. */
374
+ async move(dropId: string, to: { dest: DropDest; target?: DropTarget }): Promise<DropAnswer> {
375
+ if (to.dest === 'ask' || !(DROP_DESTS as readonly string[]).includes(to.dest)) throw new DropError('invalid_move', 400)
376
+ // Checked before anything is undone or written: a target the store would refuse must never leave a write unlinked.
377
+ if (to.target) checkTarget(to.target)
378
+ return this.locked(dropId, async () => {
379
+ const drop = this.mustGet(dropId)
380
+ if (drop.state === 'undone') throw new DropError('drop_undone', 409, 'This drop was undone.')
381
+ if (to.dest === 'session' && !to.target?.id) throw new DropError('invalid_move', 400)
382
+ if (to.dest === 'memory' && cleanDropForJev(drop.text).tripped) throw new DropError('redacted', 409, 'It looks like it holds a password or key, so it stays out of memories.')
383
+ const generation = this.nextGeneration(drop.id)
384
+ if (drop.state !== 'asking') await this.reverse(drop)
385
+ else if (this.now().getTime() > Date.parse(drop.undoUntil ?? '')) throw new DropError('undo_expired', 409, 'Undo and Move last 24 hours.')
386
+ this.retire(drop.id)
387
+ const at = this.now().toISOString()
388
+ const cleared = this.deps.drops.update(drop.id, d => {
389
+ const next: DropRecord = { ...d, links: [], timeline: [...d.timeline, { at, state: d.state, note: 'moved' }] }
390
+ delete next.hold; delete next.remind; delete next.choices
391
+ return next
392
+ })
393
+ return this.file(cleared, to.dest, to.target, { by: 'user' }, cleared.kind === 'idea' ? 0 : 1, generation, drop.dest ?? undefined)
394
+ })
395
+ }
396
+
397
+ /** Take back what filing wrote. Refuses (409) past 24 hours, for an answered ask, or when the item was edited. */
398
+ private async reverse(drop: DropRecord): Promise<void> {
399
+ if (drop.state === 'routing') throw new DropError('drop_busy', 409, 'This drop is still being filed.')
400
+ if (drop.state === 'asked') throw new DropError('undo_not_available', 409, 'A question cannot be undone. The answer stays.')
401
+ if (!drop.undoUntil || this.now().getTime() > Date.parse(drop.undoUntil)) throw new DropError('undo_expired', 409, 'Undo and Move last 24 hours.')
402
+ // A sent hold has no undo window left (holdSent clears undoUntil), so the check above already refuses it.
403
+ for (const link of drop.links) {
404
+ // Something that was already there (same words on a card, a duplicate memory, a note already on the thread) is
405
+ // only unlinked: Undo never archives or removes what this drop did not create.
406
+ if (link.created === false) continue
407
+ try {
408
+ if (link.kind === 'card' && drop.dest === 'new_card') {
409
+ const result = await this.deps.writers.archiveCard(link.domain ?? '', link.id, drop.id, drop.text)
410
+ if (result === 'touched') throw new DropError('edited', 409, 'The card was changed since, so Undo leaves it alone.')
411
+ } else if (link.kind === 'thread' && link.sourceId) {
412
+ const result = await this.deps.writers.unsourceThread(link.id, link.sourceId, drop.id)
413
+ if (result === 'touched') throw new DropError('edited', 409, 'The thread note was changed since, so Undo leaves it alone.')
414
+ } else if (link.kind === 'memory') {
415
+ const result = await this.deps.writers.pruneMemory(link.id, drop.id)
416
+ if (result === 'touched') throw new DropError('edited', 409, 'The memory was reviewed since, so Undo leaves it alone.')
417
+ }
418
+ } catch (e) {
419
+ if (e instanceof DropDestinationError) throw new DropError('undo_failed', 503, 'COS could not reach where it went. Try again.')
420
+ throw e
421
+ }
422
+ }
423
+ }
424
+
425
+ // ── Session hold, asks ─────────────────────────────────────────────
426
+
427
+ /** Control sent a held follow-up through the session's own path; the hold is spent. */
428
+ async holdSent(dropId: string): Promise<DropAnswer> {
429
+ return this.locked(dropId, async () => {
430
+ const drop = this.mustGet(dropId)
431
+ if (drop.state !== 'held' || !drop.hold) throw new DropError('not_held', 409)
432
+ if (drop.hold.sentAt) return this.answer(drop, true)
433
+ const at = this.now().toISOString()
434
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, state: 'filed', hold: { ...d.hold!, sentAt: at }, undoUntil: null,
435
+ timeline: [...d.timeline, { at, state: 'filed', dest: 'session', note: 'sent' }] }))
436
+ const generation = this.nextGeneration(drop.id)
437
+ this.retire(drop.id)
438
+ this.receipt(saved, 'receipt', `Sent to ${saved.hold!.label}`, 'The session has it now.', saved.links, [A.openSession], generation)
439
+ return this.answer(this.deps.drops.get(drop.id)!, false)
440
+ })
441
+ }
442
+
443
+ async linkAsk(dropId: string, clientJobId: string, generation: number): Promise<DropAnswer> {
444
+ if (!DROP_ID.test(clientJobId.toLowerCase()) || !Number.isSafeInteger(generation) || generation < 1) throw new DropError('invalid_ask_link', 400)
445
+ return this.locked(dropId, async () => {
446
+ const drop = this.mustGet(dropId)
447
+ if (drop.dest !== 'ask') throw new DropError('not_a_question', 409)
448
+ if (drop.ask && drop.ask.clientJobId === clientJobId.toLowerCase() && drop.ask.generation === generation) return this.answer(drop, true)
449
+ if (drop.ask?.answeredAt) throw new DropError('already_answered', 409)
450
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, ask: { clientJobId: clientJobId.toLowerCase(), generation },
451
+ links: [{ kind: 'ask', id: clientJobId.toLowerCase(), label: 'Ask' }] }))
452
+ return this.answer(saved, false)
453
+ })
454
+ }
455
+
456
+ /**
457
+ * Post finished answers into the chat, once each (key `<dropId>:followup:answer`). Read on demand by the feed and
458
+ * summary routes; only asks linked within the last six hours are looked up.
459
+ */
460
+ async syncAsks(): Promise<number> {
461
+ if (!this.deps.askLookup) return 0
462
+ let posted = 0
463
+ const cutoff = this.now().getTime() - ASK_WINDOW_MS
464
+ // Only the rows with a pending ask are copied: the summary poll calls this often.
465
+ for (const drop of this.deps.drops.select(d => d.dest === 'ask' && !!d.ask && !d.ask.answeredAt && Date.parse(d.createdAt) >= cutoff)) {
466
+ if (!drop.ask) continue
467
+ let job: AskLookup | undefined
468
+ try { job = await this.deps.askLookup(drop.ask.clientJobId, drop.ask.generation) } catch { continue }
469
+ if (!job || !['completed', 'failed', 'canceled', 'interrupted'].includes(job.status)) continue
470
+ const at = this.now().toISOString()
471
+ const ok = job.status === 'completed' && !!job.response?.trim()
472
+ const line = ok ? short(job.response!, ANSWER_CHARS) : 'The answer did not finish. Open it in Messages to try again.'
473
+ const links: DropLink[] = job.sessionId && /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/.test(job.sessionId) ? [{ kind: 'session', id: job.sessionId, label: 'Messages' }] : []
474
+ const before = this.deps.feed.byKey(`${drop.id}:followup:answer`)
475
+ this.deps.feed.insert([{ id: feedId(), kind: 'followup', threadId: drop.id, createdAt: at, readAt: null, source: 'cos',
476
+ title: `Re: ${short(drop.text)}`, line, links, actions: links.length ? [A.openMessages] : [], state: 'live', key: `${drop.id}:followup:answer` }])
477
+ this.deps.drops.update(drop.id, d => ({ ...d, ask: { ...d.ask!, answeredAt: at } }))
478
+ if (!before) posted++
479
+ }
480
+ return posted
481
+ }
482
+
483
+ // ── Reading ────────────────────────────────────────────────────────
484
+
485
+ get(dropId: string): DropAnswer {
486
+ const drop = this.deps.drops.get(dropId)
487
+ if (!drop) throw new DropError('drop_not_found', 404)
488
+ return this.answer(drop, false)
489
+ }
490
+
491
+ /**
492
+ * The pinned row. `unread` clears when the chat is opened (markRead). `waiting` (6.99.10, plan section 12) is what
493
+ * waits on an answer from you, and clears only when it is answered, done or dismissed: every live question COS asked
494
+ * (a router ask), every live item marked `needsReply` (a follow-up that needs a decision, a ping that asks), plus
495
+ * `yourMove`, the Work items in Your move, when the caller could count them. Always a whole number, never negative.
496
+ */
497
+ summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; lastLine: string; lastAt: string | null; version: number } {
498
+ const items = this.deps.feed.list()
499
+ const unread = items.filter(i => i.readAt === null && (i.kind === 'ping' || i.kind === 'followup' || i.kind === 'question')).length
500
+ const asked = items.filter(i => isWaiting(i)).length
501
+ const work = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove > 0 ? extra.yourMove : 0
502
+ const last = items[items.length - 1]
503
+ return { unread, waiting: asked + work, lastLine: last ? short(last.title && last.kind !== 'drop' ? `${last.title}. ${last.line}` : last.line, 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
504
+ }
505
+
506
+ /**
507
+ * Dismiss one item that waits on you without answering it (6.99.10): it stops counting toward `waiting` and loses
508
+ * its buttons. Only an item marked `needsReply` can be dismissed; a question is answered by one of its choices
509
+ * (Just a note among them). Idempotent: a second dismiss answers the same.
510
+ */
511
+ dismiss(itemId: string): { unread: number; waiting: number; version: number } {
512
+ const item = this.deps.feed.get(itemId)
513
+ if (!item) throw new DropError('feed_item_not_found', 404)
514
+ if (!item.needsReply) throw new DropError('not_dismissable', 409, 'Only something that waits on you can be dismissed.')
515
+ if (item.state === 'live') {
516
+ const at = this.now().toISOString()
517
+ this.deps.feed.update(new Set([item.id]), i => ({ ...i, state: 'done', actions: [], readAt: i.readAt ?? at }))
518
+ }
519
+ const s = this.summary()
520
+ return { unread: s.unread, waiting: s.waiting, version: s.version }
521
+ }
522
+
523
+ /** The chat, oldest first. `after` is an item id: only items after it. */
524
+ feed(options: { after?: string; limit?: number } = {}): { items: FeedItem[]; version: number; more: boolean } {
525
+ const items = this.deps.feed.list()
526
+ let start = 0
527
+ if (options.after) {
528
+ const index = items.findIndex(i => i.id === options.after)
529
+ if (index < 0) throw new DropError('feed_item_not_found', 404)
530
+ start = index + 1
531
+ }
532
+ const limit = Math.max(1, Math.min(200, options.limit ?? 100))
533
+ const rest = items.slice(start)
534
+ // Without `after` the newest page; with it, the next page forward.
535
+ const page = options.after ? rest.slice(0, limit) : rest.slice(-limit)
536
+ return { items: page, version: this.deps.feed.version(), more: options.after ? rest.length > limit : rest.length > limit }
537
+ }
538
+
539
+ /** The drop a chat item belongs to. */
540
+ threadOf(itemId: string): string {
541
+ const item = this.deps.feed.get(itemId)
542
+ if (!item) throw new DropError('feed_item_not_found', 404)
543
+ return item.threadId
544
+ }
545
+
546
+ markRead(through: string): { unread: number; waiting: number; version: number } {
547
+ const items = this.deps.feed.list()
548
+ const index = items.findIndex(i => i.id === through)
549
+ if (index < 0) throw new DropError('feed_item_not_found', 404)
550
+ const at = this.now().toISOString()
551
+ const ids = new Set(items.slice(0, index + 1).filter(i => i.readAt === null).map(i => i.id))
552
+ if (ids.size) this.deps.feed.update(ids, item => ({ ...item, readAt: at }))
553
+ const s = this.summary()
554
+ // Opening the chat clears unread only: what waits on you stays until it is answered, done or dismissed.
555
+ return { unread: s.unread, waiting: s.waiting, version: s.version }
556
+ }
557
+
558
+ /** History (plan section 6), P1: words over drop text and link labels, with kind and status filters, newest first. */
559
+ async history(options: { q?: string; kind?: DropKind; status?: HistoryStatus; limit?: number; before?: string } = {}): Promise<{ drops: DropView[]; more: boolean; cardStates: 'read' | 'unavailable' | 'none' }> {
560
+ let rows = this.deps.drops.list().sort((a, b) => b.createdAt.localeCompare(a.createdAt))
561
+ if (options.before) {
562
+ const index = rows.findIndex(r => r.id === options.before)
563
+ if (index < 0) throw new DropError('drop_not_found', 404)
564
+ rows = rows.slice(index + 1)
565
+ }
566
+ if (options.kind) rows = rows.filter(r => r.kind === options.kind)
567
+ if (options.q) {
568
+ const terms = options.q.normalize('NFKC').toLowerCase().split(/\s+/).filter(Boolean)
569
+ rows = rows.filter(r => {
570
+ const hay = [r.text, ...r.links.map(l => l.label), r.hold?.label ?? '', r.file?.displayName ?? ''].join(' ').normalize('NFKC').toLowerCase()
571
+ return terms.every(t => hay.includes(t))
572
+ })
573
+ }
574
+ let cards: Map<string, { workStage: string; checked: boolean }> | null = null
575
+ let cardStates: 'read' | 'unavailable' | 'none' = 'none'
576
+ if (rows.some(r => r.links.some(l => l.kind === 'card'))) {
577
+ try { cards = await this.deps.writers.cardStates(); cardStates = 'read' } catch { cardStates = 'unavailable' }
578
+ }
579
+ let views = rows.map(r => this.view(r, cards))
580
+ if (options.status) views = views.filter(v => v.status === options.status)
581
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50))
582
+ return { drops: views.slice(0, limit), more: views.length > limit, cardStates }
583
+ }
584
+
585
+ /** Releases both stores' writer locks (shutdown and tests). */
586
+ close(): void { this.deps.drops.close(); this.deps.feed.close() }
587
+
588
+ // ── Helpers ────────────────────────────────────────────────────────
589
+
590
+ private answer(drop: DropRecord, replayed: boolean): DropAnswer {
591
+ const items = this.deps.feed.list().filter(i => i.threadId === drop.id)
592
+ return { drop: this.view(drop, null), items, replayed, ...(drop.dest === 'ask' && !drop.ask ? { next: { kind: 'ask' as const, prompt: drop.text } } : {}) }
593
+ }
594
+
595
+ private view(drop: DropRecord, cards: Map<string, { workStage: string; checked: boolean }> | null): DropView {
596
+ return {
597
+ id: drop.id, text: drop.text, source: drop.source, createdAt: drop.createdAt, updatedAt: drop.updatedAt, state: drop.state,
598
+ dest: drop.dest, kind: drop.kind, status: statusOf(drop, cards), links: drop.links, ...(drop.choices ? { choices: drop.choices } : {}),
599
+ route: drop.route, undoUntil: drop.undoUntil,
600
+ canUndo: ['filed', 'held', 'asking'].includes(drop.state) && !!drop.undoUntil && this.now().getTime() <= Date.parse(drop.undoUntil) && !drop.hold?.sentAt,
601
+ ...(drop.hold ? { hold: drop.hold } : {}), ...(drop.remind ? { remind: drop.remind } : {}), ...(drop.file ? { file: drop.file } : {}),
602
+ }
603
+ }
604
+
605
+ private mustGet(id: string): DropRecord {
606
+ if (!DROP_ID.test(id)) throw new DropError('invalid_drop_id', 400)
607
+ const drop = this.deps.drops.get(id)
608
+ if (!drop) throw new DropError('drop_not_found', 404)
609
+ return drop
610
+ }
611
+
612
+ /** One change per drop at a time: a double tap gets 409 instead of two undos. */
613
+ private async locked<T>(id: string, fn: () => Promise<T>): Promise<T> {
614
+ if (this.busy.has(id) || this.inflight.has(id)) throw new DropError('drop_busy', 409, 'This drop is busy. Try again in a moment.')
615
+ this.busy.add(id)
616
+ try { return await fn() } finally { this.busy.delete(id) }
617
+ }
618
+
619
+ /** A drop left `routing` by a restart is kept as a note, with a receipt that says so. */
620
+ private recover(): void {
621
+ for (const drop of this.deps.drops.list()) {
622
+ if (drop.state !== 'routing') continue
623
+ const at = this.now().toISOString()
624
+ const saved = this.deps.drops.update(drop.id, d => ({ ...d, state: 'filed', dest: 'note', kind: d.file ? 'file' : 'note',
625
+ route: { by: 'fallback', reason: 'interrupted' }, undoUntil: new Date(Date.parse(at) + DROP_LIMITS.undoMs).toISOString(),
626
+ timeline: [...d.timeline, { at, state: 'filed', dest: 'note', note: 'interrupted' }] }))
627
+ const words = receiptWords(saved, 'note', 'interrupted')
628
+ this.receipt(saved, 'receipt', words.title, words.line, [], words.actions, 1)
629
+ }
630
+ }
631
+ }
632
+
633
+ /** A move or filing target, held to the store's own link rules before any write. */
634
+ function checkTarget(target: DropTarget): void {
635
+ try { parseLink({ kind: 'card', id: target.id, label: target.label, ...(target.domain !== undefined ? { domain: target.domain } : {}) }) }
636
+ catch { throw new DropError('invalid_target', 400, 'That destination cannot be used.') }
637
+ }
638
+
639
+ function validateInput(input: DropInput): DropInput {
640
+ if (!input || typeof input !== 'object') throw new DropError('invalid_drop', 400)
641
+ if (typeof input.dropId !== 'string' || !DROP_ID.test(input.dropId)) throw new DropError('invalid_drop_id', 400, 'dropId must be a lowercase UUID v4.')
642
+ if (typeof input.text !== 'string') throw new DropError('invalid_text', 400)
643
+ const text = input.text.replace(/\r\n?/g, '\n').trim()
644
+ if (!text || [...text].length > TEXT_MAX || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text)) throw new DropError('invalid_text', 400, `Text must be 1 to ${TEXT_MAX} characters.`)
645
+ if (!['mac', 'web', 'g2', 'telegram'].includes(input.source)) throw new DropError('invalid_source', 400)
646
+ const out: DropInput = { dropId: input.dropId, text, source: input.source }
647
+ if (input.hint !== undefined) {
648
+ const h = input.hint
649
+ if (!h || typeof h.sessionId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/.test(h.sessionId)
650
+ || (h.label !== undefined && (typeof h.label !== 'string' || h.label.length > 200 || /[\x00-\x1f\x7f]/.test(h.label)))) throw new DropError('invalid_hint', 400)
651
+ out.hint = { sessionId: h.sessionId, ...(h.label ? { label: h.label } : {}) }
652
+ }
653
+ if (input.file !== undefined) {
654
+ const f = input.file
655
+ if (!f || [f.store, f.key, f.fileID, f.displayName].some(v => typeof v !== 'string' || !v || v.length > 512 || /[\x00-\x1f\x7f]/.test(v))
656
+ || typeof f.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(f.sha256) || f.store.length > 64 || f.fileID.length > 200 || f.displayName.length > 255) throw new DropError('invalid_file', 400)
657
+ out.file = { store: f.store, key: f.key, fileID: f.fileID, sha256: f.sha256, displayName: f.displayName }
658
+ }
659
+ return out
660
+ }
661
+
662
+ /** Kinds are derived, never asked (plan section 2). */
663
+ export function kindFor(dest: DropDest, clear: number): DropKind {
664
+ switch (dest) {
665
+ case 'new_card': case 'existing_card': case 'session': return 'task'
666
+ case 'memory': return 'memory'
667
+ case 'reminder': return 'reminder'
668
+ case 'ask': return 'ask'
669
+ default: return clear >= DROP_ROUTER_LIMITS.clear ? 'note' : 'idea'
670
+ }
671
+ }
672
+
673
+ function reading(choice: DropChoice): string {
674
+ switch (choice.dest) {
675
+ case 'new_card': return 'a task for you'
676
+ case 'existing_card': return choice.target ? `an update to ${choice.target.label}` : 'an update to a card'
677
+ case 'thread': return choice.target ? `a note on the ${choice.target.label} thread` : 'a note on a thread'
678
+ case 'memory': return 'something to remember'
679
+ case 'session': return 'a follow-up for the session'
680
+ default: return 'a note'
681
+ }
682
+ }
683
+
684
+ function receiptWords(drop: DropRecord, dest: DropDest, reason?: string, movedFrom?: DropDest, nowMs = Date.now(), when: { dayMinutes?: number } = {}): { title: string; line: string; actions: FeedAction[]; needsReply?: boolean } {
685
+ const card = drop.links.find(l => l.kind === 'card'), thread = drop.links.find(l => l.kind === 'thread')
686
+ const moved = movedFrom ? 'Moved. ' : ''
687
+ switch (dest) {
688
+ case 'new_card': return card?.created === false
689
+ ? { title: `${moved}Already in Work, ${card.label}`, line: 'A card with these words was already there, so this links to it.', actions: [A.openCard, A.undo, A.move] }
690
+ : { title: `${moved}Filed to Work, ${card?.label ?? 'Inbox'}`, line: 'New card in the Inbox.', actions: [A.openCard, A.undo, A.move] }
691
+ case 'existing_card': return { title: `${moved}Added to ${card?.label ?? 'the card'}`, line: 'Linked to that card. No new card.', actions: [A.openCard, A.undo, A.move] }
692
+ case 'thread': return { title: `${moved}Added to the ${thread?.label ?? ''} thread`.replace('the thread', 'the thread'), line: 'It joins the thread for the next prep. No card.', actions: [A.openThread, A.undo, A.move] }
693
+ case 'memory': return { title: `${moved}Saved to memories`, line: 'It waits in To review until you accept it.', actions: [A.openMemory, A.undo, A.move] }
694
+ case 'session': return { title: `${moved}Held for ${drop.hold?.label ?? 'the session'}`, line: 'Send it to the session when you are ready.', actions: [A.send, A.undo, A.move] }
695
+ case 'reminder': {
696
+ const about = reminderAbout(drop)
697
+ if (drop.remind?.at) return { title: `${moved}Reminder set: ${whenLabel(Date.parse(drop.remind.at), nowMs, drop.remind.tz)}`, line: `I will remind you: ${about}.`, actions: [A.undo, A.move] }
698
+ // No time in his words (plan section 7): ask once, with the two common answers.
699
+ return { title: `${moved}When should I remind you?`, line: `I could not read a time for: ${about}.`, needsReply: true,
700
+ actions: [...whenSuggestions(nowMs, drop.remind?.tz ?? 'UTC', when).map(w => ({ id: `when:${w.id}`, label: w.label })), A.undo, A.move] }
701
+ }
702
+ default: {
703
+ const word = drop.kind === 'idea' ? 'an idea' : drop.kind === 'file' ? 'a file note' : 'a note'
704
+ const why = reason ? NOTE_REASONS[reason] ?? NOTE_REASONS.jev_unavailable : drop.route?.by === 'jev' ? 'Nothing else happens.' : 'Nothing else happens.'
705
+ return { title: `${moved}Kept as ${word}`, line: why, actions: [A.undo, A.move] }
706
+ }
707
+ }
708
+ }
709
+
710
+ function undoLine(drop: DropRecord): string {
711
+ switch (drop.dest) {
712
+ case 'new_card': return 'The Work card is archived. Your words stay here.'
713
+ case 'existing_card': return 'The link to the card is gone. The card is unchanged.'
714
+ case 'thread': return 'The note is off the thread. Your words stay here.'
715
+ case 'memory': return 'The memory is set aside. Your words stay here.'
716
+ case 'session': return 'Nothing goes to the session. Your words stay here.'
717
+ case 'reminder': return 'No reminder. Your words stay here.'
718
+ default: return drop.state === 'asking' ? 'Nothing was filed. Your words stay here.' : 'The note is archived. Your words stay here.'
719
+ }
720
+ }
721
+
722
+ export function statusOf(drop: DropRecord, cards: Map<string, { workStage: string; checked: boolean }> | null): HistoryStatus {
723
+ if (drop.state === 'undone') return 'undone'
724
+ if (drop.state === 'routing' || drop.state === 'asking' || drop.state === 'held') return 'open'
725
+ if (drop.state === 'asked') return drop.ask?.answeredAt ? 'done' : 'in_progress'
726
+ if (drop.dest === 'reminder') return drop.remind?.ackAt ? 'done' : 'open'
727
+ const card = drop.links.find(l => l.kind === 'card')
728
+ if (card) {
729
+ const state = cards?.get(card.id)
730
+ if (!state) return 'open'
731
+ if (state.checked || state.workStage === 'complete') return 'done'
732
+ return state.workStage === 'draft' || state.workStage === 'qa' ? 'in_progress' : 'open'
733
+ }
734
+ if (drop.dest === 'session') return drop.hold?.sentAt ? 'done' : 'open'
735
+ return 'done'
736
+ }
737
+
738
+ const round = (n: number) => Math.round(n * 10_000) / 10_000
739
+ export { DropStoreError }