@gotcos/glasses-server 6.46.0 → 6.47.0

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 (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * The four durable stores behind every merge action, and the one mutex that owns them
3
+ * (6.47.0, WS4).
4
+ *
5
+ * `.actions.json` what the engine did, and how to undo it
6
+ * `.suggestions.json` what it wants a person to decide
7
+ * `.tombstones.json` input pairs a person has refused; automation never revisits them
8
+ * `.engine-status.json` modes, runs, and the one-per-install first-run report
9
+ *
10
+ * ONE WRITER, ONE MUTEX. Five triggers feed the runner (an import page, two G2 finalization
11
+ * paths, orphan recovery, a 30 s tick) and three routes mutate the same files from HTTP.
12
+ * Read-modify-write on JSON from two of those at once loses one of them silently, which for
13
+ * `.tombstones.json` means a merge a person explicitly refused quietly comes back. Every
14
+ * mutation goes through `update()`, which serializes on one promise chain; nothing here
15
+ * writes a store outside it.
16
+ *
17
+ * IDS ARE DERIVED, NEVER MINTED. An action's id is a hash of its kind and its sorted
18
+ * canonical input ids, so the same merge proposed twice is the same id, and re-running the
19
+ * engine over an unchanged backlog cannot fill the store with duplicates of one decision.
20
+ *
21
+ * CANONICAL IDS. A G2 recording is its `sessionId`; a Fireflies meeting is its vendor id.
22
+ * The same two ids in both modes: an action recorded in imports mode and the same one in
23
+ * apply mode have to be the same action, or a person who switches modes sees their history
24
+ * duplicate itself.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto'
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { join } from 'node:path'
30
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
31
+ import { importsRoot } from './imported-meeting-library.js'
32
+ import type { MeetingEngineMode } from './meeting-engine-mode.js'
33
+ import { securePrivateDirectory } from './secure-user-config.js'
34
+
35
+ export const ACTIONS_FILENAME = '.actions.json'
36
+ export const SUGGESTIONS_FILENAME = '.suggestions.json'
37
+ export const TOMBSTONES_FILENAME = '.tombstones.json'
38
+ export const ENGINE_STATUS_FILENAME = '.engine-status.json'
39
+
40
+ /** Where a derived record is copied when Revert finds it edited since it was written. */
41
+ export const REVERTED_DIR_NAME = '.reverted'
42
+
43
+ export const STORE_SCHEMA = 1
44
+
45
+ /** Ceiling on remembered rows per store, oldest first. */
46
+ export const MAX_ACTIONS = 5_000
47
+ export const MAX_SUGGESTIONS = 5_000
48
+ export const MAX_TOMBSTONES = 5_000
49
+
50
+ /**
51
+ * Ceiling on remembered file hashes.
52
+ *
53
+ * The cache is rewritten to exactly what the last collected pass saw, so it prunes itself
54
+ * and this cap is a backstop against a pathological tree rather than the normal bound. It
55
+ * matters because this file is parsed on every status poll.
56
+ */
57
+ export const MAX_HASH_CACHE_ENTRIES = 20_000
58
+
59
+ export type ActionKind = 'merge' | 'split'
60
+ export type ActionTier = 'auto' | 'accepted_suggestion' | 'legacy_applied'
61
+ export type ActionState = 'pending' | 'applied' | 'failed' | 'revert_pending' | 'reverted'
62
+ /** Where the action's effect landed. `imports` writes records; `apply` drives the pipeline. */
63
+ export type ActionMode = 'imports' | 'apply'
64
+
65
+ export interface CanonicalInputs {
66
+ /** G2 `sessionId`s. */
67
+ sessionIds: string[]
68
+ /** Fireflies vendor ids. */
69
+ firefliesIds: string[]
70
+ }
71
+
72
+ export interface ActionOutput {
73
+ /** Absolute path in imports mode; operations-relative in apply mode. */
74
+ path: string
75
+ /** `blended:<h16>` or `imported:fireflies:<h16>` in imports mode; absent in apply mode. */
76
+ recordId?: string
77
+ sidecarPath?: string
78
+ }
79
+
80
+ /**
81
+ * Which way this action is being driven.
82
+ *
83
+ * WHY IT IS ON THE ROW AND NOT DERIVED FROM `state`. A failed revert is reset to `pending`
84
+ * so it can be retried, and `pending` is indistinguishable from "an apply that has not run
85
+ * yet". Deriving the direction from the state therefore turned every retried Undo into a
86
+ * Redo: the next drive spawned `--apply-merge-decision` on an action whose whole purpose was
87
+ * to take that apply back. The direction is decided once, when the action is created or when
88
+ * a Revert claims it, and survives every failure in between.
89
+ */
90
+ export type ActionDirection = 'apply' | 'revert'
91
+
92
+ /** What a pipeline child did on its way to failing. Diagnostics only; never a decision. */
93
+ export interface PipelineFailureDiagnostics {
94
+ /** Null when the child never ran, or was killed by a signal. */
95
+ code: number | null
96
+ signal: string | null
97
+ timedOut: boolean
98
+ elapsedMs: number
99
+ /** Trimmed tail of the child's stderr. Bounded; never meeting content. */
100
+ stderr?: string
101
+ /** The pipeline's own `COS_MERGE_DECISION_INVALID=` line, on exit 4. */
102
+ decisionInvalid?: string
103
+ /** Set when the child could not be spawned at all. */
104
+ spawnError?: string
105
+ }
106
+
107
+ export interface MergeActionRecord {
108
+ id: string
109
+ kind: ActionKind
110
+ tier: ActionTier
111
+ inputs: CanonicalInputs
112
+ /** The fingerprint key of the inputs when the action was decided. */
113
+ fingerprints: string
114
+ outputs: ActionOutput[]
115
+ /** sha256 of each output's markdown, in `outputs` order. */
116
+ outputSha256: string[]
117
+ state: ActionState
118
+ mode: ActionMode
119
+ /** Apply or revert. Durable, so a failed revert retries as a revert. */
120
+ direction?: ActionDirection
121
+ error?: string
122
+ /** What the last failing pipeline child did. Absent until one fails. */
123
+ diagnostics?: PipelineFailureDiagnostics
124
+ /** Epoch ms this action may next be driven. Set on a deferred pipeline apply. */
125
+ nextAt?: number
126
+ at: string
127
+ /** Bounded, so a pipeline that keeps failing does not retry forever. */
128
+ attempts?: number
129
+ /** For a split, which piece this output is. */
130
+ pieceIndex?: number
131
+ /** The parent the pipeline already merged this into, on a `legacy_applied` row. */
132
+ legacyParentPath?: string
133
+ }
134
+
135
+ /**
136
+ * The state a deferred or retried action of this direction goes back to.
137
+ *
138
+ * `pending` and `revert_pending` are the two waiting states, and which one an action waits
139
+ * in is the ONLY durable record of what the next drive should spawn.
140
+ */
141
+ export function pendingStateFor(direction: ActionDirection | undefined): ActionState {
142
+ return direction === 'revert' ? 'revert_pending' : 'pending'
143
+ }
144
+
145
+ /**
146
+ * The direction an action is being driven in.
147
+ *
148
+ * Rows written before 6.47.0's QA pass carry no `direction`, so the waiting state is the
149
+ * fallback: `revert_pending` could only have been reached through a Revert.
150
+ */
151
+ export function directionOf(action: Pick<MergeActionRecord, 'direction' | 'state'>): ActionDirection {
152
+ if (action.direction) return action.direction
153
+ return action.state === 'revert_pending' ? 'revert' : 'apply'
154
+ }
155
+
156
+ /** Both states an action can sit in while waiting to be driven. */
157
+ export function isWaitingState(state: ActionState): boolean {
158
+ return state === 'pending' || state === 'revert_pending'
159
+ }
160
+
161
+ export type SuggestionKind = 'merge' | 'would_merge' | 'split'
162
+ export type SuggestionState = 'open' | 'confirmed' | 'accepted' | 'dismissed'
163
+
164
+ export interface MergeSuggestionRecord {
165
+ id: string
166
+ kind: SuggestionKind
167
+ inputs: CanonicalInputs
168
+ fingerprints: string
169
+ evidence: { K1: number; K2: number; spans?: Array<{ startS: number; endS: number }> }
170
+ state: SuggestionState
171
+ decidedAt?: string
172
+ at: string
173
+ }
174
+
175
+ /** An unordered pair of canonical ids a person said do not belong together. */
176
+ export interface Tombstone {
177
+ pair: [string, string]
178
+ at: string
179
+ /** The action or suggestion the refusal came from. */
180
+ source?: string
181
+ }
182
+
183
+ export interface FirstRunReport {
184
+ startedAt: string
185
+ completedAt?: string
186
+ mode: MeetingEngineMode
187
+ scanned: number
188
+ auto: number
189
+ wouldMerge: number
190
+ suggested: number
191
+ none: number
192
+ alreadyMerged: number
193
+ deferredByCap: number
194
+ errors: number
195
+ }
196
+
197
+ /**
198
+ * What the clock band rejected this run, and how far off the anchors it dropped were.
199
+ *
200
+ * `PAIRING_CLOCK_BAND_S` is the one rule standing between a real merge and a neighbouring
201
+ * meeting's boilerplate, and it was chosen from 198 scored recordings. Nothing recorded how
202
+ * often it FIRES on this Mac's own data, so a band that is wrong here is invisible. These
203
+ * counts make it observable without keeping any meeting content.
204
+ */
205
+ export interface ClockBandStats {
206
+ /** Candidates that lost at least one anchor to the band. */
207
+ candidates: number
208
+ /** Anchors dropped, summed across candidates. */
209
+ anchorsDropped: number
210
+ /** Candidates the band reduced to zero shared anchors. */
211
+ candidatesZeroed: number
212
+ /** Largest absolute clock skew, in seconds, among the winners this run. */
213
+ maxWinnerSkewS: number
214
+ /** Median absolute clock skew, in seconds, among the winners this run. */
215
+ medianWinnerSkewS: number
216
+ }
217
+
218
+ export interface EngineRunSummary {
219
+ at: string
220
+ mode: MeetingEngineMode
221
+ trigger: string
222
+ scanned: number
223
+ auto: number
224
+ suggested: number
225
+ wouldMerge: number
226
+ none: number
227
+ alreadyMerged: number
228
+ deferredByCap: number
229
+ errors: number
230
+ /** Why the run did nothing, when it did nothing. */
231
+ skippedReason?: string
232
+ /** Inputs the collector refused, by reason code. */
233
+ inputsSkipped?: Record<string, number>
234
+ /** The clock band's effect this run. Absent when nothing was scored. */
235
+ clockBand?: ClockBandStats
236
+ }
237
+
238
+ /** One file's identity, so an unchanged file is never read a second time. */
239
+ export interface HashCacheEntry {
240
+ sha256: string
241
+ mtimeMs: number
242
+ size: number
243
+ }
244
+
245
+ /**
246
+ * What the last collected pass saw, so the next one can prove nothing changed.
247
+ *
248
+ * Principle 7 says the engine never blocks live capture. The pass that hashed every sidecar
249
+ * on the main thread broke that on its own: 817 MB of `.g2-chunks.json` read per pass, every
250
+ * six hours, in the same event loop as chunk writes. Both halves of the fix are here — the
251
+ * scan stamp lets a pass BAIL before collecting, and the hash cache means a pass that does
252
+ * collect only reads the files whose mtime or size moved.
253
+ */
254
+ export interface InputScanStamp {
255
+ newestMtimeMs: number
256
+ count: number
257
+ mode: MeetingEngineMode
258
+ }
259
+
260
+ export interface EngineStatusFile {
261
+ schema: number
262
+ /** Epoch ms this install first ran the engine. The D14 advisory boundary. */
263
+ engineInstalledAt?: number
264
+ /** Epoch ms the mode last moved to `apply`. The D14 boundary in apply mode. */
265
+ applyModeSince?: number
266
+ firstRun?: FirstRunReport
267
+ lastRun?: EngineRunSummary
268
+ /** Last answer from `sync_meetings.py --merge-engine-status`, and when. */
269
+ pipelineSees?: { mode: string; active: boolean; appliedActions: number } | null
270
+ pipelineSeenAt?: number
271
+ /** sha256 by absolute path, keyed on mtime and size. Rewritten to what the pass saw. */
272
+ hashCache?: Record<string, HashCacheEntry>
273
+ lastInputScan?: InputScanStamp
274
+ }
275
+
276
+ export interface ActionsStoreFile {
277
+ schema: number
278
+ actions: MergeActionRecord[]
279
+ suggestions: MergeSuggestionRecord[]
280
+ tombstones: Tombstone[]
281
+ status: EngineStatusFile
282
+ }
283
+
284
+ // ── Ids ───────────────────────────────────────────────────────────────────────
285
+
286
+ function hash16(input: string): string {
287
+ return createHash('sha256').update(input).digest('hex').slice(0, 16)
288
+ }
289
+
290
+ /**
291
+ * The canonical id list an action or suggestion hashes.
292
+ *
293
+ * Sorted, so the order the engine happened to visit inputs in cannot mint a second id for
294
+ * one decision. Joined with a comma because a G2 session id may itself contain a colon
295
+ * (`normalizeSessionId` allows it), and a separator that can appear inside a part is a
296
+ * separator that can collide.
297
+ */
298
+ export function canonicalIdList(inputs: CanonicalInputs): string[] {
299
+ return [
300
+ ...inputs.sessionIds.map(id => `g2:${id}`),
301
+ ...inputs.firefliesIds.map(id => `ff:${id}`),
302
+ ].sort()
303
+ }
304
+
305
+ export function actionIdFor(kind: string, inputs: CanonicalInputs): string {
306
+ return `a_${hash16(`${kind}:${canonicalIdList(inputs).join(',')}`)}`
307
+ }
308
+
309
+ export function suggestionIdFor(kind: string, inputs: CanonicalInputs): string {
310
+ return `s_${hash16(`${kind}:${canonicalIdList(inputs).join(',')}`)}`
311
+ }
312
+
313
+ // ── Tombstones ────────────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Every unordered pair in an input set.
317
+ *
318
+ * A tombstone is a PAIR and not a whole set, so that refusing "this capture is not that
319
+ * meeting" also blocks the three-way merge that would have swept the same wrong pair in with
320
+ * a second capture. Blocking only the exact set is how a refused merge comes back one
321
+ * recording later.
322
+ */
323
+ export function inputPairs(inputs: CanonicalInputs): Array<[string, string]> {
324
+ const ids = canonicalIdList(inputs)
325
+ // A set of ONE (a split of a single recording, a legacy adoption of a single capture) has
326
+ // no pair, and an empty pair list is a set nothing can ever block. It is remembered as its
327
+ // own self-pair instead, which blocks exactly the same single-input proposal and nothing
328
+ // wider — refusing "split this recording" must not also refuse merging it.
329
+ if (ids.length === 1) return [[ids[0], ids[0]]]
330
+ const pairs: Array<[string, string]> = []
331
+ for (let i = 0; i < ids.length; i++) {
332
+ for (let j = i + 1; j < ids.length; j++) pairs.push([ids[i], ids[j]])
333
+ }
334
+ return pairs
335
+ }
336
+
337
+ export function pairKey(pair: readonly [string, string]): string {
338
+ return [...pair].sort().join('|')
339
+ }
340
+
341
+ /** Does any pair in this input set carry a tombstone? */
342
+ export function isTombstoned(tombstones: readonly Tombstone[], inputs: CanonicalInputs): boolean {
343
+ if (tombstones.length === 0) return false
344
+ const blocked = new Set(tombstones.map(row => pairKey(row.pair)))
345
+ return inputPairs(inputs).some(pair => blocked.has(pairKey(pair)))
346
+ }
347
+
348
+ // ── The store ─────────────────────────────────────────────────────────────────
349
+
350
+ export function emptyStore(): ActionsStoreFile {
351
+ return { schema: STORE_SCHEMA, actions: [], suggestions: [], tombstones: [], status: { schema: STORE_SCHEMA } }
352
+ }
353
+
354
+ function readJsonArray<T>(path: string, key: string): T[] {
355
+ if (!existsSync(path)) return []
356
+ try {
357
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
358
+ const rows = parsed?.[key]
359
+ return Array.isArray(rows) ? (rows as T[]) : []
360
+ } catch {
361
+ // An unreadable store is an empty one for reading purposes. It is NOT emptied on
362
+ // disk: the next write rewrites it, and until then the corrupt bytes stay for a
363
+ // person to look at rather than being silently destroyed by the reader.
364
+ return []
365
+ }
366
+ }
367
+
368
+ /**
369
+ * An async mutex. One chain, so a second caller waits for the first to finish rather than
370
+ * interleaving with it.
371
+ *
372
+ * `run` never rejects the chain itself: a caller's failure is delivered to that caller and
373
+ * the chain continues, because one failed update must not wedge every later one.
374
+ */
375
+ export class AsyncMutex {
376
+ private tail: Promise<unknown> = Promise.resolve()
377
+
378
+ run<T>(work: () => T | Promise<T>): Promise<T> {
379
+ const result = this.tail.then(work, work)
380
+ this.tail = result.then(() => undefined, () => undefined)
381
+ return result
382
+ }
383
+
384
+ /** Resolves when everything currently queued has finished. */
385
+ idle(): Promise<unknown> {
386
+ return this.tail
387
+ }
388
+ }
389
+
390
+ export class MeetingActionsStore {
391
+ readonly root: string
392
+ private readonly mutex = new AsyncMutex()
393
+
394
+ constructor(options: { root?: string } = {}) {
395
+ this.root = options.root ?? importsRoot()
396
+ }
397
+
398
+ private path(filename: string): string {
399
+ return join(this.root, filename)
400
+ }
401
+
402
+ actionsPath(): string { return this.path(ACTIONS_FILENAME) }
403
+ suggestionsPath(): string { return this.path(SUGGESTIONS_FILENAME) }
404
+ tombstonesPath(): string { return this.path(TOMBSTONES_FILENAME) }
405
+ statusPath(): string { return this.path(ENGINE_STATUS_FILENAME) }
406
+ revertedDir(): string { return this.path(REVERTED_DIR_NAME) }
407
+
408
+ /** A consistent snapshot of all four files. Safe to call outside the mutex. */
409
+ read(): ActionsStoreFile {
410
+ const status = existsSync(this.statusPath())
411
+ ? (() => {
412
+ try {
413
+ const parsed = JSON.parse(readFileSync(this.statusPath(), 'utf8')) as EngineStatusFile
414
+ return parsed && typeof parsed === 'object' ? { ...parsed, schema: STORE_SCHEMA } : { schema: STORE_SCHEMA }
415
+ } catch {
416
+ return { schema: STORE_SCHEMA }
417
+ }
418
+ })()
419
+ : { schema: STORE_SCHEMA }
420
+ return {
421
+ schema: STORE_SCHEMA,
422
+ actions: readJsonArray<MergeActionRecord>(this.actionsPath(), 'actions'),
423
+ suggestions: readJsonArray<MergeSuggestionRecord>(this.suggestionsPath(), 'suggestions'),
424
+ tombstones: readJsonArray<Tombstone>(this.tombstonesPath(), 'tombstones'),
425
+ status,
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Read, mutate, write, all inside the mutex.
431
+ *
432
+ * The mutation runs on a snapshot the caller may edit freely; only the files whose rows
433
+ * actually changed are rewritten, so a status poll does not rewrite the action log.
434
+ */
435
+ update<T>(mutate: (store: ActionsStoreFile) => T | Promise<T>): Promise<T> {
436
+ return this.mutex.run(async () => {
437
+ const before = this.read()
438
+ // A DEEP copy, not a spread. Almost every mutation here edits a row in place — an
439
+ // action moving to `applied`, a suggestion to `dismissed` — and a shallow copy shares
440
+ // those row objects with `before`, so the change check compares a value with itself,
441
+ // finds no difference, and writes nothing. Under a spread the store could only ever
442
+ // record rows being ADDED, and every state change was silently dropped.
443
+ const working: ActionsStoreFile = structuredClone(before)
444
+ const result = await mutate(working)
445
+ securePrivateDirectory(this.root)
446
+ this.writeIfChanged(this.actionsPath(), 'actions', before.actions, working.actions.slice(-MAX_ACTIONS))
447
+ this.writeIfChanged(this.suggestionsPath(), 'suggestions', before.suggestions, working.suggestions.slice(-MAX_SUGGESTIONS))
448
+ this.writeIfChanged(this.tombstonesPath(), 'tombstones', before.tombstones, working.tombstones.slice(-MAX_TOMBSTONES))
449
+ const nextStatus = { ...working.status, schema: STORE_SCHEMA }
450
+ if (JSON.stringify(before.status) !== JSON.stringify(nextStatus)) {
451
+ durableAtomicWriteFileSync(this.statusPath(), `${JSON.stringify(nextStatus, null, 2)}\n`, { mode: 0o600 })
452
+ }
453
+ return result
454
+ })
455
+ }
456
+
457
+ private writeIfChanged(path: string, key: string, before: unknown[], after: unknown[]): void {
458
+ if (JSON.stringify(before) === JSON.stringify(after)) return
459
+ durableAtomicWriteFileSync(path, `${JSON.stringify({ schema: STORE_SCHEMA, [key]: after }, null, 2)}\n`, { mode: 0o600 })
460
+ }
461
+
462
+ /** Resolves when every queued mutation has finished. Tests and shutdown use it. */
463
+ idle(): Promise<unknown> {
464
+ return this.mutex.idle()
465
+ }
466
+ }
467
+
468
+ let store: MeetingActionsStore | null = null
469
+
470
+ export function getMeetingActionsStore(): MeetingActionsStore {
471
+ store ??= new MeetingActionsStore()
472
+ return store
473
+ }
474
+
475
+ /** Test seam: drop the process-wide store so a later getter rebuilds it. */
476
+ export function resetMeetingActionsStore(): void {
477
+ store = null
478
+ }