@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/cordis.patch.yml +3 -0
- package/lib/backends/database.js +198 -0
- package/lib/generated/mysql/browser.js +17 -0
- package/lib/generated/mysql/client.js +34 -0
- package/lib/generated/mysql/commonInputTypes.js +10 -0
- package/lib/generated/mysql/enums.js +10 -0
- package/lib/generated/mysql/internal/class.js +49 -0
- package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
- package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
- package/lib/generated/mysql/models/AiChatHistory.js +1 -0
- package/lib/generated/mysql/models/AiMessage.js +1 -0
- package/lib/generated/mysql/models.js +1 -0
- package/lib/generated/postgresql/browser.js +17 -0
- package/lib/generated/postgresql/client.js +34 -0
- package/lib/generated/postgresql/commonInputTypes.js +10 -0
- package/lib/generated/postgresql/enums.js +10 -0
- package/lib/generated/postgresql/internal/class.js +49 -0
- package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
- package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
- package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
- package/lib/generated/postgresql/models/AiMessage.js +1 -0
- package/lib/generated/postgresql/models.js +1 -0
- package/lib/generated/sqlite/browser.js +17 -0
- package/lib/generated/sqlite/client.js +34 -0
- package/lib/generated/sqlite/commonInputTypes.js +10 -0
- package/lib/generated/sqlite/enums.js +10 -0
- package/lib/generated/sqlite/internal/class.js +49 -0
- package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
- package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
- package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlite/models/AiMessage.js +1 -0
- package/lib/generated/sqlite/models.js +1 -0
- package/lib/generated/sqlserver/browser.js +17 -0
- package/lib/generated/sqlserver/client.js +34 -0
- package/lib/generated/sqlserver/commonInputTypes.js +10 -0
- package/lib/generated/sqlserver/enums.js +10 -0
- package/lib/generated/sqlserver/internal/class.js +49 -0
- package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
- package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
- package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlserver/models/AiMessage.js +1 -0
- package/lib/generated/sqlserver/models.js +1 -0
- package/lib/index.js +224 -0
- package/lib/projector.js +153 -0
- package/lib/types/backends/database.d.ts +37 -0
- package/lib/types/generated/mysql/browser.d.ts +15 -0
- package/lib/types/generated/mysql/client.d.ts +34 -0
- package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
- package/lib/types/generated/mysql/enums.d.ts +1 -0
- package/lib/types/generated/mysql/internal/class.d.ts +143 -0
- package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
- package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
- package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
- package/lib/types/generated/mysql/models/AiMessage.d.ts +1360 -0
- package/lib/types/generated/mysql/models.d.ts +3 -0
- package/lib/types/generated/postgresql/browser.d.ts +15 -0
- package/lib/types/generated/postgresql/client.d.ts +34 -0
- package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
- package/lib/types/generated/postgresql/enums.d.ts +1 -0
- package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
- package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
- package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
- package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
- package/lib/types/generated/postgresql/models/AiMessage.d.ts +1497 -0
- package/lib/types/generated/postgresql/models.d.ts +3 -0
- package/lib/types/generated/sqlite/browser.d.ts +15 -0
- package/lib/types/generated/sqlite/client.d.ts +34 -0
- package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
- package/lib/types/generated/sqlite/enums.d.ts +1 -0
- package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
- package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
- package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
- package/lib/types/generated/sqlite/models/AiMessage.d.ts +1494 -0
- package/lib/types/generated/sqlite/models.d.ts +3 -0
- package/lib/types/generated/sqlserver/browser.d.ts +15 -0
- package/lib/types/generated/sqlserver/client.d.ts +34 -0
- package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
- package/lib/types/generated/sqlserver/enums.d.ts +1 -0
- package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
- package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
- package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1376 -0
- package/lib/types/generated/sqlserver/models.d.ts +3 -0
- package/lib/types/index.d.ts +56 -0
- package/lib/types/projector.d.ts +48 -0
- package/lib/types/types.d.ts +49 -0
- package/lib/types.js +8 -0
- package/package.json +85 -0
- package/preview.png +0 -0
- package/prisma/schema.mysql.prisma +75 -0
- package/prisma/schema.postgresql.prisma +67 -0
- package/prisma/schema.sqlite.prisma +67 -0
- 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
|
+
}
|
package/lib/projector.js
ADDED
|
@@ -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;
|