@tanstack/ai-persistence 0.5.7 → 0.6.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/dist/esm/index.d.ts +1 -1
- package/dist/esm/memory.js +1 -1
- package/dist/esm/memory.js.map +1 -1
- package/dist/esm/middleware.d.ts +18 -20
- package/dist/esm/middleware.js +65 -4
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reconstruct.d.ts +14 -0
- package/dist/esm/reconstruct.js +89 -4
- package/dist/esm/reconstruct.js.map +1 -1
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/types.d.ts +36 -2
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-persistence/server/SKILL.md +22 -11
- package/skills/ai-persistence/stores/SKILL.md +21 -4
- package/src/index.ts +1 -0
- package/src/memory.ts +4 -1
- package/src/middleware.ts +102 -27
- package/src/reconstruct.ts +136 -5
- package/src/testkit/conformance.ts +5 -1
- package/src/types.ts +39 -2
package/src/reconstruct.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { modelMessagesToUIMessages } from '@tanstack/ai'
|
|
2
|
-
import type { UIMessage } from '@tanstack/ai'
|
|
2
|
+
import type { ModelMessage, UIMessage } from '@tanstack/ai'
|
|
3
3
|
import { validateReconstructChatStores } from './types'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AIPersistence,
|
|
6
|
+
ChatTranscriptStores,
|
|
7
|
+
MessagePage,
|
|
8
|
+
MessageStore,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const MAX_PAGE_SIZE = 500
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* The JSON body `reconstructChat` returns and a server-authoritative client
|
|
@@ -15,6 +22,9 @@ import type { AIPersistence, ChatTranscriptStores } from './types'
|
|
|
15
22
|
* approvals, client-tool/generic waits) and the run they paused, or `null` —
|
|
16
23
|
* so a reload (or another device) re-prompts the approval from the SERVER, not
|
|
17
24
|
* from client storage. Resolved via `stores.interrupts.listPending`.
|
|
25
|
+
* `page` is set only when the GET included a valid `limit`. `truncated` is true
|
|
26
|
+
* when older UI messages exist. `cursor` is the opaque `before` token for the
|
|
27
|
+
* next older window.
|
|
18
28
|
*/
|
|
19
29
|
export interface ReconstructedChat {
|
|
20
30
|
messages: Array<UIMessage>
|
|
@@ -23,6 +33,7 @@ export interface ReconstructedChat {
|
|
|
23
33
|
runId: string
|
|
24
34
|
pending: Array<Record<string, unknown>>
|
|
25
35
|
} | null
|
|
36
|
+
page?: { truncated: false } | { truncated: true; cursor: string }
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
export interface ReconstructChatOptions {
|
|
@@ -63,6 +74,11 @@ export interface ReconstructChatOptions {
|
|
|
63
74
|
* a reload re-prompts the decision from the server. Resolved via the optional
|
|
64
75
|
* `stores.interrupts.listPending`; `null` when that store is absent.
|
|
65
76
|
*
|
|
77
|
+
* Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns
|
|
78
|
+
* the newest window of UI messages plus `page`. `?before=` walks to an older
|
|
79
|
+
* window. Invalid `limit` (`0`, negative, NaN) is ignored and the full
|
|
80
|
+
* transcript is returned. `activeRun` and `interrupts` are never paged.
|
|
81
|
+
*
|
|
66
82
|
* Requires `stores.messages`. Returns an empty transcript with no active run
|
|
67
83
|
* and no interrupts when the thread id is missing or the thread is unknown, so
|
|
68
84
|
* the caller never has to special-case a first load.
|
|
@@ -94,8 +110,11 @@ export async function reconstructChat(
|
|
|
94
110
|
throw new Error('reconstructChat requires stores.messages.')
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
const requestUrl = new URL(request.url)
|
|
97
114
|
const param = options?.param ?? 'threadId'
|
|
98
|
-
const threadId =
|
|
115
|
+
const threadId = requestUrl.searchParams.get(param) ?? ''
|
|
116
|
+
const pageSize = parsePageSize(requestUrl.searchParams.get('limit'))
|
|
117
|
+
const before = parseBefore(requestUrl.searchParams.get('before'))
|
|
99
118
|
|
|
100
119
|
if (threadId && options?.authorize) {
|
|
101
120
|
const decision = await options.authorize(threadId, request)
|
|
@@ -122,7 +141,15 @@ export async function reconstructChat(
|
|
|
122
141
|
const active = threadId
|
|
123
142
|
? await persistence.stores.runs?.findActiveRun(threadId)
|
|
124
143
|
: null
|
|
125
|
-
const stored =
|
|
144
|
+
const stored =
|
|
145
|
+
threadId === ''
|
|
146
|
+
? []
|
|
147
|
+
: pageSize === undefined
|
|
148
|
+
? await messageStore.loadThread(threadId)
|
|
149
|
+
: await messageStore.loadThread(threadId, {
|
|
150
|
+
limit: pageSize + 1,
|
|
151
|
+
...(before === undefined ? {} : { before }),
|
|
152
|
+
})
|
|
126
153
|
// Pending interrupts for the thread, so a reload re-prompts the approval from
|
|
127
154
|
// the server. Each stored `payload` is the full interrupt descriptor the
|
|
128
155
|
// client hydrates; they share the run they paused.
|
|
@@ -130,8 +157,22 @@ export async function reconstructChat(
|
|
|
130
157
|
? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])
|
|
131
158
|
: []
|
|
132
159
|
const firstPending = pending[0]
|
|
160
|
+
const isPaging = pageSize !== undefined && threadId !== ''
|
|
161
|
+
const transcript = !isPaging
|
|
162
|
+
? {
|
|
163
|
+
messages: modelMessagesToUIMessages(threadMessages(stored)),
|
|
164
|
+
}
|
|
165
|
+
: Array.isArray(stored)
|
|
166
|
+
? await windowFromArray({
|
|
167
|
+
stored,
|
|
168
|
+
messageStore,
|
|
169
|
+
threadId,
|
|
170
|
+
pageSize,
|
|
171
|
+
before,
|
|
172
|
+
})
|
|
173
|
+
: windowFromMessagePage(stored, pageSize)
|
|
133
174
|
const body: ReconstructedChat = {
|
|
134
|
-
messages:
|
|
175
|
+
messages: transcript.messages,
|
|
135
176
|
activeRun: active ? { runId: active.runId } : null,
|
|
136
177
|
interrupts: firstPending
|
|
137
178
|
? {
|
|
@@ -139,6 +180,7 @@ export async function reconstructChat(
|
|
|
139
180
|
pending: pending.map((record) => record.payload),
|
|
140
181
|
}
|
|
141
182
|
: null,
|
|
183
|
+
...('page' in transcript ? { page: transcript.page } : {}),
|
|
142
184
|
}
|
|
143
185
|
return new Response(JSON.stringify(body), {
|
|
144
186
|
headers: {
|
|
@@ -147,3 +189,92 @@ export async function reconstructChat(
|
|
|
147
189
|
},
|
|
148
190
|
})
|
|
149
191
|
}
|
|
192
|
+
|
|
193
|
+
function parsePageSize(raw: string | null) {
|
|
194
|
+
if (raw == null) return
|
|
195
|
+
const pageSize = Number(raw)
|
|
196
|
+
const isValidPageSize = Number.isInteger(pageSize) && pageSize > 0
|
|
197
|
+
if (!isValidPageSize) return
|
|
198
|
+
return Math.min(pageSize, MAX_PAGE_SIZE)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseBefore(raw: string | null) {
|
|
202
|
+
if (raw == null || raw === '') return
|
|
203
|
+
return raw
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function threadMessages(
|
|
207
|
+
loaded: Array<ModelMessage> | MessagePage,
|
|
208
|
+
): Array<ModelMessage> {
|
|
209
|
+
return Array.isArray(loaded) ? loaded : loaded.messages
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function completePage() {
|
|
213
|
+
return { truncated: false as const }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function truncatedPage(cursor: string) {
|
|
217
|
+
return { truncated: true as const, cursor }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pageFromCursor(cursor: string | undefined) {
|
|
221
|
+
if (cursor === undefined || cursor === '') {
|
|
222
|
+
return completePage()
|
|
223
|
+
}
|
|
224
|
+
return truncatedPage(cursor)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function newestUiWindow(messages: Array<UIMessage>, pageSize: number) {
|
|
228
|
+
const truncated = messages.length > pageSize
|
|
229
|
+
if (!truncated) {
|
|
230
|
+
return { messages, page: completePage() }
|
|
231
|
+
}
|
|
232
|
+
const uiWindow = messages.slice(messages.length - pageSize)
|
|
233
|
+
return {
|
|
234
|
+
messages: uiWindow,
|
|
235
|
+
page: pageFromCursor(uiWindow[0]?.id),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function uiBeforeCursor(messages: Array<UIMessage>, cursor: string) {
|
|
240
|
+
const cut = messages.findIndex((message) => message.id === cursor)
|
|
241
|
+
if (cut === -1) return
|
|
242
|
+
return messages.slice(0, cut)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function windowFromMessagePage(page: MessagePage, pageSize: number) {
|
|
246
|
+
const ui = modelMessagesToUIMessages(page.messages)
|
|
247
|
+
if (ui.length > pageSize) {
|
|
248
|
+
// Extra slice uses a library-minted cursor. Keeping the adapter cursor
|
|
249
|
+
// after dropping the oldest row would skip that row on the next GET.
|
|
250
|
+
return newestUiWindow(ui, pageSize)
|
|
251
|
+
}
|
|
252
|
+
if (page.truncated) {
|
|
253
|
+
return {
|
|
254
|
+
messages: ui,
|
|
255
|
+
page: pageFromCursor(page.cursor),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { messages: ui, page: completePage() }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function windowFromArray(input: {
|
|
262
|
+
stored: Array<ModelMessage>
|
|
263
|
+
messageStore: MessageStore
|
|
264
|
+
threadId: string
|
|
265
|
+
pageSize: number
|
|
266
|
+
before: string | undefined
|
|
267
|
+
}) {
|
|
268
|
+
const { stored, messageStore, threadId, pageSize, before } = input
|
|
269
|
+
if (before === undefined) {
|
|
270
|
+
return newestUiWindow(modelMessagesToUIMessages(stored), pageSize)
|
|
271
|
+
}
|
|
272
|
+
// Array adapters own no cursor. Apply `before` to the full transcript so an
|
|
273
|
+
// adapter that ignored the hint cannot return the same newest page forever.
|
|
274
|
+
const full = threadMessages(await messageStore.loadThread(threadId))
|
|
275
|
+
const older = uiBeforeCursor(modelMessagesToUIMessages(full), before)
|
|
276
|
+
if (older === undefined) {
|
|
277
|
+
return { messages: [], page: truncatedPage(before) }
|
|
278
|
+
}
|
|
279
|
+
return newestUiWindow(older, pageSize)
|
|
280
|
+
}
|
|
@@ -171,6 +171,8 @@ export function runPersistenceConformance(
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
describe('messages', () => {
|
|
174
|
+
// One-argument loadThread is the full-thread contract. Paging
|
|
175
|
+
// (`limit` / `before`) is an optional hint; this suite does not require it.
|
|
174
176
|
it('round-trips a thread and returns [] for unknown threads', async (ctx) => {
|
|
175
177
|
const store = resolveStore('messages')
|
|
176
178
|
if (!store) return ctx.skip('store not provided')
|
|
@@ -181,7 +183,9 @@ export function runPersistenceConformance(
|
|
|
181
183
|
{ role: 'user', content: 'hi' },
|
|
182
184
|
{ role: 'assistant', content: 'hello' },
|
|
183
185
|
])
|
|
184
|
-
|
|
186
|
+
const loaded = await store.loadThread('thread-msg')
|
|
187
|
+
expect(Array.isArray(loaded)).toBe(true)
|
|
188
|
+
expect(loaded).toEqual([
|
|
185
189
|
{ role: 'user', content: 'hi' },
|
|
186
190
|
{ role: 'assistant', content: 'hello' },
|
|
187
191
|
])
|
package/src/types.ts
CHANGED
|
@@ -53,6 +53,29 @@ export type { MetadataStore, Scope }
|
|
|
53
53
|
// **ISO-8601 strings**. The middleware performs the number→ISO conversion at
|
|
54
54
|
// the boundary; do not mix the two on a single field.
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* One page of a thread from {@link MessageStore.loadThread} when the caller
|
|
58
|
+
* passed a paging hint.
|
|
59
|
+
*
|
|
60
|
+
* Middleware omits the hint and always gets a full `Array<ModelMessage>`,
|
|
61
|
+
* never this shape.
|
|
62
|
+
*
|
|
63
|
+
* `truncated: true` requires `cursor`. Without a cursor the client cannot
|
|
64
|
+
* request the next older window, so `reconstructChat` treats that page as
|
|
65
|
+
* complete.
|
|
66
|
+
*/
|
|
67
|
+
export type MessagePage =
|
|
68
|
+
| {
|
|
69
|
+
messages: Array<ModelMessage>
|
|
70
|
+
truncated: false
|
|
71
|
+
cursor?: never
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
messages: Array<ModelMessage>
|
|
75
|
+
truncated: true
|
|
76
|
+
cursor: string
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/**
|
|
57
80
|
* Durable store for a thread's full message transcript.
|
|
58
81
|
*
|
|
@@ -70,13 +93,27 @@ export type { MetadataStore, Scope }
|
|
|
70
93
|
*/
|
|
71
94
|
export interface MessageStore {
|
|
72
95
|
/**
|
|
73
|
-
* Return the
|
|
96
|
+
* Return the stored transcript for `threadId` ({@link Scope.threadId}),
|
|
74
97
|
* in insertion order.
|
|
75
98
|
*
|
|
99
|
+
* Call with only `threadId` (middleware, `onStart`, `onFinish`) and this
|
|
100
|
+
* MUST return the full transcript as an `Array<ModelMessage>`. Never a
|
|
101
|
+
* {@link MessagePage}.
|
|
102
|
+
*
|
|
103
|
+
* `options.limit` and `options.before` are an optional paging hint for
|
|
104
|
+
* hydrate. Adapters may ignore them and still return the full array. An
|
|
105
|
+
* adapter that pages returns a {@link MessagePage}.
|
|
106
|
+
*
|
|
76
107
|
* INVARIANT: returns an empty array (never `null`/`undefined`) for a thread
|
|
77
108
|
* that was never saved. Callers treat `[]` as "no history".
|
|
78
109
|
*/
|
|
79
|
-
loadThread:
|
|
110
|
+
loadThread: {
|
|
111
|
+
(threadId: string): Promise<Array<ModelMessage>>
|
|
112
|
+
(
|
|
113
|
+
threadId: string,
|
|
114
|
+
options: { limit?: number; before?: string },
|
|
115
|
+
): Promise<Array<ModelMessage> | MessagePage>
|
|
116
|
+
}
|
|
80
117
|
/**
|
|
81
118
|
* Overwrite the stored transcript for `threadId` with `messages`.
|
|
82
119
|
*
|