@modusensus/dsh-mneme 0.2.2 → 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 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-236%20passed-success)](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/ # 212 个 node: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 # 运行 212 个测试
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: 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.
package/lib/index.js CHANGED
@@ -36,6 +36,11 @@ export const apply = (ctx, config) => {
36
36
  mkdirSync(memoryDir, { recursive: true });
37
37
 
38
38
  const store = createStore(join(memoryDir, "memory.db"));
39
+ // Prune reflection failure rows older than 90 days on boot (best-effort, so
40
+ // the failure table never grows unbounded).
41
+ try {
42
+ store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
43
+ } catch { /* non-fatal */ }
39
44
  const mirror = createMirror(memoryDir);
40
45
  const service = createService({ store, mirror, config: cfg });
41
46
 
@@ -158,7 +163,7 @@ export const apply = (ctx, config) => {
158
163
  add: () => { throw new Error("commands unavailable"); },
159
164
  remove: () => false,
160
165
  list: () => []
161
- }, embedder, { vectorIndex, reranker });
166
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken);
162
167
  disposers.push(api.dispose);
163
168
  }
164
169
 
package/lib/service.js CHANGED
@@ -302,6 +302,7 @@ export function createService({ store, mirror, config, onWrite }) {
302
302
  query: ctx.query ?? null,
303
303
  expected: updated.content,
304
304
  actual: old.content,
305
+ before: { title: old.title, content: old.content, importance: old.importance },
305
306
  failure_type: "user_correction",
306
307
  memory_id: id
307
308
  });
package/lib/store.js CHANGED
@@ -42,13 +42,16 @@ CREATE TABLE IF NOT EXISTS dream_runs (
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
43
 
44
44
  -- failure_memories: records user corrections / reflection failures. Captures
45
- -- what a memory was ("actual") vs what the user changed it to ("expected")
45
+ -- what a memory was (actual) vs what the user changed it to (expected)
46
46
  -- so later reflection passes can mine recurring correction patterns.
47
+ -- before holds a JSON snapshot of the pre-change title/content/importance,
48
+ -- so a title-only or importance-only correction is still traceable.
47
49
  CREATE TABLE IF NOT EXISTS failure_memories (
48
50
  id TEXT PRIMARY KEY,
49
51
  query TEXT,
50
52
  expected TEXT,
51
53
  actual TEXT,
54
+ before TEXT,
52
55
  failure_type TEXT NOT NULL,
53
56
  memory_id TEXT,
54
57
  created_at TEXT NOT NULL
@@ -405,13 +408,14 @@ export function createStore(path) {
405
408
  * Like the dream audit trail this is bookkeeping: it never triggers write
406
409
  * hooks, so reflection mining of failures cannot loop back into the writer.
407
410
  */
408
- function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
411
+ function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
409
412
  const now = nowIso();
413
+ const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
410
414
  db.prepare(
411
- `INSERT INTO failure_memories (id, query, expected, actual, failure_type, memory_id, created_at)
412
- VALUES (?, ?, ?, ?, ?, ?, ?)`
413
- ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, failure_type, memory_id ?? null, now);
414
- return { id, query, expected, actual, failure_type, memory_id, created_at: now };
415
+ `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
417
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
418
+ return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
415
419
  }
416
420
 
417
421
  function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
@@ -423,7 +427,12 @@ export function createStore(path) {
423
427
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
428
  const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
429
  const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
426
- return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off);
430
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
431
+ .map((row) => {
432
+ let before;
433
+ try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
434
+ return { ...row, before };
435
+ });
427
436
  }
428
437
 
429
438
  /** Delete failure rows older than `before` (ISO string). Returns count removed. */
@@ -73,7 +73,7 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embed !== "function") return { indexed: 0, skipped: 0 };
76
+ if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
77
77
  const rows = store.needsEmbedding(limit);
78
78
  let indexed = 0;
79
79
  for (const row of rows) {
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.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: 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/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
@@ -36,6 +36,11 @@ export const apply = (ctx, config) => {
36
36
  mkdirSync(memoryDir, { recursive: true });
37
37
 
38
38
  const store = createStore(join(memoryDir, "memory.db"));
39
+ // Prune reflection failure rows older than 90 days on boot (best-effort, so
40
+ // the failure table never grows unbounded).
41
+ try {
42
+ store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
43
+ } catch { /* non-fatal */ }
39
44
  const mirror = createMirror(memoryDir);
40
45
  const service = createService({ store, mirror, config: cfg });
41
46
 
@@ -158,7 +163,7 @@ export const apply = (ctx, config) => {
158
163
  add: () => { throw new Error("commands unavailable"); },
159
164
  remove: () => false,
160
165
  list: () => []
161
- }, embedder, { vectorIndex, reranker });
166
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken);
162
167
  disposers.push(api.dispose);
163
168
  }
164
169
 
package/src/service.js CHANGED
@@ -302,6 +302,7 @@ export function createService({ store, mirror, config, onWrite }) {
302
302
  query: ctx.query ?? null,
303
303
  expected: updated.content,
304
304
  actual: old.content,
305
+ before: { title: old.title, content: old.content, importance: old.importance },
305
306
  failure_type: "user_correction",
306
307
  memory_id: id
307
308
  });
package/src/store.js CHANGED
@@ -42,13 +42,16 @@ CREATE TABLE IF NOT EXISTS dream_runs (
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
43
 
44
44
  -- failure_memories: records user corrections / reflection failures. Captures
45
- -- what a memory was ("actual") vs what the user changed it to ("expected")
45
+ -- what a memory was (actual) vs what the user changed it to (expected)
46
46
  -- so later reflection passes can mine recurring correction patterns.
47
+ -- before holds a JSON snapshot of the pre-change title/content/importance,
48
+ -- so a title-only or importance-only correction is still traceable.
47
49
  CREATE TABLE IF NOT EXISTS failure_memories (
48
50
  id TEXT PRIMARY KEY,
49
51
  query TEXT,
50
52
  expected TEXT,
51
53
  actual TEXT,
54
+ before TEXT,
52
55
  failure_type TEXT NOT NULL,
53
56
  memory_id TEXT,
54
57
  created_at TEXT NOT NULL
@@ -405,13 +408,14 @@ export function createStore(path) {
405
408
  * Like the dream audit trail this is bookkeeping: it never triggers write
406
409
  * hooks, so reflection mining of failures cannot loop back into the writer.
407
410
  */
408
- function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
411
+ function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
409
412
  const now = nowIso();
413
+ const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
410
414
  db.prepare(
411
- `INSERT INTO failure_memories (id, query, expected, actual, failure_type, memory_id, created_at)
412
- VALUES (?, ?, ?, ?, ?, ?, ?)`
413
- ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, failure_type, memory_id ?? null, now);
414
- return { id, query, expected, actual, failure_type, memory_id, created_at: now };
415
+ `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
417
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
418
+ return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
415
419
  }
416
420
 
417
421
  function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
@@ -423,7 +427,12 @@ export function createStore(path) {
423
427
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
428
  const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
429
  const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
426
- return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off);
430
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
431
+ .map((row) => {
432
+ let before;
433
+ try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
434
+ return { ...row, before };
435
+ });
427
436
  }
428
437
 
429
438
  /** Delete failure rows older than `before` (ISO string). Returns count removed. */
@@ -73,7 +73,7 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embed !== "function") return { indexed: 0, skipped: 0 };
76
+ if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
77
77
  const rows = store.needsEmbedding(limit);
78
78
  let indexed = 0;
79
79
  for (const row of rows) {