pipeline-moe 0.1.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 (48) hide show
  1. package/.env.example +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -0
  4. package/bin/pipeline-moe.mjs +40 -0
  5. package/package.json +68 -0
  6. package/presets/2106BUILD.json +163 -0
  7. package/presets/CHEAPBUILD.json +97 -0
  8. package/presets/FREEROOM.json +96 -0
  9. package/presets/Versa.json +145 -0
  10. package/presets/cloud-main.json +76 -0
  11. package/presets/cloud-sprint.json +70 -0
  12. package/presets/local-default.json +152 -0
  13. package/presets/main.json +147 -0
  14. package/presets/mainmix.json +145 -0
  15. package/src/circuit-breaker.ts +141 -0
  16. package/src/config.ts +46 -0
  17. package/src/custom-tools/arxiv-search.ts +186 -0
  18. package/src/custom-tools/check-room.ts +45 -0
  19. package/src/custom-tools/destroy-room.ts +45 -0
  20. package/src/custom-tools/index.ts +75 -0
  21. package/src/custom-tools/spawn-room.ts +99 -0
  22. package/src/custom-tools/stop-room.ts +50 -0
  23. package/src/custom-tools/web-read.ts +104 -0
  24. package/src/custom-tools/web-search.ts +144 -0
  25. package/src/custom-tools/youcom-search.ts +230 -0
  26. package/src/custom-tools/youtube-transcript.ts +124 -0
  27. package/src/local-model-lock.ts +52 -0
  28. package/src/model.ts +131 -0
  29. package/src/orchestrator.ts +59 -0
  30. package/src/participant.ts +423 -0
  31. package/src/path-guard.ts +13 -0
  32. package/src/personas.ts +480 -0
  33. package/src/receipts.ts +120 -0
  34. package/src/registry.ts +317 -0
  35. package/src/room-manager.ts +505 -0
  36. package/src/room.ts +1657 -0
  37. package/src/sandbox-tools.ts +141 -0
  38. package/src/server.ts +1846 -0
  39. package/src/sse.ts +86 -0
  40. package/src/sshfs.ts +139 -0
  41. package/src/store.ts +79 -0
  42. package/src/types.ts +131 -0
  43. package/src/validation.ts +36 -0
  44. package/tsconfig.json +15 -0
  45. package/web/dist/assets/index-CmMGhMKG.css +1 -0
  46. package/web/dist/assets/index-LAGqbZII.js +41 -0
  47. package/web/dist/index.html +13 -0
  48. package/web/tsconfig.json +21 -0
