@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 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. The plugin reads persisted model rows to match results across process restarts and upserts the same row ID, so duplicate results do not add rows or increment the session's message count. Existing historical `type=tool` rows are left intact.
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
 
@@ -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 = row || event?.type === 'tool/result'
182
- ? await backends[0].readMessages(sessionId, row?.id)
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?.data?.message?.source?.callId ?? result?.toolCallId;
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?: string): Promise<MessageRow[]>;
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>;
@@ -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 {
@@ -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 messages, optionally narrowing to one logical message ID. */
47
- readMessages(sessionId: string, messageId?: string): Promise<MessageRow[]>;
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.33",
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",