@amaster.ai/dsh-storage 0.1.0

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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/cordis.patch.yml +3 -0
  4. package/lib/backends/database.js +198 -0
  5. package/lib/generated/mysql/browser.js +17 -0
  6. package/lib/generated/mysql/client.js +34 -0
  7. package/lib/generated/mysql/commonInputTypes.js +10 -0
  8. package/lib/generated/mysql/enums.js +10 -0
  9. package/lib/generated/mysql/internal/class.js +49 -0
  10. package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
  11. package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
  12. package/lib/generated/mysql/models/AiChatHistory.js +1 -0
  13. package/lib/generated/mysql/models/AiMessage.js +1 -0
  14. package/lib/generated/mysql/models.js +1 -0
  15. package/lib/generated/postgresql/browser.js +17 -0
  16. package/lib/generated/postgresql/client.js +34 -0
  17. package/lib/generated/postgresql/commonInputTypes.js +10 -0
  18. package/lib/generated/postgresql/enums.js +10 -0
  19. package/lib/generated/postgresql/internal/class.js +49 -0
  20. package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
  21. package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
  22. package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
  23. package/lib/generated/postgresql/models/AiMessage.js +1 -0
  24. package/lib/generated/postgresql/models.js +1 -0
  25. package/lib/generated/sqlite/browser.js +17 -0
  26. package/lib/generated/sqlite/client.js +34 -0
  27. package/lib/generated/sqlite/commonInputTypes.js +10 -0
  28. package/lib/generated/sqlite/enums.js +10 -0
  29. package/lib/generated/sqlite/internal/class.js +49 -0
  30. package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
  31. package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
  32. package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
  33. package/lib/generated/sqlite/models/AiMessage.js +1 -0
  34. package/lib/generated/sqlite/models.js +1 -0
  35. package/lib/generated/sqlserver/browser.js +17 -0
  36. package/lib/generated/sqlserver/client.js +34 -0
  37. package/lib/generated/sqlserver/commonInputTypes.js +10 -0
  38. package/lib/generated/sqlserver/enums.js +10 -0
  39. package/lib/generated/sqlserver/internal/class.js +49 -0
  40. package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
  41. package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
  42. package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
  43. package/lib/generated/sqlserver/models/AiMessage.js +1 -0
  44. package/lib/generated/sqlserver/models.js +1 -0
  45. package/lib/index.js +224 -0
  46. package/lib/projector.js +153 -0
  47. package/lib/types/backends/database.d.ts +37 -0
  48. package/lib/types/generated/mysql/browser.d.ts +15 -0
  49. package/lib/types/generated/mysql/client.d.ts +34 -0
  50. package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
  51. package/lib/types/generated/mysql/enums.d.ts +1 -0
  52. package/lib/types/generated/mysql/internal/class.d.ts +143 -0
  53. package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
  54. package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
  55. package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
  56. package/lib/types/generated/mysql/models/AiMessage.d.ts +1360 -0
  57. package/lib/types/generated/mysql/models.d.ts +3 -0
  58. package/lib/types/generated/postgresql/browser.d.ts +15 -0
  59. package/lib/types/generated/postgresql/client.d.ts +34 -0
  60. package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
  61. package/lib/types/generated/postgresql/enums.d.ts +1 -0
  62. package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
  63. package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
  64. package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
  65. package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
  66. package/lib/types/generated/postgresql/models/AiMessage.d.ts +1497 -0
  67. package/lib/types/generated/postgresql/models.d.ts +3 -0
  68. package/lib/types/generated/sqlite/browser.d.ts +15 -0
  69. package/lib/types/generated/sqlite/client.d.ts +34 -0
  70. package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
  71. package/lib/types/generated/sqlite/enums.d.ts +1 -0
  72. package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
  73. package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
  74. package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
  75. package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
  76. package/lib/types/generated/sqlite/models/AiMessage.d.ts +1494 -0
  77. package/lib/types/generated/sqlite/models.d.ts +3 -0
  78. package/lib/types/generated/sqlserver/browser.d.ts +15 -0
  79. package/lib/types/generated/sqlserver/client.d.ts +34 -0
  80. package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
  81. package/lib/types/generated/sqlserver/enums.d.ts +1 -0
  82. package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
  83. package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
  84. package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
  85. package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
  86. package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1376 -0
  87. package/lib/types/generated/sqlserver/models.d.ts +3 -0
  88. package/lib/types/index.d.ts +56 -0
  89. package/lib/types/projector.d.ts +48 -0
  90. package/lib/types/types.d.ts +49 -0
  91. package/lib/types.js +8 -0
  92. package/package.json +85 -0
  93. package/preview.png +0 -0
  94. package/prisma/schema.mysql.prisma +75 -0
  95. package/prisma/schema.postgresql.prisma +67 -0
  96. package/prisma/schema.sqlite.prisma +67 -0
  97. package/prisma/schema.sqlserver.prisma +73 -0