@@ -0,0 +1,505 @@
1
+ // RoomManager — thin coordinator that holds multiple Room instances.
2
+ //
3
+ // In the current (single-room) mode, `createDefaultRoom()` creates one "default"
4
+ // Room and all routes talk to it. Future multi-room support adds createRoom() /
5
+ // destroyRoom() and routes keyed by roomId.
6
+
7
+ import { resolve } from "node:path"
8
+ import { readFile, writeFile, rename, mkdir, readdir } from "node:fs/promises"
9
+ import type { Dirent } from "node:fs"
10
+ import { Registry } from "./registry.js"
11
+ import { Room } from "./room.js"
12
+ import { SseHub } from "./sse.js"
13
+ import { ConversationStore } from "./store.js"
14
+ import { config } from "./config.js"
15
+ import { LocalModelLock } from "./local-model-lock.js"
16
+ import { mountSshfs, unmountSshfs } from "./sshfs.js"
17
+ import type { RoomMount } from "./sshfs.js"
18
+ import type { ResolvedModel } from "./model.js"
19
+ import type { RoomOrchestrator } from "./orchestrator.js"
20
+ import type { Persona } from "./types.js"
21
+
22
+ export interface RoomSummary {
23
+ roomId: string
24
+ name: string
25
+ participantCount: number
26
+ goalStatus: string
27
+ goalText: string | null
28
+ }
29
+
30
+ export interface RoomDetails extends RoomSummary {
31
+ isBusy: boolean
32
+ transcriptLength: number
33
+ /** The directory this room's agents are scoped to. */
34
+ workspaceDir: string
35
+ }
36
+
37
+ /** One room's durable record in the manifest. The mountpoint is deliberately
38
+ * NOT stored — it is ephemeral (/tmp). For sshfs rooms we store the original
39
+ * `sshTarget` and re-mount on restart; for custom local-path rooms we store
40
+ * `workspaceDir`; for default-scoped rooms neither is present. */
41
+ export interface RoomManifestEntry {
42
+ roomId: string
43
+ name: string
44
+ workspaceDir?: string
45
+ sshTarget?: string
46
+ }
47
+
48
+ /** Durable per-room metadata, persisted to `sessions/<roomId>/meta.json`. Unlike
49
+ * the manifest entry, it is NOT removed on destroyRoom — it outlives the room so
50
+ * a destroyed/closed room's conversation data can be resumed later. */
51
+ export interface RoomMeta {
52
+ roomId: string
53
+ name: string
54
+ /** Durable scope INPUT (local path or `user@host:/path`). Absent = default workspace. */
55
+ workspaceDir?: string
56
+ createdAt: number
57
+ }
58
+
59
+ /** A room that has on-disk data but is not currently live — a resume candidate. */
60
+ export interface ResumableRoom {
61
+ roomId: string
62
+ name: string
63
+ workspaceDir?: string
64
+ /** Latest conversation's updatedAt (else meta.createdAt, else 0). */
65
+ lastActivity: number
66
+ messageCount: number
67
+ /** false = legacy orphan (no meta.json); name derived from a conversation title. */
68
+ hasMeta: boolean
69
+ }
70
+
71
+ export class RoomManager {
72
+ private rooms = new Map<
73
+ string,
74
+ {
75
+ room: Room
76
+ name: string
77
+ workspaceDir: string
78
+ /** Live FUSE mount, present only while the room's sshfs target is mounted. */
79
+ mount?: RoomMount
80
+ /** The room's *intended* sshfs target. Survives independently of `mount`:
81
+ * a degraded restore (mount failed, VPS down) has no live mount but must
82
+ * still persist this so the target isn't lost on the next save. */
83
+ sshTarget?: string
84
+ /** Creation timestamp, persisted into meta.json and stable across renames. */
85
+ createdAt: number
86
+ }
87
+ >()
88
+ /** Process-global semaphore for serializing local-model inference across all rooms. */
89
+ private readonly localLock = new LocalModelLock()
90
+ /** Capability surface for sub-room spawning, injected by the server after
91
+ * construction (it closes over preset/mount logic that lives in server.ts).
92
+ * Passed to each room's Registry so orchestrator personas get the tools.
93
+ * Must be set before createRoom() is called for the tools to be available. */
94
+ private orchestrator?: RoomOrchestrator
95
+ /** Serializes manifest writes so concurrent room mutations never interleave
96
+ * two writeFile-to-tmp operations. Each write snapshots the current Map. */
97
+ private saveQueue: Promise<void> = Promise.resolve()
98
+
99
+ constructor(
100
+ private readonly resolved: ResolvedModel,
101
+ private readonly hub: SseHub,
102
+ private readonly explicitlyEnabledProviders: Set<string>,
103
+ private readonly seedPersonas: Persona[],
104
+ ) {}
105
+
106
+ /** Inject the sub-room orchestrator. Call once at startup, before createRoom. */
107
+ setOrchestrator(orchestrator: RoomOrchestrator): void {
108
+ this.orchestrator = orchestrator
109
+ }
110
+
111
+ /**
112
+ * Create the default room. Called once at startup in server.ts.
113
+ * Returns the Room so the caller can call room.init() and seed presets.
114
+ */
115
+ createDefaultRoom(): Room {
116
+ return this.createRoom("default", "main-room")
117
+ }
118
+
119
+ /**
120
+ * Create a named room. Each room gets its own Registry and ConversationStore.
121
+ * Shared: SseHub (events are tagged with roomId), ResolvedModel, seed personas.
122
+ */
123
+ /**
124
+ * Create a named room. Each room gets its own Registry and ConversationStore.
125
+ * @param overridePersonas When provided, replaces seedPersonas entirely (for preset-based rooms).
126
+ * When absent, seedPersonas are used as-is.
127
+ */
128
+ createRoom(
129
+ roomId: string,
130
+ name: string,
131
+ overridePersonas?: Persona[],
132
+ workspaceDir?: string,
133
+ /** Mount metadata when the room is scoped to an sshfs target. The caller
134
+ * (POST handler) performs the mount and passes the local mountpoint as
135
+ * `workspaceDir`; this records it so destroyRoom() can unmount. */
136
+ mount?: RoomMount,
137
+ /** The intended sshfs target, used when there is no live `mount` (degraded
138
+ * restore). When `mount` is present its sshTarget takes precedence — they
139
+ * are the same value in the happy path. */
140
+ sshTarget?: string,
141
+ ): Room {
142
+ if (this.rooms.has(roomId)) {
143
+ throw new Error(`Room "${roomId}" already exists`)
144
+ }
145
+
146
+ // Resolve the room's scope to an absolute path. Empty/undefined = the
147
+ // pipeline workspace (default, backward-compatible behavior). For sshfs
148
+ // rooms the caller already passes the absolute local mountpoint.
149
+ const scope = workspaceDir && workspaceDir.trim()
150
+ ? resolve(workspaceDir.trim())
151
+ : config.workspaceDir
152
+
153
+ const registry = new Registry(this.resolved, this.hub, this.explicitlyEnabledProviders, scope, roomId, this.orchestrator)
154
+ const store = new ConversationStore(resolve(config.sessionsDir, roomId))
155
+ const personas = overridePersonas ?? this.seedPersonas
156
+ const room = new Room(
157
+ registry,
158
+ this.hub,
159
+ store,
160
+ personas,
161
+ roomId,
162
+ this.localLock,
163
+ config.circuitBreaker,
164
+ scope,
165
+ !!(mount || sshTarget), // remote (sshfs) → skip per-turn full-tree snapshots
166
+ )
167
+
168
+ this.rooms.set(roomId, {
169
+ room,
170
+ name,
171
+ workspaceDir: scope,
172
+ mount,
173
+ sshTarget: mount?.sshTarget ?? sshTarget,
174
+ createdAt: Date.now(),
175
+ })
176
+ void this.saveManifest()
177
+ void this.saveRoomMeta(roomId)
178
+ return room
179
+ }
180
+
181
+ /** Get a room by id. Returns undefined when not found. */
182
+ getRoom(roomId: string): Room | undefined {
183
+ return this.rooms.get(roomId)?.room
184
+ }
185
+
186
+ /** Destroy a room and remove it from the manager. Unmounts the room's sshfs
187
+ * mount first when present — teardown is intrinsic to the room lifecycle, so
188
+ * it lives here rather than in the HTTP handler. */
189
+ async destroyRoom(roomId: string): Promise<boolean> {
190
+ const entry = this.rooms.get(roomId)
191
+ if (!entry) return false
192
+ // Stop any in-flight pipeline FIRST. Deleting the room from the Map only
193
+ // drops our handle — the Room's async chain keeps running headless (a
194
+ // "zombie"): its agents continue inference (holding the process-global
195
+ // LocalModelLock and starving every other room) and writing into a workspace
196
+ // we are about to unmount. abortCurrent() flips the abort flag, cancels any
197
+ // running goal, and awaits the in-flight agents so the unmount below is safe.
198
+ await entry.room.abortCurrent()
199
+ if (entry.mount) {
200
+ await unmountSshfs(entry.mount.mountpoint)
201
+ }
202
+ const removed = this.rooms.delete(roomId)
203
+ if (removed) void this.saveManifest()
204
+ return removed
205
+ }
206
+
207
+ /** Unmount every active sshfs mount. Called on process exit (SIGINT/SIGTERM)
208
+ * so a normal shutdown doesn't leave orphaned FUSE mounts behind. */
209
+ async cleanupAllMounts(): Promise<void> {
210
+ await Promise.all(
211
+ [...this.rooms.values()]
212
+ .filter((e) => e.mount)
213
+ .map((e) => unmountSshfs(e.mount!.mountpoint)),
214
+ )
215
+ }
216
+
217
+ /** Rename a room in place. Returns false when the room is not found. */
218
+ renameRoom(roomId: string, newName: string): boolean {
219
+ const entry = this.rooms.get(roomId)
220
+ if (!entry) return false
221
+ entry.name = newName
222
+ void this.saveManifest()
223
+ void this.saveRoomMeta(roomId)
224
+ return true
225
+ }
226
+
227
+ // ── Room metadata (resume support) ──────────────────────────────────────
228
+ // A small meta.json per room session dir records the durable display name +
229
+ // scope. It is deliberately NOT deleted on destroyRoom — it outlives the room
230
+ // so listResumableRooms() / the resume route can re-open a closed room with its
231
+ // original name and workspace scope intact.
232
+
233
+ private roomMetaPath(roomId: string): string {
234
+ return resolve(config.sessionsDir, roomId, "meta.json")
235
+ }
236
+
237
+ /** The durable scope INPUT for a room (local path or `user@host:/path`),
238
+ * mirroring manifestEntries: sshTarget wins; a non-default local path is
239
+ * stored as-is; the default workspace stores nothing. */
240
+ private durableScope(e: { workspaceDir: string; sshTarget?: string }): string | undefined {
241
+ if (e.sshTarget) return e.sshTarget
242
+ if (e.workspaceDir !== config.workspaceDir) return e.workspaceDir
243
+ return undefined
244
+ }
245
+
246
+ /** Serializes meta.json writes so two room mutations never collide on the
247
+ * shared .tmp path (mirrors saveQueue for the manifest). */
248
+ private metaQueue: Promise<void> = Promise.resolve()
249
+
250
+ /** Write `sessions/<roomId>/meta.json` from the live entry. Atomic
251
+ * (write .tmp + rename), serialized, best-effort (a failure is logged, never
252
+ * thrown), and awaitable for tests. The dir/path are snapshotted at call time
253
+ * so a queued write can't drift onto a different sessionsDir (matters in tests). */
254
+ saveRoomMeta(roomId: string): Promise<void> {
255
+ const e = this.rooms.get(roomId)
256
+ if (!e) return Promise.resolve()
257
+ const workspaceDir = this.durableScope(e)
258
+ const meta: RoomMeta = {
259
+ roomId,
260
+ name: e.name,
261
+ createdAt: e.createdAt,
262
+ ...(workspaceDir ? { workspaceDir } : {}),
263
+ }
264
+ const dir = resolve(config.sessionsDir, roomId)
265
+ const path = resolve(dir, "meta.json")
266
+ this.metaQueue = this.metaQueue
267
+ .then(() => this.writeRoomMeta(dir, path, meta))
268
+ .catch((err) => {
269
+ console.error(
270
+ `[room-meta] save failed for "${roomId}": ${err instanceof Error ? err.message : String(err)}`,
271
+ )
272
+ })
273
+ return this.metaQueue
274
+ }
275
+
276
+ private async writeRoomMeta(dir: string, path: string, meta: RoomMeta): Promise<void> {
277
+ const tmp = `${path}.tmp`
278
+ await mkdir(dir, { recursive: true })
279
+ await writeFile(tmp, JSON.stringify(meta, null, 2), "utf8")
280
+ await rename(tmp, path)
281
+ }
282
+
283
+ /** Read `sessions/<roomId>/meta.json`. Returns null when absent or malformed
284
+ * (e.g. a legacy orphan dir predating meta.json). */
285
+ async readRoomMeta(roomId: string): Promise<RoomMeta | null> {
286
+ try {
287
+ const raw = await readFile(this.roomMetaPath(roomId), "utf8")
288
+ const m: unknown = JSON.parse(raw)
289
+ if (m && typeof m === "object" && typeof (m as RoomMeta).name === "string") {
290
+ return m as RoomMeta
291
+ }
292
+ return null
293
+ } catch {
294
+ return null
295
+ }
296
+ }
297
+
298
+ /** List rooms that have on-disk session data but are NOT currently live — i.e.
299
+ * destroyed/closed rooms whose conversation can be resumed. Reads meta.json for
300
+ * the durable name/scope and the latest conversation for activity + size.
301
+ * Legacy orphans (no meta.json) fall back to the latest conversation's title. */
302
+ async listResumableRooms(): Promise<ResumableRoom[]> {
303
+ const dir = config.sessionsDir
304
+ let entries: Dirent[]
305
+ try {
306
+ entries = await readdir(dir, { withFileTypes: true })
307
+ } catch {
308
+ return []
309
+ }
310
+ const out: ResumableRoom[] = []
311
+ for (const ent of entries) {
312
+ if (!ent.isDirectory()) continue
313
+ const roomId = ent.name
314
+ if (roomId === "default") continue
315
+ if (this.rooms.has(roomId)) continue // live → already a tab, not "resumable"
316
+
317
+ const meta = await this.readRoomMeta(roomId)
318
+ const store = new ConversationStore(resolve(dir, roomId))
319
+ const convs = await store.list()
320
+ const latest = convs[0]
321
+ if (!meta && !latest) continue // empty dir — nothing to resume
322
+
323
+ out.push({
324
+ roomId,
325
+ name: meta?.name ?? latest?.title ?? roomId,
326
+ workspaceDir: meta?.workspaceDir,
327
+ lastActivity: latest?.updatedAt ?? meta?.createdAt ?? 0,
328
+ messageCount: latest?.messageCount ?? 0,
329
+ hasMeta: !!meta,
330
+ })
331
+ }
332
+ out.sort((a, b) => b.lastActivity - a.lastActivity)
333
+ return out
334
+ }
335
+
336
+ /** Resolve once all queued manifest + room-meta writes have drained. createRoom,
337
+ * renameRoom, and destroyRoom trigger these writes fire-and-forget (`void`), so
338
+ * shutdown and tests use this to wait them out before tearing down the
339
+ * sessions dir — otherwise an in-flight write can race the cleanup. */
340
+ async flushWrites(): Promise<void> {
341
+ await this.saveQueue
342
+ await this.metaQueue
343
+ }
344
+
345
+ // ── Persistence ─────────────────────────────────────────────────────────────
346
+
347
+ /** Absolute path of the manifest file, colocated with per-room session dirs. */
348
+ private manifestPath(): string {
349
+ return resolve(config.sessionsDir, "rooms.json")
350
+ }
351
+
352
+ /** Snapshot the current room set into manifest entries. */
353
+ private manifestEntries(): RoomManifestEntry[] {
354
+ return [...this.rooms.entries()].map(([roomId, e]) => {
355
+ const entry: RoomManifestEntry = { roomId, name: e.name }
356
+ if (e.sshTarget) {
357
+ // sshfs room: store the durable target, not the ephemeral mountpoint.
358
+ // Read from the entry (not e.mount) so a degraded room — intended target
359
+ // recorded but mount currently down — still persists its sshTarget.
360
+ entry.sshTarget = e.sshTarget
361
+ } else if (e.workspaceDir !== config.workspaceDir) {
362
+ // Custom local-path scope: store the path. Default scope stores nothing.
363
+ entry.workspaceDir = e.workspaceDir
364
+ }
365
+ return entry
366
+ })
367
+ }
368
+
369
+ /** Persist the current room set. Serialized through saveQueue and best-effort:
370
+ * a write failure is logged, never thrown — durability must not break a room
371
+ * mutation. Atomic (write .tmp, rename) so a crash mid-write can't corrupt.
372
+ *
373
+ * The target dir, path, and entries are all snapshotted *now* (at call time),
374
+ * not when the queued write runs. This makes each save a pure snapshot of the
375
+ * moment it was triggered — the write can't drift onto a different sessionsDir
376
+ * if config changes underneath the async queue (matters in tests; in prod
377
+ * sessionsDir is constant). */
378
+ saveManifest(): Promise<void> {
379
+ const dir = config.sessionsDir
380
+ const path = resolve(dir, "rooms.json")
381
+ const entries = this.manifestEntries()
382
+ this.saveQueue = this.saveQueue
383
+ .then(() => this.writeManifest(dir, path, entries))
384
+ .catch((err) => {
385
+ console.error(
386
+ `[room-manifest] save failed: ${err instanceof Error ? err.message : String(err)}`,
387
+ )
388
+ })
389
+ return this.saveQueue
390
+ }
391
+
392
+ private async writeManifest(
393
+ dir: string,
394
+ path: string,
395
+ entries: RoomManifestEntry[],
396
+ ): Promise<void> {
397
+ const tmp = `${path}.tmp`
398
+ await mkdir(dir, { recursive: true })
399
+ await writeFile(tmp, JSON.stringify(entries, null, 2), "utf8")
400
+ await rename(tmp, path)
401
+ }
402
+
403
+ /** Read and validate the manifest. Returns [] on missing or corrupt file —
404
+ * a bad manifest must never block startup (fall back to default-only). */
405
+ async loadManifest(): Promise<RoomManifestEntry[]> {
406
+ try {
407
+ const raw = await readFile(this.manifestPath(), "utf8")
408
+ const parsed: unknown = JSON.parse(raw)
409
+ if (!Array.isArray(parsed)) return []
410
+ return parsed.filter(
411
+ (e): e is RoomManifestEntry =>
412
+ !!e &&
413
+ typeof e === "object" &&
414
+ typeof (e as RoomManifestEntry).roomId === "string" &&
415
+ typeof (e as RoomManifestEntry).name === "string",
416
+ )
417
+ } catch {
418
+ return []
419
+ }
420
+ }
421
+
422
+ /** Re-create non-default rooms from the manifest at startup. The default room
423
+ * must already exist (createDefaultRoom) — for it we only restore a renamed
424
+ * name. sshfs rooms are re-mounted; a mount failure degrades the room to the
425
+ * pipeline workspace (logged) rather than losing it. Each restored room is
426
+ * init()'d so its saved conversation reloads. */
427
+ async restoreRooms(): Promise<void> {
428
+ const entries = await this.loadManifest()
429
+ for (const entry of entries) {
430
+ if (entry.roomId === "default") {
431
+ const def = this.rooms.get("default")
432
+ if (def && def.name !== entry.name) this.renameRoom("default", entry.name)
433
+ continue
434
+ }
435
+ if (this.rooms.has(entry.roomId)) continue
436
+
437
+ let workspaceDir: string | undefined = entry.workspaceDir
438
+ let mount: RoomMount | undefined
439
+ if (entry.sshTarget) {
440
+ try {
441
+ const mountpoint = await mountSshfs(entry.roomId, entry.sshTarget)
442
+ workspaceDir = mountpoint
443
+ mount = { mountpoint, sshTarget: entry.sshTarget }
444
+ } catch (err) {
445
+ console.warn(
446
+ `[room-restore] sshfs mount failed for "${entry.roomId}" (${entry.sshTarget}): ` +
447
+ `${err instanceof Error ? err.message : String(err)}. ` +
448
+ `Restoring in degraded mode (pipeline workspace).`,
449
+ )
450
+ workspaceDir = undefined
451
+ mount = undefined
452
+ }
453
+ }
454
+
455
+ try {
456
+ // Pass entry.sshTarget so a degraded room (mount === undefined) still
457
+ // records its intended target and survives the next save/restart.
458
+ const room = this.createRoom(
459
+ entry.roomId,
460
+ entry.name,
461
+ undefined,
462
+ workspaceDir,
463
+ mount,
464
+ entry.sshTarget,
465
+ )
466
+ await room.init()
467
+ console.log(`[room-restore] restored "${entry.roomId}" (${entry.name})`)
468
+ } catch (err) {
469
+ console.error(
470
+ `[room-restore] failed to restore "${entry.roomId}": ` +
471
+ `${err instanceof Error ? err.message : String(err)}`,
472
+ )
473
+ if (mount) await unmountSshfs(mount.mountpoint).catch(() => {})
474
+ }
475
+ }
476
+ }
477
+
478
+ /** Snapshot list of all rooms. */
479
+ listRooms(): RoomSummary[] {
480
+ return [...this.rooms.entries()].map(([id, { room, name }]) => ({
481
+ roomId: id,
482
+ name,
483
+ participantCount: room.rosterLength(),
484
+ goalStatus: room.getGoalStatus(),
485
+ goalText: room.getGoalText(),
486
+ }))
487
+ }
488
+
489
+ /** Full details for one room. Returns undefined when not found. */
490
+ getRoomDetails(roomId: string): RoomDetails | undefined {
491
+ const entry = this.rooms.get(roomId)
492
+ if (!entry) return undefined
493
+ const { room, name, workspaceDir } = entry
494
+ return {
495
+ roomId,
496
+ name,
497
+ participantCount: room.rosterLength(),
498
+ goalStatus: room.getGoalStatus(),
499
+ goalText: room.getGoalText(),
500
+ isBusy: room.isBusy(),
501
+ transcriptLength: room.getTranscript().length,
502
+ workspaceDir,
503
+ }
504
+ }
505
+ }