@modusensus/dsh-mneme 0.2.3 → 0.2.4
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 +21 -4
- package/lib/api.js +54 -4
- package/lib/client.js +58 -14
- package/lib/config.js +9 -0
- package/lib/index.js +1 -1
- package/package.json +1 -1
- package/src/api.js +54 -4
- package/src/config.js +9 -0
- package/src/index.js +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
|
|
10
10
|
> 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
|
|
11
11
|
|
|
@@ -158,15 +158,33 @@ dsh web
|
|
|
158
158
|
| `dreamThresholdChars` | `5000` | 触发整理的总字符阈值 |
|
|
159
159
|
| `dreamDelayMs` | `2000` | 整理异步延迟(去抖) |
|
|
160
160
|
| `dreamProvider` / `dreamModel` | 空 | dream 的 LLM 路由回退(默认用 agent 默认模型) |
|
|
161
|
+
| `dreamMaxTokens` | `4096` | dream LLM 调用最大 token 数 |
|
|
162
|
+
| `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |
|
|
161
163
|
| `embedProvider` | `openai` | 语义后端:`openai`(默认,兼容 v0.1)/ `local`(ONNX 离线)/ `ollama` |
|
|
162
164
|
| `localEmbedModel` | `Xenova/bge-small-zh-v1.5` | 本地 ONNX embedding 模型 |
|
|
163
165
|
| `localEmbedDimension` | `512` | 本地 embedding 向量维度 |
|
|
166
|
+
| `localEmbedDevice` | `cpu` | 本地推理设备:`cpu` / `gpu` |
|
|
164
167
|
| `localEmbedBatchSize` | `8` | 本地 embedding 批大小(1-64) |
|
|
165
168
|
| `ollamaBaseUrl` | `http://localhost:11434` | Ollama 服务地址 |
|
|
166
169
|
| `ollamaModel` | `nomic-embed-text` | Ollama embedding 模型 |
|
|
170
|
+
| `embedModelCacheDir` | 空 | 模型缓存目录(空 = transformers 默认缓存) |
|
|
171
|
+
| `embedModelMirror` | `https://hf-mirror.com` | 模型下载镜像源 |
|
|
172
|
+
| `vectorSearchTopK` | `20` | 向量搜索返回 Top-K |
|
|
173
|
+
| `vectorSearchThreshold` | `0.65` | 向量搜索相似度阈值 |
|
|
174
|
+
| `hybridSearchVectorWeight` | `0.6` | 混合搜索向量权重 |
|
|
175
|
+
| `hybridSearchKeywordWeight` | `0.4` | 混合搜索关键词权重 |
|
|
167
176
|
| `rerankEnabled` | `true` | 是否启用 Rerank 精排 |
|
|
177
|
+
| `rerankProvider` | `local` | Rerank 后端:`local` / `none` |
|
|
168
178
|
| `rerankModel` | `Xenova/bge-reranker-base` | Rerank 交叉编码模型 |
|
|
179
|
+
| `rerankBatchSize` | `8` | Rerank 批大小 |
|
|
180
|
+
| `rerankMaxCandidates` | `30` | Rerank 最大候选数 |
|
|
169
181
|
| `rerankScoreThreshold` | `0.1` | Rerank 分数阈值(低于丢弃) |
|
|
182
|
+
| `reflectionUpdateEnabled` | `true` | update 决策总开关 |
|
|
183
|
+
| `reflectionFailureTracking` | `true` | 失败追踪总开关 |
|
|
184
|
+
| `reflectionUpdateMaxPerRun` | `2` | 每次整理最多 update 数 |
|
|
185
|
+
| `reflectionUpdateMinAgeHours` | `24` | 新建记忆保护期(小时) |
|
|
186
|
+
|
|
187
|
+
> 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)。如需防护(如局域网暴露),在配置中设置 `apiToken`:写操作(画像/规则/命令)与密钥端点(`vector-config`、`vector-reindex`)需携带 `Authorization: Bearer <token>`(前端设置面板可填入同一 token),只读的 `list` / `search` / `semantic` 保持开放。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
|
|
170
188
|
|
|
171
189
|
## 🏗️ 架构
|
|
172
190
|
|
|
@@ -206,7 +224,7 @@ src/
|
|
|
206
224
|
lib/
|
|
207
225
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
208
226
|
└── *.js # src 的同步分发产物
|
|
209
|
-
test/ #
|
|
227
|
+
test/ # 236 个 node:test 测试(含审计与三轴线压测不变量)
|
|
210
228
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
|
|
211
229
|
```
|
|
212
230
|
|
|
@@ -215,14 +233,13 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
215
233
|
```bash
|
|
216
234
|
cd dsh-mneme
|
|
217
235
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
218
|
-
npm test # 运行
|
|
236
|
+
npm test # 运行 236 个测试
|
|
219
237
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
220
238
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
221
239
|
```
|
|
222
240
|
|
|
223
241
|
> 压测(`npm run stress`)三条轴线:**长会话检索**(Recall@k、陈旧残留率)、**冲突裁决**(可重放仲裁集:审计快照 hash + receipt + 幂等回放)、**多 Agent 并发**(丢更新、重复合并、事务/崩溃恢复)。每次 autoDream 运行都会写入审计表 `dream_runs`(输入快照 digest + 决策清单 + 逐 id 去向 + receipt),让高通过率下也能定位静默错误。
|
|
224
242
|
|
|
225
|
-
> 注:`npm test` 使用 `--test-isolation=none` 适配受限沙箱;普通环境可直接 `node --test`。
|
|
226
243
|
> `lib/` 是 `src/` 的同步分发产物(`npm run sync`),其中 `lib/client.js` 为手写 Web 面板源码,不受同步影响。
|
|
227
244
|
|
|
228
245
|
## 📄 设计文档
|
package/lib/api.js
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
function sendJson(res, status, payload) {
|
|
4
5
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
5
6
|
res.end(JSON.stringify(payload));
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
11
|
+
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
12
|
+
* The mask only exists in the API layer — storage keeps the real key.
|
|
13
|
+
*/
|
|
14
|
+
function maskApiKey(key) {
|
|
15
|
+
if (!key) return "";
|
|
16
|
+
if (key.length <= 8) return "***";
|
|
17
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the request carries the configured apiToken (or no token is set). */
|
|
21
|
+
function isAuthorized(req, apiToken) {
|
|
22
|
+
if (!apiToken) return true;
|
|
23
|
+
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
24
|
+
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
25
|
+
if (token === "" || token.length !== apiToken.length) return false;
|
|
26
|
+
// Constant-time comparison: avoid leaking the token via timing when the API
|
|
27
|
+
// is exposed beyond loopback.
|
|
28
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reject a request with 401 when auth is enabled and the token is missing or
|
|
33
|
+
* wrong. Returns true when the request may proceed.
|
|
34
|
+
*/
|
|
35
|
+
function requireAuth(req, res, apiToken) {
|
|
36
|
+
if (isAuthorized(req, apiToken)) return true;
|
|
37
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
9
42
|
function readBody(req) {
|
|
10
43
|
return new Promise((resolve) => {
|
|
@@ -23,7 +56,7 @@ function parseBody(text) {
|
|
|
23
56
|
}
|
|
24
57
|
}
|
|
25
58
|
|
|
26
|
-
export function createApi(ctx, service, settings, commands, embedder, semantic = null) {
|
|
59
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
|
|
27
60
|
const disposers = [];
|
|
28
61
|
|
|
29
62
|
// Ensure the service has an embedder when the API layer was handed one
|
|
@@ -113,6 +146,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
113
146
|
handler(req, res) {
|
|
114
147
|
try {
|
|
115
148
|
if (req.method === "PUT" || req.method === "POST") {
|
|
149
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
116
150
|
return readBody(req).then((text) => {
|
|
117
151
|
const body = parseBody(text);
|
|
118
152
|
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
@@ -133,6 +167,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
133
167
|
handler(req, res) {
|
|
134
168
|
try {
|
|
135
169
|
if (req.method === "PUT" || req.method === "POST") {
|
|
170
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
136
171
|
return readBody(req).then((text) => {
|
|
137
172
|
const body = parseBody(text);
|
|
138
173
|
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
@@ -152,19 +187,31 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
152
187
|
path: "/api/dsh-mneme/vector-config",
|
|
153
188
|
handler(req, res) {
|
|
154
189
|
try {
|
|
190
|
+
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
191
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
155
192
|
if (req.method === "PUT" || req.method === "POST") {
|
|
156
193
|
return readBody(req).then((text) => {
|
|
157
194
|
const body = parseBody(text);
|
|
195
|
+
// An empty apiKey, or one that already looks masked (round-trips
|
|
196
|
+
// through maskApiKey unchanged), means "keep the existing key".
|
|
197
|
+
// Only a fresh, unmasked key is treated as a real replacement.
|
|
198
|
+
const prev = settings.getVectorConfig();
|
|
199
|
+
const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
200
|
+
const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
|
|
201
|
+
const key = isMaskedOrEmpty
|
|
202
|
+
? (prev?.apiKey ?? "")
|
|
203
|
+
: incoming;
|
|
158
204
|
const cfg = settings.setVectorConfig({
|
|
159
205
|
enabled: body.enabled,
|
|
160
206
|
baseUrl: body.baseUrl,
|
|
161
|
-
apiKey:
|
|
207
|
+
apiKey: key,
|
|
162
208
|
model: body.model
|
|
163
209
|
});
|
|
164
|
-
sendJson(res, 200, { config: cfg });
|
|
210
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
165
211
|
});
|
|
166
212
|
}
|
|
167
|
-
|
|
213
|
+
const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
|
|
214
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
168
215
|
} catch {
|
|
169
216
|
sendJson(res, 500, { error: "internal" });
|
|
170
217
|
}
|
|
@@ -177,6 +224,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
177
224
|
path: "/api/dsh-mneme/vector-reindex",
|
|
178
225
|
handler(req, res) {
|
|
179
226
|
try {
|
|
227
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
180
228
|
if (!embedder) {
|
|
181
229
|
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
182
230
|
return;
|
|
@@ -227,6 +275,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
227
275
|
handler(req, res) {
|
|
228
276
|
try {
|
|
229
277
|
if (req.method === "POST") {
|
|
278
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
230
279
|
return readBody(req).then((text) => {
|
|
231
280
|
const body = parseBody(text);
|
|
232
281
|
try {
|
|
@@ -242,6 +291,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
242
291
|
});
|
|
243
292
|
}
|
|
244
293
|
if (req.method === "DELETE") {
|
|
294
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
245
295
|
const url = new URL(req.url, "http://localhost");
|
|
246
296
|
const id = url.searchParams.get("id");
|
|
247
297
|
const removed = id ? commands.remove(id) : false;
|
package/lib/client.js
CHANGED
|
@@ -9,6 +9,19 @@ window.__ModuleLoader__.load({
|
|
|
9
9
|
let { useState, useEffect, useCallback, useRef } = react;
|
|
10
10
|
let { createPortal } = reactDom;
|
|
11
11
|
|
|
12
|
+
// Unified API fetcher: attaches the optional apiToken (set in the settings
|
|
13
|
+
// panel, persisted in localStorage) as a Bearer header. When no token has
|
|
14
|
+
// been configured the header is omitted and the API stays open (default).
|
|
15
|
+
const API_TOKEN_KEY = "dsh-mneme-api-token";
|
|
16
|
+
function apiFetch(path, opts = {}) {
|
|
17
|
+
const token = (typeof window !== "undefined" && window.localStorage)
|
|
18
|
+
? window.localStorage.getItem(API_TOKEN_KEY) || ""
|
|
19
|
+
: "";
|
|
20
|
+
const headers = { ...(opts.headers || {}) };
|
|
21
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
22
|
+
return fetch(path, { ...opts, headers });
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
const inject = ["slots", "locale"];
|
|
13
26
|
|
|
14
27
|
const NS = "memory";
|
|
@@ -53,7 +66,12 @@ window.__ModuleLoader__.load({
|
|
|
53
66
|
"memory.settings.vectorSaved": "配置已保存",
|
|
54
67
|
"memory.settings.vectorReindex": "重建索引",
|
|
55
68
|
"memory.settings.vectorReindexing": "索引中…",
|
|
56
|
-
"memory.settings.vectorReindexDone": "已索引 {n} 条"
|
|
69
|
+
"memory.settings.vectorReindexDone": "已索引 {n} 条",
|
|
70
|
+
"memory.settings.apiTokenTitle": "API Token(可选)",
|
|
71
|
+
"memory.settings.apiTokenHint": "设置后,写操作与密钥接口(画像/规则/命令/向量配置)需携带 Authorization: Bearer <token>;面板只读操作不受影响。清空并保存可关闭鉴权。",
|
|
72
|
+
"memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
|
|
73
|
+
"memory.settings.apiTokenSave": "保存 Token",
|
|
74
|
+
"memory.settings.apiTokenSaved": "Token 已保存"
|
|
57
75
|
},
|
|
58
76
|
en: {
|
|
59
77
|
"memory.panel.title": "Memory",
|
|
@@ -94,7 +112,12 @@ window.__ModuleLoader__.load({
|
|
|
94
112
|
"memory.settings.vectorSaved": "Config saved",
|
|
95
113
|
"memory.settings.vectorReindex": "Reindex",
|
|
96
114
|
"memory.settings.vectorReindexing": "Indexing…",
|
|
97
|
-
"memory.settings.vectorReindexDone": "Indexed {n} items"
|
|
115
|
+
"memory.settings.vectorReindexDone": "Indexed {n} items",
|
|
116
|
+
"memory.settings.apiTokenTitle": "API Token (optional)",
|
|
117
|
+
"memory.settings.apiTokenHint": "When set, write operations and secret endpoints (profile/rules/commands/vector config) require Authorization: Bearer <token>. Read-only panel calls stay open. Save empty to disable.",
|
|
118
|
+
"memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
|
|
119
|
+
"memory.settings.apiTokenSave": "Save Token",
|
|
120
|
+
"memory.settings.apiTokenSaved": "Token saved"
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
123
|
|
|
@@ -120,7 +143,7 @@ window.__ModuleLoader__.load({
|
|
|
120
143
|
const abortRef = useRef(null);
|
|
121
144
|
|
|
122
145
|
useEffect(() => {
|
|
123
|
-
|
|
146
|
+
apiFetch("/api/dsh-mneme/vector-config")
|
|
124
147
|
.then((res) => res.json())
|
|
125
148
|
.then((d) => setVecEnabled(!!d.config?.enabled))
|
|
126
149
|
.catch(() => {});
|
|
@@ -137,7 +160,7 @@ window.__ModuleLoader__.load({
|
|
|
137
160
|
const url = query.trim()
|
|
138
161
|
? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}&mode=${semantic ? "vector" : "auto"}`
|
|
139
162
|
: `/api/dsh-mneme/list?${params.toString()}`;
|
|
140
|
-
const res = await
|
|
163
|
+
const res = await apiFetch(url, { signal: controller.signal });
|
|
141
164
|
const data = await res.json();
|
|
142
165
|
setItems(data.items || []);
|
|
143
166
|
} catch (error) {
|
|
@@ -224,14 +247,18 @@ window.__ModuleLoader__.load({
|
|
|
224
247
|
const [vectorSaved, setVectorSaved] = react.useState(false);
|
|
225
248
|
const [reindexing, setReindexing] = react.useState(false);
|
|
226
249
|
const [reindexMsg, setReindexMsg] = react.useState("");
|
|
250
|
+
const [apiToken, setApiToken] = react.useState(() =>
|
|
251
|
+
(typeof window !== "undefined" && window.localStorage) ? window.localStorage.getItem("dsh-mneme-api-token") || "" : ""
|
|
252
|
+
);
|
|
253
|
+
const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
|
|
227
254
|
|
|
228
255
|
const load = react.useCallback(async () => {
|
|
229
256
|
try {
|
|
230
257
|
const [p, r, c, v] = await Promise.all([
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
258
|
+
apiFetch("/api/dsh-mneme/profile").then((res) => res.json()),
|
|
259
|
+
apiFetch("/api/dsh-mneme/rules").then((res) => res.json()),
|
|
260
|
+
apiFetch("/api/dsh-mneme/commands").then((res) => res.json()),
|
|
261
|
+
apiFetch("/api/dsh-mneme/vector-config").then((res) => res.json())
|
|
235
262
|
]);
|
|
236
263
|
setProfile(p.profile || "");
|
|
237
264
|
setRules(Array.isArray(r.rules) ? r.rules : []);
|
|
@@ -244,7 +271,7 @@ window.__ModuleLoader__.load({
|
|
|
244
271
|
|
|
245
272
|
async function saveProfile() {
|
|
246
273
|
try {
|
|
247
|
-
await
|
|
274
|
+
await apiFetch("/api/dsh-mneme/profile", {
|
|
248
275
|
method: "PUT",
|
|
249
276
|
headers: { "Content-Type": "application/json" },
|
|
250
277
|
body: JSON.stringify({ profile })
|
|
@@ -255,7 +282,7 @@ window.__ModuleLoader__.load({
|
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
async function putRules(next) {
|
|
258
|
-
await
|
|
285
|
+
await apiFetch("/api/dsh-mneme/rules", {
|
|
259
286
|
method: "PUT",
|
|
260
287
|
headers: { "Content-Type": "application/json" },
|
|
261
288
|
body: JSON.stringify({ rules: next })
|
|
@@ -282,7 +309,7 @@ window.__ModuleLoader__.load({
|
|
|
282
309
|
const instruction = newCmd.instruction.trim();
|
|
283
310
|
if (!name || !instruction) return;
|
|
284
311
|
try {
|
|
285
|
-
const res = await
|
|
312
|
+
const res = await apiFetch("/api/dsh-mneme/commands", {
|
|
286
313
|
method: "POST",
|
|
287
314
|
headers: { "Content-Type": "application/json" },
|
|
288
315
|
body: JSON.stringify({ name, description: newCmd.description, instruction })
|
|
@@ -296,13 +323,13 @@ window.__ModuleLoader__.load({
|
|
|
296
323
|
}
|
|
297
324
|
|
|
298
325
|
async function removeCommand(id) {
|
|
299
|
-
await
|
|
326
|
+
await apiFetch(`/api/dsh-mneme/commands?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
300
327
|
setCommands(commands.filter((c) => c.id !== id));
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
async function saveVector() {
|
|
304
331
|
try {
|
|
305
|
-
await
|
|
332
|
+
await apiFetch("/api/dsh-mneme/vector-config", {
|
|
306
333
|
method: "PUT",
|
|
307
334
|
headers: { "Content-Type": "application/json" },
|
|
308
335
|
body: JSON.stringify(vector)
|
|
@@ -316,7 +343,7 @@ window.__ModuleLoader__.load({
|
|
|
316
343
|
setReindexing(true);
|
|
317
344
|
setReindexMsg("");
|
|
318
345
|
try {
|
|
319
|
-
const res = await
|
|
346
|
+
const res = await apiFetch("/api/dsh-mneme/vector-reindex");
|
|
320
347
|
const data = await res.json();
|
|
321
348
|
const n = data.indexed ?? 0;
|
|
322
349
|
setReindexMsg(t("memory.settings.vectorReindexDone").replace("{n}", String(n)));
|
|
@@ -324,6 +351,15 @@ window.__ModuleLoader__.load({
|
|
|
324
351
|
setReindexing(false);
|
|
325
352
|
}
|
|
326
353
|
|
|
354
|
+
function saveToken() {
|
|
355
|
+
try {
|
|
356
|
+
if (apiToken.trim()) window.localStorage.setItem("dsh-mneme-api-token", apiToken.trim());
|
|
357
|
+
else window.localStorage.removeItem("dsh-mneme-api-token");
|
|
358
|
+
setApiTokenSaved(true);
|
|
359
|
+
setTimeout(() => setApiTokenSaved(false), 1500);
|
|
360
|
+
} catch { /* ignore */ }
|
|
361
|
+
}
|
|
362
|
+
|
|
327
363
|
const inputStyle = { ...styles.search, marginBottom: 8 };
|
|
328
364
|
const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px" };
|
|
329
365
|
const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
|
|
@@ -341,6 +377,14 @@ window.__ModuleLoader__.load({
|
|
|
341
377
|
sectionTab === "memory"
|
|
342
378
|
? h(MemoryPanel, { t, embedded: true })
|
|
343
379
|
: h("div", { style: { overflowY: "auto" } },
|
|
380
|
+
// api token (optional)
|
|
381
|
+
h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.apiTokenTitle")),
|
|
382
|
+
h("div", { style: hintStyle }, t("memory.settings.apiTokenHint")),
|
|
383
|
+
h("div", { style: { display: "flex", gap: 6 } },
|
|
384
|
+
h("input", { style: { ...inputStyle, flex: 1, marginBottom: 0 }, type: "password", value: apiToken, placeholder: t("memory.settings.apiTokenPlaceholder"), onChange: (e) => setApiToken(e.target.value) }),
|
|
385
|
+
h("button", { style: styles.footerButton, onClick: saveToken }, t("memory.settings.apiTokenSave"))
|
|
386
|
+
),
|
|
387
|
+
apiTokenSaved && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.apiTokenSaved")),
|
|
344
388
|
// profile
|
|
345
389
|
h("div", { style: labelStyle }, t("memory.settings.profile")),
|
|
346
390
|
h("div", { style: hintStyle }, t("memory.settings.profileHint")),
|
package/lib/config.js
CHANGED
|
@@ -14,6 +14,15 @@ export const Config = z.object({
|
|
|
14
14
|
dreamModel: z.string(),
|
|
15
15
|
dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
|
|
16
16
|
|
|
17
|
+
// --- API protection ------------------------------------------------------
|
|
18
|
+
// Optional shared token for the plugin's HTTP API. Empty (default) keeps
|
|
19
|
+
// the API open (DSH binds to 127.0.0.1 and has no built-in auth); when set,
|
|
20
|
+
// sensitive endpoints (vector-config, vector-reindex, and all write ops on
|
|
21
|
+
// profile/rules/commands) require `Authorization: Bearer <apiToken>` (or
|
|
22
|
+
// `X-DSH-Mneme-Token`). Read-only list/search/semantic stay open so the
|
|
23
|
+
// Web panel keeps working without the token.
|
|
24
|
+
apiToken: z.string(),
|
|
25
|
+
|
|
17
26
|
// --- semantic: local embedding provider (v0.2) --------------------------
|
|
18
27
|
// "openai" keeps the legacy external-API path (settings vector config);
|
|
19
28
|
// "local" runs an ONNX model in-process; "ollama" calls a local Ollama.
|
package/lib/index.js
CHANGED
|
@@ -163,7 +163,7 @@ export const apply = (ctx, config) => {
|
|
|
163
163
|
add: () => { throw new Error("commands unavailable"); },
|
|
164
164
|
remove: () => false,
|
|
165
165
|
list: () => []
|
|
166
|
-
}, embedder, { vectorIndex, reranker });
|
|
166
|
+
}, embedder, { vectorIndex, reranker }, cfg.apiToken);
|
|
167
167
|
disposers.push(api.dispose);
|
|
168
168
|
}
|
|
169
169
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/api.js
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
function sendJson(res, status, payload) {
|
|
4
5
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
5
6
|
res.end(JSON.stringify(payload));
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
11
|
+
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
12
|
+
* The mask only exists in the API layer — storage keeps the real key.
|
|
13
|
+
*/
|
|
14
|
+
function maskApiKey(key) {
|
|
15
|
+
if (!key) return "";
|
|
16
|
+
if (key.length <= 8) return "***";
|
|
17
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the request carries the configured apiToken (or no token is set). */
|
|
21
|
+
function isAuthorized(req, apiToken) {
|
|
22
|
+
if (!apiToken) return true;
|
|
23
|
+
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
24
|
+
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
25
|
+
if (token === "" || token.length !== apiToken.length) return false;
|
|
26
|
+
// Constant-time comparison: avoid leaking the token via timing when the API
|
|
27
|
+
// is exposed beyond loopback.
|
|
28
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reject a request with 401 when auth is enabled and the token is missing or
|
|
33
|
+
* wrong. Returns true when the request may proceed.
|
|
34
|
+
*/
|
|
35
|
+
function requireAuth(req, res, apiToken) {
|
|
36
|
+
if (isAuthorized(req, apiToken)) return true;
|
|
37
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
9
42
|
function readBody(req) {
|
|
10
43
|
return new Promise((resolve) => {
|
|
@@ -23,7 +56,7 @@ function parseBody(text) {
|
|
|
23
56
|
}
|
|
24
57
|
}
|
|
25
58
|
|
|
26
|
-
export function createApi(ctx, service, settings, commands, embedder, semantic = null) {
|
|
59
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
|
|
27
60
|
const disposers = [];
|
|
28
61
|
|
|
29
62
|
// Ensure the service has an embedder when the API layer was handed one
|
|
@@ -113,6 +146,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
113
146
|
handler(req, res) {
|
|
114
147
|
try {
|
|
115
148
|
if (req.method === "PUT" || req.method === "POST") {
|
|
149
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
116
150
|
return readBody(req).then((text) => {
|
|
117
151
|
const body = parseBody(text);
|
|
118
152
|
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
@@ -133,6 +167,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
133
167
|
handler(req, res) {
|
|
134
168
|
try {
|
|
135
169
|
if (req.method === "PUT" || req.method === "POST") {
|
|
170
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
136
171
|
return readBody(req).then((text) => {
|
|
137
172
|
const body = parseBody(text);
|
|
138
173
|
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
@@ -152,19 +187,31 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
152
187
|
path: "/api/dsh-mneme/vector-config",
|
|
153
188
|
handler(req, res) {
|
|
154
189
|
try {
|
|
190
|
+
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
191
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
155
192
|
if (req.method === "PUT" || req.method === "POST") {
|
|
156
193
|
return readBody(req).then((text) => {
|
|
157
194
|
const body = parseBody(text);
|
|
195
|
+
// An empty apiKey, or one that already looks masked (round-trips
|
|
196
|
+
// through maskApiKey unchanged), means "keep the existing key".
|
|
197
|
+
// Only a fresh, unmasked key is treated as a real replacement.
|
|
198
|
+
const prev = settings.getVectorConfig();
|
|
199
|
+
const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
200
|
+
const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
|
|
201
|
+
const key = isMaskedOrEmpty
|
|
202
|
+
? (prev?.apiKey ?? "")
|
|
203
|
+
: incoming;
|
|
158
204
|
const cfg = settings.setVectorConfig({
|
|
159
205
|
enabled: body.enabled,
|
|
160
206
|
baseUrl: body.baseUrl,
|
|
161
|
-
apiKey:
|
|
207
|
+
apiKey: key,
|
|
162
208
|
model: body.model
|
|
163
209
|
});
|
|
164
|
-
sendJson(res, 200, { config: cfg });
|
|
210
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
165
211
|
});
|
|
166
212
|
}
|
|
167
|
-
|
|
213
|
+
const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
|
|
214
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
168
215
|
} catch {
|
|
169
216
|
sendJson(res, 500, { error: "internal" });
|
|
170
217
|
}
|
|
@@ -177,6 +224,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
177
224
|
path: "/api/dsh-mneme/vector-reindex",
|
|
178
225
|
handler(req, res) {
|
|
179
226
|
try {
|
|
227
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
180
228
|
if (!embedder) {
|
|
181
229
|
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
182
230
|
return;
|
|
@@ -227,6 +275,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
227
275
|
handler(req, res) {
|
|
228
276
|
try {
|
|
229
277
|
if (req.method === "POST") {
|
|
278
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
230
279
|
return readBody(req).then((text) => {
|
|
231
280
|
const body = parseBody(text);
|
|
232
281
|
try {
|
|
@@ -242,6 +291,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
242
291
|
});
|
|
243
292
|
}
|
|
244
293
|
if (req.method === "DELETE") {
|
|
294
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
245
295
|
const url = new URL(req.url, "http://localhost");
|
|
246
296
|
const id = url.searchParams.get("id");
|
|
247
297
|
const removed = id ? commands.remove(id) : false;
|
package/src/config.js
CHANGED
|
@@ -14,6 +14,15 @@ export const Config = z.object({
|
|
|
14
14
|
dreamModel: z.string(),
|
|
15
15
|
dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
|
|
16
16
|
|
|
17
|
+
// --- API protection ------------------------------------------------------
|
|
18
|
+
// Optional shared token for the plugin's HTTP API. Empty (default) keeps
|
|
19
|
+
// the API open (DSH binds to 127.0.0.1 and has no built-in auth); when set,
|
|
20
|
+
// sensitive endpoints (vector-config, vector-reindex, and all write ops on
|
|
21
|
+
// profile/rules/commands) require `Authorization: Bearer <apiToken>` (or
|
|
22
|
+
// `X-DSH-Mneme-Token`). Read-only list/search/semantic stay open so the
|
|
23
|
+
// Web panel keeps working without the token.
|
|
24
|
+
apiToken: z.string(),
|
|
25
|
+
|
|
17
26
|
// --- semantic: local embedding provider (v0.2) --------------------------
|
|
18
27
|
// "openai" keeps the legacy external-API path (settings vector config);
|
|
19
28
|
// "local" runs an ONNX model in-process; "ollama" calls a local Ollama.
|
package/src/index.js
CHANGED
|
@@ -163,7 +163,7 @@ export const apply = (ctx, config) => {
|
|
|
163
163
|
add: () => { throw new Error("commands unavailable"); },
|
|
164
164
|
remove: () => false,
|
|
165
165
|
list: () => []
|
|
166
|
-
}, embedder, { vectorIndex, reranker });
|
|
166
|
+
}, embedder, { vectorIndex, reranker }, cfg.apiToken);
|
|
167
167
|
disposers.push(api.dispose);
|
|
168
168
|
}
|
|
169
169
|
|