@wfm-cn/dsh-storage 0.0.0-stage → 0.3.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 (121) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -2
  4. package/cordis.patch.yml +3 -0
  5. package/lib/backends/database.js +578 -0
  6. package/lib/backfill.js +331 -0
  7. package/lib/client/index.js +79 -0
  8. package/lib/client/panel.js +299 -0
  9. package/lib/client/toast.js +66 -0
  10. package/lib/client.js +1 -0
  11. package/lib/compaction.js +108 -0
  12. package/lib/config.js +171 -0
  13. package/lib/ddl.js +323 -0
  14. package/lib/generated/mysql/browser.js +17 -0
  15. package/lib/generated/mysql/client.js +34 -0
  16. package/lib/generated/mysql/commonInputTypes.js +10 -0
  17. package/lib/generated/mysql/enums.js +10 -0
  18. package/lib/generated/mysql/internal/class.js +49 -0
  19. package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
  20. package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
  21. package/lib/generated/mysql/models/AiChatHistory.js +1 -0
  22. package/lib/generated/mysql/models/AiMessage.js +1 -0
  23. package/lib/generated/mysql/models.js +1 -0
  24. package/lib/generated/postgresql/browser.js +17 -0
  25. package/lib/generated/postgresql/client.js +34 -0
  26. package/lib/generated/postgresql/commonInputTypes.js +10 -0
  27. package/lib/generated/postgresql/enums.js +10 -0
  28. package/lib/generated/postgresql/internal/class.js +49 -0
  29. package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
  30. package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
  31. package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
  32. package/lib/generated/postgresql/models/AiMessage.js +1 -0
  33. package/lib/generated/postgresql/models.js +1 -0
  34. package/lib/generated/sqlite/browser.js +17 -0
  35. package/lib/generated/sqlite/client.js +34 -0
  36. package/lib/generated/sqlite/commonInputTypes.js +10 -0
  37. package/lib/generated/sqlite/enums.js +10 -0
  38. package/lib/generated/sqlite/internal/class.js +49 -0
  39. package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
  40. package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
  41. package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
  42. package/lib/generated/sqlite/models/AiMessage.js +1 -0
  43. package/lib/generated/sqlite/models.js +1 -0
  44. package/lib/generated/sqlserver/browser.js +17 -0
  45. package/lib/generated/sqlserver/client.js +34 -0
  46. package/lib/generated/sqlserver/commonInputTypes.js +10 -0
  47. package/lib/generated/sqlserver/enums.js +10 -0
  48. package/lib/generated/sqlserver/internal/class.js +49 -0
  49. package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
  50. package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
  51. package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
  52. package/lib/generated/sqlserver/models/AiMessage.js +1 -0
  53. package/lib/generated/sqlserver/models.js +1 -0
  54. package/lib/health.js +180 -0
  55. package/lib/index.js +681 -0
  56. package/lib/memory-tools.js +365 -0
  57. package/lib/notify.js +56 -0
  58. package/lib/projector.js +191 -0
  59. package/lib/status-route.js +172 -0
  60. package/lib/types/backends/database.d.ts +78 -0
  61. package/lib/types/backfill.d.ts +67 -0
  62. package/lib/types/client/index.d.ts +12 -0
  63. package/lib/types/client/panel.d.ts +79 -0
  64. package/lib/types/client/toast.d.ts +38 -0
  65. package/lib/types/compaction.d.ts +3 -0
  66. package/lib/types/config.d.ts +52 -0
  67. package/lib/types/ddl.d.ts +49 -0
  68. package/lib/types/generated/mysql/browser.d.ts +14 -0
  69. package/lib/types/generated/mysql/client.d.ts +33 -0
  70. package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
  71. package/lib/types/generated/mysql/enums.d.ts +1 -0
  72. package/lib/types/generated/mysql/internal/class.d.ts +143 -0
  73. package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
  74. package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
  75. package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
  76. package/lib/types/generated/mysql/models/AiMessage.d.ts +1359 -0
  77. package/lib/types/generated/mysql/models.d.ts +3 -0
  78. package/lib/types/generated/postgresql/browser.d.ts +14 -0
  79. package/lib/types/generated/postgresql/client.d.ts +33 -0
  80. package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
  81. package/lib/types/generated/postgresql/enums.d.ts +1 -0
  82. package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
  83. package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
  84. package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
  85. package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
  86. package/lib/types/generated/postgresql/models/AiMessage.d.ts +1496 -0
  87. package/lib/types/generated/postgresql/models.d.ts +3 -0
  88. package/lib/types/generated/sqlite/browser.d.ts +14 -0
  89. package/lib/types/generated/sqlite/client.d.ts +33 -0
  90. package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
  91. package/lib/types/generated/sqlite/enums.d.ts +1 -0
  92. package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
  93. package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
  94. package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
  95. package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
  96. package/lib/types/generated/sqlite/models/AiMessage.d.ts +1493 -0
  97. package/lib/types/generated/sqlite/models.d.ts +3 -0
  98. package/lib/types/generated/sqlserver/browser.d.ts +14 -0
  99. package/lib/types/generated/sqlserver/client.d.ts +33 -0
  100. package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
  101. package/lib/types/generated/sqlserver/enums.d.ts +1 -0
  102. package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
  103. package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
  104. package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
  105. package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
  106. package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1375 -0
  107. package/lib/types/generated/sqlserver/models.d.ts +3 -0
  108. package/lib/types/health.d.ts +52 -0
  109. package/lib/types/index.d.ts +91 -0
  110. package/lib/types/memory-tools.d.ts +168 -0
  111. package/lib/types/notify.d.ts +35 -0
  112. package/lib/types/projector.d.ts +51 -0
  113. package/lib/types/status-route.d.ts +48 -0
  114. package/lib/types/types.d.ts +58 -0
  115. package/lib/types.js +8 -0
  116. package/package.json +127 -4
  117. package/preview.png +0 -0
  118. package/prisma/schema.mysql.prisma +78 -0
  119. package/prisma/schema.postgresql.prisma +70 -0
  120. package/prisma/schema.sqlite.prisma +70 -0
  121. package/prisma/schema.sqlserver.prisma +76 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * AI-accessible memory query tools (Phase 2): `memory_search` / `memory_read`.
