@wfm-cn/dsh-storage 0.2.0 → 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/README.md +9 -0
- package/lib/index.js +5 -0
- package/lib/memory-tools.js +365 -0
- package/lib/types/memory-tools.d.ts +168 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -92,6 +92,15 @@ npx prisma db push --schema node_modules/@wfm-cn/dsh-storage/prisma/schema.mysql
|
|
|
92
92
|
|
|
93
93
|
镜像为无抛出缝隙:数据库错误带 `[dsh-storage]` 前缀记日志,绝不逃逸进 agent 循环;本地 DSH 持久化始终是权威数据源。成功的原生压缩事件同时写入 `utility_compressor` 快照消息(source 兼容的 `Content[]`)。完整投影语义(工具结果合并、thoughts 签名、压缩快照续读等)与上游 0.1.40 一致,详见上游文档。
|
|
94
94
|
|
|
95
|
+
## AI 记忆查询工具(Phase 2)
|
|
96
|
+
|
|
97
|
+
插件在宿主 `tools` 服务上注册两个只读的模型工具(host-side,无需客户端配合),让 AI 能检索/回忆镜像到数据库的历史对话(跨设备记忆):
|
|
98
|
+
|
|
99
|
+
- **`memory_search`** — `{ query, device?: 'all'|'current', limit?, since? }`:关键词(SQL LIKE OR 语义,通配符已转义)搜索 `ai_messages` 用户+模型行,返回 `{session_id, device, type, created_at, snippet(≤160)}`,按时间倒序。
|
|
100
|
+
- **`memory_read`** — `{ session_id, around?, limit? }`:按 `created_at` 返回该会话的对话上下文(`around` 时为该时刻及之前的窗口,恢复正序),每条内容截断 ≤500 字符。
|
|
101
|
+
|
|
102
|
+
两个工具复用同步配置(`$DSH_HOME/dsh-storage.json`):同步未配置/未启用时返回友好错误提示(引导到「会话存储」面板)。每次调用创建一次性只读连接(仅 SELECT),用完即释放;所有用户输入参数化绑定(显式 argTypes,兼容 adapter 7.9.1/7.10+),LIKE 通配符用 `ESCAPE '~'` 转义。
|
|
103
|
+
|
|
95
104
|
## 已知限制
|
|
96
105
|
|
|
97
106
|
- **PostgreSQL / SQL Server 建表需要 prisma CLI**:面板"建表"按钮的嵌入式 DDL 仅覆盖 MySQL 与 SQLite;pg/mssql 走 `prisma db push` 路线,失败时会明确提示需要 prisma CLI(或用上文的 `npx prisma db push` 手动建表)。
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { configToUrl, defaultConfigPath, dshHome, loadConfig, defaultDevice, sav
|
|
|
27
27
|
import { createTables, ensureDeviceColumn } from './ddl.js';
|
|
28
28
|
import { ConfigError, HealthMonitor, probeDatabase, testConnection, } from './health.js';
|
|
29
29
|
import { drainToasts, wireNotifications } from './notify.js';
|
|
30
|
+
import { registerMemoryTools } from './memory-tools.js';
|
|
30
31
|
import { mergeToolResult, projectEvent, toolResultCallId, usageSampleOf } from './projector.js';
|
|
31
32
|
import { registerStatusRoute } from './status-route.js';
|
|
32
33
|
export const name = 'dsh-storage';
|
|
@@ -173,6 +174,10 @@ export function apply(ctx, config) {
|
|
|
173
174
|
},
|
|
174
175
|
};
|
|
175
176
|
ctx.provide('dshStorageStatus', statusService);
|
|
177
|
+
// Phase 2: AI memory tools (memory_search / memory_read) — host-side only,
|
|
178
|
+
// registered on the dsh-tools service; a disabled/unconfigured sync config
|
|
179
|
+
// surfaces as a friendly error at call time, never at registration.
|
|
180
|
+
registerMemoryTools(ctx);
|
|
176
181
|
// Browser bridge (Task 5): the settings panel client calls this over the
|
|
177
182
|
// connection generic-RPC channel. Optional services — profiles without a
|
|
178
183
|
// web carrier simply skip the route.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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 { type DatabaseProvider } from './backends/database.js';
|
|
35
|
+
import { type SyncConfig } from './config.js';
|
|
36
|
+
/** Minimal cordis/tool surface we rely on — the package does not depend on dsh-tools types. */
|
|
37
|
+
export interface ToolDefinition {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
parameters: Record<string, unknown>;
|
|
41
|
+
output: {
|
|
42
|
+
schema: Record<string, unknown>;
|
|
43
|
+
render(args: unknown, value: unknown): Array<{
|
|
44
|
+
type: string;
|
|
45
|
+
text: string;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
execute(args: any, exec: {
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}): Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
export interface ToolRegistrarContext {
|
|
53
|
+
inject(services: string[], callback: (ctx: ToolContext) => void): unknown;
|
|
54
|
+
}
|
|
55
|
+
export interface ToolContext {
|
|
56
|
+
get(service: string): unknown;
|
|
57
|
+
effect(factory: () => unknown): unknown;
|
|
58
|
+
}
|
|
59
|
+
/** One raw adapter query: `?` placeholders + positionally aligned args/argTypes. */
|
|
60
|
+
export interface RawQuery {
|
|
61
|
+
sql: string;
|
|
62
|
+
args: unknown[];
|
|
63
|
+
argTypes: Array<{
|
|
64
|
+
scalarType: string;
|
|
65
|
+
arity: 'scalar';
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Adapter connection from `createAdapter(...).connect()` (ddl.ts seam).
|
|
70
|
+
* All four adapters' `queryRaw` return `{ columnNames, rows: unknown[][] }`.
|
|
71
|
+
*/
|
|
72
|
+
export interface RawConnection {
|
|
73
|
+
queryRaw?(query: RawQuery): Promise<unknown>;
|
|
74
|
+
executeRaw(query: RawQuery): Promise<unknown>;
|
|
75
|
+
dispose?(): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Escape LIKE wildcards in user input for use with `ESCAPE '~'`:
|
|
79
|
+
* `~` (the escape character itself), `%` and `_`. Everything else stays
|
|
80
|
+
* literal, so keywords match plain substrings.
|
|
81
|
+
*/
|
|
82
|
+
export declare function escapeLike(value: string): string;
|
|
83
|
+
/** Split a free-text query into non-empty keyword terms. */
|
|
84
|
+
export declare function splitKeywords(query: string): string[];
|
|
85
|
+
export interface SearchPlanOptions {
|
|
86
|
+
query: string;
|
|
87
|
+
device?: 'all' | 'current';
|
|
88
|
+
limit?: number;
|
|
89
|
+
since?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface SearchPlan {
|
|
92
|
+
sql: string;
|
|
93
|
+
args: unknown[];
|
|
94
|
+
argTypes: Array<{
|
|
95
|
+
scalarType: string;
|
|
96
|
+
arity: 'scalar';
|
|
97
|
+
}>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build the memory_search SELECT. Keywords OR across user+model rows
|
|
101
|
+
* (no type filter); `device:'current'` binds the configured device id;
|
|
102
|
+
* `since` (YYYY-MM-DD) filters `created_at >=` with an ISO 'T' delimiter so
|
|
103
|
+
* the string comparison also matches sqlite's TEXT timestamps.
|
|
104
|
+
*/
|
|
105
|
+
export declare function buildSearchSql(provider: DatabaseProvider, opts: SearchPlanOptions, deviceId?: string): SearchPlan;
|
|
106
|
+
export interface ReadPlanOptions {
|
|
107
|
+
sessionId: string;
|
|
108
|
+
around?: string;
|
|
109
|
+
limit?: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the memory_read SELECT. Both cases fetch the newest-first (DESC)
|
|
113
|
+
* `limit`-row window — with `around`, at-or-before the instant; without it,
|
|
114
|
+
* the most recent `limit` rows of the session. The executor always reverses
|
|
115
|
+
* the rows to chronological order before handing them to the model.
|
|
116
|
+
*/
|
|
117
|
+
export declare function buildReadSql(provider: DatabaseProvider, opts: ReadPlanOptions): SearchPlan;
|
|
118
|
+
/**
|
|
119
|
+
* Normalize adapter query results to row objects. The 7.9.1 adapters return
|
|
120
|
+
* `{ columnNames, rows: value[][] }`; tests and older shapes may already
|
|
121
|
+
* produce arrays of objects.
|
|
122
|
+
*/
|
|
123
|
+
export declare function normalizeRows(result: unknown): Array<Record<string, unknown>>;
|
|
124
|
+
/** First-index snippet ≤ max chars centered on the earliest keyword hit. */
|
|
125
|
+
export declare function snippetOf(content: string, keywords: string[], max?: number): string;
|
|
126
|
+
export interface SearchHit {
|
|
127
|
+
session_id: string;
|
|
128
|
+
device: string | null;
|
|
129
|
+
type: string;
|
|
130
|
+
created_at: string;
|
|
131
|
+
snippet: string;
|
|
132
|
+
}
|
|
133
|
+
export interface ReadMessage {
|
|
134
|
+
role: string;
|
|
135
|
+
type: string;
|
|
136
|
+
created_at: string;
|
|
137
|
+
content: string;
|
|
138
|
+
}
|
|
139
|
+
/** Shape the memory_search result list. */
|
|
140
|
+
export declare function toSearchHits(rows: Array<Record<string, unknown>>, keywords: string[]): SearchHit[];
|
|
141
|
+
/** Shape the memory_read conversation window. */
|
|
142
|
+
export declare function toReadMessages(rows: Array<Record<string, unknown>>, max?: number): ReadMessage[];
|
|
143
|
+
/** Friendly unconfigured-config error (shown to the model). */
|
|
144
|
+
export declare function notConfiguredError(reason?: string): Error;
|
|
145
|
+
/** Load the sync config and reject unless sync is enabled and complete. */
|
|
146
|
+
export declare function requireConfig(configPath?: string): SyncConfig;
|
|
147
|
+
/** Open a one-shot read-only adapter connection; caller disposes. */
|
|
148
|
+
export declare function connectReadOnly(config: SyncConfig): Promise<RawConnection>;
|
|
149
|
+
/** Run one SELECT over the connection and return row objects. */
|
|
150
|
+
export declare function runSelect(connection: RawConnection, query: RawQuery): Promise<Array<Record<string, unknown>>>;
|
|
151
|
+
export interface MemoryToolsDeps {
|
|
152
|
+
/** Config path override (tests); defaults to $DSH_HOME/dsh-storage.json. */
|
|
153
|
+
configPath?: string;
|
|
154
|
+
/** Connection factory override (tests): receives the resolved sync config. */
|
|
155
|
+
connect?: (config: SyncConfig) => Promise<RawConnection>;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Build the two tool definitions. `deps.connect` defaults to a real
|
|
159
|
+
* read-only adapter connection created from the sync config and disposed
|
|
160
|
+
* after each execution.
|
|
161
|
+
*/
|
|
162
|
+
export declare function createMemoryTools(deps?: MemoryToolsDeps): ToolDefinition[];
|
|
163
|
+
/**
|
|
164
|
+
* Register the memory tools on the host `tools` service (dsh-tools):
|
|
165
|
+
* `ctx.inject(['tools'], (toolCtx) => toolCtx.effect(() => tools.register(def)))`
|
|
166
|
+
* — the ds-harness-remote idiom. Tools are host-side only; nothing client-side.
|
|
167
|
+
*/
|
|
168
|
+
export declare function registerMemoryTools(ctx: ToolRegistrarContext, deps?: MemoryToolsDeps): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wfm-cn/dsh-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Session storage mirror for DeepSeek Harness: projects the session event stream into MySQL/PostgreSQL/SQLite/SQL Server (ai_messages / ai_chat_histories) without replacing dsh's local persistence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -126,4 +126,4 @@
|
|
|
126
126
|
"@prisma/adapter-mariadb@7.9.1": "patches/@prisma__adapter-mariadb@7.9.1.patch"
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
-
}
|
|
129
|
+
}
|