@amaster.ai/dsh-storage 0.1.7 → 0.1.9

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
@@ -48,11 +48,15 @@ npx prisma db push --schema node_modules/@amaster.ai/dsh-storage/prisma/schema.m
48
48
 
49
49
  ## Data model
50
50
 
51
- - **`ai_messages`** — one row per projected session event (user / model / tool), with `thoughts`, `tokens`, `tool_calls`, `agent_id`, `metadata` JSON columns and soft-delete.
51
+ - **`ai_messages`** — user and model rows, with `thoughts`, `tokens`, `tool_calls`, `agent_id`, `metadata` JSON columns and soft-delete. A `tool/result` updates the matching model row's `tool_calls` entry by call ID; it does not create a `type=tool` row.
52
52
  - **`ai_chat_histories`** — per-session rollup (message count, total tokens, first/last message timestamps).
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
+ 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
+
58
+ 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.
59
+
56
60
  SQL Server note: Prisma's sqlserver connector has no `Json` type, so its variant maps the JSON columns to text — the backend serializes them on write automatically (SQL Server's `ISJSON` / `JSON_VALUE` still query the text as JSON).
57
61
 
58
62
  Mirroring is a no-throw seam: backend errors are logged with a `[dsh-storage]` prefix and never escape into the agent loop.
@@ -142,6 +142,52 @@ export class DatabaseBackend {
142
142
  update: data,
143
143
  });
144
144
  }
