@gotcos/glasses-server 6.98.1 → 6.99.16

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