pi-posthorse 0.8.0 → 0.8.2
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/README.md +20 -6
- package/index.ts +42 -1555
- package/package.json +33 -7
- package/src/budget.ts +168 -0
- package/src/context-tools.ts +113 -0
- package/src/contracts.ts +124 -0
- package/src/files.ts +182 -0
- package/src/history-entries.ts +251 -0
- package/src/history-pages.ts +173 -0
- package/src/history-search.ts +308 -0
- package/src/history.ts +146 -0
- package/src/message-text.ts +139 -0
- package/src/notes.ts +314 -0
- package/src/paging.ts +156 -0
- package/src/recovery-results.ts +111 -0
- package/src/recovery.ts +377 -0
- package/src/reminders.ts +116 -0
- package/src/rollover.ts +330 -0
- package/src/session-files.ts +264 -0
- package/src/ui/cards.ts +355 -0
- package/src/ui/display.ts +312 -0
- package/src/ui/text.ts +53 -0
- package/ui.ts +44 -163
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRecord,
|
|
3
|
+
isReminderType,
|
|
4
|
+
unknownArray,
|
|
5
|
+
type EntryLike,
|
|
6
|
+
type HistoryHit,
|
|
7
|
+
type MessageLike,
|
|
8
|
+
type WindowedEntry,
|
|
9
|
+
} from "./contracts.ts";
|
|
10
|
+
import {
|
|
11
|
+
assistantFailure,
|
|
12
|
+
excerptAround,
|
|
13
|
+
imageSummary,
|
|
14
|
+
imagesOf,
|
|
15
|
+
isRecoveryCall,
|
|
16
|
+
isRecoveryTool,
|
|
17
|
+
shortKey,
|
|
18
|
+
sourceTag,
|
|
19
|
+
textOf,
|
|
20
|
+
toolIdentity,
|
|
21
|
+
} from "./message-text.ts";
|
|
22
|
+
|
|
23
|
+
export function isWindow(entry: EntryLike): boolean {
|
|
24
|
+
return (
|
|
25
|
+
entry.type === "context_window" ||
|
|
26
|
+
(entry.type === "compaction" && isRecord(entry.details) && entry.details.posthorse === 1)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
export function windowHandoff(entry: EntryLike | undefined): string | undefined {
|
|
30
|
+
return entry?.type === "compaction" ? entry.summary : entry?.handoff;
|
|
31
|
+
}
|
|
32
|
+
/** First active branch entry, including tails retained by Pi's own /compact. */
|
|
33
|
+
export function contextStart(entries: readonly EntryLike[]): {
|
|
34
|
+
start: number;
|
|
35
|
+
boundary?: EntryLike;
|
|
36
|
+
} {
|
|
37
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
38
|
+
const entry = entries[i];
|
|
39
|
+
if (entry.type === "context_window") {
|
|
40
|
+
return { start: i + 1, boundary: entry };
|
|
41
|
+
}
|
|
42
|
+
if (entry.type === "compaction") {
|
|
43
|
+
const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
44
|
+
return { start: kept >= 0 ? kept : i + 1, boundary: entry };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { start: 0 };
|
|
48
|
+
}
|
|
49
|
+
export function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
|
|
50
|
+
return new Map(
|
|
51
|
+
entries.flatMap((entry) =>
|
|
52
|
+
entry.type === "context_edit" && entry.targetId !== undefined && entry.targetId !== ""
|
|
53
|
+
? [[entry.targetId, entry]]
|
|
54
|
+
: [],
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
function flattenMessage(message: MessageLike): string {
|
|
59
|
+
if (message.role === "bashExecution") {
|
|
60
|
+
if (message.excludeFromContext === true) {
|
|
61
|
+
return "[bashExecution] (excluded from model context by Pi)";
|
|
62
|
+
}
|
|
63
|
+
return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
|
|
64
|
+
}
|
|
65
|
+
const identity =
|
|
66
|
+
message.role === "toolResult"
|
|
67
|
+
? `Tool: ${toolIdentity(message.toolName, message.namespace)}${message.toolCallId === undefined ? "" : `\nCall ID: ${message.toolCallId}`}`
|
|
68
|
+
: "";
|
|
69
|
+
const parts = [
|
|
70
|
+
textOf(message),
|
|
71
|
+
assistantFailure(message),
|
|
72
|
+
imageSummary(imagesOf(message.content)),
|
|
73
|
+
identity,
|
|
74
|
+
];
|
|
75
|
+
return `[${message.role ?? "message"}] ${parts.filter((part) => part !== "").join("\n")}`;
|
|
76
|
+
}
|
|
77
|
+
export function flattenEntry(entry: EntryLike): string | undefined {
|
|
78
|
+
switch (entry.type) {
|
|
79
|
+
case "message":
|
|
80
|
+
return flattenMessage(entry.message ?? {});
|
|
81
|
+
case "compaction":
|
|
82
|
+
case "branch_summary":
|
|
83
|
+
return `[${entry.type}] ${entry.summary ?? ""}`;
|
|
84
|
+
case "context_window":
|
|
85
|
+
return `[context_window] ${entry.handoff === undefined || entry.handoff === "" ? "No handoff" : `Handoff: ${entry.handoff}`}`;
|
|
86
|
+
case "custom_message":
|
|
87
|
+
return `[custom:${entry.customType ?? "unknown"}] ${contentText(entry.content)}`;
|
|
88
|
+
case "context_edit":
|
|
89
|
+
return entry.replacement === undefined || entry.replacement === null
|
|
90
|
+
? undefined
|
|
91
|
+
: `[context_edit] ${contentText(entry.replacement.content)}`;
|
|
92
|
+
case undefined:
|
|
93
|
+
return undefined;
|
|
94
|
+
default:
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function contentText(content: unknown): string {
|
|
99
|
+
return [textOf({ content }), imageSummary(imagesOf(content))]
|
|
100
|
+
.filter((part) => part !== "")
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
103
|
+
function ownAssistantText(message: MessageLike, images: WindowedEntry["images"]): string[] {
|
|
104
|
+
const originals = [""];
|
|
105
|
+
for (const part of unknownArray(message.content) ?? []) {
|
|
106
|
+
if (isRecoveryCall(part)) {
|
|
107
|
+
originals.push("");
|
|
108
|
+
} else {
|
|
109
|
+
const partText = textOf({ content: [part] });
|
|
110
|
+
const index = originals.length - 1;
|
|
111
|
+
if (partText !== "") {
|
|
112
|
+
originals[index] += `${originals[index] === "" ? "" : "\n"}${partText}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const metadata = [assistantFailure(message), imageSummary(images)]
|
|
117
|
+
.filter((part) => part !== "")
|
|
118
|
+
.join("\n");
|
|
119
|
+
const last = originals.length - 1;
|
|
120
|
+
if (metadata !== "") {
|
|
121
|
+
originals[last] += `${originals[last] === "" ? "" : "\n"}${metadata}`;
|
|
122
|
+
}
|
|
123
|
+
if (originals[0] !== "") {
|
|
124
|
+
originals[0] = `[assistant] ${originals[0]}`;
|
|
125
|
+
}
|
|
126
|
+
return originals;
|
|
127
|
+
}
|
|
128
|
+
function echoPriority(entry: EntryLike): 0 | 1 {
|
|
129
|
+
if (
|
|
130
|
+
entry.type === "context_window" ||
|
|
131
|
+
entry.type === "compaction" ||
|
|
132
|
+
entry.type === "branch_summary"
|
|
133
|
+
) {
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
if (entry.type === "custom_message" && isReminderType(entry.customType)) {
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
return entry.type === "message" &&
|
|
140
|
+
entry.message?.role === "toolResult" &&
|
|
141
|
+
isRecoveryTool(entry.message.toolName)
|
|
142
|
+
? 1
|
|
143
|
+
: 0;
|
|
144
|
+
}
|
|
145
|
+
function originalMatch(
|
|
146
|
+
item: WindowedEntry,
|
|
147
|
+
query: string,
|
|
148
|
+
): { readonly text?: string; readonly mixed: boolean } {
|
|
149
|
+
const message = item.entry.message;
|
|
150
|
+
if (item.entry.type !== "message" || message?.role !== "assistant") {
|
|
151
|
+
return { mixed: false };
|
|
152
|
+
}
|
|
153
|
+
if (!(unknownArray(message.content) ?? []).some(isRecoveryCall)) {
|
|
154
|
+
return { mixed: false };
|
|
155
|
+
}
|
|
156
|
+
const originals = ownAssistantText(message, item.images);
|
|
157
|
+
const original = originals.find((part) => part.toLowerCase().includes(query));
|
|
158
|
+
if (original === undefined || original === "") {
|
|
159
|
+
return { mixed: true };
|
|
160
|
+
}
|
|
161
|
+
return { mixed: true, text: original === originals[0] ? original : `[assistant] ${original}` };
|
|
162
|
+
}
|
|
163
|
+
export function historyHit(
|
|
164
|
+
item: WindowedEntry,
|
|
165
|
+
query: string,
|
|
166
|
+
source = "",
|
|
167
|
+
): HistoryHit | undefined {
|
|
168
|
+
const { entry } = item;
|
|
169
|
+
const id = entry.id;
|
|
170
|
+
if (id === undefined || id === "" || !item.text.toLowerCase().includes(query)) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const original = originalMatch(item, query);
|
|
174
|
+
const text = original.text ?? item.text;
|
|
175
|
+
let priority = echoPriority(entry);
|
|
176
|
+
if (original.mixed) {
|
|
177
|
+
priority = original.text === undefined ? 1 : 0;
|
|
178
|
+
}
|
|
179
|
+
const qualifiedId = source === "" ? id : `${id}@${shortKey(source)}`;
|
|
180
|
+
const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${qualifiedId}] `;
|
|
181
|
+
return {
|
|
182
|
+
id: qualifiedId,
|
|
183
|
+
priority,
|
|
184
|
+
text: `${header}${excerptAround(text, text.toLowerCase().indexOf(query), 100, 400)}`,
|
|
185
|
+
headerLength: header.length,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function entryContent(entry: EntryLike): unknown {
|
|
189
|
+
switch (entry.type) {
|
|
190
|
+
case "context_edit":
|
|
191
|
+
return entry.replacement?.content;
|
|
192
|
+
case "message":
|
|
193
|
+
return entry.message?.content;
|
|
194
|
+
case undefined:
|
|
195
|
+
return entry.content;
|
|
196
|
+
default:
|
|
197
|
+
return entry.content;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export function toWindowedEntry(
|
|
201
|
+
entry: EntryLike,
|
|
202
|
+
inheritedWindow: string,
|
|
203
|
+
): WindowedEntry | undefined {
|
|
204
|
+
const id = entry.id;
|
|
205
|
+
const text = flattenEntry(entry);
|
|
206
|
+
if (id === undefined || id === "" || text === undefined || text === "") {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
entry,
|
|
211
|
+
windowId: isWindow(entry) ? id : inheritedWindow,
|
|
212
|
+
text,
|
|
213
|
+
images: imagesOf(entryContent(entry)),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Window ancestry is metadata-only; extracting text/images is a separate operation. */
|
|
217
|
+
export function windowProjection(): (entry: EntryLike) => string {
|
|
218
|
+
const windows = new Map<string, string>();
|
|
219
|
+
return (entry) => {
|
|
220
|
+
const inherited = windows.get(entry.parentId ?? "") ?? "initial";
|
|
221
|
+
const windowId = isWindow(entry) ? (entry.id ?? inherited) : inherited;
|
|
222
|
+
if (entry.id !== undefined && entry.id !== "") {
|
|
223
|
+
windows.set(entry.id, windowId);
|
|
224
|
+
}
|
|
225
|
+
return windowId;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function* reverseWindowEntries(entries: readonly EntryLike[]): Generator<WindowedEntry> {
|
|
229
|
+
// Preserve parent-relative window labels without normalizing the whole branch.
|
|
230
|
+
// ponytail: ancestry still scans metadata; a native window-aware iterator could remove that cost.
|
|
231
|
+
const windows = entries.map(windowProjection());
|
|
232
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
233
|
+
const item = toWindowedEntry(entries[i], windows[i]);
|
|
234
|
+
if (item !== undefined) {
|
|
235
|
+
yield item;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function seenText(message: MessageLike): string {
|
|
240
|
+
return [
|
|
241
|
+
textOf(message),
|
|
242
|
+
assistantFailure(message),
|
|
243
|
+
imageSummary(imagesOf(message.content)),
|
|
244
|
+
message.summary,
|
|
245
|
+
message.command,
|
|
246
|
+
message.output,
|
|
247
|
+
]
|
|
248
|
+
.filter((part) => part !== undefined && part !== "")
|
|
249
|
+
.join("\n")
|
|
250
|
+
.toLowerCase();
|
|
251
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MIN_PAGE_CHARS,
|
|
3
|
+
ESTIMATED_IMAGE_CHARS,
|
|
4
|
+
type HistoryHit,
|
|
5
|
+
type PolicyContext,
|
|
6
|
+
type TextResult,
|
|
7
|
+
type WindowedEntry,
|
|
8
|
+
} from "./contracts.ts";
|
|
9
|
+
import { excerpt, shortKey, sourceTag } from "./message-text.ts";
|
|
10
|
+
import type { HistoryMatches, HistoryParams } from "./history-search.ts";
|
|
11
|
+
import type { PageAccess } from "./paging.ts";
|
|
12
|
+
|
|
13
|
+
type SearchPage = {
|
|
14
|
+
readonly parts: readonly string[];
|
|
15
|
+
readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
|
|
16
|
+
readonly next?: string;
|
|
17
|
+
};
|
|
18
|
+
function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
|
|
19
|
+
return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
|
|
20
|
+
"base64url",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
function searchFooter(next: string): string {
|
|
24
|
+
return `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
|
|
25
|
+
}
|
|
26
|
+
function hitFragment(
|
|
27
|
+
hit: HistoryHit,
|
|
28
|
+
cursor: HistoryMatches["cursor"],
|
|
29
|
+
available: number,
|
|
30
|
+
first: boolean,
|
|
31
|
+
):
|
|
32
|
+
| {
|
|
33
|
+
readonly part: string;
|
|
34
|
+
readonly end: number;
|
|
35
|
+
readonly headerLength: number;
|
|
36
|
+
}
|
|
37
|
+
| undefined {
|
|
38
|
+
const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
|
|
39
|
+
if (!first && hit.text.length - offset > available) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const prefix =
|
|
43
|
+
offset > 0 || hit.text.length > available ? `[entry ${hit.id}; from char ${offset}] ` : "";
|
|
44
|
+
if (prefix.length >= available) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const end = Math.min(hit.text.length, offset + available - prefix.length);
|
|
48
|
+
return {
|
|
49
|
+
part: prefix + hit.text.slice(offset, end),
|
|
50
|
+
end,
|
|
51
|
+
headerLength:
|
|
52
|
+
prefix.length + Math.max(0, Math.min(hit.headerLength - offset, end - offset)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function searchParts(matches: HistoryMatches, chars: number): SearchPage {
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
const spans: Array<{ headerLength: number; length: number }> = [];
|
|
58
|
+
let available = chars;
|
|
59
|
+
let next: string | undefined;
|
|
60
|
+
for (const hit of matches.hits) {
|
|
61
|
+
const fragment = hitFragment(hit, matches.cursor, available, parts.length === 0);
|
|
62
|
+
if (fragment === undefined) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
parts.push(fragment.part);
|
|
66
|
+
spans.push({ headerLength: fragment.headerLength, length: fragment.part.length });
|
|
67
|
+
available -= fragment.part.length + 1;
|
|
68
|
+
next =
|
|
69
|
+
fragment.end < hit.text.length || parts.length < matches.hits.length
|
|
70
|
+
? cursorFor(matches, hit, fragment.end)
|
|
71
|
+
: undefined;
|
|
72
|
+
if (available <= 0 || parts.length >= matches.limit) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { parts, spans, next };
|
|
77
|
+
}
|
|
78
|
+
export function searchPage(
|
|
79
|
+
matches: HistoryMatches,
|
|
80
|
+
params: HistoryParams,
|
|
81
|
+
host: PolicyContext,
|
|
82
|
+
pages: PageAccess,
|
|
83
|
+
): TextResult {
|
|
84
|
+
const chars = pages.size(host, { offset: 0, cursor: params.cursor });
|
|
85
|
+
const reserve = Math.max(
|
|
86
|
+
0,
|
|
87
|
+
...matches.hits.map((hit) => searchFooter(cursorFor(matches, hit, hit.text.length)).length),
|
|
88
|
+
);
|
|
89
|
+
if (reserve >= chars) {
|
|
90
|
+
pages.error(host, "History entry id is too large for pagination.", params.cursor);
|
|
91
|
+
}
|
|
92
|
+
const skippedNote =
|
|
93
|
+
matches.skipped > 0 && (params.cursor === undefined || params.cursor === "")
|
|
94
|
+
? `\n[Skipped ${matches.skipped} match${matches.skipped === 1 ? "" : "es"} already in your active context.]`
|
|
95
|
+
: "";
|
|
96
|
+
const page = searchParts(matches, chars - reserve - skippedNote.length);
|
|
97
|
+
const body =
|
|
98
|
+
page.parts.length > 0
|
|
99
|
+
? page.parts.join("\n")
|
|
100
|
+
: `No history matches "${excerpt(params.query ?? "", 200)}".`;
|
|
101
|
+
const tail = skippedNote + (page.next === undefined ? "" : searchFooter(page.next));
|
|
102
|
+
return pages.result(body + tail, [], {
|
|
103
|
+
kind: "history-search",
|
|
104
|
+
entries: [...page.spans],
|
|
105
|
+
footerLength: tail.length,
|
|
106
|
+
more: page.next !== undefined,
|
|
107
|
+
skipped: matches.skipped,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function readOffsets(
|
|
111
|
+
item: WindowedEntry,
|
|
112
|
+
params: HistoryParams,
|
|
113
|
+
): { readonly offset: number; readonly imageOffset: number } {
|
|
114
|
+
const offset = params.offset ?? 0;
|
|
115
|
+
const imageOffset = params.imageOffset ?? (offset === 0 ? 0 : item.images.length);
|
|
116
|
+
const id = params.id ?? "";
|
|
117
|
+
if (imageOffset > item.images.length) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Image offset ${imageOffset} is past the end of history entry "${id}" (${item.images.length} images).`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
offset > item.text.length ||
|
|
124
|
+
(offset === item.text.length && imageOffset === item.images.length)
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Offset ${offset} is past the end of history entry "${id}" (${item.text.length} chars).`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { offset, imageOffset };
|
|
131
|
+
}
|
|
132
|
+
function shownEntryId(item: WindowedEntry, id: string | undefined, source: string): string {
|
|
133
|
+
return source === "" ? (id ?? "") : `${item.entry.id ?? "unknown"}@${shortKey(source)}`;
|
|
134
|
+
}
|
|
135
|
+
export function historyReadPage(
|
|
136
|
+
item: WindowedEntry,
|
|
137
|
+
params: HistoryParams,
|
|
138
|
+
access: { readonly host: PolicyContext; readonly pages: PageAccess },
|
|
139
|
+
source = "",
|
|
140
|
+
): TextResult {
|
|
141
|
+
const shownId = shownEntryId(item, params.id, source);
|
|
142
|
+
const { offset, imageOffset } = readOffsets(item, params);
|
|
143
|
+
const firstImage = imageOffset < item.images.length ? 1 : 0;
|
|
144
|
+
const chars = access.pages.size(access.host, {
|
|
145
|
+
offset,
|
|
146
|
+
imageCount: firstImage,
|
|
147
|
+
imageOffset: item.images.length > 0 ? imageOffset : undefined,
|
|
148
|
+
});
|
|
149
|
+
const imageEnd = Math.min(
|
|
150
|
+
item.images.length,
|
|
151
|
+
imageOffset + firstImage + Math.floor((chars - MIN_PAGE_CHARS) / ESTIMATED_IMAGE_CHARS),
|
|
152
|
+
);
|
|
153
|
+
const images = item.images.slice(imageOffset, imageEnd);
|
|
154
|
+
const end = Math.min(
|
|
155
|
+
item.text.length,
|
|
156
|
+
offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
|
|
157
|
+
);
|
|
158
|
+
const more =
|
|
159
|
+
end < item.text.length || imageEnd < item.images.length
|
|
160
|
+
? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length > 0 ? ` and imageOffset ${imageEnd}` : ""}.`
|
|
161
|
+
: "";
|
|
162
|
+
const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
163
|
+
return access.pages.result(`${header}${item.text.slice(offset, end)}${more}`, images, {
|
|
164
|
+
kind: "history-read",
|
|
165
|
+
headerLength: header.length,
|
|
166
|
+
offset,
|
|
167
|
+
end,
|
|
168
|
+
total: item.text.length,
|
|
169
|
+
imageOffset,
|
|
170
|
+
imageEnd,
|
|
171
|
+
imageTotal: item.images.length,
|
|
172
|
+
});
|
|
173
|
+
}
|