145
+ 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
+ const rows = await this.prisma.aiMessage.findMany({
150
+ where: {
151
+ sessionId,
152
+ deletedAt: null,
153
+ ...(messageId ? { id: pk(`message ${sessionId}\0${messageId}`) } : {}),
154
+ },
155
+ orderBy: { createdAt: 'asc' },
156
+ });
157
+ const json = (value) => (typeof value === 'string' ? JSON.parse(value) : value);
158
+ return rows.map((row) => {
159
+ const metadata = json(row.metadata) ?? {};
160
+ const storedThoughts = json(row.thoughts);
161
+ const thoughts = Array.isArray(storedThoughts)
162
+ ? storedThoughts.map((thought) => {
163
+ if (thought &&
164
+ typeof thought === 'object' &&
165
+ !('content' in thought) &&
166
+ 'description' in thought) {
167
+ const { description, ...rest } = thought;
168
+ return { ...rest, content: description };
169
+ }
170
+ return thought;
171
+ })
172
+ : typeof storedThoughts === 'string'
173
+ ? [{ content: storedThoughts }]
174
+ : storedThoughts;
175
+ return {
176
+ id: typeof metadata.id === 'string' ? metadata.id : row.id,
177
+ sessionId: row.sessionId,
178
+ historyId: row.historyId,
179
+ type: row.type,
180
+ content: row.content,
181
+ thoughts,
182
+ model: row.model,
183
+ tokens: json(row.tokens),
184
+ toolCalls: json(row.toolCalls),
185
+ agentId: row.agentId,
186
+ metadata,
187
+ createdAt: row.createdAt,
188
+ };
189
+ });
190
+ }
145
191
  async readSession(sessionId) {
146
192
  if (!this.prisma)
147
193
  return null;
@@ -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/// One row per projected session event (user message, assistant message,\n/// tool call/result, compression snapshot, ...).\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 @@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/// One row per projected session event (user message, assistant message,\n/// tool call/result, compression snapshot, ...).\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 @@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/// One row per projected session event (user message, assistant message,\n/// tool call/result, compression snapshot, ...).\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 @@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/// One row per projected session event (user message, assistant message,\n/// tool call/result, compression snapshot, ...).\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 @@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
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import Schema from '@deepseek-ai/schemastery';
21
21
  import { DatabaseBackend } from './backends/database.js';
22
- import { projectEvent, usageSampleOf } from './projector.js';
22
+ import { mergeToolResult, projectEvent, usageSampleOf } from './projector.js';
23
23
  export const name = 'dsh-storage';
24
24
  // Event-tap only: no hard service dependency. Add 'sessionPersistence' to
25
25
  // `inject` if you want fail-fast when persistence is not composed.
@@ -145,11 +145,36 @@ export function apply(ctx, config) {
145
145
  accum = await seedAccum(sessionId);
146
146
  sessions.set(sessionId, accum);
147
147
  }
148
- const row = projectEvent(session, event, sessionId);
148
+ let row = projectEvent(session, event, sessionId);
149
+ const storedRows = row || event?.type === 'tool/result'
150
+ ? await backends[0].readMessages(sessionId, row?.id)
151
+ : [];
152
+ if (event?.type === 'tool/result')
153
+ row = mergeToolResult(event, storedRows);
154
+ const existing = row && storedRows.find((stored) => stored.id === row?.id);
155
+ if (event?.type === 'assistant/message' && row && existing && Array.isArray(row.toolCalls)) {
156
+ row.toolCalls = row.toolCalls.map((call) => {
157
+ const previous = Array.isArray(existing.toolCalls)
158
+ ? existing.toolCalls.find((stored) => stored?.id === call?.id)
159
+ : undefined;
160
+ return previous && 'result' in previous
161
+ ? {
162
+ ...call,
163
+ result: previous.result,
164
+ ...(previous.error ? { error: previous.error } : {}),
165
+ }
166
+ : call;
167
+ });
168
+ }
169
+ if (!row && event?.type === 'tool/result') {
170
+ console.error('[dsh-storage] no matching model tool call for result');
171
+ }
149
172
  if (row) {
150
- accum.messageCount += 1;
151
- accum.firstMessageAt ??= row.createdAt;
152
- accum.lastMessageAt = row.createdAt;
173
+ if (!existing && event?.type !== 'tool/result') {
174
+ accum.messageCount += 1;
175
+ accum.firstMessageAt ??= row.createdAt;
176
+ accum.lastMessageAt = row.createdAt;
177
+ }
153
178
  await fanout((backend) => backend.upsertMessage(row));
154
179
  }
155
180
  // Replacement usage accounting: the latest sample for a (turn, step)
package/lib/projector.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * (https://deepseek-harness.github.io/deepseek-harness/reference/persistence-catalog)
6
6
  * and the @deepseek-ai/dsh-llm message types:
7
7
  * - envelope: `{ type, seq, time, data }` — payload lives under `data`.
8
- * - surface events (the only ones producing LLM messages, and the only ones
9
- * projected to rows): `user/message`, `assistant/message`, `tool/result`.
8
+ * - `user/message` and `assistant/message` produce rows; `tool/result` updates
9
+ * the matching assistant row's toolCalls instead of producing a tool row.
10
10
  * - token usage (V3 log): `assistant/message.data.usage` is the settled
11
11
  * step's accounting; a failed/cancelled attempt commits `assistant/attempt`
12
12
  * instead, with the adapter's usage chunk preserved as a raw record in its
@@ -30,14 +30,25 @@ function textOf(message) {
30
30
  })
31
31
  .join('');
32
32
  }
