@nifrajs/coding-agent 3.3.0 → 3.5.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.
- package/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +5 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +138 -34
- package/dist/native.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +208 -29
- package/dist/rpc.js.map +1 -1
- package/dist/sessions.d.ts +14 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +253 -59
- package/dist/sessions.js.map +1 -1
- package/dist/verification.d.ts +23 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +113 -1
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +43 -21
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -5
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/host.ts +146 -23
- package/src/index.ts +5 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +173 -32
- package/src/replay.ts +206 -25
- package/src/rpc.ts +261 -33
- package/src/sessions.ts +271 -56
- package/src/verification.ts +140 -1
- package/src/workflows.ts +37 -25
package/src/native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
agentError,
|
|
14
14
|
type CreateSessionInput,
|
|
15
15
|
createAgentEventStream,
|
|
16
|
+
isAgentEvent,
|
|
16
17
|
type ReloadResult,
|
|
17
18
|
type SendMessageInput,
|
|
18
19
|
} from "@nifrajs/agent-protocol"
|
|
@@ -148,6 +149,7 @@ interface NativePendingApproval {
|
|
|
148
149
|
readonly resolve: (resolution: NativeApprovalResolution) => void
|
|
149
150
|
readonly timer: ReturnType<typeof setTimeout>
|
|
150
151
|
readonly onAbort: () => void
|
|
152
|
+
readonly signal: AbortSignal
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
interface NativeSession {
|
|
@@ -157,6 +159,8 @@ interface NativeSession {
|
|
|
157
159
|
controller: AbortController
|
|
158
160
|
snapshot: AgentSessionSnapshot
|
|
159
161
|
active: AgentEventStream | undefined
|
|
162
|
+
turnId: string | undefined
|
|
163
|
+
turnAbortCleanup: (() => void) | undefined
|
|
160
164
|
seq: number
|
|
161
165
|
extensionRevision: number
|
|
162
166
|
closed: boolean
|
|
@@ -205,6 +209,20 @@ export class NifraBackend implements AgentBackend {
|
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
212
|
+
if (
|
|
213
|
+
typeof input.cwd !== "string" ||
|
|
214
|
+
input.cwd.length === 0 ||
|
|
215
|
+
input.cwd.length > 4_096 ||
|
|
216
|
+
input.cwd.includes("\0")
|
|
217
|
+
)
|
|
218
|
+
throw new TypeError("nifra backend: cwd must be a bounded non-empty path")
|
|
219
|
+
if (
|
|
220
|
+
input.capabilities !== undefined &&
|
|
221
|
+
(!Array.isArray(input.capabilities) ||
|
|
222
|
+
input.capabilities.length > 256 ||
|
|
223
|
+
!input.capabilities.every((value) => boundedApprovalToken(value)))
|
|
224
|
+
)
|
|
225
|
+
throw new TypeError("nifra backend: capabilities must be bounded tokens")
|
|
208
226
|
const id = input.sessionId ?? crypto.randomUUID()
|
|
209
227
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
|
|
210
228
|
throw new TypeError("nifra backend: sessionId must be a bounded token")
|
|
@@ -228,6 +246,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
228
246
|
controller: new AbortController(),
|
|
229
247
|
snapshot,
|
|
230
248
|
active: undefined,
|
|
249
|
+
turnId: undefined,
|
|
250
|
+
turnAbortCleanup: undefined,
|
|
231
251
|
seq: 0,
|
|
232
252
|
extensionRevision: 0,
|
|
233
253
|
closed: false,
|
|
@@ -251,30 +271,38 @@ export class NifraBackend implements AgentBackend {
|
|
|
251
271
|
session.cancelled = false
|
|
252
272
|
session.controller = new AbortController()
|
|
253
273
|
const signal = session.controller.signal
|
|
274
|
+
const turnId = crypto.randomUUID()
|
|
275
|
+
session.turnId = turnId
|
|
254
276
|
if (input.signal !== undefined) {
|
|
255
277
|
const externalSignal = input.signal
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
278
|
+
const onAbort = (): void => {
|
|
279
|
+
// Capture the turn identity. A request signal must never abort a later turn that reused
|
|
280
|
+
// this session after the original stream settled.
|
|
281
|
+
if (
|
|
282
|
+
session.active !== stream ||
|
|
283
|
+
session.turnId !== turnId ||
|
|
284
|
+
session.controller.signal !== signal
|
|
262
285
|
)
|
|
286
|
+
return
|
|
287
|
+
void this.cancelTurn(session, stream, turnId, "cancelled")
|
|
288
|
+
}
|
|
289
|
+
if (externalSignal.aborted) onAbort()
|
|
290
|
+
else {
|
|
291
|
+
externalSignal.addEventListener("abort", onAbort, { once: true })
|
|
292
|
+
session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
|
|
293
|
+
// Abort can race the registration between the check above and addEventListener.
|
|
294
|
+
if (externalSignal.aborted) onAbort()
|
|
295
|
+
}
|
|
263
296
|
}
|
|
264
|
-
void this.run(session, input.message, stream, signal)
|
|
297
|
+
void this.run(session, input.message, stream, signal, turnId)
|
|
265
298
|
return stream
|
|
266
299
|
}
|
|
267
300
|
|
|
268
301
|
async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
|
|
269
302
|
const session = this.requireSession(sessionId)
|
|
270
303
|
if (session.closed) return
|
|
271
|
-
|
|
272
|
-
session.
|
|
273
|
-
session.controller.abort(reason)
|
|
274
|
-
this.update(session, "stopped")
|
|
275
|
-
this.emit(session, { type: "session.stopped", reason })
|
|
276
|
-
stream?.complete()
|
|
277
|
-
session.active = undefined
|
|
304
|
+
if (session.active === undefined) return
|
|
305
|
+
await this.cancelTurn(session, session.active, session.turnId, reason)
|
|
278
306
|
}
|
|
279
307
|
|
|
280
308
|
async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
|
|
@@ -326,9 +354,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
326
354
|
if (session.closed) return
|
|
327
355
|
session.closed = true
|
|
328
356
|
session.cancelled = true
|
|
357
|
+
session.turnAbortCleanup?.()
|
|
358
|
+
session.turnAbortCleanup = undefined
|
|
329
359
|
session.controller.abort("closed")
|
|
330
360
|
session.active?.complete()
|
|
331
361
|
session.active = undefined
|
|
362
|
+
session.turnId = undefined
|
|
332
363
|
this.sessions.delete(sessionId)
|
|
333
364
|
}
|
|
334
365
|
|
|
@@ -337,14 +368,18 @@ export class NifraBackend implements AgentBackend {
|
|
|
337
368
|
message: string,
|
|
338
369
|
stream: AgentEventStream,
|
|
339
370
|
signal: AbortSignal,
|
|
371
|
+
turnId: string,
|
|
340
372
|
): Promise<void> {
|
|
341
|
-
|
|
373
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
374
|
+
stream.complete()
|
|
375
|
+
return
|
|
376
|
+
}
|
|
342
377
|
this.update(session, "running", turnId)
|
|
343
378
|
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
344
379
|
session.messages.push({ role: "user", text: message })
|
|
345
380
|
try {
|
|
346
381
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
347
|
-
|
|
382
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
348
383
|
const raw = this.options.model.complete({
|
|
349
384
|
sessionId: session.id,
|
|
350
385
|
cwd: session.cwd,
|
|
@@ -358,10 +393,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
358
393
|
),
|
|
359
394
|
signal,
|
|
360
395
|
})
|
|
361
|
-
const response = await this.consumeModel(raw, session, turnId, signal)
|
|
396
|
+
const response = await this.consumeModel(raw, session, stream, turnId, signal)
|
|
397
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
362
398
|
if (response.type === "text") {
|
|
363
|
-
|
|
364
|
-
|
|
399
|
+
const text = boundedText(response.text, this.options.maxMessageChars)
|
|
400
|
+
session.messages.push({ role: "assistant", text })
|
|
401
|
+
this.emit(session, { type: "assistant.message", turnId, text })
|
|
365
402
|
this.finish(session, stream)
|
|
366
403
|
return
|
|
367
404
|
}
|
|
@@ -391,6 +428,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
391
428
|
typeof tool.requiresApproval === "function"
|
|
392
429
|
? await tool.requiresApproval(response.input)
|
|
393
430
|
: tool.requiresApproval === true
|
|
431
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
394
432
|
if (needsApproval) {
|
|
395
433
|
const resolution = await this.requestApproval(
|
|
396
434
|
session,
|
|
@@ -400,6 +438,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
400
438
|
response.input,
|
|
401
439
|
signal,
|
|
402
440
|
)
|
|
441
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
403
442
|
if (!resolution.approved) {
|
|
404
443
|
const code = resolution.errorCode ?? "APPROVAL_DENIED"
|
|
405
444
|
this.emit(session, {
|
|
@@ -416,6 +455,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
416
455
|
}
|
|
417
456
|
try {
|
|
418
457
|
const output = await execute(response.input, { cwd: session.cwd, signal })
|
|
458
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
419
459
|
const text = boundedText(output)
|
|
420
460
|
session.messages.push({ role: "tool", name: tool.name, text })
|
|
421
461
|
this.emit(session, {
|
|
@@ -427,7 +467,11 @@ export class NifraBackend implements AgentBackend {
|
|
|
427
467
|
output: text,
|
|
428
468
|
})
|
|
429
469
|
} catch (error) {
|
|
430
|
-
|
|
470
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
|
|
471
|
+
const message = boundedText(
|
|
472
|
+
error instanceof Error ? error.message : error,
|
|
473
|
+
this.options.maxMessageChars,
|
|
474
|
+
)
|
|
431
475
|
session.messages.push({ role: "tool", name: tool.name, text: message })
|
|
432
476
|
this.emit(session, {
|
|
433
477
|
type: "tool.completed",
|
|
@@ -441,50 +485,57 @@ export class NifraBackend implements AgentBackend {
|
|
|
441
485
|
}
|
|
442
486
|
throw new Error("native turn exceeded maxSteps")
|
|
443
487
|
} catch (error) {
|
|
444
|
-
if (session
|
|
488
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
445
489
|
stream.complete()
|
|
446
|
-
if (session.active === stream) session.active = undefined
|
|
447
490
|
return
|
|
448
491
|
}
|
|
449
|
-
|
|
492
|
+
if (signal.aborted || session.cancelled) {
|
|
493
|
+
await this.cancelTurn(session, stream, turnId, "cancelled")
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
this.update(session, "failed")
|
|
450
497
|
this.emit(session, {
|
|
451
498
|
type: "session.failed",
|
|
452
499
|
error: agentError(
|
|
453
|
-
|
|
454
|
-
error instanceof Error ? error.message :
|
|
500
|
+
"NATIVE_TURN_FAILED",
|
|
501
|
+
boundedText(error instanceof Error ? error.message : error, 4_096),
|
|
455
502
|
),
|
|
456
503
|
recoverable: true,
|
|
457
504
|
})
|
|
458
505
|
stream.complete()
|
|
459
|
-
session
|
|
506
|
+
this.clearTurn(session, stream, turnId)
|
|
460
507
|
}
|
|
461
508
|
}
|
|
462
509
|
|
|
463
510
|
private async consumeModel(
|
|
464
511
|
raw: ReturnType<NativeModelPort["complete"]>,
|
|
465
512
|
session: NativeSession,
|
|
513
|
+
stream: AgentEventStream,
|
|
466
514
|
turnId: string,
|
|
467
515
|
signal: AbortSignal,
|
|
468
516
|
): Promise<NativeModelResponse> {
|
|
469
517
|
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
470
518
|
let response: NativeModelResponse | undefined
|
|
471
519
|
for await (const chunk of raw) {
|
|
472
|
-
|
|
520
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
473
521
|
if (chunk.type === "text_delta")
|
|
474
522
|
this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
|
|
475
523
|
else response = chunk.response
|
|
476
524
|
}
|
|
477
525
|
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
526
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
478
527
|
return response
|
|
479
528
|
}
|
|
480
|
-
|
|
529
|
+
const response = await raw
|
|
530
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
531
|
+
return response
|
|
481
532
|
}
|
|
482
533
|
|
|
483
534
|
private finish(session: NativeSession, stream: AgentEventStream): void {
|
|
484
535
|
this.update(session, "idle")
|
|
485
536
|
this.emit(session, { type: "session.completed", snapshot: session.snapshot })
|
|
486
537
|
stream.complete()
|
|
487
|
-
session
|
|
538
|
+
this.clearTurn(session, stream, session.turnId)
|
|
488
539
|
}
|
|
489
540
|
|
|
490
541
|
private requestApproval(
|
|
@@ -526,6 +577,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
526
577
|
resolve,
|
|
527
578
|
timer,
|
|
528
579
|
onAbort,
|
|
580
|
+
signal,
|
|
529
581
|
}
|
|
530
582
|
this.pendingApprovals.set(approvalId, pending)
|
|
531
583
|
signal.addEventListener("abort", onAbort, { once: true })
|
|
@@ -595,7 +647,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
595
647
|
const pending = this.pendingApprovals.get(approvalId)
|
|
596
648
|
if (pending === undefined || pending.sessionId !== session.id) return undefined
|
|
597
649
|
clearTimeout(pending.timer)
|
|
598
|
-
|
|
650
|
+
pending.signal.removeEventListener("abort", pending.onAbort)
|
|
599
651
|
this.pendingApprovals.delete(approvalId)
|
|
600
652
|
const boundedReason = reason === undefined ? undefined : boundedApprovalText(reason, 512)
|
|
601
653
|
const resolution: NativeApprovalResolution = Object.freeze({
|
|
@@ -614,6 +666,76 @@ export class NifraBackend implements AgentBackend {
|
|
|
614
666
|
return resolution
|
|
615
667
|
}
|
|
616
668
|
|
|
669
|
+
private async cancelTurn(
|
|
670
|
+
session: NativeSession,
|
|
671
|
+
expectedStream: AgentEventStream | undefined,
|
|
672
|
+
expectedTurnId: string | undefined,
|
|
673
|
+
reason: string,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
if (
|
|
676
|
+
expectedStream !== undefined &&
|
|
677
|
+
(session.active !== expectedStream || session.turnId !== expectedTurnId)
|
|
678
|
+
)
|
|
679
|
+
return
|
|
680
|
+
const stream = session.active
|
|
681
|
+
const turnId = session.turnId
|
|
682
|
+
const stopReason = boundedText(reason, 512) || "cancelled"
|
|
683
|
+
session.cancelled = true
|
|
684
|
+
this.clearTurnSignal(session)
|
|
685
|
+
try {
|
|
686
|
+
session.controller.abort(stopReason)
|
|
687
|
+
} catch {
|
|
688
|
+
// AbortController.abort is normally infallible; lifecycle cleanup must still complete if a
|
|
689
|
+
// host supplies an unusual controller implementation.
|
|
690
|
+
}
|
|
691
|
+
this.update(session, "stopped")
|
|
692
|
+
this.emit(session, { type: "session.stopped", reason: stopReason })
|
|
693
|
+
stream?.complete()
|
|
694
|
+
this.clearTurn(session, stream, turnId)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private clearTurnSignal(session: NativeSession): void {
|
|
698
|
+
session.turnAbortCleanup?.()
|
|
699
|
+
session.turnAbortCleanup = undefined
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private clearTurn(
|
|
703
|
+
session: NativeSession,
|
|
704
|
+
stream: AgentEventStream | undefined,
|
|
705
|
+
turnId: string | undefined,
|
|
706
|
+
): void {
|
|
707
|
+
if (stream !== undefined && session.active !== stream) return
|
|
708
|
+
if (turnId !== undefined && session.turnId !== turnId) return
|
|
709
|
+
this.clearTurnSignal(session)
|
|
710
|
+
if (stream === undefined || session.active === stream) session.active = undefined
|
|
711
|
+
if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private isCurrentTurn(
|
|
715
|
+
session: NativeSession,
|
|
716
|
+
stream: AgentEventStream,
|
|
717
|
+
turnId: string,
|
|
718
|
+
signal: AbortSignal,
|
|
719
|
+
): boolean {
|
|
720
|
+
return (
|
|
721
|
+
!session.closed &&
|
|
722
|
+
!session.cancelled &&
|
|
723
|
+
session.active === stream &&
|
|
724
|
+
session.turnId === turnId &&
|
|
725
|
+
session.controller.signal === signal
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private assertCurrentTurn(
|
|
730
|
+
session: NativeSession,
|
|
731
|
+
stream: AgentEventStream,
|
|
732
|
+
turnId: string,
|
|
733
|
+
signal: AbortSignal,
|
|
734
|
+
): void {
|
|
735
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal))
|
|
736
|
+
throw new Error("native turn cancelled")
|
|
737
|
+
}
|
|
738
|
+
|
|
617
739
|
private emit(
|
|
618
740
|
session: NativeSession,
|
|
619
741
|
payload: import("@nifrajs/agent-protocol").AgentEventPayload,
|
|
@@ -625,6 +747,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
625
747
|
at: this.now(),
|
|
626
748
|
...payload,
|
|
627
749
|
}) as AgentEvent
|
|
750
|
+
if (!isAgentEvent(event))
|
|
751
|
+
throw new Error("nifra backend emitted an event outside protocol bounds")
|
|
628
752
|
session.snapshot = Object.freeze({
|
|
629
753
|
...session.snapshot,
|
|
630
754
|
lastSeq: event.seq,
|
|
@@ -640,7 +764,9 @@ export class NifraBackend implements AgentBackend {
|
|
|
640
764
|
): void {
|
|
641
765
|
const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
|
|
642
766
|
session.snapshot = Object.freeze(
|
|
643
|
-
status === "running" && turnId !== undefined
|
|
767
|
+
status === "running" && turnId !== undefined
|
|
768
|
+
? { ...next, activeTurnId: turnId }
|
|
769
|
+
: removeActiveTurn(next),
|
|
644
770
|
)
|
|
645
771
|
if (session.active !== undefined)
|
|
646
772
|
this.emit(session, { type: "session.updated", snapshot: session.snapshot })
|
|
@@ -664,12 +790,20 @@ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
|
664
790
|
function boundedText(value: unknown, maxChars = 64 * 1024): string {
|
|
665
791
|
try {
|
|
666
792
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
667
|
-
|
|
793
|
+
if (typeof text !== "string") return "[unserializable tool output]"
|
|
794
|
+
const suffix = "…[truncated]"
|
|
795
|
+
return text.length > maxChars
|
|
796
|
+
? `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`
|
|
797
|
+
: text
|
|
668
798
|
} catch {
|
|
669
799
|
return "[unserializable tool output]"
|
|
670
800
|
}
|
|
671
801
|
}
|
|
672
802
|
|
|
803
|
+
function boundedApprovalToken(value: unknown): value is string {
|
|
804
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
805
|
+
}
|
|
806
|
+
|
|
673
807
|
function boundedApprovalText(value: string, maxChars: number): string {
|
|
674
808
|
const text = value.trim()
|
|
675
809
|
if (text.length === 0) return "native tool"
|
|
@@ -701,3 +835,10 @@ function failedStream(error: unknown): AgentEventStream {
|
|
|
701
835
|
stream.fail(error)
|
|
702
836
|
return stream
|
|
703
837
|
}
|
|
838
|
+
|
|
839
|
+
function removeActiveTurn(
|
|
840
|
+
snapshot: Omit<AgentSessionSnapshot, "activeTurnId"> & { readonly activeTurnId?: string },
|
|
841
|
+
): Omit<AgentSessionSnapshot, "activeTurnId"> {
|
|
842
|
+
const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
|
|
843
|
+
return withoutActiveTurn
|
|
844
|
+
}
|