@amaster.ai/dsh-storage 0.1.33 → 0.1.35

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
 
@@ -24,6 +24,14 @@ import { createHash } from 'node:crypto';
24
24
  function pk(value) {
25
25
  return createHash('sha256').update(value).digest('hex').slice(0, 36);
26
26
  }
27
+ const ARCHIVE_IN_PROGRESS = 'dsh-storage:archive-in-progress';
28
+ const TOKEN_BASELINE_KEY = 'dsh-storage:token-baseline';
29
+ function metadataOf(value) {
30
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
31
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
32
+ ? parsed
33
+ : {};
34
+ }
27
35
  /**
28
36
  * Parse the prisma-style sqlserver URL
29
37
  * (sqlserver://host:1433;database=d;user=u;password=p;encrypt=true;trustServerCertificate=true)
@@ -93,6 +101,11 @@ export class DatabaseBackend {
93
101
  constructor(config) {
94
102
  this.config = config;
95
103
  }
104
+ client() {
105
+ if (!this.prisma)
106
+ throw new Error('dsh-storage database is not initialized');
107
+ return this.prisma;
108
+ }
96
109
  /** Load the pre-generated client for the configured provider (seam for tests). */
97
110
  async loadClient() {
98
111
  const mod = await import(new URL(`../generated/${this.config.provider}/client.js`, import.meta.url).href);
@@ -103,8 +116,15 @@ export class DatabaseBackend {
103
116
  this.loadClient(),
104
117
  createAdapter(this.config),
105
118
  ]);
106
- this.prisma = new PrismaClient({ adapter });
107
- await this.prisma.$connect();
119
+ const prisma = new PrismaClient({ adapter });
120
+ try {
121
+ await prisma.$connect();
122
+ this.prisma = prisma;
123
+ }
124
+ catch (error) {
125
+ await prisma.$disconnect?.().catch(() => { });
126
+ throw error;
127
+ }
108
128
  }