33
- /** Reasoning text of an assistant message, if it carried reasoning blocks. */
34
- function reasoningOf(message) {
33
+ /** Reasoning blocks of an assistant message, in their original order. */
34
+ function thoughtsOf(message) {
35
35
  const blocks = Array.isArray(message?.content) ? message.content : [];
36
- const text = blocks
37
- .filter((b) => b?.type === 'reasoning')
38
- .map((b) => b.text)
39
- .join('');
40
- return text || undefined;
36
+ const replay = message?.source?.replayState?.blocks;
37
+ const thoughts = blocks
38
+ .map((b, index) => {
39
+ if (b?.type !== 'reasoning' || !b.text)
40
+ return null;
41
+ const metadata = Array.isArray(replay) ? replay[index] : undefined;
42
+ const signature = metadata?.type === 'reasoning'
43
+ ? (metadata.signature ?? metadata.thoughtSignature)
44
+ : undefined;
45
+ return {
46
+ content: b.text,
47
+ ...(typeof signature === 'string' ? { thoughtSignature: signature } : {}),
48
+ };
49
+ })
50
+ .filter((thought) => thought !== null);
51
+ return thoughts.length ? thoughts : undefined;
41
52
  }
42
53
  function toolPartsOf(message) {
43
54
  const blocks = Array.isArray(message?.content) ? message.content : [];
@@ -76,36 +87,18 @@ export function projectEvent(_session, event, sessionId) {
76
87
  id: message?.id ?? randomUUID(),
77
88
  type: 'model',
78
89
  content: textOf(message),
79
- thoughts: reasoningOf(message),
90
+ thoughts: thoughtsOf(message),
80
91
  model: message?.source?.model ?? undefined,
81
92
  tokens: data.usage ?? undefined,
82
93
  toolCalls: toolPartsOf(message),
83
94
  metadata: {
84
95
  event: event.type,
85
96
  seq: event.seq,
97
+ ...(message?.source ? { source: message.source } : {}),
86
98
  ...(data.interrupted ? { interrupted: true } : {}),
87
99
  },
88
100
  };
89
101
  }
90
- case 'tool/result': {
91
- const message = data.message;
92
- const callId = message?.source?.callId ?? undefined;
93
- return {
94
- ...base,
95
- id: message?.id ?? randomUUID(),
96
- type: 'tool',
97
- content: textOf(message),
98
- toolCalls: [
99
- {
100
- callId,
101
- result: message?.content?.[0] ?? null,
102
- // Internal failure identity, when the call failed.
103
- ...(data.error ? { error: { name: data.error.name, code: data.error.code } } : {}),
104
- },
105
- ],
106
- metadata: { event: event.type, seq: event.seq, callId },
107
- };
108
- }
109
102
  default:
110
103
  // Log-only events (assistant/attempt, turn/step lifecycle, approvals,
111
104
  // compaction markers, ...) are not standalone rows; usage rolls up into
@@ -113,6 +106,32 @@ export function projectEvent(_session, event, sessionId) {
113
106
  return null;
114
107
  }
115
108
  }
109
+ /** Update the persisted assistant call identified by a tool result. */
110
+ export function mergeToolResult(event, rows) {
111
+ const result = event?.data?.message?.content?.[0] ?? null;
112
+ const callId = event?.data?.message?.source?.callId ?? result?.toolCallId;
113
+ if (!callId)
114
+ return null;
115
+ const model = [...rows]
116
+ .reverse()
117
+ .find((row) => row.type === 'model' &&
118
+ Array.isArray(row.toolCalls) &&
119
+ row.toolCalls.some((call) => call?.id === callId));
120
+ if (!model)
121
+ return null;
122
+ return {
123
+ ...model,
124
+ toolCalls: model.toolCalls.map((call) => call?.id === callId
125
+ ? {
126
+ ...call,
127
+ result,
128
+ error: event.data.error
129
+ ? { name: event.data.error.name, code: event.data.error.code }
130
+ : undefined,
131
+ }
132
+ : call),
133
+ };
134
+ }
116
135
  /**
117
136
  * Extract the usage sample carried by one event, keyed by its step, or null
118
137
  * when the event carries none.
@@ -31,6 +31,7 @@ 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
35
  readSession(sessionId: string): Promise<SessionRow | null>;
35
36
  upsertSession(row: SessionRow): Promise<void>;
36
37
  close(): Promise<void>;
@@ -4,8 +4,7 @@ export * as $Enums from './enums.js';
4
4
  export * from './enums.js';
5
5
  /**
6
6
  * Model AiMessage
7
- * One row per projected session event (user message, assistant message,
8
- * tool call/result, compression snapshot, ...).
7
+ * User and assistant messages; tool results update the assistant row's tool_calls.
9
8
  */
10
9
  export type AiMessage = Prisma.AiMessageModel;
11
10
  /**
@@ -23,8 +23,7 @@ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts exten
23
23
  export { Prisma };
24
24
  /**
25
25
  * Model AiMessage
26
- * One row per projected session event (user message, assistant message,
27
- * tool call/result, compression snapshot, ...).
26
+ * User and assistant messages; tool results update the assistant row's tool_calls.
28
27
  */
29
28
  export type AiMessage = Prisma.AiMessageModel;
