@modusensus/dsh-mneme 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +27 -9
  2. package/lib/api.js +54 -4
  3. package/lib/client.js +58 -14
  4. package/lib/config.js +14 -2
  5. package/lib/dream/decisions.js +174 -62
  6. package/lib/dream.js +30 -5
  7. package/lib/index.js +4 -2
  8. package/lib/mirror.js +7 -1
  9. package/lib/service.js +117 -3
  10. package/lib/store.js +40 -0
  11. package/lib/tools.js +47 -4
  12. package/package.json +3 -1
  13. package/scripts/benchmark-embed.js +201 -0
  14. package/scripts/benchmark-rerank.js +166 -0
  15. package/scripts/e2e-dsh.js +216 -0
  16. package/scripts/stress-dsh.js +255 -0
  17. package/scripts/sync-lib.js +47 -0
  18. package/src/api.js +54 -4
  19. package/src/config.js +14 -2
  20. package/src/dream/decisions.js +174 -62
  21. package/src/dream.js +30 -5
  22. package/src/index.js +4 -2
  23. package/src/mirror.js +7 -1
  24. package/src/service.js +117 -3
  25. package/src/store.js +40 -0
  26. package/src/tools.js +47 -4
  27. package/test/api.test.js +385 -0
  28. package/test/audit.test.js +290 -0
  29. package/test/client.test.js +26 -0
  30. package/test/clustering.test.js +100 -0
  31. package/test/commands.test.js +69 -0
  32. package/test/config.test.js +31 -0
  33. package/test/dream.test.js +526 -0
  34. package/test/helpers/dream-mock.js +82 -0
  35. package/test/inject.test.js +82 -0
  36. package/test/local-embedder.test.js +227 -0
  37. package/test/mirror.test.js +249 -0
  38. package/test/reflection.test.js +226 -0
  39. package/test/reranker.test.js +197 -0
  40. package/test/semantic.test.js +123 -0
  41. package/test/service-search.test.js +169 -0
  42. package/test/service.test.js +198 -0
  43. package/test/settings.test.js +101 -0
  44. package/test/store.test.js +293 -0
  45. package/test/stress.test.js +209 -0
  46. package/test/summarize.test.js +156 -0
  47. package/test/tools.test.js +265 -0
  48. package/test/vector-index.test.js +205 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-212%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-258%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -19,16 +19,17 @@
19
19
  - **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
20
20
  - **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
21
21
 
22
- ### 模型工具(6 个)
22
+ ### 模型工具(7 个)
23
23
 
24
24
  | 工具 | 功能 |
25
25
  |------|------|
26
26
  | `memory_save` | 记录一条记忆(自动按标题去重合并) |
27
27
  | `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
28
- | `memory_list` | 按类型分页列出 |
28
+ | `memory_list` | 按类型分页列出(`include_archived=true` 可查看已归档) |
29
29
  | `memory_update` | 修改已有记忆 |
30
30
  | `memory_delete` | 删除记忆 |
31
31
  | `memory_forget` | 抑制注入(降权不删除,可恢复) |
32
+ | `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
32
33
 
33
34
  ### 自动注入 + 会话摘要
34
35
 
@@ -158,15 +159,33 @@ dsh web
158
159
  | `dreamThresholdChars` | `5000` | 触发整理的总字符阈值 |
159
160
  | `dreamDelayMs` | `2000` | 整理异步延迟(去抖) |
160
161
  | `dreamProvider` / `dreamModel` | 空 | dream 的 LLM 路由回退(默认用 agent 默认模型) |
162
+ | `dreamMaxTokens` | `4096` | dream LLM 调用最大 token 数 |
163
+ | `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |
161
164
  | `embedProvider` | `openai` | 语义后端:`openai`(默认,兼容 v0.1)/ `local`(ONNX 离线)/ `ollama` |
162
165
  | `localEmbedModel` | `Xenova/bge-small-zh-v1.5` | 本地 ONNX embedding 模型 |
163
166
  | `localEmbedDimension` | `512` | 本地 embedding 向量维度 |
167
+ | `localEmbedDevice` | `cpu` | 本地推理设备:`cpu` / `gpu` |
164
168
  | `localEmbedBatchSize` | `8` | 本地 embedding 批大小(1-64) |
165
169
  | `ollamaBaseUrl` | `http://localhost:11434` | Ollama 服务地址 |
166
170
  | `ollamaModel` | `nomic-embed-text` | Ollama embedding 模型 |
167
- | `rerankEnabled` | `true` | 是否启用 Rerank 精排 |
171
+ | `embedModelCacheDir` | | 模型缓存目录(空 = transformers 默认缓存) |
172
+ | `embedModelMirror` | `https://hf-mirror.com` | 模型下载镜像源 |
173
+ | `vectorSearchTopK` | `20` | 向量搜索返回 Top-K |
174
+ | `vectorSearchThreshold` | `0.65` | 向量搜索相似度阈值 |
175
+ | `hybridSearchVectorWeight` | `0.6` | 混合搜索向量权重 |
176
+ | `hybridSearchKeywordWeight` | `0.4` | 混合搜索关键词权重 |
177
+ | `rerankEnabled` | `false` | 是否启用 Rerank 精排(显式开启才加载本地 onnxruntime 模型) |
178
+ | `rerankProvider` | `none` | Rerank 后端:`local` / `none`(默认 `none`) |
168
179
  | `rerankModel` | `Xenova/bge-reranker-base` | Rerank 交叉编码模型 |