3
+ *
4
+ * Registration contract (verified against the running DSH runtime):
5
+ * - `@deepseek-ai/dsh-tools` exposes a cordis `tools` service whose
6
+ * `register(definition)` inserts into the scoped tool layer; the definition
7
+ * is `{ name, description, parameters (plain JSON Schema), output: { schema,
8
+ * render(args, value) }, execute(args, exec), timeoutMs? }` (ToolsService
9
+ * inside the app bundle; working plugin idiom: ds-harness-remote
10
+ * `dist/remote-tools.js` → `ctx.inject(['tools'], (toolCtx) =>
11
+ * toolCtx.effect(() => tools.register(definition)))`).
12
+ * - Tool names are flat global strings (`remote_list_hosts` style), so these
13
+ * tools are `memory_search` / `memory_read`.
14
+ *
15
+ * Read-only SELECTs over the same connection config the mirror uses
16
+ * (createAdapter / configToUrl / loadConfig). Connections are disposed after
17
+ * every call. All user input is parameter-bound; LIKE wildcards in user
18
+ * keywords are escaped with an explicit `ESCAPE '~'` clause (works on all
19
+ * four providers — avoids backslash, which MySQL also treats as a string
20
+ * literal escape).
21
+ *
22
+ * Raw adapter binding detail (verified in @prisma/adapter-{mariadb,pg,mssql,libsql}
23
+ * 7.9.1 sources): each adapter maps `query.args[i]` through
24
+ * `mapArg(arg, query.argTypes[i])`, and the mssql adapter dereferences
25
+ * `argType.scalarType` unconditionally — binding any argument without
26
+ * argTypes throws (the ddl.ts comment describes this). The argType shape is
27
+ * the client runtime's `{ scalarType, arity }` record ('string' | 'int' |
28
+ * …, 'scalar' | 'list'), not the numeric ColumnTypeEnum. We therefore always
29
+ * pass argTypes and never bind Date objects (ISO strings compare correctly
30
+ * against DATETIME/TIMESTAMP/TEXT created_at on every provider).
31
+ *
32
+ * @module dsh-storage/memory-tools
33
+ */
34
+ import { createAdapter } from './backends/database.js';
35
+ import { configToUrl, loadConfig, defaultConfigPath } from './config.js';
36
+ /** argType record for one bound value (see module docstring). */
37
+ function argTypeOf(value) {
38
+ if (typeof value === 'number')
39
+ return Number.isInteger(value) ? { scalarType: 'int', arity: 'scalar' } : { scalarType: 'float', arity: 'scalar' };
40
+ return { scalarType: 'string', arity: 'scalar' };
41
+ }
42
+ /** Build the query envelope: placeholders derive from the bound args. */
43
+ function rawQuery(sql, args) {
44
+ return { sql, args, argTypes: args.map(argTypeOf) };
45
+ }
46
+ /**
47
+ * Escape LIKE wildcards in user input for use with `ESCAPE '~'`:
48
+ * `~` (the escape character itself), `%` and `_`. Everything else stays
49
+ * literal, so keywords match plain substrings.
50
+ */
51
+ export function escapeLike(value) {
52
+ return value.replace(/[~%_]/g, (ch) => `~${ch}`);
53
+ }
54
+ /** Split a free-text query into non-empty keyword terms. */
55
+ export function splitKeywords(query) {
56
+ return query.split(/\s+/).filter((term) => term.length > 0);
57
+ }
58
+ /** Clamp limit to the [1, 100] band. */
59
+ function clampLimit(value, fallback) {
60
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
61
+ return Math.min(100, Math.max(1, n));
62
+ }
63
+ /**
64
+ * Build the memory_search SELECT. Keywords OR across user+model rows
65
+ * (no type filter); `device:'current'` binds the configured device id;
66
+ * `since` (YYYY-MM-DD) filters `created_at >=` with an ISO 'T' delimiter so
67
+ * the string comparison also matches sqlite's TEXT timestamps.
68
+ */
69
+ export function buildSearchSql(provider, opts, deviceId) {
70
+ const keywords = splitKeywords(opts.query);
71
+ const limit = clampLimit(opts.limit, 20);
72
+ const args = [];
73
+ const likeTerms = keywords.map((keyword) => {
74
+ args.push(`%${escapeLike(keyword)}%`);
75
+ return `content LIKE ? ESCAPE '~'`;
76
+ });
77
+ const conditions = ['deleted_at IS NULL', 'content IS NOT NULL'];
78
+ if (likeTerms.length > 0)
79
+ conditions.push(`(${likeTerms.join(' OR ')})`);
80
+ if (opts.device === 'current') {
81
+ conditions.push('device = ?');
82
+ args.push(deviceId ?? '');
83
+ }
84
+ if (opts.since) {
85
+ conditions.push('created_at >= ?');
86
+ args.push(`${opts.since}T00:00:00`);
87
+ }
88
+ const columns = 'session_id, device, type, content, created_at';
89
+ const where = conditions.join(' AND ');
90
+ // sqlserver has no LIMIT — TOP n (n is our clamped number, safe to inline).
91
+ const sql = provider === 'sqlserver'
92
+ ? `SELECT TOP ${limit} ${columns} FROM ai_messages WHERE ${where} ORDER BY created_at DESC, id DESC`
93
+ : `SELECT ${columns} FROM ai_messages WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ?`;
94
+ if (provider !== 'sqlserver')
95
+ args.push(limit);
96
+ return { sql, args, argTypes: args.map(argTypeOf) };
97
+ }
98
+ /**
99
+ * Build the memory_read SELECT. Both cases fetch the newest-first (DESC)
100
+ * `limit`-row window — with `around`, at-or-before the instant; without it,
101
+ * the most recent `limit` rows of the session. The executor always reverses
102
+ * the rows to chronological order before handing them to the model.
103
+ */
104
+ export function buildReadSql(provider, opts) {
105
+ const limit = clampLimit(opts.limit, 30);
106
+ const args = [opts.sessionId];
107
+ let where = 'session_id = ? AND deleted_at IS NULL';
108
+ if (opts.around) {
109
+ where += ' AND created_at <= ?';
110
+ args.push(opts.around);
111
+ }
112
+ const columns = 'type, content, created_at';
113
+ const sql = provider === 'sqlserver'
114
+ ? `SELECT TOP ${limit} ${columns} FROM ai_messages WHERE ${where} ORDER BY created_at DESC, id DESC`
115
+ : `SELECT ${columns} FROM ai_messages WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ?`;
116
+ args.push(limit);
117
+ return { sql, args, argTypes: args.map(argTypeOf) };
118
+ }
119
+ /**
120
+ * Normalize adapter query results to row objects. The 7.9.1 adapters return
121
+ * `{ columnNames, rows: value[][] }`; tests and older shapes may already
122
+ * produce arrays of objects.
123
+ */
124
+ export function normalizeRows(result) {
125
+ if (Array.isArray(result))
126
+ return result.filter((row) => row && typeof row === 'object');
127
+ if (result && typeof result === 'object') {
128
+ const { columnNames, rows } = result;
129
+ if (Array.isArray(columnNames) && Array.isArray(rows))
130
+ return rows
131
+ .filter((row) => Array.isArray(row))
132
+ .map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]])));
133
+ }
134
+ return [];
135
+ }
136
+ /** ISO string for a timestamp cell (mariadb already maps to ISO strings). */
137
+ function isoOf(value) {
138
+ if (value instanceof Date)
139
+ return value.toISOString();
140
+ return typeof value === 'string' ? value : String(value ?? '');
141
+ }
142
+ /** First-index snippet ≤ max chars centered on the earliest keyword hit. */
143
+ export function snippetOf(content, keywords, max = 160) {
144
+ const text = content.replace(/\s+/g, ' ').trim();
145
+ let start = 0;
146
+ for (const keyword of keywords) {
147
+ const index = text.toLowerCase().indexOf(keyword.toLowerCase());
148
+ if (index > 20) {
149
+ start = Math.max(0, index - 40);
150
+ break;
151
+ }
152
+ }
153
+ const snippet = text.slice(start, start + (start > 0 ? max - 1 : max));
154
+ return start > 0 ? `…${snippet}` : snippet;
155
+ }
156
+ /** Shape the memory_search result list. */
157
+ export function toSearchHits(rows, keywords) {
158
+ return rows.map((row) => ({
159
+ session_id: String(row.session_id ?? ''),
160
+ device: row.device == null ? null : String(row.device),
161
+ type: String(row.type ?? ''),
162
+ created_at: isoOf(row.created_at),
163
+ snippet: snippetOf(String(row.content ?? ''), keywords),
164
+ }));
165
+ }
166
+ /** Shape the memory_read conversation window. */
167
+ export function toReadMessages(rows, max = 500) {
168
+ return rows.map((row) => {
169
+ const type = String(row.type ?? '');
170
+ const content = String(row.content ?? '');
171
+ const truncated = content.length > max ? `${content.slice(0, max - 1)}…` : content;
172
+ return {
173
+ role: type === 'user' ? 'user' : type === 'model' ? 'assistant' : type,
174
+ type,
175
+ created_at: isoOf(row.created_at),
176
+ content: truncated,
177
+ };
178
+ });
179
+ }
180
+ /** Friendly unconfigured-config error (shown to the model). */
181
+ export function notConfiguredError(reason) {
182
+ return new Error(`会话存储未配置或未启用 (session storage not configured${reason ? `: ${reason}` : ''}) — 请先在设置的「会话存储」面板配置并启用同步 / configure and enable sync in the 会话存储 settings panel first.`);
183
+ }
184
+ /** Load the sync config and reject unless sync is enabled and complete. */
185
+ export function requireConfig(configPath = defaultConfigPath()) {
186
+ const { config, error } = loadConfig(configPath);
187
+ if (error === 'parse')
188
+ throw notConfiguredError('配置文件损坏 config file unparseable');
189
+ if (error === 'missing-field')
190
+ throw notConfiguredError('配置字段缺失 missing fields');
191
+ if (!config.enabled || !config.database)
192
+ throw notConfiguredError('未启用 disabled');
193
+ return config;
194
+ }
195
+ /** Open a one-shot read-only adapter connection; caller disposes. */
196
+ export async function connectReadOnly(config) {
197
+ const factory = (await createAdapter({
198
+ provider: config.provider,
199
+ url: configToUrl(config),
200
+ timezone: config.timezone,
201
+ }));
202
+ return factory.connect();
203
+ }
204
+ /** Run one SELECT over the connection and return row objects. */
205
+ export async function runSelect(connection, query) {
206
+ if (typeof connection.queryRaw !== 'function')
207
+ throw new Error('dsh-storage adapter connection does not support queryRaw');
208
+ return normalizeRows(await connection.queryRaw(query));
209
+ }
210
+ const TOOL_NAMES = { search: 'memory_search', read: 'memory_read' };
211
+ /** Output envelope: model-visible text is the JSON of the result value. */
212
+ function jsonOutput(schema) {
213
+ return {
214
+ schema,
215
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
216
+ };
217
+ }
218
+ function objectSchema(properties, required) {
219
+ return { type: 'object', properties, required, additionalProperties: false };
220
+ }
221
+ const stringProp = (description) => ({ type: 'string', description });
222
+ const intProp = (description) => ({ type: 'integer', description });
223
+ /**
224
+ * Build the two tool definitions. `deps.connect` defaults to a real
225
+ * read-only adapter connection created from the sync config and disposed
226
+ * after each execution.
227
+ */
228
+ export function createMemoryTools(deps = {}) {
229
+ const connect = deps.connect ?? connectReadOnly;
230
+ const configPath = deps.configPath;
231
+ const withConnection = async (exec, run) => {
232
+ if (exec.signal?.aborted)
233
+ throw new Error('memory query aborted');
234
+ const config = requireConfig(configPath);
235
+ const connection = await connect(config);
236
+ try {
237
+ return await run(connection, config);
238
+ }
239
+ finally {
240
+ await connection.dispose?.().catch((error) => {
241
+ console.error('[dsh-storage] memory tool dispose failed:', error);
242
+ });
243
+ }
244
+ };
245
+ return [
246
+ {
247
+ name: TOOL_NAMES.search,
248
+ description: '搜索远程会话记忆库(跨设备的历史对话)/ Search the mirrored session memory store (cross-device conversation history). ' +
249
+ 'Keywords match user AND assistant message content (SQL LIKE, OR semantics). ' +
250
+ 'Use when recalling previous conversations, facts, decisions, or what was said on any device. ' +
251
+ 'Returns {session_id, device, type, created_at, snippet} ordered newest-first; follow up with memory_read for context.',
252
+ parameters: objectSchema({
253
+ query: stringProp('关键词,空格分隔多个词 / keywords, space-separated (AND-free OR matching)'),
254
+ device: {
255
+ type: 'string',
256
+ enum: ['all', 'current'],
257
+ description: "'all'(默认)搜索所有设备写入的行;'current' 仅限本机设备 / scope of device-stamped rows, default all",
258
+ },
259
+ limit: intProp('返回条数上限,默认 20,最大 100 / max hits, default 20, max 100'),
260
+ since: stringProp('可选,仅返回该日期(YYYY-MM-DD)之后的记录 / optional lower date bound YYYY-MM-DD'),
261
+ }, ['query']),
262
+ output: jsonOutput({
263
+ type: 'object',
264
+ properties: {
265
+ hits: {
266
+ type: 'array',
267
+ items: {
268
+ type: 'object',
269
+ properties: {
270
+ session_id: { type: 'string' },
271
+ // The host validator (dsh-tools assertSupportedJsonSchema) rejects
272
+ // array-form `type` and `anyOf`; `oneOf` is the supported nullable form.
273
+ device: { oneOf: [{ type: 'string' }, { type: 'null' }] },
274
+ type: { type: 'string' },
275
+ created_at: { type: 'string' },
276
+ snippet: { type: 'string' },
277
+ },
278
+ required: ['session_id', 'type', 'created_at', 'snippet'],
279
+ },
280
+ },
281
+ },
282
+ required: ['hits'],
283
+ }),
284
+ execute: (args, exec) => withConnection(exec, async (connection, config) => {
285
+ const query = typeof args?.query === 'string' ? args.query : '';
286
+ const keywords = splitKeywords(query);
287
+ if (keywords.length === 0)
288
+ return { hits: [] };
289
+ const plan = buildSearchSql(config.provider, {
290
+ query,
291
+ ...(args?.device === 'current' ? { device: 'current' } : {}),
292
+ ...(typeof args?.limit === 'number' ? { limit: args.limit } : {}),
293
+ ...(typeof args?.since === 'string' && args.since ? { since: args.since } : {}),
294
+ }, config.device);
295
+ const rows = await runSelect(connection, plan);
296
+ return { hits: toSearchHits(rows, keywords) };
297
+ }),
298
+ },
299
+ {
300
+ name: TOOL_NAMES.read,
301
+ description: '读取远程记忆库中某次会话的对话上下文 / Read the conversation context of one session from the remote memory store. ' +
302
+ 'Use after memory_search to see what happened around a hit: messages are returned in chronological order ' +
303
+ '(with `around`, the window ends at that instant; without it, the most recent `limit` messages), ' +
304
+ 'each truncated to ≤500 chars. Read-only.',
305
+ parameters: objectSchema({
306
+ session_id: stringProp('memory_search 返回的 session_id / session id from memory_search'),
307
+ around: stringProp('可选,ISO 日期时间;返回该时刻(含)之前的窗口 / optional ISO datetime center'),
308
+ limit: intProp('返回消息条数上限,默认 30,最大 100 / max messages, default 30, max 100'),
309
+ }, ['session_id']),
310
+ output: jsonOutput({
311
+ type: 'object',
312
+ properties: {
313
+ session_id: { type: 'string' },
314
+ messages: {
315
+ type: 'array',
316
+ items: {
317
+ type: 'object',
318
+ properties: {
319
+ role: { type: 'string' },
320
+ type: { type: 'string' },
321
+ created_at: { type: 'string' },
322
+ content: { type: 'string' },
323
+ },
324
+ required: ['role', 'type', 'created_at', 'content'],
325
+ },
326
+ },
327
+ },
328
+ required: ['session_id', 'messages'],
329
+ }),
330
+ execute: (args, exec) => withConnection(exec, async (connection, config) => {
331
+ const sessionId = typeof args?.session_id === 'string' ? args.session_id : '';
332
+ if (!sessionId)
333
+ return { session_id: '', messages: [] };
334
+ const plan = buildReadSql(config.provider, {
335
+ sessionId,
336
+ ...(typeof args?.around === 'string' && args.around ? { around: args.around } : {}),
337
+ ...(typeof args?.limit === 'number' ? { limit: args.limit } : {}),
338
+ });
339
+ const rows = await runSelect(connection, plan);
340
+ // Both windows (with and without `around`) were fetched newest-first;
341
+ // the model always receives chronological (ascending) order.
342
+ return {
343
+ session_id: sessionId,
344
+ messages: toReadMessages([...rows].reverse()),
345
+ };
346
+ }),
347
+ },
348
+ ];
349
+ }
350
+ /**
351
+ * Register the memory tools on the host `tools` service (dsh-tools):
352
+ * `ctx.inject(['tools'], (toolCtx) => toolCtx.effect(() => tools.register(def)))`
353
+ * — the ds-harness-remote idiom. Tools are host-side only; nothing client-side.
354
+ */
355
+ export function registerMemoryTools(ctx, deps = {}) {
356
+ ctx.inject(['tools'], (toolCtx) => {
357
+ const tools = toolCtx.get('tools');
358
+ if (!tools || typeof tools.register !== 'function') {
359
+ console.error('[dsh-storage] tools service missing — memory tools not registered');
360
+ return;
361
+ }
362
+ for (const definition of createMemoryTools(deps))
363
+ toolCtx.effect(() => tools.register(definition));
364
+ });
365
+ }
package/lib/notify.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 同步中断/恢复通知 (Task 6): the host side records toast notifications on
3
+ * HealthMonitor transitions; the browser client (src/client/toast.tsx) polls
4
+ * them through the status bridge and renders a React portal independent of
5
+ * the settings panel. The monitor already guarantees transitions only
6
+ * (same-state results never re-fire), so each record is a real state change.
7
+ */
8
+ const MAX_TOASTS = 20;
9
+ let nextId = 1;
10
+ const toasts = [];
11
+ /** 中断 toast: good → bad. */
12
+ export function notifyGoodToBad(lastError) {
13
+ return push({
14
+ kind: 'interrupted',
15
+ // No "点击查看" suffix: the toast's click only dismisses — the DSH host
16
+ // exposes no navigation API reachable from the body-mounted portal, so
17
+ // the copy must not promise a click-through.
18
+ text: `⚠️ 会话同步中断:${lastError ?? '未知错误'}`,
19
+ });
20
+ }
21
+ /** 恢复 toast: bad → good, with a backfill hint for the gap. */
22
+ export function notifyBadToGood() {
23
+ return push({
24
+ kind: 'recovered',
25
+ text: '✅ 会话同步已恢复',
26
+ hint: '如中断期间有新会话,可在设置面板点「补传」补齐',
27
+ });
28
+ }
29
+ function push(record) {
30
+ const toast = { ...record, id: nextId++, createdAt: Date.now() };
31
+ toasts.push(toast);
32
+ if (toasts.length > MAX_TOASTS)
33
+ toasts.splice(0, toasts.length - MAX_TOASTS);
34
+ return toast;
35
+ }
36
+ /** Records newer than `sinceId` (polling cursor for the client). */
37
+ export function drainToasts(sinceId = 0) {
38
+ return toasts.filter((toast) => toast.id > sinceId);
39
+ }
40
+ /** Test/reset hook: drop all history. */
41
+ export function clearToasts() {
42
+ toasts.length = 0;
43
+ }
44
+ /**
45
+ * Wire the toast records onto a monitor's transitions. Good→bad strictly
46
+ * means ok → down (a config error was never "good"); bad→good covers
47
+ * down → ok and config-error → ok.
48
+ */
49
+ export function wireNotifications(monitor) {
50
+ return monitor.onTransition((to, from, status) => {
51
+ if (to === 'down' && from === 'ok')
52
+ notifyGoodToBad(status?.lastError);
53
+ else if (to === 'ok' && (from === 'down' || from === 'config-error'))
54
+ notifyBadToGood();
55
+ });
56
+ }
@@ -0,0 +1,191 @@
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
+ * - `user/message` and `assistant/message` produce rows; `tool/result` updates
9
+ * the matching assistant row's toolCalls instead of producing a tool row.
10
+ * - token usage (V3 log): `assistant/message.data.usage` is the settled
11
+ * step's accounting; a failed/cancelled attempt commits `assistant/attempt`
12
+ * instead, with the adapter's usage chunk preserved as a raw record in its
13
+ * embedded compact stream (recovered in usageSampleOf). `turn/end` carries
14
+ * none — it only serves as a rollup checkpoint.
15
+ * - compaction summaries enter the surface as a `user/message` with
16
+ * `surfaceOp: replace`, so they flow through the user path unchanged.
17
+ */
18
+ import { randomUUID } from 'node:crypto';
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+ /** Visible text of a message: its `text` blocks, unwrapping tool-result blocks. */
21
+ function textOf(message) {
22
+ const blocks = Array.isArray(message?.content) ? message.content : [];
23
+ return blocks
24
+ .map((b) => {
25
+ if (b?.type === 'text')
26
+ return b.text ?? '';
27
+ if (b?.type === 'tool-result' && Array.isArray(b.content))
28
+ return textOf({ content: b.content });
29
+ return '';
30
+ })
31
+ .join('');
32
+ }
33
+ /** Reasoning blocks of an assistant message, in their original order. */
34
+ function thoughtsOf(message) {
35
+ const blocks = Array.isArray(message?.content) ? message.content : [];
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;
52
+ }
53
+ function toolPartsOf(message) {
54
+ const blocks = Array.isArray(message?.content) ? message.content : [];
55
+ const calls = blocks.filter((b) => b?.type === 'tool-call');
56
+ return calls.length > 0 ? calls : undefined;
57
+ }
58
+ /**
59
+ * Map one session event to zero or one message row. Returns null for events
60
+ * that should not be persisted as standalone rows (log-only events: chunks,
61
+ * turn/step lifecycle, approvals, ...).
62
+ */
63
+ export function projectEvent(_session, event, sessionId) {
64
+ const data = event?.data ?? {};
65
+ const base = {
66
+ sessionId,
67
+ historyId: null,
68
+ agentId: 'main',
69
+ createdAt: new Date(event?.time ?? Date.now()),
70
+ };
71
+ switch (event?.type) {
72
+ case 'user/message': {
73
+ // data IS the UserMessage.
74
+ const message = data;
75
+ return {
76
+ ...base,
77
+ id: message.id ?? randomUUID(),
78
+ type: 'user',
79
+ content: textOf(message),
80
+ metadata: { event: event.type, seq: event.seq, message },
81
+ };
82
+ }
83
+ case 'assistant/message': {
84
+ const message = data.message;
85
+ return {
86
+ ...base,
87
+ id: message?.id ?? randomUUID(),
88
+ type: 'model',
89
+ content: textOf(message),
90
+ thoughts: thoughtsOf(message),
91
+ model: message?.source?.model ?? undefined,
92
+ tokens: data.usage ?? undefined,
93
+ toolCalls: toolPartsOf(message),
94
+ metadata: {
95
+ event: event.type,
96
+ seq: event.seq,
97
+ ...(message?.source ? { source: message.source } : {}),
98
+ ...(data.interrupted ? { interrupted: true } : {}),
99
+ },
100
+ };
101
+ }
102
+ default:
103
+ // Log-only events (assistant/attempt, turn/step lifecycle, approvals,
104
+ // compaction markers, ...) are not standalone rows; usage rolls up into
105
+ // SessionRow via usageSampleOf (assistant/message and assistant/attempt).
106
+ return null;
107
+ }
108
+ }
109
+ /** Identify the assistant tool call answered by either supported result shape. */
110
+ export function toolResultCallId(event) {
111
+ const message = event?.data?.message;
112
+ const first = message?.content?.[0];
113
+ const callId = message?.toolCallId ??
114
+ message?.source?.callId ??
115
+ (first?.type === 'tool-result' ? first.toolCallId : undefined);
116
+ return typeof callId === 'string' ? callId : undefined;
117
+ }
118
+ /** Update the persisted assistant call identified by a tool result. */
119
+ export function mergeToolResult(event, rows) {
120
+ const message = event?.data?.message;
121
+ const content = Array.isArray(message?.content) ? message.content : [];
122
+ const legacy = content.length === 1 && content[0]?.type === 'tool-result' ? content[0] : undefined;
123
+ const callId = toolResultCallId(event);
124
+ if (!callId)
125
+ return null;
126
+ const result = legacy ??
127
+ (message
128
+ ? {
129
+ type: 'tool-result',
130
+ toolCallId: callId,
131
+ content,
132
+ ...(typeof message.isError === 'boolean' ? { isError: message.isError } : {}),
133
+ }
134
+ : null);
135
+ const model = [...rows]
136
+ .reverse()
137
+ .find((row) => row.type === 'model' &&
138
+ Array.isArray(row.toolCalls) &&
139
+ row.toolCalls.some((call) => call?.id === callId));
140
+ if (!model)
141
+ return null;
142
+ return {
143
+ ...model,
144
+ toolCalls: model.toolCalls.map((call) => call?.id === callId
145
+ ? {
146
+ ...call,
147
+ result,
148
+ error: event.data.error
149
+ ? { name: event.data.error.name, code: event.data.error.code }
150
+ : undefined,
151
+ }
152
+ : call),
153
+ };
154
+ }
155
+ /**
156
+ * Extract one model call's usage, keyed by its durable event sequence.
157
+ *
158
+ * V3 session log (dsh-session@0.1.6-alpha.2):
159
+ * - `assistant/message.data.usage` — the settled call's accounting;
160
+ * - `assistant/attempt` — a failed/retried/cancelled call commits no surface
161
+ * message; the adapter's usage chunk survives as a raw `chunk` record in the
162
+ * embedded compact `stream` (deltas are packed, but usage is a raw type).
163
+ *
164
+ * Calls on one `(turn, step)` are additive: a retry is another billed model
165
+ * call, not a progressive snapshot. Only redelivery of the same event sequence
166
+ * is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
167
+ * billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
168
+ * session logs — they are not subsets of inputTokens).
169
+ */
170
+ export function usageSampleOf(event) {
171
+ const total = (u) => ({
172
+ input: (u?.inputTokens ?? 0) + (u?.cacheReadTokens ?? 0) + (u?.cacheWriteTokens ?? 0),
173
+ output: u?.outputTokens ?? 0,
174
+ });
175
+ let data;
176
+ let usage;
177
+ if (event?.type === 'assistant/message') {
178
+ data = event.data;
179
+ usage = data?.usage;
180
+ }
181
+ else if (event?.type === 'assistant/attempt') {
182
+ data = event.data;
183
+ usage = (Array.isArray(data?.stream) ? data.stream : []).find((r) => r?.type === 'chunk' && r.chunk?.type === 'usage')?.chunk?.usage;
184
+ }
185
+ else {
186
+ return null;
187
+ }
188
+ if (!usage || !Number.isSafeInteger(event?.seq))
189
+ return null;
190
+ return { key: String(event.seq), sample: total(usage) };
191
+ }