109
129
  /** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
110
130
  jsonField(value) {
@@ -130,31 +150,125 @@ export class DatabaseBackend {
130
150
  createdAt: row.createdAt,
131
151
  };
132
152
  }
133
- async upsertMessage(row) {
134
- if (!this.prisma)
135
- return;
153
+ async writeMessage(client, row) {
136
154
  // NUL-separated: a space-joined pair would be ambiguous ('a b','c' ≡ 'a','b c').
137
155
  const id = pk(`message ${row.sessionId}\0${row.id}`);
138
156
  const data = this.toDbData(row);
139
- await this.prisma.aiMessage.upsert({
157
+ await client.aiMessage.upsert({
140
158
  where: { id },
141
159
  create: { id, sessionId: row.sessionId, historyId: row.historyId, ...data },
142
160
  update: data,
143
161
  });
144
162
  }
163
+ async upsertMessage(row) {
164
+ await this.writeMessage(this.client(), row);
165
+ }
145
166
  async readMessages(sessionId, messageId) {
146
- if (!this.prisma)
147
- 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
- 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
- },
167
+ return this.readRows({
168
+ sessionId,
169
+ deletedAt: null,
170
+ id: pk(`message ${sessionId}\0${messageId}`),
171
+ });
172
+ }
173
+ async readToolCallMessages(sessionId, callId) {
174
+ const prisma = this.client();
175
+ const provider = this.config.provider;
176
+ const sqlserver = provider === 'sqlserver';
177
+ // SQL Server formats datetime2 with all seven fractional digits: a raw JS
178
+ // Date would lose the 100ns precision needed by keyset pagination.
179
+ const sortTime = provider === 'mysql'
180
+ ? "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f')"
181
+ : provider === 'postgresql'
182
+ ? "to_char(created_at, 'YYYY-MM-DD HH24:MI:SS.US')"
183
+ : sqlserver
184
+ ? "FORMAT(created_at, 'yyyy-MM-ddTHH:mm:ss.fffffff')"
185
+ : 'CAST(created_at AS TEXT)';
186
+ let cursor;
187
+ let nullPhase = false;
188
+ for (;;) {
189
+ const params = provider === 'mysql' || provider === 'sqlite' ? [sessionId, sessionId] : [sessionId];
190
+ let before = '';
191
+ if (provider === 'postgresql') {
192
+ // PostgreSQL defaults to NULLS FIRST, which cannot be paginated with a
193
+ // single mixed-time cursor. Walk non-NULL timestamps first, then IDs
194
+ // of NULL rows, instead of relying on a NULLS LAST index definition.
195
+ if (nullPhase) {
196
+ before =
197
+ cursor?.time === null ? 'AND created_at IS NULL AND id < ?' : 'AND created_at IS NULL';
198
+ if (cursor)
199
+ params.push(cursor.id);
200
+ }
201
+ else {
202
+ before = 'AND created_at IS NOT NULL';
203
+ if (cursor?.time) {
204
+ before += ' AND (created_at < ? OR (created_at = ? AND id < ?))';
205
+ params.push(cursor.time, cursor.time, cursor.id);
206
+ }
207
+ }
208
+ }
209
+ else if (cursor) {
210
+ if (cursor.time === null) {
211
+ before = 'AND created_at IS NULL AND id < ?';
212
+ params.push(cursor.id);
213
+ }
214
+ else {
215
+ before = 'AND (created_at < ? OR (created_at = ? AND id < ?) OR created_at IS NULL)';
216
+ params.push(cursor.time, cursor.time, cursor.id);
217
+ }
218
+ }
219
+ const orderBy = provider === 'postgresql' && nullPhase ? 'id DESC' : 'created_at DESC, id DESC';
220
+ let sql = `SELECT ${sqlserver ? 'TOP 100 ' : ''}id, ${sqlserver ? 'session_id, ' : ''}tool_calls, ${sortTime} AS sort_time
221
+ FROM ai_messages WHERE session_id = ?
222
+ ${provider === 'sqlite' ? 'AND session_id COLLATE BINARY = ?' : ''}
223
+ ${provider === 'mysql' ? 'AND BINARY session_id = BINARY ?' : ''}
224
+ AND deleted_at IS NULL AND history_id IS NULL AND type = 'model' AND tool_calls IS NOT NULL
225
+ ${before} ORDER BY ${orderBy} ${sqlserver ? '' : 'LIMIT 100'}`;
226
+ if (sqlserver || provider === 'postgresql') {
227
+ let index = 0;
228
+ sql = sql.replaceAll('?', () => `${sqlserver ? '@P' : '$'}${++index}`);
229
+ }
230
+ const page = await prisma.$queryRawUnsafe(sql, ...params);
231
+ for (const row of page) {
232
+ // The SQL Server index can seek on session_id without a collation
233
+ // expression; verify exact identity here instead of losing index use.
234
+ if (sqlserver && row.session_id !== undefined && row.session_id !== sessionId)
235
+ continue;
236
+ let calls = row.tool_calls;
237
+ if (typeof calls === 'string') {
238
+ try {
239
+ calls = JSON.parse(calls);
240
+ }
241
+ catch {
242
+ continue;
243
+ }
244
+ }
245
+ if (Array.isArray(calls) && calls.some((call) => call?.id === callId)) {
246
+ return this.readRows({
247
+ sessionId,
248
+ deletedAt: null,
249
+ historyId: null,
250
+ type: 'model',
251
+ id: row.id,
252
+ });
253
+ }
254
+ }
255
+ if (page.length < 100) {
256
+ if (provider === 'postgresql' && !nullPhase) {
257
+ nullPhase = true;
258
+ cursor = undefined;
259
+ continue;
260
+ }
261
+ return [];
262
+ }
263
+ const last = page.at(-1);
264
+ cursor = { id: last.id, time: last.sort_time };
265
+ }
266
+ }
267
+ async readRows(where) {
268
+ const rows = await this.client().aiMessage.findMany({
269
+ where,
157
270
  orderBy: { createdAt: 'asc' },
271
+ take: 1,
158
272
  });
159
273
  const json = (value) => (typeof value === 'string' ? JSON.parse(value) : value);
160
274
  return rows.map((row) => {
@@ -191,9 +305,7 @@ export class DatabaseBackend {
191
305
  });
192
306
  }
193
307
  async readSession(sessionId) {
194
- if (!this.prisma)
195
- return null;
196
- const row = await this.prisma.aiChatHistory.findFirst({
308
+ const row = await this.client().aiChatHistory.findFirst({
197
309
  where: { sessionId, deletedAt: null, messages: { none: {} } },
198
310
  });
199
311
  if (!row)
@@ -213,14 +325,12 @@ export class DatabaseBackend {
213
325
  : row.metadata,
214
326
  };
215
327
  }
216
- async upsertSession(row) {
217
- if (!this.prisma)
218
- return;
328
+ async writeSession(client, row) {
219
329
  // Find-then-write is race-free because the plugin serializes event
220
330
  // processing per session (see the per-session chain in index.ts). The
221
331
  // cuid primary key matches the source project and early-scaffold rows,
222
332
  // so existing data is continued, never duplicated.
223
- const existing = await this.prisma.aiChatHistory.findFirst({
333
+ const existing = await client.aiChatHistory.findFirst({
224
334
  where: { sessionId: row.sessionId, deletedAt: null, messages: { none: {} } },
225
335
  select: { id: true },
226
336
  });
@@ -234,16 +344,25 @@ export class DatabaseBackend {
234
344
  metadata: this.jsonField(row.metadata),
235
345
  };
236
346
  if (existing) {
237
- await this.prisma.aiChatHistory.update({ where: { id: existing.id }, data });
347
+ await client.aiChatHistory.update({ where: { id: existing.id }, data });
238
348
  }
239
349
  else {
240
- await this.prisma.aiChatHistory.create({ data });
350
+ await client.aiChatHistory.create({ data });
241
351
  }
242
352
  }
353
+ async upsertSession(row) {
354
+ await this.writeSession(this.client(), row);
355
+ }
356
+ async upsertEvent(message, session) {
357
+ await this.client().$transaction(async (tx) => {
358
+ if (message)
359
+ await this.writeMessage(tx, message);
360
+ await this.writeSession(tx, session);
361
+ });
362
+ }
243
363
  async archiveSession(sessionId) {
244
- if (!this.prisma)
245
- throw new Error('dsh-storage database is not initialized');
246
- return this.prisma.$transaction(async (tx) => {
364
+ const client = this.client();
365
+ const { expected, historyId } = await client.$transaction(async (tx) => {
247
366
  const where = { sessionId, historyId: null, deletedAt: null };
248
367
  const stats = await tx.aiMessage.aggregate({
249
368
  where,
@@ -251,30 +370,148 @@ export class DatabaseBackend {
251
370
  _min: { createdAt: true },
252
371
  _max: { createdAt: true },
253
372
  });
254
- if (stats._count._all === 0)
255
- return null;
256
- // Active rollups have no attached messages; an archive gains them below.
257
- const active = await tx.aiChatHistory.findFirst({
258
- where: { sessionId, deletedAt: null, messages: { none: {} } },
259
- select: { id: true },
373
+ const expected = stats._count._all;
374
+ // A previous batch may have attached messages already. Resume that marked
375
+ // history before considering a newer active rollup or an empty finalization.
376
+ const interrupted = await tx.aiMessage.findFirst({
377
+ where: { sessionId, historyId: { not: null }, deletedAt: null },
378
+ orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
379
+ select: { historyId: true },
260
380
  });
381
+ if (interrupted?.historyId) {
382
+ const candidate = await tx.aiChatHistory.findFirst({
383
+ where: { id: interrupted.historyId },
384
+ select: { metadata: true },
385
+ });
386
+ if (metadataOf(candidate?.metadata)[ARCHIVE_IN_PROGRESS] === true)
387
+ return { expected, historyId: interrupted.historyId };
388
+ }
389
+ if (expected === 0)
390
+ return { expected, historyId: null };
261
391
  const rollup = {
262
- messageCount: stats._count._all,
392
+ messageCount: expected,
263
393
  firstMessageAt: stats._min.createdAt,
264
394
  lastMessageAt: stats._max.createdAt,
265
395
  };
266
- const history = active
267
- ? await tx.aiChatHistory.update({ where: { id: active.id }, data: rollup })
268
- : await tx.aiChatHistory.create({
269
- data: { sessionId, ...rollup, totalTokens: 0n },
270
- select: { id: true },
396
+ // Active rollups have no attached messages; an archive gains them below.
397
+ const active = await tx.aiChatHistory.findFirst({
398
+ where: { sessionId, deletedAt: null, messages: { none: {} } },
399
+ select: { id: true, metadata: true, totalTokens: true },
400
+ });
401
+ if (active) {
402
+ await tx.aiChatHistory.update({
403
+ where: { id: active.id },
404
+ data: {
405
+ ...rollup,
406
+ metadata: this.jsonField({
407
+ ...metadataOf(active.metadata),
408
+ [TOKEN_BASELINE_KEY]: Number(active.totalTokens ?? 0n),
409
+ [ARCHIVE_IN_PROGRESS]: true,
410
+ }),
411
+ },
412
+ });
413
+ return { expected, historyId: active.id };
414
+ }
415
+ const history = await tx.aiChatHistory.create({
416
+ data: {
417
+ sessionId,
418
+ ...rollup,
419
+ totalTokens: 0n,
420
+ metadata: this.jsonField({ [ARCHIVE_IN_PROGRESS]: true }),
421
+ },
422
+ select: { id: true },
423
+ });
424
+ return { expected, historyId: history.id };
425
+ });
426
+ if (!historyId)
427
+ return null;
428
+ // Keep each write batch short instead of holding one transaction over the
429
+ // entire active segment. The final aggregate is the correctness barrier.
430
+ let updated = 0;
431
+ while (updated < expected) {
432
+ const candidates = await client.aiMessage.findMany({
433
+ where: { sessionId, historyId: null, deletedAt: null },
434
+ select: { id: true },
435
+ take: 100,
436
+ });
437
+ if (candidates.length === 0)
438
+ break;
439
+ const batch = await client.aiMessage.updateMany({
440
+ where: { id: { in: candidates.map((row) => row.id) } },
441
+ data: { historyId },
442
+ });
443
+ if (batch.count === 0)
444
+ break;
445
+ updated += batch.count;
446
+ }
447
+ if (updated !== expected)
448
+ throw new Error('active messages changed during archive');
449
+ await client.$transaction(async (tx) => {
450
+ const stats = await tx.aiMessage.aggregate({
451
+ where: { historyId, deletedAt: null },
452
+ _count: { _all: true },
453
+ _min: { createdAt: true },
454
+ _max: { createdAt: true },
455
+ });
456
+ const current = await tx.aiChatHistory.findFirst({
457
+ where: { id: historyId },
458
+ select: { metadata: true, totalTokens: true },
459
+ });
460
+ const latestActive = await tx.aiChatHistory.findFirst({
461
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
462
+ orderBy: { lastMessageAt: 'desc' },
463
+ select: { totalTokens: true, metadata: true },
464
+ });
465
+ const currentTokens = current?.totalTokens ?? 0n;
466
+ const baselineValue = metadataOf(latestActive?.metadata)[TOKEN_BASELINE_KEY];
467
+ const baseline = Number(baselineValue);
468
+ let totalTokens = currentTokens;
469
+ if (latestActive && Number.isSafeInteger(baseline) && baseline >= 0) {
470
+ // The baseline records the active rollup's seed total. Everything
471
+ // after it belongs to this archive, including restart-only deltas.
472
+ const delta = latestActive.totalTokens - BigInt(baseline);
473
+ if (delta > 0n)
474
+ totalTokens += delta;
475
+ }
476
+ else if (latestActive && latestActive.totalTokens > currentTokens) {
477
+ // Legacy rows without a baseline keep the cumulative compatibility path.
478
+ totalTokens = latestActive.totalTokens;
479
+ }
480
+ const metadata = { ...metadataOf(current?.metadata) };
481
+ delete metadata[ARCHIVE_IN_PROGRESS];
482
+ await tx.aiChatHistory.update({
483
+ where: { id: historyId },
484
+ data: {
485
+ messageCount: stats._count._all,
486
+ totalTokens,
487
+ firstMessageAt: stats._min.createdAt,
488
+ lastMessageAt: stats._max.createdAt,
489
+ metadata: this.jsonField(metadata),
490
+ },
491
+ });
492
+ // A recovery may have created a second active rollup during the failed
493
+ // batches. Do not let its pre-archive counts seed the next live segment.
494
+ await tx.aiChatHistory.updateMany({
495
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
496
+ data: { messageCount: 0, totalTokens: 0n, firstMessageAt: null, lastMessageAt: null },
497
+ });
498
+ const activeRollups = await tx.aiChatHistory.findMany({
499
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
500
+ select: { id: true, metadata: true },
501
+ });
502
+ for (const active of activeRollups ?? []) {
503
+ // A completed archive must not leak its retry marker or stale baseline
504
+ // into the next live segment, or the next archive will skip freezing.
505
+ const activeMetadata = { ...metadataOf(active.metadata) };
506
+ delete activeMetadata[ARCHIVE_IN_PROGRESS];
507
+ delete activeMetadata[TOKEN_BASELINE_KEY];
508
+ await tx.aiChatHistory.update({
509
+ where: { id: active.id },
510
+ data: { metadata: this.jsonField(activeMetadata) },
271
511
  });
272
- const updated = await tx.aiMessage.updateMany({ where, data: { historyId: history.id } });
273
- if (updated.count !== stats._count._all) {
274
- throw new Error('active messages changed during archive');
275
512
  }
276
- return history.id;
277
513
  });
514
+ return historyId;
278
515
  }
279
516
  async close() {
280
517
  await this.prisma?.$disconnect?.();
@@ -15,7 +15,7 @@ const config = {
15
15
  "clientVersion": "7.9.1",
16
16
  "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
17
17
  "activeProvider": "mysql",
18
- "inlineSchema": "// Prisma schema for the dsh-storage database backend — MySQL variant (Prisma 7).\n//\n// Table layout matches the source project's ai_messages / ai_chat_histories\n// (packages/core/prisma/schema.prisma) so existing data stays compatible:\n// - `type` values come from the dsh session event map\n// ('user' | 'model' | 'tool' | ...) instead of gemini-cli message roles.\n// - `agent_id` distinguishes the main agent from subagent writers.\n// - no user_id: tenancy is carried by session_id, not a column.\n//\n// The client is pre-generated at package build time (`pnpm generate`, output\n// src/generated/mysql, compiled into lib/generated) — consumers do NOT run\n// `prisma generate`. To create/upgrade tables, run `prisma db push` /\n// migrations against this file; the connection URL lives in prisma.config.ts\n// (Prisma 7 dropped datasource.url from the schema).\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/mysql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"mysql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.LongText\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
18
+ "inlineSchema": "// Prisma schema for the dsh-storage database backend — MySQL variant (Prisma 7).\n//\n// Table layout matches the source project's ai_messages / ai_chat_histories\n// (packages/core/prisma/schema.prisma) so existing data stays compatible:\n// - `type` values come from the dsh session event map\n// ('user' | 'model' | 'tool' | ...) instead of gemini-cli message roles.\n// - `agent_id` distinguishes the main agent from subagent writers.\n// - no user_id: tenancy is carried by session_id, not a column.\n//\n// The client is pre-generated at package build time (`pnpm generate`, output\n// src/generated/mysql, compiled into lib/generated) — consumers do NOT run\n// `prisma generate`. To create/upgrade tables, run `prisma db push` /\n// migrations against this file; the connection URL lives in prisma.config.ts\n// (Prisma 7 dropped datasource.url from the schema).\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/mysql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"mysql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.LongText\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
19
19
  "runtimeDataModel": {
20
20
  "models": {},
21
21
  "enums": {},
@@ -15,7 +15,7 @@ const config = {
15
15
  "clientVersion": "7.9.1",
16
16
  "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
17
17
  "activeProvider": "postgresql",
18
- "inlineSchema": "// Prisma schema for the dsh-storage database backend — PostgreSQL variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with PostgreSQL native types\n// (`@db.Text` instead of `@db.LongText`, `@db.Timestamp` instead of\n// `@db.DateTime`). Client pre-generation and table creation work as described\n// in schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/postgresql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.Text\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.Timestamp(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.Timestamp(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
18
+ "inlineSchema": "// Prisma schema for the dsh-storage database backend — PostgreSQL variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with PostgreSQL native types\n// (`@db.Text` instead of `@db.LongText`, `@db.Timestamp` instead of\n// `@db.DateTime`). Client pre-generation and table creation work as described\n// in schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/postgresql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.Text\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.Timestamp(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.Timestamp(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
19
19
  "runtimeDataModel": {
20
20
  "models": {},
21
21
  "enums": {},
@@ -15,7 +15,7 @@ const config = {
15
15
  "clientVersion": "7.9.1",
16
16
  "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
17
17
  "activeProvider": "sqlite",
18
- "inlineSchema": "// Prisma schema for the dsh-storage database backend — SQLite variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, without native type attributes\n// (SQLite has no `@db.*` vocabulary here). Client pre-generation and table\n// creation work as described in schema.mysql.prisma's header. Connection URL\n// shape: file:/absolute/path/to/db.sqlite.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlite\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n historyId String? @map(\"history_id\")\n type String\n content String\n thoughts Json?\n model String?\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\")\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\")\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n title String?\n summary String?\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\")\n lastMessageAt DateTime? @map(\"last_message_at\")\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
18
+ "inlineSchema": "// Prisma schema for the dsh-storage database backend — SQLite variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, without native type attributes\n// (SQLite has no `@db.*` vocabulary here). Client pre-generation and table\n// creation work as described in schema.mysql.prisma's header. Connection URL\n// shape: file:/absolute/path/to/db.sqlite.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlite\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n historyId String? @map(\"history_id\")\n type String\n content String\n thoughts Json?\n model String?\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\")\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\")\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n title String?\n summary String?\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\")\n lastMessageAt DateTime? @map(\"last_message_at\")\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
19
19
  "runtimeDataModel": {
20
20
  "models": {},
21
21
  "enums": {},
@@ -15,7 +15,7 @@ const config = {
15
15
  "clientVersion": "7.9.1",
16
16
  "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
17
17
  "activeProvider": "sqlserver",
18
- "inlineSchema": "// Prisma schema for the dsh-storage database backend — SQL Server variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with SQL Server native types. Prisma's\n// sqlserver connector has NO Json type, so every JSON column is a plain text\n// column here — the backend serializes them on write automatically (derived\n// from `provider: sqlserver`; SQL Server's own ISJSON/JSON_VALUE still query\n// the text as JSON).\n//\n// Connection URL shape (parsed by the backend into a node-mssql config):\n// sqlserver://host:1433;database=dbname;user=sa;password=p;encrypt=true;trustServerCertificate=true\n// Client pre-generation and table creation work as described in\n// schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlserver\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlserver\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.NVarChar(Max)\n thoughts String? @db.NVarChar(Max)\n model String? @db.VarChar(100)\n tokens String? @db.NVarChar(Max)\n toolCalls String? @map(\"tool_calls\") @db.NVarChar(Max)\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata String? @db.NVarChar(Max)\n createdAt DateTime @default(now()) @map(\"created_at\") @db.DateTime2\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.DateTime2\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.NVarChar(Max)\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime2\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime2\n metadata String? @db.NVarChar(Max)\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
18
+ "inlineSchema": "// Prisma schema for the dsh-storage database backend — SQL Server variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with SQL Server native types. Prisma's\n// sqlserver connector has NO Json type, so every JSON column is a plain text\n// column here — the backend serializes them on write automatically (derived\n// from `provider: sqlserver`; SQL Server's own ISJSON/JSON_VALUE still query\n// the text as JSON).\n//\n// Connection URL shape (parsed by the backend into a node-mssql config):\n// sqlserver://host:1433;database=dbname;user=sa;password=p;encrypt=true;trustServerCertificate=true\n// Client pre-generation and table creation work as described in\n// schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlserver\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlserver\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.NVarChar(Max)\n thoughts String? @db.NVarChar(Max)\n model String? @db.VarChar(100)\n tokens String? @db.NVarChar(Max)\n toolCalls String? @map(\"tool_calls\") @db.NVarChar(Max)\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata String? @db.NVarChar(Max)\n createdAt DateTime @default(now()) @map(\"created_at\") @db.DateTime2\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.DateTime2\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.NVarChar(Max)\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime2\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime2\n metadata String? @db.NVarChar(Max)\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
19
19
  "runtimeDataModel": {
20
20
  "models": {},
21
21
  "enums": {},
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.
@@ -35,6 +35,8 @@ export const Config = Schema.object({
35
35
  });
36
36
  /** Metadata key for our usage bookkeeping — namespaced so other writers' fields never collide with it. */
