@jskit-ai/assistant-core 0.1.222 → 0.1.223
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
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeText } from "./normalize.js";
|
|
2
|
+
import { conversationLogPage } from "../../shared/conversation/pagination.js";
|
|
2
3
|
|
|
3
4
|
/** Conversation policy, independent of database, filesystem, identity and HTTP. */
|
|
4
5
|
export function createConversationTranscript({ storage, clock = () => new Date(), applicationTurns = false } = {}) {
|
|
@@ -15,30 +16,43 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false } = {}) {
|
|
19
|
+
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false, pageUnit = "turns" } = {}) {
|
|
19
20
|
if (typeof presentation !== "boolean") throw new TypeError("Transcript presentation requires an explicit boolean.");
|
|
21
|
+
if (!["turns", "messages"].includes(pageUnit)) throw new TypeError("Transcript pageUnit must be turns or messages.");
|
|
20
22
|
return storage.read(scope, async (transaction) => {
|
|
21
23
|
const allIds = await transaction.listTurnIds();
|
|
22
|
-
const visible = presentation
|
|
23
|
-
.
|
|
24
|
+
const visible = presentation || pageUnit === "messages"
|
|
25
|
+
? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
|
|
26
|
+
.filter(turn => hasMessages(turn) && (!presentation || !isCompletedEnvelopeTurn(turn))) : null;
|
|
24
27
|
const ids = visible ? visible.map(turn => turn.turnId) : allIds;
|
|
25
28
|
const before = normalizeText(beforeTurnId);
|
|
26
29
|
const requestedLimit = Number.parseInt(String(limit || ""), 10);
|
|
27
30
|
const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
let page;
|
|
32
|
+
if (pageUnit === "messages") {
|
|
33
|
+
page = conversationLogPage(visible, { beforeTurnId: before, limit: pageLimit });
|
|
34
|
+
} else {
|
|
35
|
+
const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
|
|
36
|
+
const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
|
|
37
|
+
const selected = ids.slice(start, end);
|
|
38
|
+
page = {
|
|
39
|
+
conversationLog: visible ? visible.slice(start, end)
|
|
40
|
+
: (await Promise.all(selected.map(id => transaction.readTurn(id)))).filter(hasMessages),
|
|
41
|
+
hasMoreBefore: start > 0,
|
|
42
|
+
nextBeforeTurnId: start > 0 ? ids[start] : ""
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const turns = page.conversationLog;
|
|
33
46
|
return {
|
|
34
47
|
conversationLog: turns,
|
|
35
48
|
pagination: {
|
|
49
|
+
...(pageUnit === "messages" ? { pageUnit } : {}),
|
|
36
50
|
beforeTurnId: before,
|
|
37
51
|
count: turns.length,
|
|
38
|
-
hasMoreBefore:
|
|
52
|
+
hasMoreBefore: page.hasMoreBefore,
|
|
39
53
|
limit: pageLimit,
|
|
40
54
|
newestTurnId: turns.at(-1)?.turnId || "",
|
|
41
|
-
nextBeforeTurnId:
|
|
55
|
+
nextBeforeTurnId: page.nextBeforeTurnId,
|
|
42
56
|
oldestTurnId: turns[0]?.turnId || "",
|
|
43
57
|
totalTurnCount: ids.length
|
|
44
58
|
}
|
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
// Original paged transcript representation, shared by all browser bindings.
|
|
2
|
-
const CONVERSATION_LOG_PAGE_LIMIT =
|
|
2
|
+
const CONVERSATION_LOG_PAGE_LIMIT = 10;
|
|
3
|
+
|
|
4
|
+
const messageRoles = ["system", "user", "assistant"];
|
|
5
|
+
|
|
6
|
+
// Live updates retain whole boundary turns. Reasoning/progress never uses the
|
|
7
|
+
// message allowance, and never evicts the question it follows.
|
|
8
|
+
function conversationLogPageStart(turns, limit, end = turns.length) {
|
|
9
|
+
if (!limit) return 0;
|
|
10
|
+
let remaining = limit;
|
|
11
|
+
let start = end;
|
|
12
|
+
while (start > 0 && remaining > 0) {
|
|
13
|
+
start -= 1;
|
|
14
|
+
remaining -= messageRoles.filter(role => turns[start]?.[role]).length;
|
|
15
|
+
}
|
|
16
|
+
return turns.slice(0, start).some(turn => messageRoles.some(role => turn?.[role])) ? start : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A cursor can stop between the question and answer of one stored turn. It is
|
|
20
|
+
// read-only presentation state; stored turn and message identities never change.
|
|
21
|
+
function conversationLogPage(turns, { beforeTurnId = "", limit = 0 } = {}) {
|
|
22
|
+
const messages = turns.flatMap((turn, index) => messageRoles.filter(role => turn[role])
|
|
23
|
+
.map(role => ({ index, role, turnId: turn.turnId })));
|
|
24
|
+
let boundary = turns.findIndex(turn => turn.turnId === beforeTurnId);
|
|
25
|
+
let beforeRole = "";
|
|
26
|
+
if (boundary < 0 && beforeTurnId.startsWith("message:")) {
|
|
27
|
+
try {
|
|
28
|
+
const [id, role] = JSON.parse(beforeTurnId.slice(8));
|
|
29
|
+
if (messageRoles.includes(role)) {
|
|
30
|
+
boundary = turns.findIndex(turn => turn.turnId === id);
|
|
31
|
+
beforeRole = role;
|
|
32
|
+
}
|
|
33
|
+
} catch { /* An unknown cursor retains the existing latest-page behavior. */ }
|
|
34
|
+
}
|
|
35
|
+
const beforeRoleIndex = beforeRole ? messageRoles.indexOf(beforeRole) : 0;
|
|
36
|
+
const foundEnd = boundary < 0 ? -1 : messages.findIndex(message =>
|
|
37
|
+
message.index > boundary ||
|
|
38
|
+
(message.index === boundary && messageRoles.indexOf(message.role) >= beforeRoleIndex));
|
|
39
|
+
const end = foundEnd < 0 ? messages.length : foundEnd;
|
|
40
|
+
const start = limit > 0 ? Math.max(0, end - limit) : 0;
|
|
41
|
+
const first = messages[start];
|
|
42
|
+
const firstRoleIndex = start > 0 ? messageRoles.indexOf(first.role) : 0;
|
|
43
|
+
const startTurn = start > 0 ? first.index : 0;
|
|
44
|
+
const endTurn = boundary < 0 ? turns.length : boundary + (beforeRole ? 1 : 0);
|
|
45
|
+
const selected = turns.slice(startTurn, endTurn).map((turn, offset) => {
|
|
46
|
+
const index = startTurn + offset;
|
|
47
|
+
const roles = messageRoles.filter(role => turn[role]);
|
|
48
|
+
const fromRole = index === startTurn ? firstRoleIndex : 0;
|
|
49
|
+
const toRole = index === boundary && beforeRole ? beforeRoleIndex : messageRoles.length;
|
|
50
|
+
const keep = roles.filter(role => {
|
|
51
|
+
const position = messageRoles.indexOf(role);
|
|
52
|
+
return position >= fromRole && position < toRole;
|
|
53
|
+
});
|
|
54
|
+
if (keep.length === roles.length) return turn;
|
|
55
|
+
// Progress belongs to the later part of a split turn, next to its answer.
|
|
56
|
+
const keepActivity = keep.includes(roles.at(-1));
|
|
57
|
+
const result = { ...turn, paginationPartial: true,
|
|
58
|
+
thinking: keepActivity ? turn.thinking : [], commentary: keepActivity ? turn.commentary : [] };
|
|
59
|
+
for (const role of messageRoles) if (!keep.includes(role)) result[role] = null;
|
|
60
|
+
result.messages = (turn.messages || [turn.system, turn.user, ...(turn.thinking || []),
|
|
61
|
+
...(turn.commentary || []), turn.assistant].filter(Boolean))
|
|
62
|
+
.filter(message => keep.includes(message.role) || (keepActivity && !messageRoles.includes(message.role)));
|
|
63
|
+
return result;
|
|
64
|
+
}).filter(turn => messageRoles.some(role => turn[role]) || turn.thinking?.length || turn.commentary?.length);
|
|
65
|
+
let nextBeforeTurnId = "";
|
|
66
|
+
if (start > 0) {
|
|
67
|
+
const split = messageRoles.slice(0, firstRoleIndex).some(role => turns[first.index][role]);
|
|
68
|
+
nextBeforeTurnId = split ? `message:${JSON.stringify([first.turnId, first.role])}` : first.turnId;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
conversationLog: selected,
|
|
72
|
+
hasMoreBefore: start > 0,
|
|
73
|
+
nextBeforeTurnId
|
|
74
|
+
};
|
|
75
|
+
}
|
|
3
76
|
|
|
4
77
|
function isRecord(value) {
|
|
5
78
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -8,6 +81,7 @@ function isRecord(value) {
|
|
|
8
81
|
function normalizeConversationLogPagination(pagination = {}) {
|
|
9
82
|
const source = isRecord(pagination) ? pagination : {};
|
|
10
83
|
return {
|
|
84
|
+
...(source.pageUnit === "messages" ? { pageUnit: "messages" } : {}),
|
|
11
85
|
beforeTurnId: String(source.beforeTurnId || "").trim(),
|
|
12
86
|
count: Number.isFinite(Number(source.count)) ? Number(source.count) : 0,
|
|
13
87
|
hasMoreBefore: source.hasMoreBefore === true,
|
|
@@ -47,7 +121,22 @@ function mergeConversationLogPages(pages = []) {
|
|
|
47
121
|
continue;
|
|
48
122
|
}
|
|
49
123
|
if (indexes.has(turnId)) {
|
|
50
|
-
|
|
124
|
+
const index = indexes.get(turnId);
|
|
125
|
+
const previous = orderedTurns[index];
|
|
126
|
+
if (!turn.paginationPartial && !previous.paginationPartial) {
|
|
127
|
+
orderedTurns[index] = turn;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const merged = { ...previous, ...turn };
|
|
131
|
+
for (const role of messageRoles) merged[role] = turn[role] || previous[role];
|
|
132
|
+
const [activity, previousActivity] = [turn, previous].map(part =>
|
|
133
|
+
(part.messages || [...(part.thinking || []), ...(part.commentary || [])])
|
|
134
|
+
.filter(message => !messageRoles.includes(message.role)));
|
|
135
|
+
const retainedActivity = activity.length ? activity : previousActivity;
|
|
136
|
+
merged.thinking = retainedActivity.filter(message => message.role === "thinking");
|
|
137
|
+
merged.commentary = retainedActivity.filter(message => message.role === "commentary");
|
|
138
|
+
merged.messages = [merged.system, merged.user, ...retainedActivity, merged.assistant].filter(Boolean);
|
|
139
|
+
orderedTurns[index] = merged;
|
|
51
140
|
continue;
|
|
52
141
|
}
|
|
53
142
|
indexes.set(turnId, orderedTurns.length);
|
|
@@ -71,6 +160,8 @@ function conversationLogReadQuery({
|
|
|
71
160
|
|
|
72
161
|
export {
|
|
73
162
|
CONVERSATION_LOG_PAGE_LIMIT,
|
|
163
|
+
conversationLogPageStart,
|
|
164
|
+
conversationLogPage,
|
|
74
165
|
normalizeConversationLogPagination,
|
|
75
166
|
normalizeConversationLogPage,
|
|
76
167
|
mergeConversationLogPages,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeThinkingMessageText } from "./thinkingText.js";
|
|
2
|
-
import { normalizeConversationLogPagination } from "./pagination.js";
|
|
2
|
+
import { conversationLogPageStart, normalizeConversationLogPagination } from "./pagination.js";
|
|
3
3
|
|
|
4
4
|
function isRecord(value) {
|
|
5
5
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -125,14 +125,17 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
|
|
|
125
125
|
numeric: true
|
|
126
126
|
}
|
|
127
127
|
));
|
|
128
|
+
const pagination = normalizeConversationLogPagination(source.pagination);
|
|
128
129
|
const limit = Number.parseInt(String(options.limit || ""), 10);
|
|
129
130
|
const limitedTurns = Number.isFinite(limit) && limit > 0
|
|
130
|
-
? nextTurns.slice(-limit)
|
|
131
|
+
? nextTurns.slice(pagination.pageUnit === "messages" ? conversationLogPageStart(nextTurns, limit) : -limit)
|
|
131
132
|
: nextTurns;
|
|
132
133
|
const wasTrimmed = limitedTurns.length < nextTurns.length;
|
|
133
|
-
const pagination = normalizeConversationLogPagination(source.pagination);
|
|
134
134
|
const hasMoreBefore = pagination.hasMoreBefore || wasTrimmed;
|
|
135
135
|
const oldestTurnId = String(limitedTurns[0]?.turnId || "").trim();
|
|
136
|
+
const beforeCursor = oldestTurnId === pagination.oldestTurnId
|
|
137
|
+
? pagination.nextBeforeTurnId || oldestTurnId
|
|
138
|
+
: oldestTurnId;
|
|
136
139
|
return {
|
|
137
140
|
...source,
|
|
138
141
|
conversationLog: limitedTurns,
|
|
@@ -141,7 +144,7 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
|
|
|
141
144
|
count: limitedTurns.length,
|
|
142
145
|
hasMoreBefore,
|
|
143
146
|
newestTurnId: String(limitedTurns.at(-1)?.turnId || "").trim(),
|
|
144
|
-
nextBeforeTurnId: hasMoreBefore ?
|
|
147
|
+
nextBeforeTurnId: hasMoreBefore ? beforeCursor : "",
|
|
145
148
|
oldestTurnId
|
|
146
149
|
}
|
|
147
150
|
};
|
|
@@ -4,6 +4,8 @@ import { createMemoryConversationStorage } from "../src/server/conversation/memo
|
|
|
4
4
|
import { createConversationStorage } from "../src/server/conversation/storage.js";
|
|
5
5
|
import { createConversationTranscript } from "../src/server/conversation/transcript.js";
|
|
6
6
|
import { conversationTurnsFromMessages } from "../src/shared/conversation/turns.js";
|
|
7
|
+
import { CONVERSATION_LOG_PAGE_LIMIT, conversationLogReadQuery, mergeConversationLogPages } from "../src/shared/conversation/pagination.js";
|
|
8
|
+
import { applyConversationLogPatch } from "../src/shared/conversation/patches.js";
|
|
7
9
|
import { verifyConversationStorageContract } from "../src/testing/conversationStorageContract.js";
|
|
8
10
|
|
|
9
11
|
test("new output identity persists on its original message while older records remain unchanged", async () => {
|
|
@@ -248,3 +250,63 @@ test("completed envelope presentation has an honest empty page when all canonica
|
|
|
248
250
|
newestTurnId: "", nextBeforeTurnId: "", oldestTurnId: "", totalTurnCount: 0 });
|
|
249
251
|
assert.equal((await transcript.readConversationLog("empty")).length, 1);
|
|
250
252
|
});
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
test("history pages load ten real messages across reasoning rows and split turns without rewriting storage", async () => {
|
|
256
|
+
const storage = createMemoryConversationStorage();
|
|
257
|
+
const transcript = createConversationTranscript({ storage });
|
|
258
|
+
await storage.write("large", async tx => {
|
|
259
|
+
let id = 0;
|
|
260
|
+
const append = async (role, turnId = String(++id).padStart(6, "0")) => {
|
|
261
|
+
await tx.appendMessage(turnId, { role, messageId: `${turnId}-${role}`, text: `${role} ${turnId}`, at: "2026-10-14" });
|
|
262
|
+
return turnId;
|
|
263
|
+
};
|
|
264
|
+
await append("thinking");
|
|
265
|
+
for (let index = 0; index < 123; index++) {
|
|
266
|
+
const turnId = await append("user");
|
|
267
|
+
await append("commentary", turnId);
|
|
268
|
+
if (index < 122) await append("assistant", turnId);
|
|
269
|
+
for (let reasoning = 0; reasoning < 9; reasoning++) await append("thinking");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
const before = await transcript.readConversationLog("large");
|
|
273
|
+
assert.equal(CONVERSATION_LOG_PAGE_LIMIT, 10);
|
|
274
|
+
assert.equal(conversationLogReadQuery().limit, "10");
|
|
275
|
+
const pages = [];
|
|
276
|
+
let cursor = "";
|
|
277
|
+
let remaining = 245;
|
|
278
|
+
do {
|
|
279
|
+
const page = await transcript.readConversationLogPage("large", { limit: 10, beforeTurnId: cursor, pageUnit: "messages" });
|
|
280
|
+
const count = page.conversationLog.reduce((sum, turn) => sum + [turn.system, turn.user, turn.assistant].filter(Boolean).length, 0);
|
|
281
|
+
assert.equal(count, Math.min(10, remaining));
|
|
282
|
+
remaining -= count;
|
|
283
|
+
assert.equal(page.pagination.hasMoreBefore, remaining > 0);
|
|
284
|
+
assert.notEqual(page.pagination.nextBeforeTurnId, cursor);
|
|
285
|
+
pages.unshift(page);
|
|
286
|
+
cursor = page.pagination.nextBeforeTurnId;
|
|
287
|
+
} while (cursor);
|
|
288
|
+
assert.equal(remaining, 0);
|
|
289
|
+
assert.match(pages.at(-1).pagination.nextBeforeTurnId, /^message:/);
|
|
290
|
+
const merged = mergeConversationLogPages(pages).conversationLog;
|
|
291
|
+
const identities = rows => rows.flatMap(turn => turn.messages.map(message => message.messageId));
|
|
292
|
+
assert.deepEqual(identities(merged), identities(before), "all messages and reasoning survive pagination exactly once and in order");
|
|
293
|
+
assert.deepEqual(await transcript.readConversationLog("large"), before, "reading pages never changes saved rows");
|
|
294
|
+
const firstAnswer = pages.at(-1).conversationLog[0];
|
|
295
|
+
assert.equal(firstAnswer.user, null);
|
|
296
|
+
assert.ok(firstAnswer.assistant);
|
|
297
|
+
const patched = applyConversationLogPatch(pages.at(-1), { type: "upsert-turn", turn: {
|
|
298
|
+
turnId: "999999", thinking: [{ role: "thinking", text: "More reasoning" }]
|
|
299
|
+
} }, { limit: 10 });
|
|
300
|
+
assert.equal(patched.conversationLog[0].turnId, firstAnswer.turnId);
|
|
301
|
+
assert.equal(patched.pagination.nextBeforeTurnId, pages.at(-1).pagination.nextBeforeTurnId);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("reasoning-only histories remain readable without creating phantom message pages", async () => {
|
|
305
|
+
const storage = createMemoryConversationStorage();
|
|
306
|
+
const transcript = createConversationTranscript({ storage });
|
|
307
|
+
for (let index = 0; index < 25; index++) await transcript.writeConversationThinkingMessage("activity", { text: `Thought ${index}` });
|
|
308
|
+
const page = await transcript.readConversationLogPage("activity", { limit: 10, pageUnit: "messages" });
|
|
309
|
+
assert.equal(page.conversationLog.length, 25);
|
|
310
|
+
assert.equal(page.pagination.hasMoreBefore, false);
|
|
311
|
+
assert.equal(page.pagination.nextBeforeTurnId, "");
|
|
312
|
+
});
|