@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,56 @@
1
+ /**
2
+ * Drop it is OFF for every user by default (plan decision 4: on for Miles's Mac at the candidate, listed before any
3
+ * promotion). Two ways to turn it on, read at every request so no restart is needed for the file:
4
+ * - COS_DROP_IT in the server's environment wins when it is set (1, true, yes, on; anything else is off);
5
+ * - otherwise `data/drop-it-settings.json` `{ "enabled": true }`, written by PUT /api/drops/settings from Control.
6
+ * A missing, unreadable or malformed file reads as off.
7
+ */
8
+ import { lstatSync, readFileSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+ import { dataPath } from './data-dir.js'
11
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
12
+ import { securePrivateDirectory } from './secure-user-config.js'
13
+ import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
14
+
15
+ export const DROP_IT_PROTOCOL = 1
16
+ /** 6.99.13: files one drop takes, the Ask's own cap (shared/media-attachment.ts MAX_ATTACHMENTS_PER_PROMPT). */
17
+ export const DROP_ATTACHMENTS_MAX = MAX_ATTACHMENTS_PER_PROMPT
18
+ export const DROP_IT_SETTINGS_FILE = () => dataPath('drop-it-settings.json')
19
+
20
+ export function dropItSetting(file = DROP_IT_SETTINGS_FILE()): boolean {
21
+ try {
22
+ const stat = lstatSync(file)
23
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 4096) return false
24
+ const value = JSON.parse(readFileSync(file, 'utf8')) as { enabled?: unknown }
25
+ return value?.enabled === true
26
+ } catch { return false }
27
+ }
28
+ export function dropItEnvValue(env: NodeJS.ProcessEnv = process.env): boolean | null {
29
+ const raw = env.COS_DROP_IT
30
+ if (raw === undefined || raw.trim() === '') return null
31
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())
32
+ }
33
+ export function dropItEnabled(env: NodeJS.ProcessEnv = process.env, file = DROP_IT_SETTINGS_FILE()): boolean {
34
+ return dropItEnvValue(env) ?? dropItSetting(file)
35
+ }
36
+ export function dropItSource(env: NodeJS.ProcessEnv = process.env): 'env' | 'settings' {
37
+ return dropItEnvValue(env) === null ? 'settings' : 'env'
38
+ }
39
+ export function writeDropItSetting(enabled: boolean, file = DROP_IT_SETTINGS_FILE()): void {
40
+ securePrivateDirectory(dirname(file))
41
+ durableAtomicWriteFileSync(file, JSON.stringify({ enabled }) + '\n', { mode: 0o600 })
42
+ }
43
+
44
+ /** The capability block for /api/health and /api/models. Codes only: no counts, no words. Additive. */
45
+ export function dropItCapability(env: NodeJS.ProcessEnv = process.env, file = DROP_IT_SETTINGS_FILE()): { protocol: number; enabled: boolean; source: 'env' | 'settings'; p2Settings: number; attachments: number; thread?: { protocol: number } } {
46
+ // p2Settings (6.99.11): GET/PUT /api/drops/settings/p2 exists. Clients that do not see it hide the P2 controls.
47
+ // attachments (6.99.13): POST /api/drops takes this many attachmentIds. A client that does not see it sends words only.
48
+ // thread (6.99.16): the COS thread routes (routes/cos-thread.ts), present only while Drop it is on, as they are.
49
+ const enabled = dropItEnabled(env, file)
50
+ return {
51
+ protocol: DROP_IT_PROTOCOL, enabled, source: dropItSource(env), p2Settings: 1, attachments: DROP_ATTACHMENTS_MAX,
52
+ ...(enabled ? { thread: { protocol: COS_THREAD_CAPABILITY_PROTOCOL } } : {}),
53
+ }
54
+ }
55
+ /** 6.99.16: the COS thread's protocol (lib/cos-thread.ts COS_THREAD_PROTOCOL; kept here so this module imports nothing new). */
56
+ export const COS_THREAD_CAPABILITY_PROTOCOL = 1
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Drop it P1 (plan PLAN_drop_it_2026-11-04 sections 1, 3 and 6): the two files behind the pinned COS chat.
3
+ *
4
+ * - `data/drops/drops.json`: every drop. Its words, kind, where it went (links), a status timeline, an undo window,
5
+ * and the first answer to its dropId so a repeated POST returns that answer.
6
+ * - `data/inbox/feed.json`: the chat's messages, each pointing at a drop id. Persistent, no daily rollover, and
7
+ * separate from the Messages store (lib/conversation.ts): nothing here takes a global message number or an era,
8
+ * so a drop never adds a row to Messages and never moves the lens message counter (canary 11).
9
+ *
10
+ * Built like WorkIntakeStore: one writer lock per folder, a 0700 folder and 0600 files owned by this user, atomic
11
+ * writes, and a bad row set aside on its own instead of taking the store down. Nothing here logs words: the only
12
+ * console line is a count of quarantined rows.
13
+ */
14
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
17
+ import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
18
+
19
+ export class DropStoreError extends Error {
20
+ constructor(readonly code: string, readonly status = 409) { super(code) }
21
+ }
22
+
23
+ export const DROP_SOURCES = ['mac', 'web', 'g2', 'telegram'] as const
24
+ export type DropSource = typeof DROP_SOURCES[number]
25
+ export const DROP_DESTS = ['new_card', 'existing_card', 'thread', 'memory', 'session', 'note', 'reminder', 'ask'] as const
26
+ export type DropDest = typeof DROP_DESTS[number]
27
+ export const DROP_KINDS = ['idea', 'task', 'note', 'memory', 'file', 'reminder', 'ask'] as const
28
+ export type DropKind = typeof DROP_KINDS[number]
29
+ /** routing: saved, not yet filed. asking: one question is open. held: a session follow-up waiting for Send. */
30
+ export const DROP_STATES = ['routing', 'filed', 'asking', 'held', 'asked', 'undone'] as const
31
+ export type DropState = typeof DROP_STATES[number]
32
+ export const LINK_KINDS = ['card', 'session', 'handoff', 'thread', 'memory', 'meeting', 'file', 'reminder', 'ask'] as const
33
+ export type LinkKind = typeof LINK_KINDS[number]
34
+
35
+ export interface DropLink {
36
+ kind: LinkKind
37
+ id: string
38
+ label: string
39
+ /** card: the Work domain; thread: the source id written into the thread (Undo needs it). */
40
+ domain?: string
41
+ sourceId?: string
42
+ /** false when the destination answered with something that was already there: Undo then only unlinks it. */
43
+ created?: boolean
44
+ }
45
+ export interface DropChoice { id: string; dest: DropDest; label: string; detail: string; target?: { id: string; domain?: string; label: string } }
46
+ export interface DropRoute { by: 'rule' | 'jev' | 'user' | 'fallback'; p?: number; reason?: string; tokens?: number; provider?: string }
47
+ export interface DropFileRef { store: string; key: string; fileID: string; sha256: string; displayName: string }
48
+ /**
49
+ * 6.99.13: a file dropped with the words (COS Control Web). `id` is the media store's id: the bytes live there, pinned
50
+ * to this drop (MediaStore.pinForDrop), and open through GET /api/media/:id/content. Undo removes them.
51
+ */
52
+ export interface DropAttachment { id: string; name: string; mime: string; size: number }
53
+ export interface DropRemind { at: string | null; tz: string; firedAt: string | null; ackAt: string | null; snoozes: number }
54
+ export interface DropTimelineEntry { at: string; state: DropState; dest?: DropDest; note?: string }
55
+ export interface DropAsk { clientJobId: string; generation: number; answeredAt?: string }
56
+
57
+ export interface DropRecord {
58
+ id: string
59
+ text: string
60
+ source: DropSource
61
+ createdAt: string
62
+ updatedAt: string
63
+ state: DropState
64
+ dest: DropDest | null
65
+ kind: DropKind
66
+ links: DropLink[]
67
+ route: DropRoute | null
68
+ choices?: DropChoice[]
69
+ hold?: { sessionId: string; label: string; sentAt?: string }
70
+ remind?: DropRemind
71
+ file?: DropFileRef
72
+ attachments?: DropAttachment[]
73
+ ask?: DropAsk
74
+ hint?: { sessionId: string }
75
+ /** Undo and Move work until this instant (24 hours after filing). */
76
+ undoUntil: string | null
77
+ timeline: DropTimelineEntry[]
78
+ }
79
+
80
+ export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'answer'] as const
81
+ export type FeedKind = typeof FEED_KINDS[number]
82
+ export interface FeedAction { id: string; label: string }
83
+ export interface FeedItem {
84
+ id: string
85
+ kind: FeedKind
86
+ /** The drop id, or the ping's own id. */
87
+ threadId: string
88
+ createdAt: string
89
+ readAt: string | null
90
+ source: DropSource | 'cos'
91
+ title?: string
92
+ line: string
93
+ links: DropLink[]
94
+ actions: FeedAction[]
95
+ /** live: its buttons work; done: answered or superseded (buttons gone). */
96
+ state: 'live' | 'done'
97
+ undoUntil?: string | null
98
+ /** Exactly-once key: a second insert with the same key returns the first item. */
99
+ key: string
100
+ /**
101
+ * 6.99.10 (plan section 12): this item waits on an answer from you (a ping or follow-up that needs a decision). It
102
+ * counts toward `waiting` on GET /api/inbox/summary while it is live, and stops counting when it is answered, done or
103
+ * dismissed. Kept only when it is a real boolean; an older row without it is valid and does not wait.
104
+ */
105
+ needsReply?: boolean
106
+ }
107
+
108
+ export const DROP_LIMITS = {
109
+ text: 2_000, label: 200, line: 2_000, title: 200, links: 12, choices: 3, timeline: 40,
110
+ drops: 20_000, feed: 20_000, fileBytes: 32_000_000, quarantineKept: 500,
111
+ /** Files on one drop: the Ask's own cap (shared/media-attachment.ts MAX_ATTACHMENTS_PER_PROMPT). */
112
+ attachments: 5, attachmentName: 255,
113
+ /** Retention (plan section 6): drop records and links a year; undone drops and chat receipts 30 days. */
114
+ keepDropDays: 365, keepUndoneDays: 30, keepReceiptDays: 30,
115
+ undoMs: 24 * 60 * 60_000,
116
+ } as const
117
+
118
+ export const DROP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
119
+ export const FEED_ID = /^fi_[a-f0-9]{32}$/
120
+ /** A media store id (shared/media-attachment.ts MEDIA_ID_RE). */
121
+ export const ATTACHMENT_ID = /^m_[a-f0-9]{24}$/
122
+ const LINK_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
123
+ const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
124
+
125
+ const str = (v: unknown, max: number, allowNewlines = false): string | null =>
126
+ typeof v === 'string' && v.length <= max && (allowNewlines ? !CONTROL.test(v) : !/[\x00-\x1f\x7f]/.test(v)) ? v : null
127
+ const iso = (v: unknown): string | null => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v)) ? v : null
128
+ const isoOrNull = (v: unknown): string | null | undefined => v === null ? null : iso(v) ?? undefined
129
+ const oneOf = <T extends string>(list: readonly T[], v: unknown): T | null => typeof v === 'string' && (list as readonly string[]).includes(v) ? v as T : null
130
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
131
+
132
+ export function parseLink(raw: unknown): DropLink {
133
+ if (!record(raw)) throw new DropStoreError('invalid_link', 400)
134
+ const kind = oneOf(LINK_KINDS, raw.kind), id = str(raw.id, 200), label = str(raw.label, DROP_LIMITS.label)
135
+ if (!kind || !id || !LINK_ID.test(id) || label === null) throw new DropStoreError('invalid_link', 400)
136
+ const link: DropLink = { kind, id, label }
137
+ if (raw.domain !== undefined) { const d = str(raw.domain, 64); if (!d) throw new DropStoreError('invalid_link', 400); link.domain = d }
138
+ if (raw.sourceId !== undefined) { const s = str(raw.sourceId, 200); if (!s || !LINK_ID.test(s)) throw new DropStoreError('invalid_link', 400); link.sourceId = s }
139
+ if (raw.created !== undefined) { if (typeof raw.created !== 'boolean') throw new DropStoreError('invalid_link', 400); link.created = raw.created }
140
+ return link
141
+ }
142
+
143
+ function parseChoice(raw: unknown): DropChoice {
144
+ if (!record(raw)) throw new DropStoreError('invalid_choice', 400)
145
+ const id = str(raw.id, 16), dest = oneOf(DROP_DESTS, raw.dest), label = str(raw.label, DROP_LIMITS.label), detail = str(raw.detail, DROP_LIMITS.line)
146
+ if (!id || !/^[a-z0-9_]{1,16}$/.test(id) || !dest || label === null || detail === null) throw new DropStoreError('invalid_choice', 400)
147
+ const choice: DropChoice = { id, dest, label, detail }
148
+ if (raw.target !== undefined) {
149
+ const t = raw.target
150
+ if (!record(t)) throw new DropStoreError('invalid_choice', 400)
151
+ const tid = str(t.id, 200), tlabel = str(t.label, DROP_LIMITS.label)
152
+ if (!tid || !LINK_ID.test(tid) || tlabel === null) throw new DropStoreError('invalid_choice', 400)
153
+ choice.target = { id: tid, label: tlabel, ...(typeof t.domain === 'string' && t.domain.length <= 64 ? { domain: t.domain } : {}) }
154
+ }
155
+ return choice
156
+ }
157
+
158
+ export function parseAttachment(raw: unknown): DropAttachment {
159
+ if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
160
+ const id = typeof raw.id === 'string' && ATTACHMENT_ID.test(raw.id) ? raw.id : null
161
+ const name = str(raw.name, DROP_LIMITS.attachmentName), mime = str(raw.mime, 100)
162
+ if (!id || !name || !mime || !/^[a-z]+\/[a-z0-9.+-]+$/i.test(mime) || !Number.isSafeInteger(raw.size) || (raw.size as number) < 0) throw new DropStoreError('invalid_drop', 400)
163
+ return { id, name, mime, size: raw.size as number }
164
+ }
165
+
166
+ /** Validate one drop record from the journal. Unknown fields are dropped, never trusted. */
167
+ export function parseDropRecord(raw: unknown): DropRecord {
168
+ if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
169
+ const id = typeof raw.id === 'string' && DROP_ID.test(raw.id) ? raw.id : null
170
+ const text = str(raw.text, DROP_LIMITS.text, true)
171
+ const source = oneOf(DROP_SOURCES, raw.source), state = oneOf(DROP_STATES, raw.state), kind = oneOf(DROP_KINDS, raw.kind)
172
+ const createdAt = iso(raw.createdAt), updatedAt = iso(raw.updatedAt)
173
+ const dest = raw.dest === null ? null : oneOf(DROP_DESTS, raw.dest)
174
+ if (!id || !text || !text.trim() || !source || !state || !kind || !createdAt || !updatedAt || (raw.dest !== null && !dest)) throw new DropStoreError('invalid_drop', 400)
175
+ if (!Array.isArray(raw.links) || raw.links.length > DROP_LIMITS.links || !Array.isArray(raw.timeline)) throw new DropStoreError('invalid_drop', 400)
176
+ const undoUntil = raw.undoUntil === null ? null : iso(raw.undoUntil)
177
+ if (raw.undoUntil !== null && !undoUntil) throw new DropStoreError('invalid_drop', 400)
178
+ const drop: DropRecord = { id, text, source, createdAt, updatedAt, state, dest, kind, links: raw.links.map(parseLink),
179
+ route: null, undoUntil, timeline: [] }
180
+ if (record(raw.route)) {
181
+ const by = oneOf(['rule', 'jev', 'user', 'fallback'] as const, raw.route.by)
182
+ if (!by) throw new DropStoreError('invalid_drop', 400)
183
+ drop.route = { by }
184
+ if (typeof raw.route.p === 'number' && raw.route.p >= 0 && raw.route.p <= 1) drop.route.p = raw.route.p
185
+ if (typeof raw.route.reason === 'string' && /^[a-z_]{1,64}$/.test(raw.route.reason)) drop.route.reason = raw.route.reason
186
+ if (Number.isSafeInteger(raw.route.tokens) && (raw.route.tokens as number) >= 0) drop.route.tokens = raw.route.tokens as number
187
+ if (raw.route.provider === 'jev' || raw.route.provider === 'subq') drop.route.provider = raw.route.provider
188
+ }
189
+ if (raw.choices !== undefined) {
190
+ if (!Array.isArray(raw.choices) || raw.choices.length > DROP_LIMITS.choices) throw new DropStoreError('invalid_drop', 400)
191
+ drop.choices = raw.choices.map(parseChoice)
192
+ }
193
+ if (raw.hold !== undefined) {
194
+ const h = raw.hold
195
+ const sessionId = record(h) ? str(h.sessionId, 200) : null, label = record(h) ? str(h.label, DROP_LIMITS.label) : null
196
+ if (!sessionId || !LINK_ID.test(sessionId) || label === null) throw new DropStoreError('invalid_drop', 400)
197
+ drop.hold = { sessionId, label, ...(record(h) && iso(h.sentAt) ? { sentAt: iso(h.sentAt)! } : {}) }
198
+ }
199
+ if (raw.remind !== undefined) {
200
+ const r = raw.remind
201
+ if (!record(r)) throw new DropStoreError('invalid_drop', 400)
202
+ const at = isoOrNull(r.at), firedAt = isoOrNull(r.firedAt), ackAt = isoOrNull(r.ackAt), tz = str(r.tz, 64)
203
+ if (at === undefined || firedAt === undefined || ackAt === undefined || !tz || !Number.isSafeInteger(r.snoozes) || (r.snoozes as number) < 0) throw new DropStoreError('invalid_drop', 400)
204
+ drop.remind = { at, tz, firedAt, ackAt, snoozes: r.snoozes as number }
205
+ }
206
+ if (raw.file !== undefined) {
207
+ const f = raw.file
208
+ if (!record(f)) throw new DropStoreError('invalid_drop', 400)
209
+ const store = str(f.store, 64), key = str(f.key, 512), fileID = str(f.fileID, 200), sha256 = str(f.sha256, 64), displayName = str(f.displayName, 255)
210
+ if (!store || !key || !fileID || !sha256 || !/^[a-f0-9]{64}$/.test(sha256) || !displayName) throw new DropStoreError('invalid_drop', 400)
211
+ drop.file = { store, key, fileID, sha256, displayName }
212
+ }
213
+ if (raw.attachments !== undefined) {
214
+ if (!Array.isArray(raw.attachments) || raw.attachments.length > DROP_LIMITS.attachments) throw new DropStoreError('invalid_drop', 400)
215
+ drop.attachments = raw.attachments.map(parseAttachment)
216
+ }
217
+ if (raw.ask !== undefined) {
218
+ const a = raw.ask
219
+ if (!record(a) || typeof a.clientJobId !== 'string' || !DROP_ID.test(a.clientJobId.toLowerCase()) || !Number.isSafeInteger(a.generation) || (a.generation as number) < 1) throw new DropStoreError('invalid_drop', 400)
220
+ drop.ask = { clientJobId: a.clientJobId.toLowerCase(), generation: a.generation as number, ...(iso(a.answeredAt) ? { answeredAt: iso(a.answeredAt)! } : {}) }
221
+ }
222
+ if (raw.hint !== undefined) {
223
+ const sessionId = record(raw.hint) ? str(raw.hint.sessionId, 200) : null
224
+ if (!sessionId || !LINK_ID.test(sessionId)) throw new DropStoreError('invalid_drop', 400)
225
+ drop.hint = { sessionId }
226
+ }
227
+ for (const entry of raw.timeline.slice(-DROP_LIMITS.timeline)) {
228
+ if (!record(entry)) throw new DropStoreError('invalid_drop', 400)
229
+ const at = iso(entry.at), st = oneOf(DROP_STATES, entry.state)
230
+ if (!at || !st) throw new DropStoreError('invalid_drop', 400)
231
+ const row: DropTimelineEntry = { at, state: st }
232
+ const d = oneOf(DROP_DESTS, entry.dest); if (d) row.dest = d
233
+ if (typeof entry.note === 'string' && /^[a-z_]{1,64}$/.test(entry.note)) row.note = entry.note
234
+ drop.timeline.push(row)
235
+ }
236
+ return drop
237
+ }
238
+
239
+ export function parseFeedItem(raw: unknown): FeedItem {
240
+ if (!record(raw)) throw new DropStoreError('invalid_feed_item', 400)
241
+ const id = typeof raw.id === 'string' && FEED_ID.test(raw.id) ? raw.id : null
242
+ const kind = oneOf(FEED_KINDS, raw.kind), threadId = str(raw.threadId, 200), createdAt = iso(raw.createdAt)
243
+ const readAt = isoOrNull(raw.readAt)
244
+ const source = raw.source === 'cos' ? 'cos' as const : oneOf(DROP_SOURCES, raw.source)
245
+ const line = str(raw.line, DROP_LIMITS.line, true), key = str(raw.key, 300)
246
+ const state = raw.state === 'live' || raw.state === 'done' ? raw.state : null
247
+ if (!id || !kind || !threadId || !LINK_ID.test(threadId) || !createdAt || readAt === undefined || !source || line === null || !key || !state) throw new DropStoreError('invalid_feed_item', 400)
248
+ if (!Array.isArray(raw.links) || raw.links.length > DROP_LIMITS.links || !Array.isArray(raw.actions) || raw.actions.length > 6) throw new DropStoreError('invalid_feed_item', 400)
249
+ const actions = raw.actions.map(a => {
250
+ const aid = record(a) ? str(a.id, 40) : null, label = record(a) ? str(a.label, 80) : null
251
+ if (!aid || !/^[a-z0-9_:-]{1,40}$/.test(aid) || !label) throw new DropStoreError('invalid_feed_item', 400)
252
+ return { id: aid, label }
253
+ })
254
+ const item: FeedItem = { id, kind, threadId, createdAt, readAt, source, line, links: raw.links.map(parseLink), actions, state, key }
255
+ if (raw.title !== undefined) { const t = str(raw.title, DROP_LIMITS.title, true); if (t === null) throw new DropStoreError('invalid_feed_item', 400); item.title = t }
256
+ if (raw.undoUntil !== undefined) { const u = isoOrNull(raw.undoUntil); if (u === undefined) throw new DropStoreError('invalid_feed_item', 400); item.undoUntil = u }
257
+ if (typeof raw.needsReply === 'boolean') item.needsReply = raw.needsReply
258
+ return item
259
+ }
260
+
261
+ /** A 0700 folder owned by this user, then the writer lock, then the journal (or nothing yet). */
262
+ function openJournal(root: string, file: string, label: string): { path: string; lock: ServerInstanceLock; journal: Record<string, unknown> | null } {
263
+ const dir = resolve(root)
264
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
265
+ const stat = lstatSync(dir)
266
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new DropStoreError(`${label}_store_unsafe`, 503)
267
+ const path = join(dir, file)
268
+ let lock: ServerInstanceLock
269
+ try { lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
270
+ catch { throw new DropStoreError(`${label}_store_locked`, 503) }
271
+ if (!existsSync(path)) return { path, lock, journal: null }
272
+ try {
273
+ const st = lstatSync(path)
274
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o077) || st.size > DROP_LIMITS.fileBytes) throw new Error('unsafe')
275
+ const journal = JSON.parse(readFileSync(path, 'utf8'))
276
+ if (!record(journal) || journal.version !== 1) throw new Error('version')
277
+ return { path, lock, journal }
278
+ } catch {
279
+ lock.release()
280
+ throw new DropStoreError(`${label}_store_recovery_required`, 503)
281
+ }
282
+ }
283
+
284
+ function readRows<T>(journal: Record<string, unknown> | null, key: string, parse: (raw: unknown) => T, id: (row: T) => string, label: string): { rows: T[]; quarantined: unknown[] } {
285
+ const rows: T[] = [], quarantined: unknown[] = []
286
+ if (!journal) return { rows, quarantined }
287
+ const list = journal[key]
288
+ if (!Array.isArray(list)) throw new DropStoreError(`${label}_store_recovery_required`, 503)
289
+ const seen = new Set<string>()
290
+ for (const raw of list) {
291
+ try {
292
+ const row = parse(raw)
293
+ if (seen.has(id(row))) throw new Error('duplicate')
294
+ seen.add(id(row)); rows.push(row)
295
+ } catch { quarantined.push(raw) } // one bad row never takes the store down
296
+ }
297
+ if (Array.isArray(journal.quarantined)) quarantined.push(...journal.quarantined)
298
+ return { rows, quarantined: quarantined.slice(-DROP_LIMITS.quarantineKept) }
299
+ }
300
+
301
+ export class DropStore {
302
+ private rows: DropRecord[] = []
303
+ private quarantined: unknown[] = []
304
+ private readonly path: string
305
+ private readonly lock: ServerInstanceLock
306
+ private closed = false
307
+ constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
308
+ const opened = openJournal(root, 'drops.json', 'drop')
309
+ this.path = opened.path; this.lock = opened.lock
310
+ try {
311
+ const read = readRows(opened.journal, 'drops', parseDropRecord, r => r.id, 'drop')
312
+ this.rows = read.rows; this.quarantined = read.quarantined
313
+ } catch (e) { this.close(); throw e }
314
+ if (this.quarantined.length) console.warn(`[drops] ${this.quarantined.length} unreadable drop record(s) set aside`)
315
+ }
316
+ list(): DropRecord[] { return structuredClone(this.rows) }
317
+ get(id: string): DropRecord | undefined { const row = this.rows.find(r => r.id === id); return row ? structuredClone(row) : undefined }
318
+ /** Copies of the matching rows only (a poll that needs a few rows does not copy them all). */
319
+ select(match: (row: Readonly<DropRecord>) => boolean): DropRecord[] { return this.rows.filter(match).map(r => structuredClone(r)) }
320
+ ids(): Set<string> { return new Set(this.rows.map(r => r.id)) }
321
+ quarantineCount(): number { return this.quarantined.length }
322
+
323
+ /** Insert a new drop. A repeated id returns the stored one unchanged (idempotent on the drop id). */
324
+ insert(drop: DropRecord): { drop: DropRecord; inserted: boolean } {
325
+ const existing = this.get(drop.id)
326
+ if (existing) return { drop: existing, inserted: false }
327
+ const parsed = parseDropRecord(drop)
328
+ this.write([parsed, ...this.rows])
329
+ return { drop: structuredClone(parsed), inserted: true }
330
+ }
331
+
332
+ /** Replace one drop with `fn(current)`. Validated before it is written. */
333
+ update(id: string, fn: (current: DropRecord) => DropRecord): DropRecord {
334
+ const index = this.rows.findIndex(r => r.id === id)
335
+ if (index < 0) throw new DropStoreError('drop_not_found', 404)
336
+ const next = parseDropRecord({ ...fn(structuredClone(this.rows[index])), updatedAt: this.now().toISOString() })
337
+ if (next.id !== id) throw new DropStoreError('invalid_drop', 400)
338
+ const rows = [...this.rows]; rows[index] = next
339
+ this.write(rows)
340
+ return structuredClone(next)
341
+ }
342
+
343
+ private write(rows: DropRecord[]): void {
344
+ if (this.closed) throw new DropStoreError('drop_store_closed', 503)
345
+ const now = this.now().getTime()
346
+ const kept = rows.filter(r => {
347
+ const age = now - Date.parse(r.updatedAt)
348
+ return r.state === 'undone' ? age <= DROP_LIMITS.keepUndoneDays * 86_400_000 : age <= DROP_LIMITS.keepDropDays * 86_400_000
349
+ })
350
+ if (kept.length > DROP_LIMITS.drops) throw new DropStoreError('drop_store_full', 503)
351
+ // Never write a file the reader would refuse (it reads at most maxBytes): undone rows go first, oldest first.
352
+ let body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
353
+ while (Buffer.byteLength(body) > this.maxBytes) {
354
+ let oldest = -1
355
+ kept.forEach((r, i) => { if (r.state === 'undone' && (oldest < 0 || r.updatedAt < kept[oldest].updatedAt)) oldest = i })
356
+ if (oldest < 0) throw new DropStoreError('drop_store_full', 503)
357
+ kept.splice(oldest, 1)
358
+ body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
359
+ }
360
+ durableAtomicWriteFileSync(this.path, body)
361
+ this.rows = kept
362
+ }
363
+ close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
364
+ }
365
+
366
+ export class InboxFeedStore {
367
+ private items: FeedItem[] = []
368
+ private quarantined: unknown[] = []
369
+ private versionNo = 0
370
+ private readonly path: string
371
+ private readonly lock: ServerInstanceLock
372
+ private closed = false
373
+ constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
374
+ const opened = openJournal(root, 'feed.json', 'inbox')
375
+ this.path = opened.path; this.lock = opened.lock
376
+ try {
377
+ const read = readRows(opened.journal, 'items', parseFeedItem, i => i.id, 'inbox')
378
+ this.items = read.rows; this.quarantined = read.quarantined
379
+ const v = opened.journal?.feedVersion
380
+ this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
381
+ } catch (e) { this.close(); throw e }
382
+ if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
383
+ }
384
+ /** Oldest first, as a chat reads. */
385
+ list(): FeedItem[] { return structuredClone(this.items) }
386
+ version(): number { return this.versionNo }
387
+ byKey(key: string): FeedItem | undefined { const item = this.items.find(i => i.key === key); return item ? structuredClone(item) : undefined }
388
+ get(id: string): FeedItem | undefined { const item = this.items.find(i => i.id === id); return item ? structuredClone(item) : undefined }
389
+ quarantineCount(): number { return this.quarantined.length }
390
+
391
+ /**
392
+ * Insert under the exactly-once key (plan section 4). The key is checked and the item written in one synchronous
393
+ * step, so two callers can never both insert it. A repeat returns the first item.
394
+ */
395
+ insert(items: FeedItem[]): FeedItem[] {
396
+ const out: FeedItem[] = [], add: FeedItem[] = []
397
+ for (const raw of items) {
398
+ const existing = this.items.find(i => i.key === raw.key) ?? add.find(i => i.key === raw.key)
399
+ if (existing) { out.push(structuredClone(existing)); continue }
400
+ const item = parseFeedItem(raw)
401
+ add.push(item); out.push(structuredClone(item))
402
+ }
403
+ if (add.length) this.write([...this.items, ...add], add.length)
404
+ return out
405
+ }
406
+
407
+ /** Remove the items `drop` answers true for (the drops store let their drop go). Writes only when one goes. */
408
+ purge(drop: (item: Readonly<FeedItem>) => boolean): number {
409
+ const kept = this.items.filter(i => !drop(i))
410
+ const removed = this.items.length - kept.length
411
+ if (removed) this.write(kept, 1)
412
+ return removed
413
+ }
414
+
415
+ update(ids: ReadonlySet<string>, fn: (item: FeedItem) => FeedItem): FeedItem[] {
416
+ let changed = 0
417
+ const next = this.items.map(item => {
418
+ if (!ids.has(item.id)) return item
419
+ const updated = parseFeedItem(fn(structuredClone(item)))
420
+ if (updated.id !== item.id || updated.key !== item.key) throw new DropStoreError('invalid_feed_item', 400)
421
+ changed++
422
+ return updated
423
+ })
424
+ if (changed) this.write(next, 1)
425
+ return this.items.filter(i => ids.has(i.id)).map(i => structuredClone(i))
426
+ }
427
+
428
+ private write(items: FeedItem[], bump: number): void {
429
+ if (this.closed) throw new DropStoreError('inbox_store_closed', 503)
430
+ const now = this.now().getTime()
431
+ const kept = items.filter(i => {
432
+ const age = now - Date.parse(i.createdAt)
433
+ return i.kind === 'drop' ? age <= DROP_LIMITS.keepDropDays * 86_400_000 : age <= DROP_LIMITS.keepReceiptDays * 86_400_000
434
+ })
435
+ if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
436
+ const version = this.versionNo + bump
437
+ // Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
438
+ const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
439
+ let body = serialize()
440
+ while (Buffer.byteLength(body) > this.maxBytes) {
441
+ const oldest = kept.findIndex(i => i.kind !== 'drop')
442
+ if (oldest < 0) throw new DropStoreError('inbox_store_full', 503)
443
+ kept.splice(oldest, 1)
444
+ body = serialize()
445
+ }
446
+ durableAtomicWriteFileSync(this.path, body)
447
+ this.items = kept; this.versionNo = version
448
+ }
449
+ close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
450
+ }