@modusensus/dsh-mneme 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +239 -207
- package/cordis.patch.yml +15 -15
- package/lib/api.js +256 -245
- package/lib/client.js +461 -461
- package/lib/commands.js +64 -64
- package/lib/config.js +55 -15
- package/lib/dream/clustering.js +118 -0
- package/lib/dream/decisions.js +177 -121
- package/lib/dream.js +467 -209
- package/lib/embedding.js +97 -97
- package/lib/index.js +176 -120
- package/lib/inject.js +47 -47
- package/lib/local-embedder.js +265 -0
- package/lib/mirror.js +131 -131
- package/lib/reranker.js +210 -0
- package/lib/service.js +324 -174
- package/lib/settings.js +142 -142
- package/lib/store.js +466 -310
- package/lib/summarize.js +171 -171
- package/lib/tools.js +230 -241
- package/lib/vector-index.js +106 -0
- package/package.json +7 -3
- package/src/api.js +256 -245
- package/src/commands.js +64 -64
- package/src/config.js +55 -15
- package/src/dream/clustering.js +118 -0
- package/src/dream/decisions.js +177 -121
- package/src/dream.js +467 -209
- package/src/embedding.js +97 -97
- package/src/index.js +176 -120
- package/src/inject.js +47 -47
- package/src/local-embedder.js +265 -0
- package/src/mirror.js +131 -131
- package/src/reranker.js +210 -0
- package/src/service.js +324 -174
- package/src/settings.js +142 -142
- package/src/store.js +466 -310
- package/src/summarize.js +171 -171
- package/src/tools.js +230 -241
- package/src/vector-index.js +106 -0
package/src/api.js
CHANGED
|
@@ -1,245 +1,256 @@
|
|
|
1
|
-
import { URL } from "node:url";
|
|
2
|
-
|
|
3
|
-
function sendJson(res, status, payload) {
|
|
4
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
5
|
-
res.end(JSON.stringify(payload));
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
9
|
-
function readBody(req) {
|
|
10
|
-
return new Promise((resolve) => {
|
|
11
|
-
let body = "";
|
|
12
|
-
req.on("data", (chunk) => { body += chunk; });
|
|
13
|
-
req.on("end", () => resolve(body));
|
|
14
|
-
req.on("error", () => resolve(""));
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function parseBody(text) {
|
|
19
|
-
try {
|
|
20
|
-
return JSON.parse(text || "{}");
|
|
21
|
-
} catch {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createApi(ctx, service, settings, commands, embedder) {
|
|
27
|
-
const disposers = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
|
|
3
|
+
function sendJson(res, status, payload) {
|
|
4
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
5
|
+
res.end(JSON.stringify(payload));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
9
|
+
function readBody(req) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let body = "";
|
|
12
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
13
|
+
req.on("end", () => resolve(body));
|
|
14
|
+
req.on("error", () => resolve(""));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseBody(text) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text || "{}");
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null) {
|
|
27
|
+
const disposers = [];
|
|
28
|
+
|
|
29
|
+
// Ensure the service has an embedder when the API layer was handed one
|
|
30
|
+
// (tests wire the embedder through the API instead of index.js). Without
|
|
31
|
+
// this, /api/dsh-mneme/search would silently degrade to keyword-only.
|
|
32
|
+
if (embedder && typeof service.setEmbedder === "function") {
|
|
33
|
+
service.setEmbedder(embedder);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const register = (route) => {
|
|
37
|
+
disposers.push(ctx.webServer.register(route));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
41
|
+
register({
|
|
42
|
+
kind: "prefix",
|
|
43
|
+
path: "/api/dsh-mneme",
|
|
44
|
+
handler(req, res) {
|
|
45
|
+
sendJson(res, 404, { error: "not-found" });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
register({
|
|
50
|
+
kind: "exact",
|
|
51
|
+
path: "/api/dsh-mneme/list",
|
|
52
|
+
handler(req, res) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(req.url, "http://localhost");
|
|
55
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
56
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
57
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
58
|
+
const items = service.toApiList(service.list({ type, limit, offset }));
|
|
59
|
+
sendJson(res, 200, { items, total: service.count(type) });
|
|
60
|
+
} catch {
|
|
61
|
+
sendJson(res, 500, { error: "internal" });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
register({
|
|
67
|
+
kind: "exact",
|
|
68
|
+
path: "/api/dsh-mneme/search",
|
|
69
|
+
handler(req, res) {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(req.url, "http://localhost");
|
|
72
|
+
const q = url.searchParams.get("q") ?? "";
|
|
73
|
+
const limit = Number(url.searchParams.get("limit") ?? 20);
|
|
74
|
+
// mode: auto (default) | keyword | vector | hybrid
|
|
75
|
+
const mode = url.searchParams.get("mode") ?? "auto";
|
|
76
|
+
const rerank = url.searchParams.get("rerank") !== "false";
|
|
77
|
+
const query = q.trim();
|
|
78
|
+
if (!query) {
|
|
79
|
+
sendJson(res, 200, { items: [], mode: "keyword" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Route through the unified semantic pipeline; any vector/rerank
|
|
83
|
+
// failure degrades to keyword results inside searchMemories. The
|
|
84
|
+
// returned promise lets the test double await the async search.
|
|
85
|
+
return Promise.resolve(
|
|
86
|
+
service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
|
|
87
|
+
).then((rows) => {
|
|
88
|
+
// mode reflects what actually happened: rows marked `vector` came
|
|
89
|
+
// through the semantic path, everything else is keyword fallback.
|
|
90
|
+
const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
|
|
91
|
+
sendJson(res, 200, { items: service.toApiList(rows), mode: used });
|
|
92
|
+
}).catch(() => {
|
|
93
|
+
sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
sendJson(res, 500, { error: "internal" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- user profile ---
|
|
102
|
+
register({
|
|
103
|
+
kind: "exact",
|
|
104
|
+
path: "/api/dsh-mneme/profile",
|
|
105
|
+
handler(req, res) {
|
|
106
|
+
try {
|
|
107
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
108
|
+
return readBody(req).then((text) => {
|
|
109
|
+
const body = parseBody(text);
|
|
110
|
+
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
111
|
+
sendJson(res, 200, { profile: settings.getProfile() });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
sendJson(res, 200, { profile: settings.getProfile() });
|
|
115
|
+
} catch {
|
|
116
|
+
sendJson(res, 500, { error: "internal" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- rules ---
|
|
122
|
+
register({
|
|
123
|
+
kind: "exact",
|
|
124
|
+
path: "/api/dsh-mneme/rules",
|
|
125
|
+
handler(req, res) {
|
|
126
|
+
try {
|
|
127
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
128
|
+
return readBody(req).then((text) => {
|
|
129
|
+
const body = parseBody(text);
|
|
130
|
+
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
131
|
+
sendJson(res, 200, { rules: settings.getRules() });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
sendJson(res, 200, { rules: settings.getRules() });
|
|
135
|
+
} catch {
|
|
136
|
+
sendJson(res, 500, { error: "internal" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// --- vector search config ---
|
|
142
|
+
register({
|
|
143
|
+
kind: "exact",
|
|
144
|
+
path: "/api/dsh-mneme/vector-config",
|
|
145
|
+
handler(req, res) {
|
|
146
|
+
try {
|
|
147
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
148
|
+
return readBody(req).then((text) => {
|
|
149
|
+
const body = parseBody(text);
|
|
150
|
+
const cfg = settings.setVectorConfig({
|
|
151
|
+
enabled: body.enabled,
|
|
152
|
+
baseUrl: body.baseUrl,
|
|
153
|
+
apiKey: body.apiKey,
|
|
154
|
+
model: body.model
|
|
155
|
+
});
|
|
156
|
+
sendJson(res, 200, { config: cfg });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
sendJson(res, 200, { config: settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" } });
|
|
160
|
+
} catch {
|
|
161
|
+
sendJson(res, 500, { error: "internal" });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// --- vector re-index (backfill embeddings for rows missing them) ---
|
|
167
|
+
register({
|
|
168
|
+
kind: "exact",
|
|
169
|
+
path: "/api/dsh-mneme/vector-reindex",
|
|
170
|
+
handler(req, res) {
|
|
171
|
+
try {
|
|
172
|
+
if (!embedder) {
|
|
173
|
+
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const url = new URL(req.url, "http://localhost");
|
|
177
|
+
const limit = Number(url.searchParams.get("limit") ?? 100);
|
|
178
|
+
// Unified re-index entry: works for both the legacy OpenAI embedder and
|
|
179
|
+
// the new local/ollama backends (which have no reindexMissing method).
|
|
180
|
+
const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
|
|
181
|
+
const task = viaIndex
|
|
182
|
+
? semantic.vectorIndex.rebuildIndex(embedder, { limit })
|
|
183
|
+
: embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
184
|
+
task.then((result) => {
|
|
185
|
+
sendJson(res, 200, result);
|
|
186
|
+
}).catch(() => {
|
|
187
|
+
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
sendJson(res, 500, { error: "internal" });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// --- semantic pipeline status (model, index, reranker) ---
|
|
196
|
+
register({
|
|
197
|
+
kind: "exact",
|
|
198
|
+
path: "/api/dsh-mneme/semantic",
|
|
199
|
+
handler(req, res) {
|
|
200
|
+
try {
|
|
201
|
+
const stats = semantic?.vectorIndex?.getStats?.() ?? null;
|
|
202
|
+
sendJson(res, 200, {
|
|
203
|
+
embedProvider: embedder ? (embedder.constructor?.name ?? "unknown") : null,
|
|
204
|
+
modelHash: embedder?.modelHash ?? null,
|
|
205
|
+
dimension: embedder?.dimension ?? null,
|
|
206
|
+
reranker: semantic?.reranker ? "ready" : null,
|
|
207
|
+
index: stats
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
sendJson(res, 500, { error: "internal" });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// --- custom commands ---
|
|
216
|
+
register({
|
|
217
|
+
kind: "exact",
|
|
218
|
+
path: "/api/dsh-mneme/commands",
|
|
219
|
+
handler(req, res) {
|
|
220
|
+
try {
|
|
221
|
+
if (req.method === "POST") {
|
|
222
|
+
return readBody(req).then((text) => {
|
|
223
|
+
const body = parseBody(text);
|
|
224
|
+
try {
|
|
225
|
+
const command = commands.add({
|
|
226
|
+
name: body.name,
|
|
227
|
+
description: body.description,
|
|
228
|
+
instruction: body.instruction
|
|
229
|
+
});
|
|
230
|
+
sendJson(res, 200, { command });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
sendJson(res, 400, { error: error.message });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (req.method === "DELETE") {
|
|
237
|
+
const url = new URL(req.url, "http://localhost");
|
|
238
|
+
const id = url.searchParams.get("id");
|
|
239
|
+
const removed = id ? commands.remove(id) : false;
|
|
240
|
+
sendJson(res, 200, { removed });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
sendJson(res, 200, { commands: commands.list() });
|
|
244
|
+
} catch {
|
|
245
|
+
sendJson(res, 500, { error: "internal" });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
routes: 7,
|
|
252
|
+
dispose: () => {
|
|
253
|
+
for (const dispose of disposers) dispose();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
package/src/commands.js
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
// Custom slash-command manager: keeps the DSH command registry in sync with
|
|
2
|
-
// user-defined commands persisted in SQLite. Commands are registered on boot
|
|
3
|
-
// and (re)registered on add/remove through the API.
|
|
4
|
-
//
|
|
5
|
-
// Each custom command's handler returns the user-authored instruction as a
|
|
6
|
-
// success result; the DSH UI surfaces it as a model-directed instruction.
|
|
7
|
-
export function createCommandManager({ ctx, settings, logger }) {
|
|
8
|
-
const registered = new Map(); // name -> disposer
|
|
9
|
-
|
|
10
|
-
function registerOne(command) {
|
|
11
|
-
if (registered.has(command.name)) return;
|
|
12
|
-
let dispose;
|
|
13
|
-
try {
|
|
14
|
-
dispose = ctx.commands.register({
|
|
15
|
-
name: command.name,
|
|
16
|
-
description: command.description || `自定义指令 ${command.name}`,
|
|
17
|
-
handler: () => ({ kind: "success", text: command.instruction })
|
|
18
|
-
});
|
|
19
|
-
} catch (error) {
|
|
20
|
-
logger?.warn?.(`dsh-mneme: failed to register command /${command.name}: ${String(error)}`);
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
registered.set(command.name, dispose);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function unregisterOne(name) {
|
|
27
|
-
const dispose = registered.get(name);
|
|
28
|
-
if (dispose) {
|
|
29
|
-
try {
|
|
30
|
-
dispose();
|
|
31
|
-
} catch {
|
|
32
|
-
/* ignore double-dispose */
|
|
33
|
-
}
|
|
34
|
-
registered.delete(name);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Register every stored command (boot-time sync). */
|
|
39
|
-
function sync() {
|
|
40
|
-
for (const command of settings.listCommands()) registerOne(command);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Add (or replace) a command and register it live. */
|
|
44
|
-
function add({ name, description, instruction }) {
|
|
45
|
-
const command = settings.addCommand({ name, description, instruction });
|
|
46
|
-
registerOne(command);
|
|
47
|
-
return command;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Remove a command by id and unregister it live. */
|
|
51
|
-
function remove(id) {
|
|
52
|
-
const existing = settings.listCommands().find((c) => c.id === id);
|
|
53
|
-
if (!existing) return false;
|
|
54
|
-
if (!settings.removeCommand(id)) return false;
|
|
55
|
-
unregisterOne(existing.name);
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function dispose() {
|
|
60
|
-
for (const name of [...registered.keys()]) unregisterOne(name);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return { sync, add, remove, list: () => settings.listCommands(), dispose };
|
|
64
|
-
}
|
|
1
|
+
// Custom slash-command manager: keeps the DSH command registry in sync with
|
|
2
|
+
// user-defined commands persisted in SQLite. Commands are registered on boot
|
|
3
|
+
// and (re)registered on add/remove through the API.
|
|
4
|
+
//
|
|
5
|
+
// Each custom command's handler returns the user-authored instruction as a
|
|
6
|
+
// success result; the DSH UI surfaces it as a model-directed instruction.
|
|
7
|
+
export function createCommandManager({ ctx, settings, logger }) {
|
|
8
|
+
const registered = new Map(); // name -> disposer
|
|
9
|
+
|
|
10
|
+
function registerOne(command) {
|
|
11
|
+
if (registered.has(command.name)) return;
|
|
12
|
+
let dispose;
|
|
13
|
+
try {
|
|
14
|
+
dispose = ctx.commands.register({
|
|
15
|
+
name: command.name,
|
|
16
|
+
description: command.description || `自定义指令 ${command.name}`,
|
|
17
|
+
handler: () => ({ kind: "success", text: command.instruction })
|
|
18
|
+
});
|
|
19
|
+
} catch (error) {
|
|
20
|
+
logger?.warn?.(`dsh-mneme: failed to register command /${command.name}: ${String(error)}`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
registered.set(command.name, dispose);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function unregisterOne(name) {
|
|
27
|
+
const dispose = registered.get(name);
|
|
28
|
+
if (dispose) {
|
|
29
|
+
try {
|
|
30
|
+
dispose();
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore double-dispose */
|
|
33
|
+
}
|
|
34
|
+
registered.delete(name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register every stored command (boot-time sync). */
|
|
39
|
+
function sync() {
|
|
40
|
+
for (const command of settings.listCommands()) registerOne(command);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Add (or replace) a command and register it live. */
|
|
44
|
+
function add({ name, description, instruction }) {
|
|
45
|
+
const command = settings.addCommand({ name, description, instruction });
|
|
46
|
+
registerOne(command);
|
|
47
|
+
return command;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove a command by id and unregister it live. */
|
|
51
|
+
function remove(id) {
|
|
52
|
+
const existing = settings.listCommands().find((c) => c.id === id);
|
|
53
|
+
if (!existing) return false;
|
|
54
|
+
if (!settings.removeCommand(id)) return false;
|
|
55
|
+
unregisterOne(existing.name);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dispose() {
|
|
60
|
+
for (const name of [...registered.keys()]) unregisterOne(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { sync, add, remove, list: () => settings.listCommands(), dispose };
|
|
64
|
+
}
|