180
+ | `rerankBatchSize` | `8` | Rerank 批大小 |
181
+ | `rerankMaxCandidates` | `30` | Rerank 最大候选数 |
169
182
  | `rerankScoreThreshold` | `0.1` | Rerank 分数阈值(低于丢弃) |
183
+ | `reflectionUpdateEnabled` | `true` | update 决策总开关 |
184
+ | `reflectionFailureTracking` | `true` | 失败追踪总开关 |
185
+ | `reflectionUpdateMaxPerRun` | `2` | 每次整理最多 update 数 |
186
+ | `reflectionUpdateMinAgeHours` | `24` | 新建记忆保护期(小时) |
187
+
188
+ > 🔐 **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
189
 
171
190
  ## 🏗️ 架构
172
191
 
@@ -178,7 +197,7 @@ dsh web
178
197
  │ 服务层:saveWithDedupe / injectCandidates │
179
198
  │ / mergeHumanEdits / onWrite 钩子 │
180
199
  ├─────────────────────────────────────────────────┤
181
- │ 模型接口:6 个工具 + 自动注入 + 会话摘要 │
200
+ │ 模型接口:7 个工具 + 自动注入 + 会话摘要 │
182
201
  ├─────────────────────────────────────────────────┤
183
202
  │ autoDream:阈值调度 → LLM 决策清单 │
184
203
  │ → 校验(fail-safe)→ 应用 → 摘要 │
@@ -195,7 +214,7 @@ src/
195
214
  ├── mirror.js # Markdown 镜像(渲染/解析,人工优先)
196
215
  ├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子)
197
216
  ├── config.js # schemastery 配置 schema
198
- ├── tools.js # 6 个模型工具(defineTool)
217
+ ├── tools.js # 7 个模型工具(defineTool)
199
218
  ├── inject.js # systemPrompt.context 动态注入
200
219
  ├── summarize.js # 会话结束 LLM 摘要
201
220
  ├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
@@ -206,7 +225,7 @@ src/
206
225
  lib/
207
226
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
208
227
  └── *.js # src 的同步分发产物
209
- test/ # 212 个 node:test 测试(含审计与三轴线压测不变量)
228
+ test/ # 258 个 node:test 测试(含审计与三轴线压测不变量)
210
229
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
211
230
  ```
212
231
 
@@ -215,14 +234,13 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
215
234
  ```bash
216
235
  cd dsh-mneme
217
236
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
218
- npm test # 运行 212 个测试
237
+ npm test # 运行 258 个测试
219
238
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
220
239
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
221
240
  ```
222
241
 
223
242
  > 压测(`npm run stress`)三条轴线:**长会话检索**(Recall@k、陈旧残留率)、**冲突裁决**(可重放仲裁集:审计快照 hash + receipt + 幂等回放)、**多 Agent 并发**(丢更新、重复合并、事务/崩溃恢复)。每次 autoDream 运行都会写入审计表 `dream_runs`(输入快照 digest + 决策清单 + 逐 id 去向 + receipt),让高通过率下也能定位静默错误。
224
243
 
225
- > 注:`npm test` 使用 `--test-isolation=none` 适配受限沙箱;普通环境可直接 `node --test`。
226
244
  > `lib/` 是 `src/` 的同步分发产物(`npm run sync`),其中 `lib/client.js` 为手写 Web 面板源码,不受同步影响。
227
245
 
228
246
  ## 📄 设计文档
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: body.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
- sendJson(res, 200, { config: settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" } });
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
- fetch("/api/dsh-mneme/vector-config")
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 fetch(url, { signal: controller.signal });
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
- fetch("/api/dsh-mneme/profile").then((res) => res.json()),
232
- fetch("/api/dsh-mneme/rules").then((res) => res.json()),
233
- fetch("/api/dsh-mneme/commands").then((res) => res.json()),
234
- fetch("/api/dsh-mneme/vector-config").then((res) => res.json())
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 fetch("/api/dsh-mneme/profile", {
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 fetch("/api/dsh-mneme/rules", {
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 fetch("/api/dsh-mneme/commands", {
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 fetch(`/api/dsh-mneme/commands?id=${encodeURIComponent(id)}`, { method: "DELETE" });
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 fetch("/api/dsh-mneme/vector-config", {
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 fetch("/api/dsh-mneme/vector-reindex");
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.
@@ -40,8 +49,11 @@ export const Config = z.object({
40
49
  hybridSearchKeywordWeight: z.number().min(0).max(1).default(0.4),
41
50
 
42
51
  // --- semantic: rerank layer (v0.2) --------------------------------------
43
- rerankEnabled: z.boolean().default(true),
44
- rerankProvider: z.union([z.const("local"), z.const("none")]).default("local"),
52
+ // Opt-in by default (item ⑥): the local cross-encoder pulls in onnxruntime
53
+ // (transformers.js) at init, so a bare install must not load it. Only an
54
+ // explicit rerankEnabled=true + rerankProvider="local" constructs LocalReranker.
55
+ rerankEnabled: z.boolean().default(false),
56
+ rerankProvider: z.union([z.const("local"), z.const("none")]).default("none"),
45
57
  rerankModel: z.string().default("Xenova/bge-reranker-base"),
46
58
  rerankBatchSize: z.natural().min(1).max(64).default(8),
47
59
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),