@@ -0,0 +1,90 @@
1
+ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
+ /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
4
+ // @ts-nocheck
5
+ /*
6
+ * WARNING: This is an internal file that is subject to change!
7
+ *
8
+ * 🛑 Under no circumstances should you import this file directly! 🛑
9
+ *
10
+ * All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
11
+ * While this enables partial backward compatibility, it is not part of the stable public API.
12
+ *
13
+ * If you are looking for your Models, Enums, and Input Types, please import them from the respective
14
+ * model files in the `model` directory!
15
+ */
16
+ import * as runtime from "@prisma/client/runtime/index-browser";
17
+ export const Decimal = runtime.Decimal;
18
+ export const NullTypes = {
19
+ DbNull: runtime.NullTypes.DbNull,
20
+ JsonNull: runtime.NullTypes.JsonNull,
21
+ AnyNull: runtime.NullTypes.AnyNull,
22
+ };
23
+ /**
24
+ * Helper for filtering JSON entries that have `null` on the database (empty on the db)
25
+ *
26
+ * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
27
+ */
28
+ export const DbNull = runtime.DbNull;
29
+ /**
30
+ * Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
31
+ *
32
+ * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
33
+ */
34
+ export const JsonNull = runtime.JsonNull;
35
+ /**
36
+ * Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
37
+ *
38
+ * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
39
+ */
40
+ export const AnyNull = runtime.AnyNull;
41
+ export const ModelName = {
42
+ AiMessage: 'AiMessage',
43
+ AiChatHistory: 'AiChatHistory'
44
+ };
45
+ /*
46
+ * Enums
47
+ */
48
+ export const TransactionIsolationLevel = runtime.makeStrictEnum({
49
+ ReadUncommitted: 'ReadUncommitted',
50
+ ReadCommitted: 'ReadCommitted',
51
+ RepeatableRead: 'RepeatableRead',
52
+ Serializable: 'Serializable',
53
+ Snapshot: 'Snapshot'
54
+ });
55
+ export const AiMessageScalarFieldEnum = {
56
+ id: 'id',
57
+ sessionId: 'sessionId',
58
+ historyId: 'historyId',
59
+ type: 'type',
60
+ content: 'content',
61
+ thoughts: 'thoughts',
62
+ model: 'model',
63
+ tokens: 'tokens',
64
+ toolCalls: 'toolCalls',
65
+ agentId: 'agentId',
66
+ metadata: 'metadata',
67
+ createdAt: 'createdAt',
68
+ updatedAt: 'updatedAt',
69
+ deletedAt: 'deletedAt'
70
+ };
71
+ export const AiChatHistoryScalarFieldEnum = {
72
+ id: 'id',
73
+ sessionId: 'sessionId',
74
+ title: 'title',
75
+ summary: 'summary',
76
+ messageCount: 'messageCount',
77
+ totalTokens: 'totalTokens',
78
+ firstMessageAt: 'firstMessageAt',
79
+ lastMessageAt: 'lastMessageAt',
80
+ metadata: 'metadata',
81
+ deletedAt: 'deletedAt'
82
+ };
83
+ export const SortOrder = {
84
+ asc: 'asc',
85
+ desc: 'desc'
86
+ };
87
+ export const NullsOrder = {
88
+ first: 'first',
89
+ last: 'last'
90
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * dsh-storage — mirrors the dsh session event stream into the ai_messages /
3
+ * ai_chat_histories tables (MySQL/PostgreSQL/SQLite/SQL Server).
4
+ *
5
+ * Design: dsh's own session persistence (JSONL/SQLite) stays authoritative.
6
+ * This plugin taps `session/event` (the same seam the built-in persistence
7
+ * coordinator uses) and projects events into MessageRow / SessionRow shaped
8
+ * after the source project's ai_messages / ai_chat_histories tables.
9
+ *
10
+ * A2A task state (task metadata in Redis, workspace archives in GCS) is NOT
11
+ * here — that belongs to the A2A layer, see dsh-a2a's TaskStore backends.
12
+ *
13
+ * Resume caveat (docs/subsystems/persistence.md): constructor seeds do not
14
+ * emit events. On agent resume, only events >= `session.firstLiveSeq` fire
15
+ * on this stream — historical rows must come from the database itself, not
16
+ * from replaying the tap.
17
+ *
18
+ * @module dsh-storage
19
+ */
20
+ import Schema from '@deepseek-ai/schemastery';
21
+ import { DatabaseBackend } from './backends/database.js';
22
+ import { projectEvent, usageSampleOf } from './projector.js';
23
+ export const name = 'dsh-storage';
24
+ // Event-tap only: no hard service dependency. Add 'sessionPersistence' to
25
+ // `inject` if you want fail-fast when persistence is not composed.
26
+ export const inject = [];
27
+ export const Config = Schema.object({
28
+ enabled: Schema.boolean().default(false),
29
+ database: Schema.object({
30
+ enabled: Schema.boolean().default(false),
31
+ provider: Schema.union(['mysql', 'postgresql', 'sqlite', 'sqlserver']).default('sqlite'),
32
+ url: Schema.string().role('secret').default(''),
33
+ }),
34
+ });
35
+ /** Metadata key for our usage bookkeeping — namespaced so other writers' fields never collide with it. */
36
+ const LAST_USAGE_KEY = 'dsh-storage:lastUsage';
37
+ /** Shape-guarded restore of our usage sample from arbitrary metadata. */
38
+ function usageSampleOfMetadata(value) {
39
+ const v = value;
40
+ return v &&
41
+ typeof v.key === 'string' &&
42
+ typeof v.input === 'number' &&
43
+ typeof v.output === 'number'
44
+ ? { key: v.key, input: v.input, output: v.output }
45
+ : undefined;
46
+ }
47
+ export function apply(ctx, config) {
48
+ // Lifecycle: this cordis fork has no 'ready'/'dispose' events — startup
49
+ // work goes in ctx.effect() (runs immediately at plugin load; the returned
50
+ // disposer runs on fiber unload at shutdown). Event taps verified against
51
+ // @deepseek-ai/dsh-session@0.1.0-rc.7: session/event(session, event),
52
+ // session/disposed(session), and the awaited session/flush(session)
53
+ // durability checkpoint. The cast stays because the dsh-session types that
54
+ // declare these event names are not installed.
55
+ const on = ctx.on.bind(ctx);
56
+ if (!config.enabled || !config.database.enabled || !config.database.url)
57
+ return;
58
+ const backends = [
59
+ new DatabaseBackend({
60
+ provider: config.database.provider,
61
+ url: config.database.url,
62
+ }),
63
+ ];
64
+ const sessions = new Map();
65
+ // Row writes are fire-and-forget during the run, but tracked so the
66
+ // session/flush checkpoint and shutdown can drain them — a one-shot
67
+ // (headless) run would otherwise lose the tail events to process exit.
68
+ const pending = new Set();
69
+ function track(task) {
70
+ pending.add(task);
71
+ void task.finally(() => pending.delete(task));
72
+ return task;
73
+ }
74
+ function guard(call) {
75
+ // Mirroring must never break the agent loop: log and swallow.
76
+ return Promise.all(backends.map((backend) => call(backend).catch((error) => console.error(`[dsh-storage] ${backend.name} error:`, error)))).then(() => { });
77
+ }
78
+ // Backend init starts at plugin load; every write chains behind it so no
79
+ // row is lost to a still-connecting backend.
80
+ let started;
81
+ function fanout(call) {
82
+ return track(started.then(() => guard(call)));
83
+ }
84
+ // Per-session serialization: all event processing for one session runs
85
+ // through a promise chain, so rollup writes complete in event order
86
+ // (latest-wins — concurrent absolute snapshots must not land out of order)
87
+ // and the first event of an unknown session seeds its accumulator from the
88
+ // stored row before any write (resume: constructor seeds are not re-emitted,
89
+ // so history must come from the database, not from zero).
90
+ const chains = new Map();
91
+ function enqueue(sessionId, task) {
92
+ // First task of a session also chains behind backend init, so seedAccum
93
+ // never reads from a still-connecting backend (prisma === null would
94
+ // masquerade as "row not found" and zero-seed over the stored rollup).
95
+ const next = (chains.get(sessionId) ?? started)
96
+ .then(task)
97
+ .catch((error) => console.error('[dsh-storage] mirror task error:', error));
98
+ chains.set(sessionId, next);
99
+ track(next);
100
+ }
101
+ async function seedAccum(sessionId) {
102
+ // "Row not found" (a new session) is the only zero-seed case. A read
103
+ // failure is NOT "not found": fail the task so the next event re-seeds,
104
+ // instead of accumulating from zero and overwriting the stored rollup.
105
+ let readFailure;
106
+ for (const backend of backends) {
107
+ try {
108
+ const row = await backend.readSession?.(sessionId);
109
+ if (row) {
110
+ return {
111
+ messageCount: row.messageCount,
112
+ totalTokens: row.totalTokens,
113
+ title: row.title ?? undefined,
114
+ firstMessageAt: row.firstMessageAt ?? undefined,
115
+ lastMessageAt: row.lastMessageAt ?? undefined,
116
+ // Preserve every existing metadata field; we only own our key.
117
+ metadata: { ...(row.metadata ?? {}) },
118
+ usageSample: usageSampleOfMetadata(row.metadata?.[LAST_USAGE_KEY]),
119
+ };
120
+ }
121
+ }
122
+ catch (error) {
123
+ readFailure ??= error;
124
+ console.error(`[dsh-storage] ${backend.name} error:`, error);
125
+ }
126
+ }
127
+ if (readFailure)
128
+ throw readFailure;
129
+ return { messageCount: 0, totalTokens: 0, metadata: {} };
130
+ }
131
+ ctx.effect(() => {
132
+ started = track(guard(async (backend) => backend.init?.()));
133
+ return async () => {
134
+ sessions.clear();
135
+ await Promise.all([...pending]);
136
+ await guard(async (backend) => backend.close?.());
137
+ };
138
+ });
139
+ /* eslint-disable @typescript-eslint/no-explicit-any */
140
+ on('session/event', (session, event) => {
141
+ const sessionId = session?.id ?? 'unknown';
142
+ enqueue(sessionId, async () => {
143
+ let accum = sessions.get(sessionId);
144
+ if (!accum) {
145
+ accum = await seedAccum(sessionId);
146
+ sessions.set(sessionId, accum);
147
+ }
148
+ const row = projectEvent(session, event, sessionId);
149
+ if (row) {
150
+ accum.messageCount += 1;
151
+ accum.firstMessageAt ??= row.createdAt;
152
+ accum.lastMessageAt = row.createdAt;
153
+ await fanout((backend) => backend.upsertMessage(row));
154
+ }
155
+ // Replacement usage accounting: the latest sample for a (turn, step)
156
+ // supersedes the previous one — fold only the delta, so the
157
+ // chunk↔message pair and progressive samples never double-count, and a
158
+ // failed step (usage chunk, no message) still counts. Samples of one
159
+ // step arrive adjacently, so a single latest sample is all we track.
160
+ const sampled = usageSampleOf(event);
161
+ let usageDelta = 0;
162
+ if (sampled) {
163
+ const prev = accum.usageSample;
164
+ usageDelta =
165
+ sampled.sample.input +
166
+ sampled.sample.output -
167
+ (prev && prev.key === sampled.key ? prev.input + prev.output : 0);
168
+ if (!prev || prev.key !== sampled.key || usageDelta !== 0) {
169
+ accum.usageSample = { key: sampled.key, ...sampled.sample };
170
+ accum.totalTokens += usageDelta;
171
+ }
172
+ }
173
+ // Latest-wins title snapshot (log-only `session/title` event; payload
174
+ // { title, messageSeqs, source } verified against dsh-session-title@0.1.0-rc.7).
175
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string') {
176
+ accum.title = event.data.title;
177
+ }
178
+ if (row ||
179
+ usageDelta !== 0 ||
180
+ event?.type === 'turn/end' ||
181
+ event?.type === 'session/title') {
182
+ // Merge our bookkeeping into the existing metadata instead of
183
+ // replacing the column — other fields (present or future) survive.
184
+ const metadata = {
185
+ ...accum.metadata,
186
+ ...(accum.usageSample ? { [LAST_USAGE_KEY]: accum.usageSample } : {}),
187
+ };
188
+ const sessionRow = {
189
+ sessionId,
190
+ title: accum.title ?? null,
191
+ messageCount: accum.messageCount,
192
+ totalTokens: accum.totalTokens,
193
+ firstMessageAt: accum.firstMessageAt ?? null,
194
+ lastMessageAt: accum.lastMessageAt ?? null,
195
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
196
+ };
197
+ await fanout((backend) => backend.upsertSession(sessionRow));
198
+ }
199
+ });
200
+ });
201
+ on('session/disposed', (session) => {
202
+ const sessionId = session?.id ?? 'unknown';
203
+ // The delete must run inside the session's chain — a synchronous delete
204
+ // here races the queued event tasks (the map may not even have the entry
205
+ // yet at emit time, and queued tasks would keep growing the accumulator
206
+ // the dispose was meant to drop).
207
+ enqueue(sessionId, async () => {
208
+ sessions.delete(sessionId);
209
+ });
210
+ // Delete the chain entry only once it has settled AND is still the same
211
+ // entry — deleting earlier would let a recreated session race the
212
+ // orphaned writes; skipping the delete when a new task has since chained
213
+ // on preserves order.
214
+ const chain = chains.get(sessionId);
215
+ chain?.finally(() => {
216
+ if (chains.get(sessionId) === chain)
217
+ chains.delete(sessionId);
218
+ });
219
+ });
220
+ // Durability checkpoint: the store awaits every session/flush listener.
221
+ on('session/flush', async () => {
222
+ await Promise.all([...pending]);
223
+ });
224
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Projects dsh session events onto MessageRow / SessionRow.
3
+ *
4
+ * Event shapes verified against the dsh persistence catalog
5
+ * (https://deepseek-harness.github.io/deepseek-harness/reference/persistence-catalog)
6
+ * and the @deepseek-ai/dsh-llm message types:
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`.
10
+ * - token usage: `assistant/chunk` `{ type: 'usage' }` is authoritative per
11
+ * step; `assistant/message`'s `data.usage` only counts when no usage chunk
12
+ * was seen for the step (dedup in usageOf). `turn/end` carries none — it
13
+ * only serves as a rollup checkpoint.
14
+ * - compaction summaries enter the surface as a `user/message` with
15
+ * `surfaceOp: replace`, so they flow through the user path unchanged.
16
+ */
17
+ import { randomUUID } from 'node:crypto';
18
+ /* eslint-disable @typescript-eslint/no-explicit-any */
19
+ /** Visible text of a message: its `text` blocks, unwrapping tool-result blocks. */
20
+ function textOf(message) {
21
+ const blocks = Array.isArray(message?.content) ? message.content : [];
22
+ return blocks
23
+ .map((b) => {
24
+ if (b?.type === 'text')
25
+ return b.text ?? '';
26
+ if (b?.type === 'tool-result' && Array.isArray(b.content))
27
+ return textOf({ content: b.content });
28
+ return '';
29
+ })
30
+ .join('');
31
+ }
32
+ /** Reasoning text of an assistant message, if it carried reasoning blocks. */
33
+ function reasoningOf(message) {
34
+ const blocks = Array.isArray(message?.content) ? message.content : [];
35
+ const text = blocks
36
+ .filter((b) => b?.type === 'reasoning')
37
+ .map((b) => b.text)
38
+ .join('');
39
+ return text || undefined;
40
+ }
41
+ function toolPartsOf(message) {
42
+ const blocks = Array.isArray(message?.content) ? message.content : [];
43
+ const calls = blocks.filter((b) => b?.type === 'tool-call');
44
+ return calls.length > 0 ? calls : undefined;
45
+ }
46
+ /**
47
+ * Map one session event to zero or one message row. Returns null for events
48
+ * that should not be persisted as standalone rows (log-only events: chunks,
49
+ * turn/step lifecycle, approvals, ...).
50
+ */
51
+ export function projectEvent(_session, event, sessionId) {
52
+ const data = event?.data ?? {};
53
+ const base = {
54
+ sessionId,
55
+ historyId: null,
56
+ agentId: 'main',
57
+ createdAt: new Date(event?.time ?? Date.now()),
58
+ };
59
+ switch (event?.type) {
60
+ case 'user/message': {
61
+ // data IS the UserMessage.
62
+ const message = data;
63
+ return {
64
+ ...base,
65
+ id: message.id ?? randomUUID(),
66
+ type: 'user',
67
+ content: textOf(message),
68
+ metadata: { event: event.type, seq: event.seq },
69
+ };
70
+ }
71
+ case 'assistant/message': {
72
+ const message = data.message;
73
+ return {
74
+ ...base,
75
+ id: message?.id ?? randomUUID(),
76
+ type: 'model',
77
+ content: textOf(message),
78
+ thoughts: reasoningOf(message),
79
+ model: message?.source?.model ?? undefined,
80
+ tokens: data.usage ?? undefined,
81
+ toolCalls: toolPartsOf(message),
82
+ metadata: {
83
+ event: event.type,
84
+ seq: event.seq,
85
+ ...(data.interrupted ? { interrupted: true } : {}),
86
+ },
87
+ };
88
+ }
89
+ case 'tool/result': {
90
+ const message = data.message;
91
+ const callId = message?.source?.callId ?? undefined;
92
+ return {
93
+ ...base,
94
+ id: message?.id ?? randomUUID(),
95
+ type: 'tool',
96
+ content: textOf(message),
97
+ toolCalls: [
98
+ {
99
+ callId,
100
+ result: message?.content?.[0] ?? null,
101
+ // Internal failure identity, when the call failed.
102
+ ...(data.error ? { error: { name: data.error.name, code: data.error.code } } : {}),
103
+ },
104
+ ],
105
+ metadata: { event: event.type, seq: event.seq, callId },
106
+ };
107
+ }
108
+ default:
109
+ // Log-only events (assistant/chunk, turn/step lifecycle, approvals,
110
+ // compaction markers, ...) are not standalone rows; usage rolls up into
111
+ // SessionRow via usageOf (usage chunks and assistant/message).
112
+ return null;
113
+ }
114
+ }
115
+ /**
116
+ * Extract the usage sample carried by one event, keyed by its step, or null
117
+ * when the event carries none.
118
+ *
119
+ * dsh-llm emits usage as:
120
+ * - `assistant/chunk` with `chunk.type === 'usage'` — the step's accounting,
121
+ * emitted even when the request later fails (a failed step produces no
122
+ * assistant/message, so message-only accounting would miss it entirely);
123
+ * - `assistant/message.data.usage` — the same step's final accounting.
124
+ *
125
+ * Samples for one `(turn, step)` are REPLACEMENTS, not additive: later
126
+ * samples supersede earlier ones (progressive chunks, then the message).
127
+ * The caller keeps the latest sample per step and folds only the delta into
128
+ * its rollup. Totals include the cache buckets: cacheRead/cacheWrite are
129
+ * billed in their own buckets (observed cacheReadTokens ≫ inputTokens in
130
+ * dsh session logs — they are not subsets of inputTokens).
131
+ */
132
+ export function usageSampleOf(event) {
133
+ const total = (u) => ({
134
+ input: (u?.inputTokens ?? 0) + (u?.cacheReadTokens ?? 0) + (u?.cacheWriteTokens ?? 0),
135
+ output: u?.outputTokens ?? 0,
136
+ });
137
+ let data;
138
+ let usage;
139
+ if (event?.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') {
140
+ data = event.data;
141
+ usage = data.chunk.usage;
142
+ }
143
+ else if (event?.type === 'assistant/message') {
144
+ data = event.data;
145
+ usage = data?.usage;
146
+ }
147
+ else {
148
+ return null;
149
+ }
150
+ if (!usage || data?.turn == null || data?.step == null)
151
+ return null;
152
+ return { key: `${data.turn}:${data.step}`, sample: total(usage) };
153
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * MySQL/PostgreSQL/SQLite/SQL Server backend via Prisma 7. The clients are
3
+ * pre-generated at package build time (`pnpm generate`, see
4
+ * scripts/prisma-generate.mjs) from the schema variants in prisma/ and
5
+ * shipped compiled in lib/generated — consumers never run `prisma generate`.
6
+ *
7
+ * Prisma 7 requires a driver adapter per database; `@prisma/client` and the
8
+ * adapter for your provider are optional peer dependencies (sqlite uses
9
+ * `@prisma/adapter-libsql` — no native build scripts, unlike
10
+ * `@prisma/adapter-better-sqlite3`). Table creation
11
+ * (`prisma db push` / migrations) uses the shipped schema variant with the
12
+ * connection URL in the user's prisma.config.ts.
13
+ */
14
+ import type { MessageRow, SessionRow, StorageBackend } from '../types.js';
15
+ export type DatabaseProvider = 'mysql' | 'postgresql' | 'sqlite' | 'sqlserver';
16
+ export interface DatabaseBackendConfig {
17
+ provider: DatabaseProvider;
18
+ /** Full connection URL, e.g. mysql://user:pass@host:3306/dbname, file:/data/dsh.db */
19
+ url: string;
20
+ }
21
+ export declare function createAdapter(config: DatabaseBackendConfig): Promise<unknown>;
22
+ export declare class DatabaseBackend implements StorageBackend {
23
+ private config;
24
+ readonly name = "database";
25
+ private prisma;
26
+ constructor(config: DatabaseBackendConfig);
27
+ /** Load the pre-generated client for the configured provider (seam for tests). */
28
+ protected loadClient(): Promise<any>;
29
+ init(): Promise<void>;
30
+ /** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
31
+ private jsonField;
32
+ private toDbData;
33
+ upsertMessage(row: MessageRow): Promise<void>;
34
+ readSession(sessionId: string): Promise<SessionRow | null>;
35
+ upsertSession(row: SessionRow): Promise<void>;
36
+ close(): Promise<void>;
37
+ }
@@ -0,0 +1,15 @@
1
+ import * as Prisma from './internal/prismaNamespaceBrowser.js';
2
+ export { Prisma };
3
+ export * as $Enums from './enums.js';
4
+ export * from './enums.js';
5
+ /**
6
+ * Model AiMessage
7
+ * One row per projected session event (user message, assistant message,
8
+ * tool call/result, compression snapshot, ...).
9
+ */
10
+ export type AiMessage = Prisma.AiMessageModel;
11
+ /**
12
+ * Model AiChatHistory
13
+ * Per-session rollup, maintained incrementally by the projector.
14
+ */
15
+ export type AiChatHistory = Prisma.AiChatHistoryModel;
@@ -0,0 +1,34 @@
1
+ import * as runtime from "@prisma/client/runtime/client";
2
+ import * as $Class from "./internal/class.js";
3
+ import * as Prisma from "./internal/prismaNamespace.js";
4
+ export * as $Enums from './enums.js';
5
+ export * from "./enums.js";
6
+ /**
7
+ * ## Prisma Client
8
+ *
9
+ * Type-safe database client for TypeScript
10
+ * @example
11
+ * ```
12
+ * const prisma = new PrismaClient({
13
+ * adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
14
+ * })
15
+ * // Fetch zero or more AiMessages
16
+ * const aiMessages = await prisma.aiMessage.findMany()
17
+ * ```
18
+ *
19
+ * Read more in our [docs](https://pris.ly/d/client).
20
+ */
21
+ export declare const PrismaClient: $Class.PrismaClientConstructor;
22
+ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
23
+ export { Prisma };
24
+ /**
25
+ * Model AiMessage
26
+ * One row per projected session event (user message, assistant message,
27
+ * tool call/result, compression snapshot, ...).
28
+ */
29
+ export type AiMessage = Prisma.AiMessageModel;
30
+ /**
31
+ * Model AiChatHistory
32
+ * Per-session rollup, maintained incrementally by the projector.
33
+ */
34
+ export type AiChatHistory = Prisma.AiChatHistoryModel;