30
29
  /**
@@ -2,8 +2,7 @@ import type * as runtime from "@prisma/client/runtime/client";
2
2
  import type * as Prisma from "../internal/prismaNamespace.js";
3
3
  /**
4
4
  * Model AiMessage
5
- * One row per projected session event (user message, assistant message,
6
- * tool call/result, compression snapshot, ...).
5
+ * User and assistant messages; tool results update the assistant row's tool_calls.
7
6
  */
8
7
  export type AiMessageModel = runtime.Types.Result.DefaultSelection<Prisma.$AiMessagePayload>;
9
8
  export type AggregateAiMessage = {
@@ -4,8 +4,7 @@ export * as $Enums from './enums.js';
4
4
  export * from './enums.js';
5
5
  /**
6
6
  * Model AiMessage
7
- * One row per projected session event (user message, assistant message,
8
- * tool call/result, compression snapshot, ...).
7
+ * User and assistant messages; tool results update the assistant row's tool_calls.
9
8
  */
10
9
  export type AiMessage = Prisma.AiMessageModel;
11
10
  /**
@@ -23,8 +23,7 @@ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts exten
23
23
  export { Prisma };
24
24
  /**
25
25
  * Model AiMessage
26
- * One row per projected session event (user message, assistant message,
27
- * tool call/result, compression snapshot, ...).
26
+ * User and assistant messages; tool results update the assistant row's tool_calls.
28
27
  */
29
28
  export type AiMessage = Prisma.AiMessageModel;
30
29
  /**
@@ -2,8 +2,7 @@ import type * as runtime from "@prisma/client/runtime/client";
2
2
  import type * as Prisma from "../internal/prismaNamespace.js";
3
3
  /**
4
4
  * Model AiMessage
5
- * One row per projected session event (user message, assistant message,
6
- * tool call/result, compression snapshot, ...).
5
+ * User and assistant messages; tool results update the assistant row's tool_calls.
7
6
  */
8
7
  export type AiMessageModel = runtime.Types.Result.DefaultSelection<Prisma.$AiMessagePayload>;
9
8
  export type AggregateAiMessage = {
@@ -4,8 +4,7 @@ export * as $Enums from './enums.js';
4
4
  export * from './enums.js';
5
5
  /**
6
6
  * Model AiMessage
7
- * One row per projected session event (user message, assistant message,
8
- * tool call/result, compression snapshot, ...).
7
+ * User and assistant messages; tool results update the assistant row's tool_calls.
9
8
  */
10
9
  export type AiMessage = Prisma.AiMessageModel;
11
10
  /**
@@ -23,8 +23,7 @@ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts exten
23
23
  export { Prisma };
24
24
  /**
25
25
  * Model AiMessage
26
- * One row per projected session event (user message, assistant message,
27
- * tool call/result, compression snapshot, ...).
26
+ * User and assistant messages; tool results update the assistant row's tool_calls.
28
27
  */
29
28
  export type AiMessage = Prisma.AiMessageModel;
30
29
  /**
@@ -2,8 +2,7 @@ import type * as runtime from "@prisma/client/runtime/client";
2
2
  import type * as Prisma from "../internal/prismaNamespace.js";
3
3
  /**
4
4
  * Model AiMessage
5
- * One row per projected session event (user message, assistant message,
6
- * tool call/result, compression snapshot, ...).
5
+ * User and assistant messages; tool results update the assistant row's tool_calls.
7
6
  */
8
7
  export type AiMessageModel = runtime.Types.Result.DefaultSelection<Prisma.$AiMessagePayload>;
9
8
  export type AggregateAiMessage = {
@@ -4,8 +4,7 @@ export * as $Enums from './enums.js';
4
4
  export * from './enums.js';
5
5
  /**
6
6
  * Model AiMessage
7
- * One row per projected session event (user message, assistant message,
8
- * tool call/result, compression snapshot, ...).
7
+ * User and assistant messages; tool results update the assistant row's tool_calls.
9
8
  */
10
9
  export type AiMessage = Prisma.AiMessageModel;
11
10
  /**
@@ -23,8 +23,7 @@ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts exten
23
23
  export { Prisma };
24
24
  /**
25
25
  * Model AiMessage
26
- * One row per projected session event (user message, assistant message,
27
- * tool call/result, compression snapshot, ...).
26
+ * User and assistant messages; tool results update the assistant row's tool_calls.
28
27
  */
29
28
  export type AiMessage = Prisma.AiMessageModel;
30
29
  /**
@@ -2,8 +2,7 @@ import type * as runtime from "@prisma/client/runtime/client";
2
2
  import type * as Prisma from "../internal/prismaNamespace.js";
3
3
  /**
4
4
  * Model AiMessage
5
- * One row per projected session event (user message, assistant message,
6
- * tool call/result, compression snapshot, ...).
5
+ * User and assistant messages; tool results update the assistant row's tool_calls.
7
6
  */
8
7
  export type AiMessageModel = runtime.Types.Result.DefaultSelection<Prisma.$AiMessagePayload>;
9
8
  export type AggregateAiMessage = {
@@ -5,8 +5,8 @@
5
5
  * (https://deepseek-harness.github.io/deepseek-harness/reference/persistence-catalog)
6
6
  * and the @deepseek-ai/dsh-llm message types:
7
7
  * - envelope: `{ type, seq, time, data }` — payload lives under `data`.
8
- * - surface events (the only ones producing LLM messages, and the only ones
9
- * projected to rows): `user/message`, `assistant/message`, `tool/result`.
8
+ * - `user/message` and `assistant/message` produce rows; `tool/result` updates
9
+ * the matching assistant row's toolCalls instead of producing a tool row.
10
10
  * - token usage (V3 log): `assistant/message.data.usage` is the settled
11
11
  * step's accounting; a failed/cancelled attempt commits `assistant/attempt`
12
12
  * instead, with the adapter's usage chunk preserved as a raw record in its
@@ -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
+ /** Update the persisted assistant call identified by a tool result. */
26
+ export declare function mergeToolResult(event: any, rows: MessageRow[]): MessageRow | null;
25
27
  export interface UsageSample {
26
28
  input: number;
27
29
  output: number;
@@ -43,6 +43,8 @@ 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
48
  upsertMessage(row: MessageRow): Promise<void>;
47
49
  upsertSession(row: SessionRow): Promise<void>;
48
50
  close?(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-storage",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -23,8 +23,7 @@ datasource db {
23
23
  provider = "mysql"
24
24
  }
25
25
 
26
- /// One row per projected session event (user message, assistant message,
27
- /// tool call/result, compression snapshot, ...).
26
+ /// User and assistant messages; tool results update the assistant row's tool_calls.
28
27
  model AiMessage {
29
28
  id String @id @default(cuid()) @db.Char(36)
30
29
  sessionId String @map("session_id") @db.VarChar(100)
@@ -15,8 +15,7 @@ datasource db {
15
15
  provider = "postgresql"
16
16
  }
17
17
 
18
- /// One row per projected session event (user message, assistant message,
19
- /// tool call/result, compression snapshot, ...).
18
+ /// User and assistant messages; tool results update the assistant row's tool_calls.
20
19
  model AiMessage {
21
20
  id String @id @default(cuid()) @db.Char(36)
22
21
  sessionId String @map("session_id") @db.VarChar(100)
@@ -15,8 +15,7 @@ datasource db {
15
15
  provider = "sqlite"
16
16
  }
17
17
 
18
- /// One row per projected session event (user message, assistant message,
19
- /// tool call/result, compression snapshot, ...).
18
+ /// User and assistant messages; tool results update the assistant row's tool_calls.
20
19
  model AiMessage {
21
20
  id String @id @default(cuid())
22
21
  sessionId String @map("session_id")
@@ -21,8 +21,7 @@ datasource db {
21
21
  provider = "sqlserver"
22
22
  }
23
23
 
24
- /// One row per projected session event (user message, assistant message,
25
- /// tool call/result, compression snapshot, ...).
24
+ /// User and assistant messages; tool results update the assistant row's tool_calls.
26
25
  model AiMessage {
27
26
  id String @id @default(cuid()) @db.Char(36)
28
27
  sessionId String @map("session_id") @db.VarChar(100)