@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/cordis.patch.yml +3 -0
- package/lib/backends/database.js +578 -0
- package/lib/backfill.js +331 -0
- package/lib/client/index.js +79 -0
- package/lib/client/panel.js +299 -0
- package/lib/client/toast.js +66 -0
- package/lib/client.js +1 -0
- package/lib/compaction.js +108 -0
- package/lib/config.js +171 -0
- package/lib/ddl.js +323 -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/health.js +180 -0
- package/lib/index.js +681 -0
- package/lib/memory-tools.js +365 -0
- package/lib/notify.js +56 -0
- package/lib/projector.js +191 -0
- package/lib/status-route.js +172 -0
- package/lib/types/backends/database.d.ts +78 -0
- package/lib/types/backfill.d.ts +67 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/panel.d.ts +79 -0
- package/lib/types/client/toast.d.ts +38 -0
- package/lib/types/compaction.d.ts +3 -0
- package/lib/types/config.d.ts +52 -0
- package/lib/types/ddl.d.ts +49 -0
- package/lib/types/generated/mysql/browser.d.ts +14 -0
- package/lib/types/generated/mysql/client.d.ts +33 -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 +1359 -0
- package/lib/types/generated/mysql/models.d.ts +3 -0
- package/lib/types/generated/postgresql/browser.d.ts +14 -0
- package/lib/types/generated/postgresql/client.d.ts +33 -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 +1496 -0
- package/lib/types/generated/postgresql/models.d.ts +3 -0
- package/lib/types/generated/sqlite/browser.d.ts +14 -0
- package/lib/types/generated/sqlite/client.d.ts +33 -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 +1493 -0
- package/lib/types/generated/sqlite/models.d.ts +3 -0
- package/lib/types/generated/sqlserver/browser.d.ts +14 -0
- package/lib/types/generated/sqlserver/client.d.ts +33 -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 +1375 -0
- package/lib/types/generated/sqlserver/models.d.ts +3 -0
- package/lib/types/health.d.ts +52 -0
- package/lib/types/index.d.ts +91 -0
- package/lib/types/memory-tools.d.ts +168 -0
- package/lib/types/notify.d.ts +35 -0
- package/lib/types/projector.d.ts +51 -0
- package/lib/types/status-route.d.ts +48 -0
- package/lib/types/types.d.ts +58 -0
- package/lib/types.js +8 -0
- package/package.json +127 -4
- package/preview.png +0 -0
- package/prisma/schema.mysql.prisma +78 -0
- package/prisma/schema.postgresql.prisma +70 -0
- package/prisma/schema.sqlite.prisma +70 -0
- 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
|
+
}
|
package/lib/projector.js
ADDED
|
@@ -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
|
+
}
|