@matthewfl/pi-contemplator 0.1.7 → 0.1.8
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/package.json +2 -2
- package/src/agents/contemplator/agent.ts +193 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test": "npm run test:unit && npm run test:e2e",
|
|
43
43
|
"test:unit": "vitest run",
|
|
44
|
-
"test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
|
|
44
|
+
"test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-history.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@earendil-works/pi-agent-core": "*",
|
|
@@ -2,7 +2,7 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage,
|
|
|
2
2
|
import { Type, type Message, type Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Static } from "typebox";
|
|
4
4
|
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
|
-
import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
8
8
|
import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
|
|
@@ -19,6 +19,66 @@ import { buildContemplatorSystemPrompt } from "./prompts.js";
|
|
|
19
19
|
import { runStructuralReview } from "../reviewer/agent.js";
|
|
20
20
|
import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
|
|
21
21
|
|
|
22
|
+
const CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS = 20_000;
|
|
23
|
+
const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
|
|
24
|
+
const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
|
|
25
|
+
const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
|
|
26
|
+
const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
|
|
27
|
+
const CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS = "This is an older prefix of a private contemplator transcript. Be concise. Preserve durable user intent, decisions, evidence, unresolved reasoning gaps, and review/probe outcomes. Newer transcript messages are retained verbatim after this checkpoint.";
|
|
28
|
+
|
|
29
|
+
function contemplatorMessageTokens(message: AgentMessage): number {
|
|
30
|
+
try {
|
|
31
|
+
return Math.max(1, estimateAgentMessageTokens(message));
|
|
32
|
+
} catch {
|
|
33
|
+
return Math.max(1, Math.ceil(JSON.stringify(message).length / 4));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function contemplatorHistoryTokens(history: readonly AgentMessage[]): number {
|
|
38
|
+
return history.reduce((total, message) => total + contemplatorMessageTokens(message), 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contemplatorHistoryTriggerTokens(model: Model<any>): number {
|
|
42
|
+
const contextWindow = Number(model.contextWindow);
|
|
43
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS;
|
|
44
|
+
return Math.max(1, Math.min(CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_HISTORY_CONTEXT_FRACTION)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return a user-message boundary that leaves approximately targetTokens recent. */
|
|
48
|
+
function recentHistoryStart(history: readonly AgentMessage[], entryIds: readonly (string | undefined)[], targetTokens: number): number | undefined {
|
|
49
|
+
let tokens = 0;
|
|
50
|
+
let start = history.length;
|
|
51
|
+
while (start > 0 && tokens < targetTokens) {
|
|
52
|
+
start--;
|
|
53
|
+
tokens += contemplatorMessageTokens(history[start]);
|
|
54
|
+
}
|
|
55
|
+
while (start > 0 && history[start].role !== "user") start--;
|
|
56
|
+
// Retained messages are durable pointers, not copied payloads. If a legacy
|
|
57
|
+
// snapshot supplied messages without entry ids, retain only the newer complete
|
|
58
|
+
// updates whose original om.contemplator.message entries can be referenced.
|
|
59
|
+
let lastMissingId = -1;
|
|
60
|
+
for (let index = start; index < entryIds.length; index++) {
|
|
61
|
+
if (entryIds[index] === undefined) lastMissingId = index;
|
|
62
|
+
}
|
|
63
|
+
if (lastMissingId >= 0) {
|
|
64
|
+
start = lastMissingId + 1;
|
|
65
|
+
while (start < history.length && history[start].role !== "user") start++;
|
|
66
|
+
}
|
|
67
|
+
return start > 0 && start < history.length ? start : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pick a smaller complete oldest prefix after a length-truncated first attempt. */
|
|
71
|
+
function smallerPrefixEnd(history: readonly AgentMessage[], initialEnd: number): number | undefined {
|
|
72
|
+
const target = Math.max(1, Math.floor(contemplatorHistoryTokens(history.slice(0, initialEnd)) / 2));
|
|
73
|
+
let tokens = 0;
|
|
74
|
+
for (let index = 0; index < initialEnd; index++) {
|
|
75
|
+
tokens += contemplatorMessageTokens(history[index]);
|
|
76
|
+
const next = index + 1;
|
|
77
|
+
if (tokens >= target && next < initialEnd && history[next].role === "user") return next;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
22
82
|
interface PendingUpdate {
|
|
23
83
|
observations: string[];
|
|
24
84
|
reviews: string[];
|
|
@@ -211,6 +271,8 @@ export function createRequestReviewTool(
|
|
|
211
271
|
|
|
212
272
|
export class Contemplator {
|
|
213
273
|
private history: AgentMessage[] = [];
|
|
274
|
+
/** Ledger entry id for each private-history message; used by compact checkpoints to retain a suffix without copying it. */
|
|
275
|
+
private historyEntryIds: Array<string | undefined> = [];
|
|
214
276
|
private pending: PendingUpdate | undefined;
|
|
215
277
|
private running = false;
|
|
216
278
|
/** Invalidates stale/hard-timed-out flush finalizers across session changes. */
|
|
@@ -300,6 +362,7 @@ export class Contemplator {
|
|
|
300
362
|
this.consecutiveFlushFailures = 0;
|
|
301
363
|
this.agentActiveSince = undefined;
|
|
302
364
|
this.history = [];
|
|
365
|
+
this.historyEntryIds = [];
|
|
303
366
|
this.pending = undefined;
|
|
304
367
|
this.seenObservationIds.clear();
|
|
305
368
|
this.seenReviewIds.clear();
|
|
@@ -441,6 +504,8 @@ export class Contemplator {
|
|
|
441
504
|
if (this.running && !resetTracking) return;
|
|
442
505
|
if (tipId === this.restoredTipId && !resetTracking) return;
|
|
443
506
|
this.history = [];
|
|
507
|
+
this.historyEntryIds = [];
|
|
508
|
+
const historyMessagesByEntryId = new Map<string, AgentMessage>();
|
|
444
509
|
let resetProjection: ReturnType<typeof fullProjection> | undefined;
|
|
445
510
|
if (resetTracking) {
|
|
446
511
|
this.deliveredProbeIds.clear();
|
|
@@ -468,14 +533,27 @@ export class Contemplator {
|
|
|
468
533
|
for (const entry of entries) {
|
|
469
534
|
if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
|
|
470
535
|
const state = entry.data as { history?: unknown };
|
|
471
|
-
if (Array.isArray(state.history))
|
|
536
|
+
if (Array.isArray(state.history)) {
|
|
537
|
+
this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
|
|
538
|
+
this.historyEntryIds = this.history.map(() => undefined);
|
|
539
|
+
}
|
|
472
540
|
}
|
|
473
541
|
if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
|
|
474
|
-
const data = entry.data as { message?: unknown; compacted?: unknown };
|
|
542
|
+
const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown };
|
|
475
543
|
const message = data.message;
|
|
476
544
|
if (message && typeof message === "object") {
|
|
477
|
-
|
|
478
|
-
|
|
545
|
+
const typedMessage = message as AgentMessage;
|
|
546
|
+
historyMessagesByEntryId.set(entry.id, typedMessage);
|
|
547
|
+
if (data.compacted === true) {
|
|
548
|
+
const retainedIds = Array.isArray(data.retainedMessageEntryIds)
|
|
549
|
+
? data.retainedMessageEntryIds.filter((id): id is string => typeof id === "string" && historyMessagesByEntryId.has(id))
|
|
550
|
+
: [];
|
|
551
|
+
this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
|
|
552
|
+
this.historyEntryIds = [entry.id, ...retainedIds];
|
|
553
|
+
} else {
|
|
554
|
+
this.history.push(typedMessage);
|
|
555
|
+
this.historyEntryIds.push(entry.id);
|
|
556
|
+
}
|
|
479
557
|
}
|
|
480
558
|
}
|
|
481
559
|
if (entry.customType === OM_REVIEWER_STATE && entry.data && typeof entry.data === "object") {
|
|
@@ -862,7 +940,7 @@ export class Contemplator {
|
|
|
862
940
|
for (const message of runMessages) {
|
|
863
941
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
864
942
|
this.history.push(message);
|
|
865
|
-
this.
|
|
943
|
+
this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
|
|
866
944
|
promptPersisted = true;
|
|
867
945
|
this.markTipPersisted(ctx);
|
|
868
946
|
}
|
|
@@ -892,7 +970,13 @@ export class Contemplator {
|
|
|
892
970
|
}
|
|
893
971
|
if (sessionGeneration === this.sessionGeneration) {
|
|
894
972
|
workerWatchdog.progress();
|
|
895
|
-
|
|
973
|
+
try {
|
|
974
|
+
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
|
|
975
|
+
} catch (compactionError) {
|
|
976
|
+
// The intervention and its durable messages are already complete. Private
|
|
977
|
+
// history maintenance must never relabel that successful work as failed.
|
|
978
|
+
debugLog("contemplator.compaction_postponed", { reason: compactionError instanceof Error ? compactionError.message : String(compactionError) });
|
|
979
|
+
}
|
|
896
980
|
}
|
|
897
981
|
} catch (error) {
|
|
898
982
|
failed = true;
|
|
@@ -1134,6 +1218,24 @@ export class Contemplator {
|
|
|
1134
1218
|
}
|
|
1135
1219
|
}
|
|
1136
1220
|
|
|
1221
|
+
/**
|
|
1222
|
+
* Append one private-history entry and attribute its synchronous ledger id.
|
|
1223
|
+
* Pi does not return the id from appendEntry(), so prefer the exact data
|
|
1224
|
+
* object and accept a cloned payload only when exactly one matching entry was
|
|
1225
|
+
* appended. Ambiguity returns undefined rather than pointing at another
|
|
1226
|
+
* writer's message.
|
|
1227
|
+
*/
|
|
1228
|
+
private appendContemplatorHistoryEntry(ctx: MemoryUpdateCtx, data: Record<string, unknown>): string | undefined {
|
|
1229
|
+
const branch = ctx.sessionManager.getBranch() as readonly Entry[];
|
|
1230
|
+
const before = branch.length;
|
|
1231
|
+
this.pi.appendEntry(CONTEMPLATOR_MESSAGE, data);
|
|
1232
|
+
const candidates = (ctx.sessionManager.getBranch() as readonly Entry[])
|
|
1233
|
+
.slice(before)
|
|
1234
|
+
.filter((entry) => entry.customType === CONTEMPLATOR_MESSAGE);
|
|
1235
|
+
return candidates.find((entry) => entry.data === data)?.id
|
|
1236
|
+
?? (candidates.length === 1 ? candidates[0].id : undefined);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1137
1239
|
private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
|
|
1138
1240
|
this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
|
|
1139
1241
|
return this.restoredTipId;
|
|
@@ -1157,27 +1259,73 @@ export class Contemplator {
|
|
|
1157
1259
|
return own?.id;
|
|
1158
1260
|
}
|
|
1159
1261
|
|
|
1160
|
-
private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1262
|
+
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
|
|
1263
|
+
const history = this.history.slice();
|
|
1264
|
+
const historyEntryIds = this.historyEntryIds.slice();
|
|
1265
|
+
const historyTokens = contemplatorHistoryTokens(history);
|
|
1266
|
+
const triggerTokens = contemplatorHistoryTriggerTokens(model);
|
|
1267
|
+
if (history.length < 12 || historyTokens < triggerTokens) return;
|
|
1268
|
+
|
|
1269
|
+
const keepRecentTokens = Math.min(CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS, Math.max(1, Math.floor(triggerTokens * 0.5)));
|
|
1270
|
+
const initialPrefixEnd = recentHistoryStart(history, historyEntryIds, keepRecentTokens);
|
|
1271
|
+
if (initialPrefixEnd === undefined) return;
|
|
1272
|
+
const previousMessageCount = history.length;
|
|
1164
1273
|
debugLog("contemplator.compaction_start", {
|
|
1165
1274
|
historyMessageCount: previousMessageCount,
|
|
1166
|
-
|
|
1275
|
+
historyTokens,
|
|
1276
|
+
triggerTokens,
|
|
1277
|
+
keepRecentTokens,
|
|
1278
|
+
prefixMessageCount: initialPrefixEnd,
|
|
1279
|
+
retainedMessageCount: history.length - initialPrefixEnd,
|
|
1167
1280
|
});
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1281
|
+
|
|
1282
|
+
const summarizePrefix = async (prefixEnd: number) => generateSummaryWithUsage(
|
|
1283
|
+
history.slice(0, prefixEnd),
|
|
1284
|
+
model,
|
|
1285
|
+
CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
|
|
1286
|
+
apiKey,
|
|
1287
|
+
headers,
|
|
1288
|
+
signal,
|
|
1289
|
+
CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
let prefixEnd = initialPrefixEnd;
|
|
1293
|
+
let summaryWithUsage: Awaited<ReturnType<typeof generateSummaryWithUsage>>;
|
|
1294
|
+
try {
|
|
1295
|
+
summaryWithUsage = await summarizePrefix(prefixEnd);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
1298
|
+
const fallbackEnd = /generation hit the token cap/i.test(failure)
|
|
1299
|
+
? smallerPrefixEnd(history, initialPrefixEnd)
|
|
1300
|
+
: undefined;
|
|
1301
|
+
if (fallbackEnd === undefined) {
|
|
1302
|
+
debugLog("contemplator.compaction_postponed", { reason: failure, historyTokens, prefixMessageCount: prefixEnd });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
prefixEnd = fallbackEnd;
|
|
1306
|
+
debugLog("contemplator.compaction_retry_smaller_prefix", { reason: failure, prefixMessageCount: prefixEnd, retainedMessageCount: history.length - prefixEnd });
|
|
1307
|
+
try {
|
|
1308
|
+
summaryWithUsage = await summarizePrefix(prefixEnd);
|
|
1309
|
+
} catch (fallbackError) {
|
|
1310
|
+
debugLog("contemplator.compaction_postponed", {
|
|
1311
|
+
reason: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
|
1312
|
+
historyTokens,
|
|
1313
|
+
prefixMessageCount: prefixEnd,
|
|
1314
|
+
});
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1170
1319
|
this.runtime.recordAgentUsage(summaryWithUsage.usage);
|
|
1171
1320
|
if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
|
|
1172
1321
|
debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
|
|
1173
1322
|
return;
|
|
1174
1323
|
}
|
|
1175
|
-
const summary = summaryWithUsage.text;
|
|
1176
1324
|
const summaryModel = model as Model<any> & { api?: unknown; provider?: string; id?: string };
|
|
1177
1325
|
const summaryUsage = summaryWithUsage.usage;
|
|
1178
|
-
|
|
1326
|
+
const summaryMessage = {
|
|
1179
1327
|
role: "assistant",
|
|
1180
|
-
content: [{ type: "text", text: `Previous contemplator context summary:\n${
|
|
1328
|
+
content: [{ type: "text", text: `Previous contemplator context summary:\n${summaryWithUsage.text}` }],
|
|
1181
1329
|
api: summaryModel.api,
|
|
1182
1330
|
provider: summaryModel.provider ?? "unknown",
|
|
1183
1331
|
model: summaryModel.id ?? "contemplator",
|
|
@@ -1191,12 +1339,37 @@ export class Contemplator {
|
|
|
1191
1339
|
},
|
|
1192
1340
|
stopReason: "stop",
|
|
1193
1341
|
timestamp: Date.now(),
|
|
1194
|
-
} as AgentMessage
|
|
1195
|
-
|
|
1342
|
+
} as AgentMessage;
|
|
1343
|
+
// A flush currently owns private-history mutation while this awaits the
|
|
1344
|
+
// model, and session/tree movement invalidates the generation above. Still,
|
|
1345
|
+
// preserve any future append-only writer rather than replacing a stale
|
|
1346
|
+
// snapshot and silently dropping messages. Non-append mutation is unsafe to
|
|
1347
|
+
// merge, so postpone and let a later pass compact the live transcript.
|
|
1348
|
+
const liveHistory = this.history;
|
|
1349
|
+
const liveHistoryEntryIds = this.historyEntryIds;
|
|
1350
|
+
const snapshotIsLivePrefix = liveHistory.length >= history.length
|
|
1351
|
+
&& history.every((message, index) => liveHistory[index] === message && liveHistoryEntryIds[index] === historyEntryIds[index]);
|
|
1352
|
+
if (!snapshotIsLivePrefix) {
|
|
1353
|
+
debugLog("contemplator.compaction_postponed", { reason: "private history changed during compaction" });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const retainedMessages = liveHistory.slice(prefixEnd);
|
|
1357
|
+
const retainedMessageEntryIds = liveHistoryEntryIds.slice(prefixEnd).filter((id): id is string => typeof id === "string");
|
|
1358
|
+
if (retainedMessageEntryIds.length !== retainedMessages.length) {
|
|
1359
|
+
debugLog("contemplator.compaction_postponed", { reason: "retained history lacked durable entry ids", retainedMessageCount: retainedMessages.length, retainedReferenceCount: retainedMessageEntryIds.length });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const checkpoint = { version: 2, compacted: true, message: summaryMessage, retainedMessageEntryIds };
|
|
1363
|
+
const checkpointEntryId = this.appendContemplatorHistoryEntry(ctx, checkpoint);
|
|
1364
|
+
this.history = [summaryMessage, ...retainedMessages];
|
|
1365
|
+
this.historyEntryIds = [checkpointEntryId, ...retainedMessageEntryIds];
|
|
1366
|
+
this.markTipPersisted(ctx);
|
|
1196
1367
|
debugLog("contemplator.compaction_complete", {
|
|
1197
1368
|
previousMessageCount,
|
|
1198
1369
|
newMessageCount: this.history.length,
|
|
1199
|
-
|
|
1370
|
+
prefixMessageCount: prefixEnd,
|
|
1371
|
+
retainedMessageCount: retainedMessages.length,
|
|
1372
|
+
summaryLength: summaryWithUsage.text.length,
|
|
1200
1373
|
});
|
|
1201
1374
|
}
|
|
1202
1375
|
}
|