@amaster.ai/dsh-storage 0.1.32 → 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
 
@@ -63,6 +63,26 @@ SQL Server note: Prisma's sqlserver connector has no `Json` type, so its variant
63
63
 
64
64
  Mirroring is a no-throw seam: backend errors are logged with a `[dsh-storage]` prefix and never escape into the agent loop.
65
65
 
66
+ Successful native `compaction/end` events also write a `utility_compressor` message. Its
67
+ `content` is the source-compatible `Content[]` JSON array of the complete surviving
68
+ conversation: summary plus retained tail, with tool calls/results paired by ID. System
69
+ prompts are excluded. The surface is captured at the event, before asynchronous database
70
+ writes; failed/canceled compactions leave the previous snapshot intact. Snapshot rows do
71
+ not increment the visible message count. Adjacent messages with the same role merge their
72
+ ordered parts, since the legacy reader would otherwise skip the retained tail.
73
+
74
+ Consumers can resume from the latest active compression snapshot plus later message rows.
75
+ Snapshots and newly mirrored rows include `metadata.sessionCreatedAt` and `metadata.seq`
76
+ to distinguish resumed session generations and messages in the same database second.
77
+ Older snapshots without this metadata retain their creation-time boundary. The mirror
78
+ does not fetch attachments or invent usable paths/URLs from opaque attachment IDs.
79
+ Attachments, developer updates, extension blocks and non-object tool arguments are
80
+ preserved as labelled JSON data in text parts when legacy `Content[]` cannot represent
81
+ them natively. Such snapshots preserve reference information, not attachment bytes or a
82
+ complete native session/tool registry. Attachment bytes still need their existing durable
83
+ storage. An unchanged legacy reader remains timestamp-based; it must consume the sequence
84
+ metadata to distinguish later messages in the same database second.
85
+
66
86
  ## Archive a session
67
87
 
68
88
  When handling a session-clear request, call `await ctx.get('dshStorage').archiveSession(sessionId)` after stopping the live turn. It waits for queued mirror writes, then transactionally assigns all current, non-deleted `ai_messages` rows (`history_id IS NULL`) to a non-deleted `ai_chat_histories` row. The returned history ID can be used to query those rows directly from the database; a second call with no active messages returns `null`. A database failure rejects this explicit operation instead of reporting a successful clear.
@@ -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) => {
@@ -0,0 +1,108 @@
1
+ function recordedData(block) {
2
+ return { text: `[Recorded DSH ${block.type} data]\n${JSON.stringify(block)}` };
3
+ }
4
+ /** Legacy Content[] checkpoints remain readable by databaseChatStorage. */
5
+ export function compressionSnapshot(session, event) {
6
+ if (event?.type !== 'compaction/end' || event.data?.error !== undefined)
7
+ return null;
8
+ const calls = new Map();
9
+ const messages = session.deriveMessages().flatMap((message) => {
10
+ if (message.role === 'system')
11
+ return [];
12
+ if (message.role === 'tool') {
13
+ const id = message.toolCallId ?? message.source?.callId;
14
+ const name = calls.get(id);
15
+ if (!name)
16
+ return [
17
+ {
18
+ role: 'user',
19
+ parts: [
20
+ recordedData({
21
+ type: 'tool-result',
22
+ toolCallId: id,
23
+ content: message.content,
24
+ isError: Boolean(message.isError),
25
+ }),
26
+ ],
27
+ },
28
+ ];
29
+ return [
30
+ {
31
+ role: 'user',
32
+ parts: [
33
+ {
34
+ functionResponse: {
35
+ id,
36
+ name,
37
+ response: { output: message.content, isError: Boolean(message.isError) },
38
+ },
39
+ },
40
+ ],
41
+ },
42
+ ];
43
+ }
44
+ const parts = message.content.map((block, index) => {
45
+ if (message.role === 'developer')
46
+ return recordedData(block);
47
+ switch (block.type) {
48
+ case 'text':
49
+ return { text: block.text };
50
+ case 'reasoning': {
51
+ const replay = message.source?.replayState?.blocks?.[index];
52
+ const signature = replay?.signature ?? replay?.thoughtSignature;
53
+ return {
54
+ text: block.text,
55
+ thought: true,
56
+ ...(signature ? { thoughtSignature: signature } : {}),
57
+ };
58
+ }
59
+ case 'tool-call': {
60
+ calls.delete(block.id);
61
+ let args;
62
+ try {
63
+ args = block.arguments ? JSON.parse(block.arguments) : {};
64
+ }
65
+ catch {
66
+ return recordedData(block);
67
+ }
68
+ if (!args || typeof args !== 'object' || Array.isArray(args))
69
+ return recordedData(block);
70
+ calls.set(block.id, block.name);
71
+ return {
72
+ functionCall: { id: block.id, name: block.name, args },
73
+ };
74
+ }
75
+ default:
76
+ return recordedData(block);
77
+ }
78
+ });
79
+ return [{ role: message.role === 'assistant' ? 'model' : 'user', parts }];
80
+ });
81
+ const content = [];
82
+ for (const message of messages) {
83
+ const previous = content.at(-1);
84
+ // Legacy recovery discards adjacent equal roles instead of merging them.
85
+ if (previous && previous.role === message.role)
86
+ previous.parts.push(...message.parts);
87
+ else
88
+ content.push(message);
89
+ }
90
+ return {
91
+ id: event.data.compactionId,
92
+ sessionId: session.id,
93
+ historyId: null,
94
+ agentId: 'main',
95
+ type: 'utility_compressor',
96
+ content: JSON.stringify(content),
97
+ model: 'agent-history-provider-summarizer',
98
+ createdAt: new Date(event.time),
99
+ metadata: {
100
+ id: event.data.compactionId,
101
+ event: event.type,
102
+ seq: event.seq,
103
+ sessionCreatedAt: session.header?.createdAt,
104
+ sessionId: session.id,
105
+ newHistoryLength: content.length,
106
+ },
107
+ };
108
+ }
package/lib/index.js CHANGED
@@ -19,7 +19,8 @@
19
19
  */
20
20
  import Schema from '@deepseek-ai/schemastery';
21
21
  import { DatabaseBackend } from './backends/database.js';
22
- import { mergeToolResult, projectEvent, usageSampleOf } from './projector.js';
22
+ import { compressionSnapshot } from './compaction.js';
23
+ import { mergeToolResult, projectEvent, toolResultCallId, usageSampleOf } from './projector.js';
23
24
  export const name = 'dsh-storage';
24
25
  // Event-tap only: no hard service dependency. Add 'sessionPersistence' to
25
26
  // `inject` if you want fail-fast when persistence is not composed.
@@ -62,6 +63,54 @@ export function apply(ctx, config) {
62
63
  }),
