pi-condense 2.5.0 → 2.7.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/CHANGELOG.md +20 -0
- package/PRUNING.md +138 -23
- package/README.md +17 -1
- package/index.ts +305 -116
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +168 -5
- package/src/commands.ts +44 -11
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +89 -10
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import { CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY, CUSTOM_TYPE_DEDUP_ALIAS } from "./types.js";
|
|
4
|
+
import type { CapturedBatch } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const batch = (
|
|
7
|
+
turnIndex: number,
|
|
8
|
+
timestamp: number,
|
|
9
|
+
calls: { id: string; ts?: number; text: string }[],
|
|
10
|
+
): CapturedBatch => ({
|
|
11
|
+
turnIndex,
|
|
12
|
+
timestamp,
|
|
13
|
+
assistantText: "",
|
|
14
|
+
toolCalls: calls.map((c) => ({
|
|
15
|
+
toolCallId: c.id,
|
|
16
|
+
toolName: "bash",
|
|
17
|
+
args: { cmd: "ls" },
|
|
18
|
+
resultText: c.text,
|
|
19
|
+
isError: false,
|
|
20
|
+
...(c.ts !== undefined ? { resultTimestamp: c.ts } : {}),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("occurrence keying", () => {
|
|
25
|
+
test("two batches reusing one provider id keep both records", () => {
|
|
26
|
+
const indexer = new ToolCallIndexer();
|
|
27
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
28
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
29
|
+
|
|
30
|
+
expect(indexer.getRecord("bash_23@1150")?.resultText).toBe("FIRST");
|
|
31
|
+
expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("SECOND");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("isSummarized is keyed by occurrence, so a live reused id is not summarized", () => {
|
|
35
|
+
const indexer = new ToolCallIndexer();
|
|
36
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
37
|
+
|
|
38
|
+
expect(indexer.isSummarized("bash_23@1150")).toBe(true);
|
|
39
|
+
expect(indexer.isSummarized("bash_23@3150")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("getRecordsForId returns every occurrence of a bare id, chronologically", () => {
|
|
43
|
+
const indexer = new ToolCallIndexer();
|
|
44
|
+
indexer.addBatch(batch(0, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
45
|
+
indexer.addBatch(batch(1, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
46
|
+
|
|
47
|
+
const records = indexer.getRecordsForId("bash_23");
|
|
48
|
+
expect(records.map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("getRecordsForId on an occurrence key or short ref returns exactly one", () => {
|
|
52
|
+
const indexer = new ToolCallIndexer();
|
|
53
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
54
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
55
|
+
|
|
56
|
+
expect(indexer.getRecordsForId("bash_23@1150").map((r) => r.resultText)).toEqual(["FIRST"]);
|
|
57
|
+
expect(indexer.getRecordsForId("t1").map((r) => r.resultText)).toEqual(["FIRST"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("short refs resolve per occurrence", () => {
|
|
61
|
+
const indexer = new ToolCallIndexer();
|
|
62
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
63
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
64
|
+
indexer.registerSummaryRefs([
|
|
65
|
+
{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
|
|
66
|
+
{ shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
expect(indexer.getRecord("t1")?.resultText).toBe("FIRST");
|
|
70
|
+
expect(indexer.getRecord("t2")?.resultText).toBe("SECOND");
|
|
71
|
+
expect(indexer.getShortRefForToolCallId("bash_23@2150")).toBe("t2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("allocateSummaryRefs mints refs carrying the occurrence timestamp", () => {
|
|
75
|
+
const indexer = new ToolCallIndexer();
|
|
76
|
+
const b = batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]);
|
|
77
|
+
const refs = indexer.allocateSummaryRefs(b);
|
|
78
|
+
|
|
79
|
+
expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("content dedup does not alias across batches that merely share an id", () => {
|
|
83
|
+
const indexer = new ToolCallIndexer();
|
|
84
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
85
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
86
|
+
|
|
87
|
+
// Each occurrence's content resolves to its OWN occurrence key, never
|
|
88
|
+
// to the other occurrence merely because they share a bare id.
|
|
89
|
+
expect(indexer.lookupByContent("bash", "FIRST")).toBe("bash_23@1150");
|
|
90
|
+
expect(indexer.lookupByContent("bash", "SECOND")).toBe("bash_23@2150");
|
|
91
|
+
expect(indexer.lookupByContent("bash", "UNSEEN")).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("registerDuplicate persists both occurrence sides and reuses the short ref", () => {
|
|
95
|
+
const indexer = new ToolCallIndexer();
|
|
96
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
97
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
98
|
+
|
|
99
|
+
let persisted: any;
|
|
100
|
+
indexer.registerDuplicate("bash_99@4150", "bash_23@1150", (customType, data) => {
|
|
101
|
+
persisted = { customType, data };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(persisted).toEqual({
|
|
105
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
106
|
+
data: {
|
|
107
|
+
newToolCallId: "bash_99",
|
|
108
|
+
newResultTimestamp: 4150,
|
|
109
|
+
originalToolCallId: "bash_23",
|
|
110
|
+
originalResultTimestamp: 1150,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect(indexer.isSummarized("bash_99@4150")).toBe(true);
|
|
114
|
+
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("SAME");
|
|
115
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("getRecordsForId includes a dedup-alias occurrence, labelled with its own timestamp", () => {
|
|
119
|
+
const indexer = new ToolCallIndexer();
|
|
120
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
121
|
+
// Alias shares the SAME bare id as the original but occurred later - the
|
|
122
|
+
// G5 conformance shape: a content-deduplicated collision on a reused id.
|
|
123
|
+
indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
|
|
124
|
+
|
|
125
|
+
const records = indexer.getRecordsForId("bash_23");
|
|
126
|
+
expect(records).toHaveLength(2);
|
|
127
|
+
expect(records.map((r) => r.resultText)).toEqual(["SAME", "SAME"]);
|
|
128
|
+
expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("summary body lookups are occurrence-keyed", () => {
|
|
132
|
+
const indexer = new ToolCallIndexer();
|
|
133
|
+
indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST");
|
|
134
|
+
|
|
135
|
+
expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@1150"])).toBe(true);
|
|
136
|
+
expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@3150"])).toBe(false);
|
|
137
|
+
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@1150"])).toBe("summary of FIRST");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("fail-closed asymmetry: isSummarized vs bare-id resolution", () => {
|
|
142
|
+
test("single occurrence: isSummarized on the bare id is strict-false, resolveToolCallId/getRecord fall back", () => {
|
|
143
|
+
const indexer = new ToolCallIndexer();
|
|
144
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
145
|
+
|
|
146
|
+
expect(indexer.isSummarized("bash_23")).toBe(false);
|
|
147
|
+
expect(indexer.resolveToolCallId("bash_23")).toBe("bash_23@1150");
|
|
148
|
+
expect(indexer.getRecord("bash_23")?.resultText).toBe("FIRST");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("two occurrences: resolveToolCallId on the bare id is ambiguous, getRecordsForId expands both", () => {
|
|
152
|
+
const indexer = new ToolCallIndexer();
|
|
153
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
154
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
155
|
+
|
|
156
|
+
expect(indexer.resolveToolCallId("bash_23")).toBeUndefined();
|
|
157
|
+
expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("mixed legacy + new occurrence under the same bare id: hasLegacyBareRecord is false (fail-closed), getRecordsForId returns both", () => {
|
|
161
|
+
const indexer = new ToolCallIndexer();
|
|
162
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", text: "OLD" }]), () => {});
|
|
163
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "NEW" }]), () => {});
|
|
164
|
+
|
|
165
|
+
// A mixed bare+occurrence id must fail closed: the pruner's legacy-bare
|
|
166
|
+
// path would otherwise stub an unrelated live occurrence with the stale
|
|
167
|
+
// legacy record (F1 regression - see pruner.test.ts).
|
|
168
|
+
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
169
|
+
expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["OLD", "NEW"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("hasLegacyBareRecord: true for pure-legacy, false for pure-occurrence, false for mixed", () => {
|
|
173
|
+
const legacyOnly = new ToolCallIndexer();
|
|
174
|
+
legacyOnly.addBatch(batch(0, 1000, [{ id: "bash_1", text: "OLD" }]), () => {});
|
|
175
|
+
expect(legacyOnly.hasLegacyBareRecord("bash_1")).toBe(true);
|
|
176
|
+
|
|
177
|
+
const occOnly = new ToolCallIndexer();
|
|
178
|
+
occOnly.addBatch(batch(0, 1000, [{ id: "bash_2", ts: 1150, text: "NEW" }]), () => {});
|
|
179
|
+
expect(occOnly.hasLegacyBareRecord("bash_2")).toBe(false);
|
|
180
|
+
|
|
181
|
+
const mixed = new ToolCallIndexer();
|
|
182
|
+
mixed.addBatch(batch(0, 1000, [{ id: "bash_3", text: "OLD" }]), () => {});
|
|
183
|
+
mixed.addBatch(batch(1, 2000, [{ id: "bash_3", ts: 2150, text: "NEW" }]), () => {});
|
|
184
|
+
expect(mixed.hasLegacyBareRecord("bash_3")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("rebuild is order-independent: a dedup alias entry appearing BEFORE its summary entry still inherits the short ref", () => {
|
|
188
|
+
const indexEntry = {
|
|
189
|
+
type: "custom",
|
|
190
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
191
|
+
data: {
|
|
192
|
+
toolCalls: [
|
|
193
|
+
{
|
|
194
|
+
toolCallId: "bash_23",
|
|
195
|
+
toolName: "bash",
|
|
196
|
+
args: {},
|
|
197
|
+
resultText: "FIRST",
|
|
198
|
+
isError: false,
|
|
199
|
+
turnIndex: 0,
|
|
200
|
+
timestamp: 1000,
|
|
201
|
+
resultTimestamp: 1150,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const dedupEntry = {
|
|
207
|
+
type: "custom",
|
|
208
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
209
|
+
data: {
|
|
210
|
+
newToolCallId: "bash_99",
|
|
211
|
+
newResultTimestamp: 4150,
|
|
212
|
+
originalToolCallId: "bash_23",
|
|
213
|
+
originalResultTimestamp: 1150,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const summaryEntry = {
|
|
217
|
+
type: "custom_message",
|
|
218
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
219
|
+
content: "summary text",
|
|
220
|
+
details: {
|
|
221
|
+
toolCallRefs: [{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }],
|
|
222
|
+
toolNames: ["bash"],
|
|
223
|
+
turnIndex: 0,
|
|
224
|
+
timestamp: 1000,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const indexer = new ToolCallIndexer();
|
|
229
|
+
// dedup entry appears BEFORE the summary entry in the branch, unlike every other test here.
|
|
230
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, dedupEntry, summaryEntry] } } as any;
|
|
231
|
+
indexer.reconstructFromSession(ctx);
|
|
232
|
+
|
|
233
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("session rebuild", () => {
|
|
238
|
+
test("rebuilds occurrence records, both short refs and the dedup alias", () => {
|
|
239
|
+
const indexEntry = {
|
|
240
|
+
type: "custom",
|
|
241
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
242
|
+
data: {
|
|
243
|
+
toolCalls: [
|
|
244
|
+
{
|
|
245
|
+
toolCallId: "bash_23",
|
|
246
|
+
toolName: "bash",
|
|
247
|
+
args: {},
|
|
248
|
+
resultText: "FIRST",
|
|
249
|
+
isError: false,
|
|
250
|
+
turnIndex: 0,
|
|
251
|
+
timestamp: 1000,
|
|
252
|
+
resultTimestamp: 1150,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
toolCallId: "bash_23",
|
|
256
|
+
toolName: "bash",
|
|
257
|
+
args: {},
|
|
258
|
+
resultText: "SECOND",
|
|
259
|
+
isError: false,
|
|
260
|
+
turnIndex: 1,
|
|
261
|
+
timestamp: 2000,
|
|
262
|
+
resultTimestamp: 2150,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const summaryEntry = {
|
|
268
|
+
type: "custom_message",
|
|
269
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
270
|
+
content: "summary text",
|
|
271
|
+
details: {
|
|
272
|
+
toolCallRefs: [
|
|
273
|
+
{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
|
|
274
|
+
{ shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
|
|
275
|
+
],
|
|
276
|
+
toolNames: ["bash", "bash"],
|
|
277
|
+
turnIndex: 1,
|
|
278
|
+
timestamp: 2000,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const dedupEntry = {
|
|
282
|
+
type: "custom",
|
|
283
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
284
|
+
data: {
|
|
285
|
+
newToolCallId: "bash_99",
|
|
286
|
+
newResultTimestamp: 4150,
|
|
287
|
+
originalToolCallId: "bash_23",
|
|
288
|
+
originalResultTimestamp: 1150,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const indexer = new ToolCallIndexer();
|
|
293
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, summaryEntry, dedupEntry] } } as any;
|
|
294
|
+
indexer.reconstructFromSession(ctx);
|
|
295
|
+
|
|
296
|
+
expect(indexer.getRecord("t1")?.resultText).toBe("FIRST");
|
|
297
|
+
expect(indexer.getRecord("t2")?.resultText).toBe("SECOND");
|
|
298
|
+
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST");
|
|
299
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
300
|
+
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("legacy entries without resultTimestamp keep bare-id keys", () => {
|
|
304
|
+
const indexEntry = {
|
|
305
|
+
type: "custom",
|
|
306
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
307
|
+
data: {
|
|
308
|
+
toolCalls: [
|
|
309
|
+
{
|
|
310
|
+
toolCallId: "bash_7",
|
|
311
|
+
toolName: "bash",
|
|
312
|
+
args: {},
|
|
313
|
+
resultText: "OLD",
|
|
314
|
+
isError: false,
|
|
315
|
+
turnIndex: 0,
|
|
316
|
+
timestamp: 1000,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const legacyDedupEntry = {
|
|
322
|
+
type: "custom",
|
|
323
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
324
|
+
data: { newToolCallId: "bash_8", originalToolCallId: "bash_7" },
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const indexer = new ToolCallIndexer();
|
|
328
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, legacyDedupEntry] } } as any;
|
|
329
|
+
indexer.reconstructFromSession(ctx);
|
|
330
|
+
|
|
331
|
+
expect(indexer.getRecord("bash_7")?.resultText).toBe("OLD");
|
|
332
|
+
expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
|
|
333
|
+
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
334
|
+
expect(indexer.isSummarized("bash_8")).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
});
|