37
37
  const LAST_USAGE_KEY = 'dsh-storage:lastUsage';
38
+ const TOKEN_BASELINE_KEY = 'dsh-storage:token-baseline';
39
+ const ARCHIVE_IN_PROGRESS = 'dsh-storage:archive-in-progress';
38
40
  /** Shape-guarded restore of our usage sample from arbitrary metadata. */
39
41
  function usageSampleOfMetadata(value) {
40
42
  const v = value;
@@ -63,24 +65,85 @@ export function apply(ctx, config) {
63
65
  }),
64
66
  ];
65
67
  const sessions = new Map();
68
+ const toolCallMessages = new Map();
69
+ const TOOL_CALL_CACHE_LIMIT = 1000;
70
+ function rememberToolCalls(sessionId, row) {
71
+ if (row?.type !== 'model' || !Array.isArray(row.toolCalls))
72
+ return;
73
+ for (const call of row.toolCalls) {
74
+ if (typeof call?.id !== 'string')
75
+ continue;
76
+ const calls = toolCallMessages.get(sessionId) ?? new Map();
77
+ // Bound each session's cache; Map iteration evicts the oldest insertion first.
78
+ if (!calls.has(call.id) && calls.size >= TOOL_CALL_CACHE_LIMIT) {
79
+ const oldest = calls.keys().next().value;
80
+ if (oldest !== undefined)
81
+ calls.delete(oldest);
82
+ }
83
+ calls.set(call.id, row.id);
84
+ toolCallMessages.set(sessionId, calls);
85
+ }
86
+ }
87
+ async function readToolResultRows(session, sessionId, event) {
88
+ const callId = toolResultCallId(event);
89
+ if (!callId)
90
+ return [];
91
+ let messageId = toolCallMessages.get(sessionId)?.get(callId);
92
+ if (!messageId && typeof session?.deriveMessages === 'function') {
93
+ const messages = session.deriveMessages();
94
+ for (let index = messages.length - 1; index >= 0; index--) {
95
+ const message = messages[index];
96
+ if (message.role === 'assistant' &&
97
+ typeof message.id === 'string' &&
98
+ Array.isArray(message.content) &&
99
+ message.content.some((block) => block?.type === 'tool-call' && block.id === callId)) {
100
+ messageId = message.id;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ if (messageId) {
106
+ const rows = await backends[0].readMessages(sessionId, messageId);
107
+ if (rows.some((row) => Array.isArray(row.toolCalls) && row.toolCalls.some((call) => call?.id === callId)))
108
+ return rows;
109
+ }
110
+ // Cold resume and eviction fall back to bounded database candidate pages.
111
+ const rows = await backends[0].readToolCallMessages(sessionId, callId);
112
+ for (const row of rows)
113
+ rememberToolCalls(sessionId, row);
114
+ return rows;
115
+ }
66
116
  // Row writes are fire-and-forget during the run, but tracked so the
67
117
  // session/flush checkpoint and shutdown can drain them — a one-shot
68
118
  // (headless) run would otherwise lose the tail events to process exit.
69
119
  const pending = new Set();
70
120
  function track(task) {
71
121
  pending.add(task);
72
- void task.finally(() => pending.delete(task));
122
+ void task.then(() => pending.delete(task), () => pending.delete(task));
73
123
  return task;
74
124
  }
75
125
  function guard(call) {
76
126
  // Mirroring must never break the agent loop: log and swallow.
77
127
  return Promise.all(backends.map((backend) => call(backend).catch((error) => console.error(`[dsh-storage] ${backend.name} error:`, error)))).then(() => { });
78
128
  }
79
- // Backend init starts at plugin load; every write chains behind it so no
80
- // row is lost to a still-connecting backend.
129
+ // All sessions share one in-flight init. A failed attempt is cleared so the
130
+ // next event can retry once; no read or write passes a failed init barrier.
81
131
  let started;
132
+ function ensureStarted() {
133
+ if (!started) {
134
+ const attempt = track(Promise.all(backends.map((backend) => backend.init?.())).then(() => { }));
135
+ started = attempt;
136
+ void attempt.catch(() => {
137
+ if (started === attempt)
138
+ started = undefined;
139
+ });
140
+ }
141
+ return started;
142
+ }
82
143
  function fanout(call) {
83
- return track(started.then(() => guard(call)));
144
+ // Event failures reject into the per-session queue, which is the no-throw
145
+ // boundary. Swallowing here would make a partial write look committed.
146
+ return track(ensureStarted().then(() => Promise.all(backends.map(call)).then(() => { })));
84
147
  }
85
148
  // Per-session serialization: all event processing for one session runs
86
149
  // through a promise chain, so rollup writes complete in event order
@@ -90,10 +153,10 @@ export function apply(ctx, config) {
90
153
  // so history must come from the database, not from zero).
91
154
  const chains = new Map();
92
155
  function enqueue(sessionId, task) {
93
- // First task of a session also chains behind backend init, so seedAccum
94
- // never reads from a still-connecting backend (prisma === null would
95
- // masquerade as "row not found" and zero-seed over the stored rollup).
96
- const next = (chains.get(sessionId) ?? started)
156
+ // Every task rechecks the init barrier: a failed earlier attempt must not
157
+ // become a resolved chain that lets later events zero-seed or no-op write.
158
+ const next = (chains.get(sessionId) ?? Promise.resolve())
159
+ .then(() => ensureStarted())
97
160
  .then(task)
98
161
  .catch((error) => console.error('[dsh-storage] mirror task error:', error));
99
162
  chains.set(sessionId, next);
@@ -115,7 +178,9 @@ export function apply(ctx, config) {
115
178
  firstMessageAt: row.firstMessageAt ?? undefined,
116
179
  lastMessageAt: row.lastMessageAt ?? undefined,
117
180
  // Preserve every existing metadata field; we only own our key.
118
- metadata: { ...(row.metadata ?? {}) },
181
+ // The baseline distinguishes cumulative active totals from a
182
+ // restart's fresh-segment deltas during archive recovery.
183
+ metadata: { [TOKEN_BASELINE_KEY]: row.totalTokens, ...(row.metadata ?? {}) },
119
184
  usageSample: usageSampleOfMetadata(row.metadata?.[LAST_USAGE_KEY]),
120
185
  };
121
186
  }
@@ -127,13 +192,14 @@ export function apply(ctx, config) {
127
192
  }
128
193
  if (readFailure)
129
194
  throw readFailure;
130
- return { messageCount: 0, totalTokens: 0, metadata: {} };
195
+ return { messageCount: 0, totalTokens: 0, metadata: { [TOKEN_BASELINE_KEY]: 0 } };
131
196
  }
132
197
  ctx.effect(() => {
133
- started = track(guard(async (backend) => backend.init?.()));
198
+ void ensureStarted().catch((error) => console.error('[dsh-storage] database error:', error));
134
199
  return async () => {
135
200
  sessions.clear();
136
- await Promise.all([...pending]);
201
+ toolCallMessages.clear();
202
+ await Promise.allSettled([...pending]);
137
203
  await guard(async (backend) => backend.close?.());
138
204
  };
139
205
  });
@@ -141,9 +207,20 @@ export function apply(ctx, config) {
141
207
  archiveSession(sessionId) {
142
208
  // Join the same per-session chain as event writes, then discard the old
143
209
  // in-memory rollup only after the database transaction commits.
144
- const archived = (chains.get(sessionId) ?? started).then(async () => {
210
+ const archived = (chains.get(sessionId) ?? Promise.resolve())
211
+ .then(() => ensureStarted())
212
+ .then(async () => {
213
+ const accum = sessions.get(sessionId);
214
+ if (accum && !accum.metadata[ARCHIVE_IN_PROGRESS]) {
215
+ // Freeze inside the session queue: all earlier events have now
216
+ // committed, and this first baseline survives every retry until
217
+ // the archive actually succeeds.
218
+ accum.metadata[TOKEN_BASELINE_KEY] = accum.totalTokens;
219
+ accum.metadata[ARCHIVE_IN_PROGRESS] = true;
220
+ }
145
221
  const id = await backends[0].archiveSession(sessionId);
146
222
  sessions.delete(sessionId);
223
+ toolCallMessages.delete(sessionId);
147
224
  return id;
148
225
  });
149
226
  const settled = archived.then(() => { }, () => { });
@@ -169,18 +246,20 @@ export function apply(ctx, config) {
169
246
  await fanout((backend) => backend.upsertMessage(snapshot));
170
247
  return;
171
248
  }
172
- let accum = sessions.get(sessionId);
173
- if (!accum) {
174
- accum = await seedAccum(sessionId);
175
- sessions.set(sessionId, accum);
176
- }
249
+ const stored = sessions.get(sessionId) ?? (await seedAccum(sessionId));
250
+ sessions.set(sessionId, stored);
251
+ // Stage every change on a copy. The live accumulator advances only
252
+ // after the message + rollup transaction commits.
253
+ const accum = { ...stored, metadata: { ...stored.metadata } };
177
254
  let row = projectEvent(session, event, sessionId);
178
255
  if (row && session.header?.createdAt !== undefined) {
179
256
  row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
180
257
  }
181
- const storedRows = row || event?.type === 'tool/result'
182
- ? await backends[0].readMessages(sessionId, row?.id)
183
- : [];
258
+ const storedRows = event?.type === 'tool/result'
259
+ ? await readToolResultRows(session, sessionId, event)
260
+ : row
261
+ ? await backends[0].readMessages(sessionId, row.id)
262
+ : [];
184
263
  if (event?.type === 'tool/result')
185
264
  row = mergeToolResult(event, storedRows);
186
265
  const existing = row && storedRows.find((stored) => stored.id === row?.id);
@@ -201,28 +280,21 @@ export function apply(ctx, config) {
201
280
  if (!row && event?.type === 'tool/result') {
202
281
  console.error('[dsh-storage] no matching model tool call for result');
203
282
  }
204
- if (row) {
205
- if (!existing && event?.type !== 'tool/result') {
206
- accum.messageCount += 1;
207
- accum.firstMessageAt ??= row.createdAt;
208
- accum.lastMessageAt = row.createdAt;
209
- }
210
- await fanout((backend) => backend.upsertMessage(row));
283
+ if (row && !existing && event?.type !== 'tool/result') {
284
+ accum.messageCount += 1;
285
+ accum.firstMessageAt ??= row.createdAt;
286
+ accum.lastMessageAt = row.createdAt;
211
287
  }
212
- // Replacement usage accounting: the latest sample for a (turn, step)
213
- // supersedes the previous one — fold only the delta, so the
214
- // chunk↔message pair and progressive samples never double-count, and a
215
- // failed step (usage chunk, no message) still counts. Samples of one
216
- // step arrive adjacently, so a single latest sample is all we track.
288
+ // Every settled attempt/message is one model call; retries on the same
289
+ // step are additive rather than replacement snapshots.
217
290
  const sampled = usageSampleOf(event);
218
291
  let usageDelta = 0;
219
292
  if (sampled) {
220
- const prev = accum.usageSample;
221
- usageDelta =
222
- sampled.sample.input +
223
- sampled.sample.output -
224
- (prev && prev.key === sampled.key ? prev.input + prev.output : 0);
225
- if (!prev || prev.key !== sampled.key || usageDelta !== 0) {
293
+ const sequence = Number(sampled.key);
294
+ const previous = Number(accum.usageSample?.key);
295
+ const duplicate = Number.isSafeInteger(sequence) && Number.isSafeInteger(previous) && sequence <= previous;
296
+ if (!duplicate) {
297
+ usageDelta = sampled.sample.input + sampled.sample.output;
226
298
  accum.usageSample = { key: sampled.key, ...sampled.sample };
227
299
  accum.totalTokens += usageDelta;
228
300
  }
@@ -251,8 +323,10 @@ export function apply(ctx, config) {
251
323
  lastMessageAt: accum.lastMessageAt ?? null,
252
324
  ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
253
325
  };
254
- await fanout((backend) => backend.upsertSession(sessionRow));
326
+ await fanout((backend) => backend.upsertEvent(row, sessionRow));
255
327
  }
328
+ sessions.set(sessionId, accum);
329
+ rememberToolCalls(sessionId, row);
256
330
  });
257
331
  });
258
332
  ctx.on('session/disposed', (session) => {
@@ -263,6 +337,7 @@ export function apply(ctx, config) {
263
337
  // the dispose was meant to drop).
264
338
  enqueue(sessionId, async () => {
265
339
  sessions.delete(sessionId);
340
+ toolCallMessages.delete(sessionId);
266
341
  });
267
342
  // Delete the chain entry only once it has settled AND is still the same
268
343
  // entry — deleting earlier would let a recreated session race the
@@ -274,8 +349,19 @@ export function apply(ctx, config) {
274
349
  chains.delete(sessionId);
275
350
  });
276
351
  });
277
- // Durability checkpoint: the store awaits every session/flush listener.
278
- ctx.on('session/flush', async () => {
279
- await Promise.all([...pending]);
352
+ // Durability checkpoint: wait only for this session. Shutdown still drains
353
+ // the global pending set; an unrelated slow session must not block this one.
354
+ ctx.on('session/flush', async (session) => {
355
+ const chain = chains.get(session?.id ?? 'unknown');
356
+ if (chain) {
357
+ await chain;
358
+ return;
359
+ }
360
+ try {
361
+ await ensureStarted();
362
+ }
363
+ catch (error) {
364
+ console.error('[dsh-storage] database error:', error);
365
+ }
280
366
  });
281
367
  }
package/lib/projector.js CHANGED
@@ -106,12 +106,32 @@ export function projectEvent(_session, event, sessionId) {
106
106
  return null;
107
107
  }
108
108
  }
109
+ /** Identify the assistant tool call answered by either supported result shape. */
110
+ export function toolResultCallId(event) {
111
+ const message = event?.data?.message;
112
+ const first = message?.content?.[0];
113
+ const callId = message?.toolCallId ??
114
+ message?.source?.callId ??
115
+ (first?.type === 'tool-result' ? first.toolCallId : undefined);
116
+ return typeof callId === 'string' ? callId : undefined;
117
+ }
109
118
  /** Update the persisted assistant call identified by a tool result. */
110
119
  export function mergeToolResult(event, rows) {
111
- const result = event?.data?.message?.content?.[0] ?? null;
112
- const callId = event?.data?.message?.source?.callId ?? result?.toolCallId;
120
+ const message = event?.data?.message;
121
+ const content = Array.isArray(message?.content) ? message.content : [];
122
+ const legacy = content.length === 1 && content[0]?.type === 'tool-result' ? content[0] : undefined;
123
+ const callId = toolResultCallId(event);
113
124
  if (!callId)
114
125
  return null;
126
+ const result = legacy ??
127
+ (message
128
+ ? {
129
+ type: 'tool-result',
130
+ toolCallId: callId,
131
+ content,
132
+ ...(typeof message.isError === 'boolean' ? { isError: message.isError } : {}),
133
+ }
134
+ : null);
115
135
  const model = [...rows]
116
136
  .reverse()
117
137
  .find((row) => row.type === 'model' &&
@@ -133,21 +153,19 @@ export function mergeToolResult(event, rows) {
133
153
  };
134
154
  }
135
155
  /**
136
- * Extract the usage sample carried by one event, keyed by its step, or null
137
- * when the event carries none.
156
+ * Extract one model call's usage, keyed by its durable event sequence.
138
157
  *
139
158
  * V3 session log (dsh-session@0.1.6-alpha.2):
140
- * - `assistant/message.data.usage` — the settled step's final accounting;
141
- * - `assistant/attempt` — a failed/retried/cancelled step commits no surface
159
+ * - `assistant/message.data.usage` — the settled call's accounting;
160
+ * - `assistant/attempt` — a failed/retried/cancelled call commits no surface
142
161
  * message; the adapter's usage chunk survives as a raw `chunk` record in the
143
162
  * embedded compact `stream` (deltas are packed, but usage is a raw type).
144
163
  *
145
- * Samples for one `(turn, step)` are REPLACEMENTS, not additive: later
146
- * samples supersede earlier ones (a failed attempt's usage, then the settled
147
- * message's). The caller keeps the latest sample per step and folds only the
148
- * delta into its rollup. Totals include the cache buckets: cacheRead/cacheWrite
149
- * are billed in their own buckets (observed cacheReadTokens ≫ inputTokens in
150
- * dsh session logs — they are not subsets of inputTokens).
164
+ * Calls on one `(turn, step)` are additive: a retry is another billed model
165
+ * call, not a progressive snapshot. Only redelivery of the same event sequence
166
+ * is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
167
+ * billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
168
+ * session logs — they are not subsets of inputTokens).
151
169
  */
152
170
  export function usageSampleOf(event) {
153
171
  const total = (u) => ({
@@ -167,7 +185,7 @@ export function usageSampleOf(event) {
167
185
  else {
168
186
  return null;
169
187
  }
170
- if (!usage || data?.turn == null || data?.step == null)
188
+ if (!usage || !Number.isSafeInteger(event?.seq))
171
189
  return null;
172
- return { key: `${data.turn}:${data.step}`, sample: total(usage) };
190
+ return { key: String(event.seq), sample: total(usage) };
173
191
  }
@@ -24,16 +24,22 @@ export declare class DatabaseBackend implements StorageBackend {
24
24
  readonly name = "database";
25
25
  private prisma;
26
26
  constructor(config: DatabaseBackendConfig);
27
+ private client;
27
28
  /** Load the pre-generated client for the configured provider (seam for tests). */
28
29
  protected loadClient(): Promise<any>;
29
30
  init(): Promise<void>;
30
31
  /** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
31
32
  private jsonField;
32
33
  private toDbData;
34
+ private writeMessage;
33
35
  upsertMessage(row: MessageRow): Promise<void>;
34
- readMessages(sessionId: string, messageId?: string): Promise<MessageRow[]>;
36
+ readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
37
+ readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
38
+ private readRows;
35
39
  readSession(sessionId: string): Promise<SessionRow | null>;
40
+ private writeSession;
36
41
  upsertSession(row: SessionRow): Promise<void>;
42
+ upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
37
43
  archiveSession(sessionId: string): Promise<string | null>;
38
44
  close(): Promise<void>;
39
45
  }
@@ -22,29 +22,29 @@ import Schema from '@deepseek-ai/schemastery';
22
22
  import { type DatabaseProvider } from './backends/database.js';
23
23
  export declare const name = "dsh-storage";
24
24
  export declare const inject: string[];
25
- export declare const Config: Schema<Schemastery.ObjectS<{
26
- enabled: Schema<boolean, boolean>;
27
- database: Schema<Schemastery.ObjectS<{
28
- enabled: Schema<boolean, boolean>;
29
- provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
30
- url: Schema<string, string>;
31
- }>, Schemastery.ObjectT<{
32
- enabled: Schema<boolean, boolean>;
33
- provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
34
- url: Schema<string, string>;
35
- }>>;
36
- }>, Schemastery.ObjectT<{
37
- enabled: Schema<boolean, boolean>;
38
- database: Schema<Schemastery.ObjectS<{
39
- enabled: Schema<boolean, boolean>;
40
- provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
41
- url: Schema<string, string>;
42
- }>, Schemastery.ObjectT<{
43
- enabled: Schema<boolean, boolean>;
44
- provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
45
- url: Schema<string, string>;
46
- }>>;
47
- }>>;
25
+ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
26
+ enabled: Schema<boolean, boolean, "defined">;
27
+ database: Schema<Schemastery.ObjectS<NoInfer<{
28
+ enabled: Schema<boolean, boolean, "defined">;
29
+ provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
30
+ url: Schema<string, string, "defined">;
31
+ }>>, Schemastery.ObjectT<NoInfer<{
32
+ enabled: Schema<boolean, boolean, "defined">;
33
+ provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
34
+ url: Schema<string, string, "defined">;
35
+ }>>, "plain">;
36
+ }>>, Schemastery.ObjectT<NoInfer<{
37
+ enabled: Schema<boolean, boolean, "defined">;
38
+ database: Schema<Schemastery.ObjectS<NoInfer<{
39
+ enabled: Schema<boolean, boolean, "defined">;
40
+ provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
41
+ url: Schema<string, string, "defined">;
42
+ }>>, Schemastery.ObjectT<NoInfer<{
43
+ enabled: Schema<boolean, boolean, "defined">;
44
+ provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
45
+ url: Schema<string, string, "defined">;
46
+ }>>, "plain">;
47
+ }>>, "plain">;
48
48
  export interface StoragePluginConfig {
49
49
  enabled: boolean;
50
50
  database: {
@@ -22,6 +22,8 @@ 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
+ /** Identify the assistant tool call answered by either supported result shape. */
26
+ export declare function toolResultCallId(event: any): string | undefined;
25
27
  /** Update the persisted assistant call identified by a tool result. */
26
28
  export declare function mergeToolResult(event: any, rows: MessageRow[]): MessageRow | null;
27
29
  export interface UsageSample {
@@ -29,21 +31,19 @@ export interface UsageSample {
29
31
  output: number;
30
32
  }
31
33
  /**
32
- * Extract the usage sample carried by one event, keyed by its step, or null
33
- * when the event carries none.
34
+ * Extract one model call's usage, keyed by its durable event sequence.
34
35
  *
35
36
  * V3 session log (dsh-session@0.1.6-alpha.2):
36
- * - `assistant/message.data.usage` — the settled step's final accounting;
37
- * - `assistant/attempt` — a failed/retried/cancelled step commits no surface
37
+ * - `assistant/message.data.usage` — the settled call's accounting;
38
+ * - `assistant/attempt` — a failed/retried/cancelled call commits no surface
38
39
  * message; the adapter's usage chunk survives as a raw `chunk` record in the
39
40
  * embedded compact `stream` (deltas are packed, but usage is a raw type).
40
41
  *
41
- * Samples for one `(turn, step)` are REPLACEMENTS, not additive: later
42
- * samples supersede earlier ones (a failed attempt's usage, then the settled
43
- * message's). The caller keeps the latest sample per step and folds only the
44
- * delta into its rollup. Totals include the cache buckets: cacheRead/cacheWrite
45
- * are billed in their own buckets (observed cacheReadTokens ≫ inputTokens in
46
- * dsh session logs — they are not subsets of inputTokens).
42
+ * Calls on one `(turn, step)` are additive: a retry is another billed model
43
+ * call, not a progressive snapshot. Only redelivery of the same event sequence
44
+ * is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
45
+ * billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
46
+ * session logs — they are not subsets of inputTokens).
47
47
  */
48
48
  export declare function usageSampleOf(event: any): {
49
49
  key: string;
@@ -33,20 +33,23 @@ export interface SessionRow {
33
33
  metadata?: Record<string, unknown>;
34
34
  }
35
35
  /**
36
- * A storage backend receives already-projected rows. Backends must be
37
- * no-throw from the plugin's perspective: implementations report errors to
38
- * the plugin logger and swallow them, mirroring must never break the agent
39
- * loop.
36
+ * A storage backend receives already-projected rows. Backend errors reject
37
+ * into the plugin's per-session queue, whose catch/log boundary keeps
38
+ * mirroring failures out of the agent loop.
40
39
  */
41
40
  export interface StorageBackend {
42
41
  readonly name: string;
43
42
  init?(): Promise<void>;
44
43
  /** Read the stored session row, for seeding the live rollup on resume. */
45
44
  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[]>;
45
+ /** Read one persisted logical message, including archived rows for redelivery. */
46
+ readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
47
+ /** Find the newest active model message containing this exact tool call. */
48
+ readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
48
49
  upsertMessage(row: MessageRow): Promise<void>;
49
50
  upsertSession(row: SessionRow): Promise<void>;
51
+ /** Atomically persist one event's optional message and its session rollup. */
52
+ upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
50
53
  /** Move active messages into a queryable history row; null when already empty. */
51
54
  archiveSession(sessionId: string): Promise<string | null>;
52
55
  close?(): Promise<void>;
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.35",
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",
@@ -33,7 +33,7 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@deepseek-ai/schemastery": "^3.18.1"
36
+ "@deepseek-ai/schemastery": "^3.18.5"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.1",
@@ -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.4",
66
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
67
67
  "@prisma/adapter-libsql": "^7.9.1",
68
68
  "@prisma/adapter-mariadb": "^7.9.1",
69
69
  "@prisma/adapter-mssql": "^7.9.1",
@@ -49,6 +49,8 @@ model AiMessage {
49
49
  @@index([deletedAt], map: "idx_deleted")
50
50
  @@index([agentId], map: "idx_agent")
51
51
  @@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
52
+ @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
53
+ @@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
52
54
  @@map("ai_messages")
53
55
  }
54
56
 
@@ -41,6 +41,8 @@ model AiMessage {
41
41
  @@index([deletedAt], map: "idx_deleted")
42
42
  @@index([agentId], map: "idx_agent")
43
43
  @@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
44
+ @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
45
+ @@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
44
46
  @@map("ai_messages")
45
47
  }
46
48
 
@@ -41,6 +41,8 @@ model AiMessage {
41
41
  @@index([deletedAt], map: "idx_deleted")
42
42
  @@index([agentId], map: "idx_agent")
43
43
  @@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
44
+ @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
45
+ @@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
44
46
  @@map("ai_messages")
45
47
  }
46
48
 
@@ -47,6 +47,8 @@ model AiMessage {
47
47
  @@index([deletedAt], map: "idx_deleted")
48
48
  @@index([agentId], map: "idx_agent")
49
49
  @@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
50
+ @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
51
+ @@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
50
52
  @@map("ai_messages")
51
53
  }
52
54