@amaster.ai/dsh-storage 0.1.33 → 0.1.34
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 +1 -1
- package/lib/backends/database.js +79 -8
- package/lib/index.js +58 -4
- package/lib/projector.js +5 -1
- package/lib/types/backends/database.d.ts +3 -1
- package/lib/types/projector.d.ts +1 -0
- package/lib/types/types.d.ts +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ The logical message id rides in `metadata.id`; message rows use a deterministic
|
|
|
55
55
|
|
|
56
56
|
For `user/message` rows, `content` remains the text projection, while `metadata.message` retains the original user message as received, including `source` and ordered content blocks. Image/file blocks keep whatever attachment reference, path, URL, or inline data the incoming message actually contains; this mirror does not fetch or rewrite attachments. Re-projection writes the same raw message into the existing row.
|
|
57
57
|
|
|
58
|
-
The `tool_calls` array retains dsh's tool-call blocks (`id`, `name`, `arguments`); the matching entry gains the structured `result` block and, on failure, an `error` identity.
|
|
58
|
+
The `tool_calls` array retains dsh's tool-call blocks (`id`, `name`, `arguments`); the matching entry gains the structured `result` block and, on failure, an `error` identity. Live call IDs are cached, and restored calls can resolve their message ID from the model surface: both paths read one message primary key. Older calls are searched in keyset pages of at most 100 ID/tool-call candidates, matched in the plugin and discarded before fetching the next page. Tool-result lookup SQL does not search JSON, join tables, or nest SELECT statements. Duplicate results update the same row and do not increment message counts. Existing historical `type=tool` rows are left intact.
|
|
59
59
|
|
|
60
60
|
When an assistant message contains dsh `reasoning` blocks, `thoughts` is an array of `{ content }` entries in block order. If the adapter supplies a matching signature in the message's `source.replayState.blocks`, that entry also carries `thoughtSignature`. The full model `source`, including opaque `replayState`, is preserved in row metadata for lossless provider replay. The backend's `readMessages()` also accepts older `{ description }` entries and JSON string thoughts, returning them as `{ content }`. `subject` and a per-thought timestamp are not invented when the upstream message does not supply them.
|
|
61
61
|
|
package/lib/backends/database.js
CHANGED
|
@@ -143,18 +143,89 @@ export class DatabaseBackend {
|
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
145
|
async readMessages(sessionId, messageId) {
|
|
146
|
+
return this.readRows({
|
|
147
|
+
sessionId,
|
|
148
|
+
deletedAt: null,
|
|
149
|
+
id: pk(`message ${sessionId}\0${messageId}`),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async readToolCallMessages(sessionId, callId) {
|
|
153
|
+
if (!this.prisma)
|
|
154
|
+
return [];
|
|
155
|
+
const provider = this.config.provider;
|
|
156
|
+
const sqlserver = provider === 'sqlserver';
|
|
157
|
+
const sortTime = provider === 'mysql'
|
|
158
|
+
? "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f')"
|
|
159
|
+
: provider === 'postgresql'
|
|
160
|
+
? "to_char(created_at, 'YYYY-MM-DD HH24:MI:SS.US')"
|
|
161
|
+
: sqlserver
|
|
162
|
+
? 'CONVERT(varchar(33), created_at, 126)'
|
|
163
|
+
: 'CAST(created_at AS TEXT)';
|
|
164
|
+
let cursor;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const params = provider === 'mysql' || provider === 'sqlite' ? [sessionId, sessionId] : [sessionId];
|
|
167
|
+
let before = '';
|
|
168
|
+
if (cursor) {
|
|
169
|
+
if (cursor.time === null) {
|
|
170
|
+
before =
|
|
171
|
+
provider === 'postgresql'
|
|
172
|
+
? 'AND (created_at IS NOT NULL OR (created_at IS NULL AND id < ?))'
|
|
173
|
+
: 'AND created_at IS NULL AND id < ?';
|
|
174
|
+
params.push(cursor.id);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
before = `AND (created_at < ? OR (created_at = ? AND id < ?)${provider === 'postgresql' ? '' : ' OR created_at IS NULL'})`;
|
|
178
|
+
params.push(cursor.time, cursor.time, cursor.id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let sql = `SELECT ${sqlserver ? 'TOP 100 ' : ''}id, tool_calls, ${sortTime} AS sort_time
|
|
182
|
+
FROM ai_messages WHERE session_id = ?
|
|
183
|
+
${provider === 'sqlite' ? 'AND session_id COLLATE BINARY = ?' : ''}
|
|
184
|
+
${provider === 'mysql' ? 'AND BINARY session_id = BINARY ?' : ''}
|
|
185
|
+
AND deleted_at IS NULL AND history_id IS NULL AND type = 'model' AND tool_calls IS NOT NULL
|
|
186
|
+
${sqlserver
|
|
187
|
+
? `AND session_id COLLATE Latin1_General_100_BIN2 = @P1
|
|
188
|
+
AND DATALENGTH(CAST(session_id AS nvarchar(max))) = DATALENGTH(CAST(@P1 AS nvarchar(max)))`
|
|
189
|
+
: ''}
|
|
190
|
+
${before} ORDER BY created_at DESC, id DESC ${sqlserver ? '' : 'LIMIT 100'}`;
|
|
191
|
+
if (sqlserver || provider === 'postgresql') {
|
|
192
|
+
let index = 0;
|
|
193
|
+
sql = sql.replaceAll('?', () => `${sqlserver ? '@P' : '$'}${++index}`);
|
|
194
|
+
}
|
|
195
|
+
const page = await this.prisma.$queryRawUnsafe(sql, ...params);
|
|
196
|
+
for (const row of page) {
|
|
197
|
+
let calls = row.tool_calls;
|
|
198
|
+
if (typeof calls === 'string') {
|
|
199
|
+
try {
|
|
200
|
+
calls = JSON.parse(calls);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(calls) && calls.some((call) => call?.id === callId)) {
|
|
207
|
+
return this.readRows({
|
|
208
|
+
sessionId,
|
|
209
|
+
deletedAt: null,
|
|
210
|
+
historyId: null,
|
|
211
|
+
type: 'model',
|
|
212
|
+
id: row.id,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (page.length < 100)
|
|
217
|
+
return [];
|
|
218
|
+
const last = page.at(-1);
|
|
219
|
+
cursor = { id: last.id, time: last.sort_time };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async readRows(where) {
|
|
146
223
|
if (!this.prisma)
|
|
147
224
|
return [];
|
|
148
|
-
// ponytail: tool-call lookup scans one session; add an indexed call lookup if that becomes slow.
|
|
149
|
-
// Scan only the active segment for tool results; exact-ID reads still find
|
|
150
|
-
// archived rows so redelivery does not count the same message twice.
|
|
151
225
|
const rows = await this.prisma.aiMessage.findMany({
|
|
152
|
-
where
|
|
153
|
-
sessionId,
|
|
154
|
-
deletedAt: null,
|
|
155
|
-
...(messageId ? { id: pk(`message ${sessionId}\0${messageId}`) } : { historyId: null }),
|
|
156
|
-
},
|
|
226
|
+
where,
|
|
157
227
|
orderBy: { createdAt: 'asc' },
|
|
228
|
+
take: 1,
|
|
158
229
|
});
|
|
159
230
|
const json = (value) => (typeof value === 'string' ? JSON.parse(value) : value);
|
|
160
231
|
return rows.map((row) => {
|
package/lib/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import Schema from '@deepseek-ai/schemastery';
|
|
21
21
|
import { DatabaseBackend } from './backends/database.js';
|
|
22
22
|
import { compressionSnapshot } from './compaction.js';
|
|
23
|
-
import { mergeToolResult, projectEvent, usageSampleOf } from './projector.js';
|
|
23
|
+
import { mergeToolResult, projectEvent, toolResultCallId, usageSampleOf } from './projector.js';
|
|
24
24
|
export const name = 'dsh-storage';
|
|
25
25
|
// Event-tap only: no hard service dependency. Add 'sessionPersistence' to
|
|
26
26
|
// `inject` if you want fail-fast when persistence is not composed.
|
|
@@ -63,6 +63,54 @@ export function apply(ctx, config) {
|
|
|
63
63
|
}),
|
|
64
64
|
];
|
|
65
65
|
const sessions = new Map();
|
|
66
|
+
const toolCallMessages = new Map();
|
|
67
|
+
const TOOL_CALL_CACHE_LIMIT = 1000;
|
|
68
|
+
function rememberToolCalls(sessionId, row) {
|
|
69
|
+
if (row?.type !== 'model' || !Array.isArray(row.toolCalls))
|
|
70
|
+
return;
|
|
71
|
+
for (const call of row.toolCalls) {
|
|
72
|
+
if (typeof call?.id !== 'string')
|
|
73
|
+
continue;
|
|
74
|
+
const calls = toolCallMessages.get(sessionId) ?? new Map();
|
|
75
|
+
// Bound each session's cache; Map iteration evicts the oldest insertion first.
|
|
76
|
+
if (!calls.has(call.id) && calls.size >= TOOL_CALL_CACHE_LIMIT) {
|
|
77
|
+
const oldest = calls.keys().next().value;
|
|
78
|
+
if (oldest !== undefined)
|
|
79
|
+
calls.delete(oldest);
|
|
80
|
+
}
|
|
81
|
+
calls.set(call.id, row.id);
|
|
82
|
+
toolCallMessages.set(sessionId, calls);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function readToolResultRows(session, sessionId, event) {
|
|
86
|
+
const callId = toolResultCallId(event);
|
|
87
|
+
if (!callId)
|
|
88
|
+
return [];
|
|
89
|
+
let messageId = toolCallMessages.get(sessionId)?.get(callId);
|
|
90
|
+
if (!messageId && typeof session?.deriveMessages === 'function') {
|
|
91
|
+
const messages = session.deriveMessages();
|
|
92
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
93
|
+
const message = messages[index];
|
|
94
|
+
if (message.role === 'assistant' &&
|
|
95
|
+
typeof message.id === 'string' &&
|
|
96
|
+
Array.isArray(message.content) &&
|
|
97
|
+
message.content.some((block) => block?.type === 'tool-call' && block.id === callId)) {
|
|
98
|
+
messageId = message.id;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (messageId) {
|
|
104
|
+
const rows = await backends[0].readMessages(sessionId, messageId);
|
|
105
|
+
if (rows.some((row) => Array.isArray(row.toolCalls) && row.toolCalls.some((call) => call?.id === callId)))
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
// Older calls absent from the live surface are searched in bounded database pages.
|
|
109
|
+
const rows = await backends[0].readToolCallMessages(sessionId, callId);
|
|
110
|
+
for (const row of rows)
|
|
111
|
+
rememberToolCalls(sessionId, row);
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
66
114
|
// Row writes are fire-and-forget during the run, but tracked so the
|
|
67
115
|
// session/flush checkpoint and shutdown can drain them — a one-shot
|
|
68
116
|
// (headless) run would otherwise lose the tail events to process exit.
|
|
@@ -133,6 +181,7 @@ export function apply(ctx, config) {
|
|
|
133
181
|
started = track(guard(async (backend) => backend.init?.()));
|
|
134
182
|
return async () => {
|
|
135
183
|
sessions.clear();
|
|
184
|
+
toolCallMessages.clear();
|
|
136
185
|
await Promise.all([...pending]);
|
|
137
186
|
await guard(async (backend) => backend.close?.());
|
|
138
187
|
};
|
|
@@ -144,6 +193,7 @@ export function apply(ctx, config) {
|
|
|
144
193
|
const archived = (chains.get(sessionId) ?? started).then(async () => {
|
|
145
194
|
const id = await backends[0].archiveSession(sessionId);
|
|
146
195
|
sessions.delete(sessionId);
|
|
196
|
+
toolCallMessages.delete(sessionId);
|
|
147
197
|
return id;
|
|
148
198
|
});
|
|
149
199
|
const settled = archived.then(() => { }, () => { });
|
|
@@ -178,9 +228,11 @@ export function apply(ctx, config) {
|
|
|
178
228
|
if (row && session.header?.createdAt !== undefined) {
|
|
179
229
|
row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
|
|
180
230
|
}
|
|
181
|
-
const storedRows =
|
|
182
|
-
? await
|
|
183
|
-
:
|
|
231
|
+
const storedRows = event?.type === 'tool/result'
|
|
232
|
+
? await readToolResultRows(session, sessionId, event)
|
|
233
|
+
: row
|
|
234
|
+
? await backends[0].readMessages(sessionId, row.id)
|
|
235
|
+
: [];
|
|
184
236
|
if (event?.type === 'tool/result')
|
|
185
237
|
row = mergeToolResult(event, storedRows);
|
|
186
238
|
const existing = row && storedRows.find((stored) => stored.id === row?.id);
|
|
@@ -208,6 +260,7 @@ export function apply(ctx, config) {
|
|
|
208
260
|
accum.lastMessageAt = row.createdAt;
|
|
209
261
|
}
|
|
210
262
|
await fanout((backend) => backend.upsertMessage(row));
|
|
263
|
+
rememberToolCalls(sessionId, row);
|
|
211
264
|
}
|
|
212
265
|
// Replacement usage accounting: the latest sample for a (turn, step)
|
|
213
266
|
// supersedes the previous one — fold only the delta, so the
|
|
@@ -263,6 +316,7 @@ export function apply(ctx, config) {
|
|
|
263
316
|
// the dispose was meant to drop).
|
|
264
317
|
enqueue(sessionId, async () => {
|
|
265
318
|
sessions.delete(sessionId);
|
|
319
|
+
toolCallMessages.delete(sessionId);
|
|
266
320
|
});
|
|
267
321
|
// Delete the chain entry only once it has settled AND is still the same
|
|
268
322
|
// entry — deleting earlier would let a recreated session race the
|
package/lib/projector.js
CHANGED
|
@@ -106,10 +106,14 @@ export function projectEvent(_session, event, sessionId) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
export function toolResultCallId(event) {
|
|
110
|
+
const callId = event?.data?.message?.source?.callId ?? event?.data?.message?.content?.[0]?.toolCallId;
|
|
111
|
+
return typeof callId === 'string' ? callId : undefined;
|
|
112
|
+
}
|
|
109
113
|
/** Update the persisted assistant call identified by a tool result. */
|
|
110
114
|
export function mergeToolResult(event, rows) {
|
|
111
115
|
const result = event?.data?.message?.content?.[0] ?? null;
|
|
112
|
-
const callId = event
|
|
116
|
+
const callId = toolResultCallId(event);
|
|
113
117
|
if (!callId)
|
|
114
118
|
return null;
|
|
115
119
|
const model = [...rows]
|
|
@@ -31,7 +31,9 @@ export declare class DatabaseBackend implements StorageBackend {
|
|
|
31
31
|
private jsonField;
|
|
32
32
|
private toDbData;
|
|
33
33
|
upsertMessage(row: MessageRow): Promise<void>;
|
|
34
|
-
readMessages(sessionId: string, messageId
|
|
34
|
+
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
35
|
+
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
36
|
+
private readRows;
|
|
35
37
|
readSession(sessionId: string): Promise<SessionRow | null>;
|
|
36
38
|
upsertSession(row: SessionRow): Promise<void>;
|
|
37
39
|
archiveSession(sessionId: string): Promise<string | null>;
|
package/lib/types/projector.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { MessageRow } from './types.js';
|
|
|
22
22
|
* turn/step lifecycle, approvals, ...).
|
|
23
23
|
*/
|
|
24
24
|
export declare function projectEvent(_session: any, event: any, sessionId: string): MessageRow | null;
|
|
25
|
+
export declare function toolResultCallId(event: any): string | undefined;
|
|
25
26
|
/** Update the persisted assistant call identified by a tool result. */
|
|
26
27
|
export declare function mergeToolResult(event: any, rows: MessageRow[]): MessageRow | null;
|
|
27
28
|
export interface UsageSample {
|
package/lib/types/types.d.ts
CHANGED
|
@@ -43,8 +43,10 @@ export interface StorageBackend {
|
|
|
43
43
|
init?(): Promise<void>;
|
|
44
44
|
/** Read the stored session row, for seeding the live rollup on resume. */
|
|
45
45
|
readSession?(sessionId: string): Promise<SessionRow | null>;
|
|
46
|
-
/** Read persisted
|
|
47
|
-
readMessages(sessionId: string, messageId
|
|
46
|
+
/** Read one persisted logical message, including archived rows for redelivery. */
|
|
47
|
+
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
48
|
+
/** Find at most one active model message containing this exact tool call. */
|
|
49
|
+
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
48
50
|
upsertMessage(row: MessageRow): Promise<void>;
|
|
49
51
|
upsertSession(row: SessionRow): Promise<void>;
|
|
50
52
|
/** Move active messages into a queryable history row; null when already empty. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amaster.ai/dsh-storage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Session storage mirror for DeepSeek Harness: projects the session event stream into MySQL/PostgreSQL/SQLite/SQL Server (ai_messages / ai_chat_histories) without replacing dsh's local persistence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|