@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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.222",
11
+ "@jskit-ai/assistant-core": "0.1.223",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.222",
3
+ "version": "0.1.223",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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 ? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
23
- .filter(turn => hasMessages(turn) && !isCompletedEnvelopeTurn(turn)) : null;
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
- const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
29
- const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
30
- const selected = ids.slice(start, end);
31
- const turns = visible ? visible.slice(start, end)
32
- : (await Promise.all(selected.map((id) => transaction.readTurn(id)))).filter(hasMessages);
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: start > 0,
52
+ hasMoreBefore: page.hasMoreBefore,
39
53
  limit: pageLimit,
40
54
  newestTurnId: turns.at(-1)?.turnId || "",
41
- nextBeforeTurnId: start > 0 ? turns[0]?.turnId || selected[0] || "" : "",
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 = 20;
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
- orderedTurns[indexes.get(turnId)] = turn;
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 ? oldestTurnId : "",
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
+ });