63
64
  ];
64
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
+ }
65
114
  // Row writes are fire-and-forget during the run, but tracked so the
66
115
  // session/flush checkpoint and shutdown can drain them — a one-shot
67
116
  // (headless) run would otherwise lose the tail events to process exit.
@@ -132,6 +181,7 @@ export function apply(ctx, config) {
132
181
  started = track(guard(async (backend) => backend.init?.()));
133
182
  return async () => {
134
183
  sessions.clear();
184
+ toolCallMessages.clear();
135
185
  await Promise.all([...pending]);
136
186
  await guard(async (backend) => backend.close?.());
137
187
  };
@@ -143,6 +193,7 @@ export function apply(ctx, config) {
143
193
  const archived = (chains.get(sessionId) ?? started).then(async () => {
144
194
  const id = await backends[0].archiveSession(sessionId);
145
195
  sessions.delete(sessionId);
196
+ toolCallMessages.delete(sessionId);
146
197
  return id;
147
198
  });
148
199
  const settled = archived.then(() => { }, () => { });
@@ -154,16 +205,34 @@ export function apply(ctx, config) {
154
205
  /* eslint-disable @typescript-eslint/no-explicit-any */
155
206
  ctx.on('session/event', (session, event) => {
156
207
  const sessionId = session?.id ?? 'unknown';
208
+ let snapshot;
209
+ try {
210
+ // Capture the committed surface now; queued writes must not observe later steps.
211
+ snapshot = compressionSnapshot(session, event);
212
+ }
213
+ catch (error) {
214
+ console.error('[dsh-storage] compaction snapshot error:', error);
215
+ return;
216
+ }
157
217
  enqueue(sessionId, async () => {
218
+ if (snapshot) {
219
+ await fanout((backend) => backend.upsertMessage(snapshot));
220
+ return;
221
+ }
158
222
  let accum = sessions.get(sessionId);
159
223
  if (!accum) {
160
224
  accum = await seedAccum(sessionId);
161
225
  sessions.set(sessionId, accum);
162
226
  }
163
227
  let row = projectEvent(session, event, sessionId);
164
- const storedRows = row || event?.type === 'tool/result'
165
- ? await backends[0].readMessages(sessionId, row?.id)
166
- : [];
228
+ if (row && session.header?.createdAt !== undefined) {
229
+ row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
230
+ }
231
+ const storedRows = event?.type === 'tool/result'
232
+ ? await readToolResultRows(session, sessionId, event)
233
+ : row
234
+ ? await backends[0].readMessages(sessionId, row.id)
235
+ : [];
167
236
  if (event?.type === 'tool/result')
168
237
  row = mergeToolResult(event, storedRows);
169
238
  const existing = row && storedRows.find((stored) => stored.id === row?.id);
@@ -191,6 +260,7 @@ export function apply(ctx, config) {
191
260
  accum.lastMessageAt = row.createdAt;
192
261
  }
193
262
  await fanout((backend) => backend.upsertMessage(row));
263
+ rememberToolCalls(sessionId, row);
194
264
  }
195
265
  // Replacement usage accounting: the latest sample for a (turn, step)
196
266
  // supersedes the previous one — fold only the delta, so the
@@ -246,6 +316,7 @@ export function apply(ctx, config) {
246
316
  // the dispose was meant to drop).
247
317
  enqueue(sessionId, async () => {
248
318
  sessions.delete(sessionId);
319
+ toolCallMessages.delete(sessionId);
249
320
  });
250
321
  // Delete the chain entry only once it has settled AND is still the same
251
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>;
@@ -0,0 +1,3 @@
1
+ import type { MessageRow } from './types.js';
2
+ /** Legacy Content[] checkpoints remain readable by databaseChatStorage. */
3
+ export declare function compressionSnapshot(session: any, event: any): MessageRow | 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.32",
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",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@deepseek-ai/cordis": "4.0.5",
66
- "@deepseek-ai/dsh-session": "0.2.1-rc.3",
66
+ "@deepseek-ai/dsh-session": "0.2.1-rc.4",
67
67
  "@prisma/adapter-libsql": "^7.9.1",
68
68
  "@prisma/adapter-mariadb": "^7.9.1",
69
69
  "@prisma/adapter-mssql": "^7.9.1",