@amaster.ai/dsh-storage 0.1.14 → 0.1.15

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
@@ -53,6 +53,8 @@ npx prisma db push --schema node_modules/@amaster.ai/dsh-storage/prisma/schema.m
53
53
 
54
54
  The logical message id rides in `metadata.id`; message rows use a deterministic hash of `(session_id, message id)` as their primary key, so re-projected events upsert in place rather than duplicate — on every connector. Session rows are matched by `session_id` and keep their cuid primary keys. One deviation from the source project: no `user_id` column — tenancy rides on `session_id`.
55
55
 
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
+
56
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.
57
59
 
58
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,6 +63,12 @@ SQL Server note: Prisma's sqlserver connector has no `Json` type, so its variant
61
63
 
62
64
  Mirroring is a no-throw seam: backend errors are logged with a `[dsh-storage]` prefix and never escape into the agent loop.
63
65
 
66
+ ## Archive a session
67
+
68
+ 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.
69
+
70
+ Archived rollups stay queryable. The plugin recognizes an active rollup as a history row with no associated messages, so the next projected event creates a fresh rollup instead of overwriting the archived one. The caller owns stopping/replacing the dsh session surface and any A2A task state; this operation only archives the database mirror.
71
+
64
72
  ## Compatibility
65
73
 
66
74
  Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs — check the `TODO(verify)` markers in `src/` before upgrading dsh.
@@ -146,11 +146,13 @@ export class DatabaseBackend {
146
146
  if (!this.prisma)
147
147
  return [];
148
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.
149
151
  const rows = await this.prisma.aiMessage.findMany({
150
152
  where: {
151
153
  sessionId,
152
154
  deletedAt: null,
153
- ...(messageId ? { id: pk(`message ${sessionId}\0${messageId}`) } : {}),
155
+ ...(messageId ? { id: pk(`message ${sessionId}\0${messageId}`) } : { historyId: null }),
154
156
  },
155
157
  orderBy: { createdAt: 'asc' },
156
158
  });
@@ -192,7 +194,7 @@ export class DatabaseBackend {
192
194
  if (!this.prisma)
193
195
  return null;
194
196
  const row = await this.prisma.aiChatHistory.findFirst({
195
- where: { sessionId, deletedAt: null },
197
+ where: { sessionId, deletedAt: null, messages: { none: {} } },
196
198
  });
197
199
  if (!row)
198
200
  return null;
@@ -219,7 +221,7 @@ export class DatabaseBackend {
219
221
  // cuid primary key matches the source project and early-scaffold rows,
220
222
  // so existing data is continued, never duplicated.
221
223
  const existing = await this.prisma.aiChatHistory.findFirst({
222
- where: { sessionId: row.sessionId, deletedAt: null },
224
+ where: { sessionId: row.sessionId, deletedAt: null, messages: { none: {} } },
223
225
  select: { id: true },
224
226
  });
225
227
  const data = {
@@ -238,6 +240,42 @@ export class DatabaseBackend {
238
240
  await this.prisma.aiChatHistory.create({ data });
239
241
  }
240
242
  }
243
+ async archiveSession(sessionId) {
244
+ if (!this.prisma)
245
+ throw new Error('dsh-storage database is not initialized');
246
+ return this.prisma.$transaction(async (tx) => {
247
+ const where = { sessionId, historyId: null, deletedAt: null };
248
+ const stats = await tx.aiMessage.aggregate({
249
+ where,
250
+ _count: { _all: true },
251
+ _min: { createdAt: true },
252
+ _max: { createdAt: true },
253
+ });
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 },
260
+ });
261
+ const rollup = {
262
+ messageCount: stats._count._all,
263
+ firstMessageAt: stats._min.createdAt,
264
+ lastMessageAt: stats._max.createdAt,
265
+ };
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 },
271
+ });
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
+ }
276
+ return history.id;
277
+ });
278
+ }
241
279
  async close() {
242
280
  await this.prisma?.$disconnect?.();
243
281
  }
package/lib/index.js CHANGED
@@ -136,6 +136,21 @@ export function apply(ctx, config) {
136
136
  await guard(async (backend) => backend.close?.());
137
137
  };
138
138
  });
139
+ ctx.provide('dshStorage', {
140
+ archiveSession(sessionId) {
141
+ // Join the same per-session chain as event writes, then discard the old
142
+ // in-memory rollup only after the database transaction commits.
143
+ const archived = (chains.get(sessionId) ?? started).then(async () => {
144
+ const id = await backends[0].archiveSession(sessionId);
145
+ sessions.delete(sessionId);
146
+ return id;
147
+ });
148
+ const settled = archived.then(() => { }, () => { });
149
+ chains.set(sessionId, settled);
150
+ track(settled);
151
+ return archived;
152
+ },
153
+ });
139
154
  /* eslint-disable @typescript-eslint/no-explicit-any */
140
155
  ctx.on('session/event', (session, event) => {
141
156
  const sessionId = session?.id ?? 'unknown';
package/lib/projector.js CHANGED
@@ -77,7 +77,7 @@ export function projectEvent(_session, event, sessionId) {
77
77
  id: message.id ?? randomUUID(),
78
78
  type: 'user',
79
79
  content: textOf(message),
80
- metadata: { event: event.type, seq: event.seq },
80
+ metadata: { event: event.type, seq: event.seq, message },
81
81
  };
82
82
  }
83
83
  case 'assistant/message': {
@@ -34,5 +34,6 @@ export declare class DatabaseBackend implements StorageBackend {
34
34
  readMessages(sessionId: string, messageId?: string): Promise<MessageRow[]>;
35
35
  readSession(sessionId: string): Promise<SessionRow | null>;
36
36
  upsertSession(row: SessionRow): Promise<void>;
37
+ archiveSession(sessionId: string): Promise<string | null>;
37
38
  close(): Promise<void>;
38
39
  }
@@ -53,4 +53,8 @@ export interface StoragePluginConfig {
53
53
  url: string;
54
54
  };
55
55
  }
56
+ /** Explicit write API for a consumer's session-clear flow. */
57
+ export interface DshStorageService {
58
+ archiveSession(sessionId: string): Promise<string | null>;
59
+ }
56
60
  export declare function apply(ctx: Context, config: StoragePluginConfig): void;
@@ -47,5 +47,7 @@ export interface StorageBackend {
47
47
  readMessages(sessionId: string, messageId?: string): Promise<MessageRow[]>;
48
48
  upsertMessage(row: MessageRow): Promise<void>;
49
49
  upsertSession(row: SessionRow): Promise<void>;
50
+ /** Move active messages into a queryable history row; null when already empty. */
51
+ archiveSession(sessionId: string): Promise<string | null>;
50
52
  close?(): Promise<void>;
51
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-storage",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",