@gotcos/glasses-server 6.98.0 → 6.99.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,45 @@
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
+
14
+ export const DROP_IT_PROTOCOL = 1
15
+ export const DROP_IT_SETTINGS_FILE = () => dataPath('drop-it-settings.json')
16
+
17
+ export function dropItSetting(file = DROP_IT_SETTINGS_FILE()): boolean {
18
+ try {
19
+ const stat = lstatSync(file)
20
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 4096) return false
21
+ const value = JSON.parse(readFileSync(file, 'utf8')) as { enabled?: unknown }
22
+ return value?.enabled === true
23
+ } catch { return false }
24
+ }
25
+ export function dropItEnvValue(env: NodeJS.ProcessEnv = process.env): boolean | null {
26
+ const raw = env.COS_DROP_IT
27
+ if (raw === undefined || raw.trim() === '') return null
28
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())
29
+ }
30
+ export function dropItEnabled(env: NodeJS.ProcessEnv = process.env, file = DROP_IT_SETTINGS_FILE()): boolean {
31
+ return dropItEnvValue(env) ?? dropItSetting(file)
32
+ }
33
+ export function dropItSource(env: NodeJS.ProcessEnv = process.env): 'env' | 'settings' {
34
+ return dropItEnvValue(env) === null ? 'settings' : 'env'
35
+ }
36
+ export function writeDropItSetting(enabled: boolean, file = DROP_IT_SETTINGS_FILE()): void {
37
+ securePrivateDirectory(dirname(file))
38
+ durableAtomicWriteFileSync(file, JSON.stringify({ enabled }) + '\n', { mode: 0o600 })
39
+ }
40
+
41
+ /** The capability block for /api/health and /api/models. Codes only: no counts, no words. Additive. */
42
+ export function dropItCapability(env: NodeJS.ProcessEnv = process.env): { protocol: number; enabled: boolean; source: 'env' | 'settings'; p2Settings: number } {
43
+ // p2Settings (6.99.11): GET/PUT /api/drops/settings/p2 exists. Clients that do not see it hide the P2 controls.
44
+ return { protocol: DROP_IT_PROTOCOL, enabled: dropItEnabled(env), source: dropItSource(env), p2Settings: 1 }
45
+ }
@@ -0,0 +1,428 @@
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
+ export interface DropRemind { at: string | null; tz: string; firedAt: string | null; ackAt: string | null; snoozes: number }
49
+ export interface DropTimelineEntry { at: string; state: DropState; dest?: DropDest; note?: string }
50
+ export interface DropAsk { clientJobId: string; generation: number; answeredAt?: string }
51
+
52
+ export interface DropRecord {
53
+ id: string
54
+ text: string
55
+ source: DropSource
56
+ createdAt: string
57
+ updatedAt: string
58
+ state: DropState
59
+ dest: DropDest | null
60
+ kind: DropKind
61
+ links: DropLink[]
62
+ route: DropRoute | null
63
+ choices?: DropChoice[]
64
+ hold?: { sessionId: string; label: string; sentAt?: string }
65
+ remind?: DropRemind
66
+ file?: DropFileRef
67
+ ask?: DropAsk
68
+ hint?: { sessionId: string }
69
+ /** Undo and Move work until this instant (24 hours after filing). */
70
+ undoUntil: string | null
71
+ timeline: DropTimelineEntry[]
72
+ }
73
+
74
+ export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'answer'] as const
75
+ export type FeedKind = typeof FEED_KINDS[number]
76
+ export interface FeedAction { id: string; label: string }
77
+ export interface FeedItem {
78
+ id: string
79
+ kind: FeedKind
80
+ /** The drop id, or the ping's own id. */
81
+ threadId: string
82
+ createdAt: string
83
+ readAt: string | null
84
+ source: DropSource | 'cos'
85
+ title?: string
86
+ line: string
87
+ links: DropLink[]
88
+ actions: FeedAction[]
89
+ /** live: its buttons work; done: answered or superseded (buttons gone). */
90
+ state: 'live' | 'done'
91
+ undoUntil?: string | null
92
+ /** Exactly-once key: a second insert with the same key returns the first item. */
93
+ key: string
94
+ /**
95
+ * 6.99.10 (plan section 12): this item waits on an answer from you (a ping or follow-up that needs a decision). It
96
+ * counts toward `waiting` on GET /api/inbox/summary while it is live, and stops counting when it is answered, done or
97
+ * dismissed. Kept only when it is a real boolean; an older row without it is valid and does not wait.
98
+ */
99
+ needsReply?: boolean
100
+ }
101
+
102
+ export const DROP_LIMITS = {
103
+ text: 2_000, label: 200, line: 2_000, title: 200, links: 12, choices: 3, timeline: 40,
104
+ drops: 20_000, feed: 20_000, fileBytes: 32_000_000, quarantineKept: 500,
105
+ /** Retention (plan section 6): drop records and links a year; undone drops and chat receipts 30 days. */
106
+ keepDropDays: 365, keepUndoneDays: 30, keepReceiptDays: 30,
107
+ undoMs: 24 * 60 * 60_000,
108
+ } as const
109
+
110
+ 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}$/
111
+ export const FEED_ID = /^fi_[a-f0-9]{32}$/
112
+ const LINK_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
113
+ const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
114
+
115
+ const str = (v: unknown, max: number, allowNewlines = false): string | null =>
116
+ typeof v === 'string' && v.length <= max && (allowNewlines ? !CONTROL.test(v) : !/[\x00-\x1f\x7f]/.test(v)) ? v : null
117
+ const iso = (v: unknown): string | null => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v)) ? v : null
118
+ const isoOrNull = (v: unknown): string | null | undefined => v === null ? null : iso(v) ?? undefined
119
+ 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
120
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
121
+
122
+ export function parseLink(raw: unknown): DropLink {
123
+ if (!record(raw)) throw new DropStoreError('invalid_link', 400)
124
+ const kind = oneOf(LINK_KINDS, raw.kind), id = str(raw.id, 200), label = str(raw.label, DROP_LIMITS.label)
125
+ if (!kind || !id || !LINK_ID.test(id) || label === null) throw new DropStoreError('invalid_link', 400)
126
+ const link: DropLink = { kind, id, label }
127
+ if (raw.domain !== undefined) { const d = str(raw.domain, 64); if (!d) throw new DropStoreError('invalid_link', 400); link.domain = d }
128
+ 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 }
129
+ if (raw.created !== undefined) { if (typeof raw.created !== 'boolean') throw new DropStoreError('invalid_link', 400); link.created = raw.created }
130
+ return link
131
+ }
132
+
133
+ function parseChoice(raw: unknown): DropChoice {
134
+ if (!record(raw)) throw new DropStoreError('invalid_choice', 400)
135
+ 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)
136
+ if (!id || !/^[a-z0-9_]{1,16}$/.test(id) || !dest || label === null || detail === null) throw new DropStoreError('invalid_choice', 400)
137
+ const choice: DropChoice = { id, dest, label, detail }
138
+ if (raw.target !== undefined) {
139
+ const t = raw.target
140
+ if (!record(t)) throw new DropStoreError('invalid_choice', 400)
141
+ const tid = str(t.id, 200), tlabel = str(t.label, DROP_LIMITS.label)
142
+ if (!tid || !LINK_ID.test(tid) || tlabel === null) throw new DropStoreError('invalid_choice', 400)
143
+ choice.target = { id: tid, label: tlabel, ...(typeof t.domain === 'string' && t.domain.length <= 64 ? { domain: t.domain } : {}) }
144
+ }
145
+ return choice
146
+ }
147
+
148
+ /** Validate one drop record from the journal. Unknown fields are dropped, never trusted. */
149
+ export function parseDropRecord(raw: unknown): DropRecord {
150
+ if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
151
+ const id = typeof raw.id === 'string' && DROP_ID.test(raw.id) ? raw.id : null
152
+ const text = str(raw.text, DROP_LIMITS.text, true)
153
+ const source = oneOf(DROP_SOURCES, raw.source), state = oneOf(DROP_STATES, raw.state), kind = oneOf(DROP_KINDS, raw.kind)
154
+ const createdAt = iso(raw.createdAt), updatedAt = iso(raw.updatedAt)
155
+ const dest = raw.dest === null ? null : oneOf(DROP_DESTS, raw.dest)
156
+ if (!id || !text || !text.trim() || !source || !state || !kind || !createdAt || !updatedAt || (raw.dest !== null && !dest)) throw new DropStoreError('invalid_drop', 400)
157
+ if (!Array.isArray(raw.links) || raw.links.length > DROP_LIMITS.links || !Array.isArray(raw.timeline)) throw new DropStoreError('invalid_drop', 400)
158
+ const undoUntil = raw.undoUntil === null ? null : iso(raw.undoUntil)
159
+ if (raw.undoUntil !== null && !undoUntil) throw new DropStoreError('invalid_drop', 400)
160
+ const drop: DropRecord = { id, text, source, createdAt, updatedAt, state, dest, kind, links: raw.links.map(parseLink),
161
+ route: null, undoUntil, timeline: [] }
162
+ if (record(raw.route)) {
163
+ const by = oneOf(['rule', 'jev', 'user', 'fallback'] as const, raw.route.by)
164
+ if (!by) throw new DropStoreError('invalid_drop', 400)
165
+ drop.route = { by }
166
+ if (typeof raw.route.p === 'number' && raw.route.p >= 0 && raw.route.p <= 1) drop.route.p = raw.route.p
167
+ if (typeof raw.route.reason === 'string' && /^[a-z_]{1,64}$/.test(raw.route.reason)) drop.route.reason = raw.route.reason
168
+ if (Number.isSafeInteger(raw.route.tokens) && (raw.route.tokens as number) >= 0) drop.route.tokens = raw.route.tokens as number
169
+ if (raw.route.provider === 'jev' || raw.route.provider === 'subq') drop.route.provider = raw.route.provider
170
+ }
171
+ if (raw.choices !== undefined) {
172
+ if (!Array.isArray(raw.choices) || raw.choices.length > DROP_LIMITS.choices) throw new DropStoreError('invalid_drop', 400)
173
+ drop.choices = raw.choices.map(parseChoice)
174
+ }
175
+ if (raw.hold !== undefined) {
176
+ const h = raw.hold
177
+ const sessionId = record(h) ? str(h.sessionId, 200) : null, label = record(h) ? str(h.label, DROP_LIMITS.label) : null
178
+ if (!sessionId || !LINK_ID.test(sessionId) || label === null) throw new DropStoreError('invalid_drop', 400)
179
+ drop.hold = { sessionId, label, ...(record(h) && iso(h.sentAt) ? { sentAt: iso(h.sentAt)! } : {}) }
180
+ }
181
+ if (raw.remind !== undefined) {
182
+ const r = raw.remind
183
+ if (!record(r)) throw new DropStoreError('invalid_drop', 400)
184
+ const at = isoOrNull(r.at), firedAt = isoOrNull(r.firedAt), ackAt = isoOrNull(r.ackAt), tz = str(r.tz, 64)
185
+ if (at === undefined || firedAt === undefined || ackAt === undefined || !tz || !Number.isSafeInteger(r.snoozes) || (r.snoozes as number) < 0) throw new DropStoreError('invalid_drop', 400)
186
+ drop.remind = { at, tz, firedAt, ackAt, snoozes: r.snoozes as number }
187
+ }
188
+ if (raw.file !== undefined) {
189
+ const f = raw.file
190
+ if (!record(f)) throw new DropStoreError('invalid_drop', 400)
191
+ 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)
192
+ if (!store || !key || !fileID || !sha256 || !/^[a-f0-9]{64}$/.test(sha256) || !displayName) throw new DropStoreError('invalid_drop', 400)
193
+ drop.file = { store, key, fileID, sha256, displayName }
194
+ }
195
+ if (raw.ask !== undefined) {
196
+ const a = raw.ask
197
+ 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)
198
+ drop.ask = { clientJobId: a.clientJobId.toLowerCase(), generation: a.generation as number, ...(iso(a.answeredAt) ? { answeredAt: iso(a.answeredAt)! } : {}) }
199
+ }
200
+ if (raw.hint !== undefined) {
201
+ const sessionId = record(raw.hint) ? str(raw.hint.sessionId, 200) : null
202
+ if (!sessionId || !LINK_ID.test(sessionId)) throw new DropStoreError('invalid_drop', 400)
203
+ drop.hint = { sessionId }
204
+ }
205
+ for (const entry of raw.timeline.slice(-DROP_LIMITS.timeline)) {
206
+ if (!record(entry)) throw new DropStoreError('invalid_drop', 400)
207
+ const at = iso(entry.at), st = oneOf(DROP_STATES, entry.state)
208
+ if (!at || !st) throw new DropStoreError('invalid_drop', 400)
209
+ const row: DropTimelineEntry = { at, state: st }
210
+ const d = oneOf(DROP_DESTS, entry.dest); if (d) row.dest = d
211
+ if (typeof entry.note === 'string' && /^[a-z_]{1,64}$/.test(entry.note)) row.note = entry.note
212
+ drop.timeline.push(row)
213
+ }
214
+ return drop
215
+ }
216
+
217
+ export function parseFeedItem(raw: unknown): FeedItem {
218
+ if (!record(raw)) throw new DropStoreError('invalid_feed_item', 400)
219
+ const id = typeof raw.id === 'string' && FEED_ID.test(raw.id) ? raw.id : null
220
+ const kind = oneOf(FEED_KINDS, raw.kind), threadId = str(raw.threadId, 200), createdAt = iso(raw.createdAt)
221
+ const readAt = isoOrNull(raw.readAt)
222
+ const source = raw.source === 'cos' ? 'cos' as const : oneOf(DROP_SOURCES, raw.source)
223
+ const line = str(raw.line, DROP_LIMITS.line, true), key = str(raw.key, 300)
224
+ const state = raw.state === 'live' || raw.state === 'done' ? raw.state : null
225
+ if (!id || !kind || !threadId || !LINK_ID.test(threadId) || !createdAt || readAt === undefined || !source || line === null || !key || !state) throw new DropStoreError('invalid_feed_item', 400)
226
+ 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)
227
+ const actions = raw.actions.map(a => {
228
+ const aid = record(a) ? str(a.id, 40) : null, label = record(a) ? str(a.label, 80) : null
229
+ if (!aid || !/^[a-z0-9_:-]{1,40}$/.test(aid) || !label) throw new DropStoreError('invalid_feed_item', 400)
230
+ return { id: aid, label }
231
+ })
232
+ const item: FeedItem = { id, kind, threadId, createdAt, readAt, source, line, links: raw.links.map(parseLink), actions, state, key }
233
+ 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 }
234
+ if (raw.undoUntil !== undefined) { const u = isoOrNull(raw.undoUntil); if (u === undefined) throw new DropStoreError('invalid_feed_item', 400); item.undoUntil = u }
235
+ if (typeof raw.needsReply === 'boolean') item.needsReply = raw.needsReply
236
+ return item
237
+ }
238
+
239
+ /** A 0700 folder owned by this user, then the writer lock, then the journal (or nothing yet). */
240
+ function openJournal(root: string, file: string, label: string): { path: string; lock: ServerInstanceLock; journal: Record<string, unknown> | null } {
241
+ const dir = resolve(root)
242
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
243
+ const stat = lstatSync(dir)
244
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new DropStoreError(`${label}_store_unsafe`, 503)
245
+ const path = join(dir, file)
246
+ let lock: ServerInstanceLock
247
+ try { lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
248
+ catch { throw new DropStoreError(`${label}_store_locked`, 503) }
249
+ if (!existsSync(path)) return { path, lock, journal: null }
250
+ try {
251
+ const st = lstatSync(path)
252
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o077) || st.size > DROP_LIMITS.fileBytes) throw new Error('unsafe')
253
+ const journal = JSON.parse(readFileSync(path, 'utf8'))
254
+ if (!record(journal) || journal.version !== 1) throw new Error('version')
255
+ return { path, lock, journal }
256
+ } catch {
257
+ lock.release()
258
+ throw new DropStoreError(`${label}_store_recovery_required`, 503)
259
+ }
260
+ }
261
+
262
+ function readRows<T>(journal: Record<string, unknown> | null, key: string, parse: (raw: unknown) => T, id: (row: T) => string, label: string): { rows: T[]; quarantined: unknown[] } {
263
+ const rows: T[] = [], quarantined: unknown[] = []
264
+ if (!journal) return { rows, quarantined }
265
+ const list = journal[key]
266
+ if (!Array.isArray(list)) throw new DropStoreError(`${label}_store_recovery_required`, 503)
267
+ const seen = new Set<string>()
268
+ for (const raw of list) {
269
+ try {
270
+ const row = parse(raw)
271
+ if (seen.has(id(row))) throw new Error('duplicate')
272
+ seen.add(id(row)); rows.push(row)
273
+ } catch { quarantined.push(raw) } // one bad row never takes the store down
274
+ }
275
+ if (Array.isArray(journal.quarantined)) quarantined.push(...journal.quarantined)
276
+ return { rows, quarantined: quarantined.slice(-DROP_LIMITS.quarantineKept) }
277
+ }
278
+
279
+ export class DropStore {
280
+ private rows: DropRecord[] = []
281
+ private quarantined: unknown[] = []
282
+ private readonly path: string
283
+ private readonly lock: ServerInstanceLock
284
+ private closed = false
285
+ constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
286
+ const opened = openJournal(root, 'drops.json', 'drop')
287
+ this.path = opened.path; this.lock = opened.lock
288
+ try {
289
+ const read = readRows(opened.journal, 'drops', parseDropRecord, r => r.id, 'drop')
290
+ this.rows = read.rows; this.quarantined = read.quarantined
291
+ } catch (e) { this.close(); throw e }
292
+ if (this.quarantined.length) console.warn(`[drops] ${this.quarantined.length} unreadable drop record(s) set aside`)
293
+ }
294
+ list(): DropRecord[] { return structuredClone(this.rows) }
295
+ get(id: string): DropRecord | undefined { const row = this.rows.find(r => r.id === id); return row ? structuredClone(row) : undefined }
296
+ /** Copies of the matching rows only (a poll that needs a few rows does not copy them all). */
297
+ select(match: (row: Readonly<DropRecord>) => boolean): DropRecord[] { return this.rows.filter(match).map(r => structuredClone(r)) }
298
+ ids(): Set<string> { return new Set(this.rows.map(r => r.id)) }
299
+ quarantineCount(): number { return this.quarantined.length }
300
+
301
+ /** Insert a new drop. A repeated id returns the stored one unchanged (idempotent on the drop id). */
302
+ insert(drop: DropRecord): { drop: DropRecord; inserted: boolean } {
303
+ const existing = this.get(drop.id)
304
+ if (existing) return { drop: existing, inserted: false }
305
+ const parsed = parseDropRecord(drop)
306
+ this.write([parsed, ...this.rows])
307
+ return { drop: structuredClone(parsed), inserted: true }
308
+ }
309
+
310
+ /** Replace one drop with `fn(current)`. Validated before it is written. */
311
+ update(id: string, fn: (current: DropRecord) => DropRecord): DropRecord {
312
+ const index = this.rows.findIndex(r => r.id === id)
313
+ if (index < 0) throw new DropStoreError('drop_not_found', 404)
314
+ const next = parseDropRecord({ ...fn(structuredClone(this.rows[index])), updatedAt: this.now().toISOString() })
315
+ if (next.id !== id) throw new DropStoreError('invalid_drop', 400)
316
+ const rows = [...this.rows]; rows[index] = next
317
+ this.write(rows)
318
+ return structuredClone(next)
319
+ }
320
+
321
+ private write(rows: DropRecord[]): void {
322
+ if (this.closed) throw new DropStoreError('drop_store_closed', 503)
323
+ const now = this.now().getTime()
324
+ const kept = rows.filter(r => {
325
+ const age = now - Date.parse(r.updatedAt)
326
+ return r.state === 'undone' ? age <= DROP_LIMITS.keepUndoneDays * 86_400_000 : age <= DROP_LIMITS.keepDropDays * 86_400_000
327
+ })
328
+ if (kept.length > DROP_LIMITS.drops) throw new DropStoreError('drop_store_full', 503)
329
+ // Never write a file the reader would refuse (it reads at most maxBytes): undone rows go first, oldest first.
330
+ let body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
331
+ while (Buffer.byteLength(body) > this.maxBytes) {
332
+ let oldest = -1
333
+ kept.forEach((r, i) => { if (r.state === 'undone' && (oldest < 0 || r.updatedAt < kept[oldest].updatedAt)) oldest = i })
334
+ if (oldest < 0) throw new DropStoreError('drop_store_full', 503)
335
+ kept.splice(oldest, 1)
336
+ body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
337
+ }
338
+ durableAtomicWriteFileSync(this.path, body)
339
+ this.rows = kept
340
+ }
341
+ close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
342
+ }
343
+
344
+ export class InboxFeedStore {
345
+ private items: FeedItem[] = []
346
+ private quarantined: unknown[] = []
347
+ private versionNo = 0
348
+ private readonly path: string
349
+ private readonly lock: ServerInstanceLock
350
+ private closed = false
351
+ constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
352
+ const opened = openJournal(root, 'feed.json', 'inbox')
353
+ this.path = opened.path; this.lock = opened.lock
354
+ try {
355
+ const read = readRows(opened.journal, 'items', parseFeedItem, i => i.id, 'inbox')
356
+ this.items = read.rows; this.quarantined = read.quarantined
357
+ const v = opened.journal?.feedVersion
358
+ this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
359
+ } catch (e) { this.close(); throw e }
360
+ if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
361
+ }
362
+ /** Oldest first, as a chat reads. */
363
+ list(): FeedItem[] { return structuredClone(this.items) }
364
+ version(): number { return this.versionNo }
365
+ byKey(key: string): FeedItem | undefined { const item = this.items.find(i => i.key === key); return item ? structuredClone(item) : undefined }
366
+ get(id: string): FeedItem | undefined { const item = this.items.find(i => i.id === id); return item ? structuredClone(item) : undefined }
367
+ quarantineCount(): number { return this.quarantined.length }
368
+
369
+ /**
370
+ * Insert under the exactly-once key (plan section 4). The key is checked and the item written in one synchronous
371
+ * step, so two callers can never both insert it. A repeat returns the first item.
372
+ */
373
+ insert(items: FeedItem[]): FeedItem[] {
374
+ const out: FeedItem[] = [], add: FeedItem[] = []
375
+ for (const raw of items) {
376
+ const existing = this.items.find(i => i.key === raw.key) ?? add.find(i => i.key === raw.key)
377
+ if (existing) { out.push(structuredClone(existing)); continue }
378
+ const item = parseFeedItem(raw)
379
+ add.push(item); out.push(structuredClone(item))
380
+ }
381
+ if (add.length) this.write([...this.items, ...add], add.length)
382
+ return out
383
+ }
384
+
385
+ /** Remove the items `drop` answers true for (the drops store let their drop go). Writes only when one goes. */
386
+ purge(drop: (item: Readonly<FeedItem>) => boolean): number {
387
+ const kept = this.items.filter(i => !drop(i))
388
+ const removed = this.items.length - kept.length
389
+ if (removed) this.write(kept, 1)
390
+ return removed
391
+ }
392
+
393
+ update(ids: ReadonlySet<string>, fn: (item: FeedItem) => FeedItem): FeedItem[] {
394
+ let changed = 0
395
+ const next = this.items.map(item => {
396
+ if (!ids.has(item.id)) return item
397
+ const updated = parseFeedItem(fn(structuredClone(item)))
398
+ if (updated.id !== item.id || updated.key !== item.key) throw new DropStoreError('invalid_feed_item', 400)
399
+ changed++
400
+ return updated
401
+ })
402
+ if (changed) this.write(next, 1)
403
+ return this.items.filter(i => ids.has(i.id)).map(i => structuredClone(i))
404
+ }
405
+
406
+ private write(items: FeedItem[], bump: number): void {
407
+ if (this.closed) throw new DropStoreError('inbox_store_closed', 503)
408
+ const now = this.now().getTime()
409
+ const kept = items.filter(i => {
410
+ const age = now - Date.parse(i.createdAt)
411
+ return i.kind === 'drop' ? age <= DROP_LIMITS.keepDropDays * 86_400_000 : age <= DROP_LIMITS.keepReceiptDays * 86_400_000
412
+ })
413
+ if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
414
+ const version = this.versionNo + bump
415
+ // Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
416
+ const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
417
+ let body = serialize()
418
+ while (Buffer.byteLength(body) > this.maxBytes) {
419
+ const oldest = kept.findIndex(i => i.kind !== 'drop')
420
+ if (oldest < 0) throw new DropStoreError('inbox_store_full', 503)
421
+ kept.splice(oldest, 1)
422
+ body = serialize()
423
+ }
424
+ durableAtomicWriteFileSync(this.path, body)
425
+ this.items = kept; this.versionNo = version
426
+ }
427
+ close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
428
+ }