diff --git a/AGENTS.md b/AGENTS.md
index 51c9d485..c8843e34 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,7 +24,7 @@ dsh-mneme 是 DSH 宿主的记忆插件(蒸馏 / 注入 / 检索 / 巩固 / sc
- 不 `amend` / force-push 别人的提交,不把别人未提交的改动 `stash` 走;
- **提交不得有 AI 署名**(无 `Co-Authored-By`、无 "Generated with")——见 [CONTRIBUTING](CONTRIBUTING.md);
- **本机私有信息不进公开文件**:车道分配、端口占用、记忆库路径这类只对某台机器成立的东西,放不进库的本地板(`.worktrees/coordination.md`)或 `.git/info/exclude`;本文件只放对所有人都成立的纪律(定位见开头:薄入口);
-- 两个宿主都加载本插件时:`memoryDir` 可共用(`src/store.js` 的 `busy_timeout` + WAL 已为多进程就绪),但 `externalApiEnabled`(默认端口 8790)与 `autoDream` 后台任务**只能一边开**,否则 EADDRINUSE + 重复做梦(镜像/审计双写)。
+- 两个宿主都加载本插件时:`memoryDir` 可共用(`src/store.js` 的 `busy_timeout` + WAL 已为多进程就绪),但 `externalApiEnabled`(默认端口 8790)与 `autoDream` 后台任务**只能一边开**,否则 EADDRINUSE + 重复做梦(镜像/审计双写)。独立服务(`dsh-mneme-serve`)同理:与 DSH 外部访问抢同一端口、**二选一**;它第一期无 LLM,巩固结构性只在宿主侧,不构成第二个做梦者。
## 模块地图(按功能面)
@@ -44,7 +44,7 @@ dsh-mneme 是 DSH 宿主的记忆插件(蒸馏 / 注入 / 检索 / 巩固 / sc
| 复用统计 | `src/recall-stats.js` | recall_runs 只读聚合(#217,Top-N 召回 + 僵尸率) |
| 冷启动 | `src/bootstrap.js` | 从仓库文件反向构建初始记忆(POST /bootstrap) |
| 配置 | `src/config.js`(schema + lightMode)、`src/settings.js`(feature flags 白名单) | 一切行为开关的家 |
-| API 面 | `src/api.js`(宿主内 /api/dsh-mneme/*)、`src/api-standalone.js`(Bearer 数据面)、`bin/dsh-mneme-mcp.mjs`(MCP stdio) | 对外三张脸 |
+| API 面 | `src/api.js`(宿主内 /api/dsh-mneme/*)、`src/api-standalone.js`(Bearer 数据面)、`bin/dsh-mneme-mcp.mjs`(MCP stdio)、`src/serve.js` + `bin/dsh-mneme-serve.mjs`(独立服务 daemon,无 LLM 数据面) | 对外四张脸 |
| 面板 | `lib/client.js` | 面板侧产物,无 src 对应物 |
| 运行时 | `src/runtime/*` | 模型下载(断点续传)/ 校验 / adopt |
| 命令 | `src/commands.js` | 斜杠命令注册与派发 |
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6f3aa15c..e3e03f02 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -159,6 +159,7 @@ The standalone external API (`src/api-standalone.js`) and the `bin/cli.mjs` clie
- Keep the route surface read/write on memories only; new routes need tests in `test/standalone-api.test.js` (they spin the real server on port 0).
- Auth additions/changes must keep `timingSafeEqual` token comparison and the `GET /health` exception.
- The CLI is dependency-free by contract - do not add imports to `bin/cli.mjs`.
+- The standalone daemon (`bin/dsh-mneme-serve.mjs` → `src/serve.js`) is exempt from that contract: it mounts `lib/serve.js` by design. Keep it a pure data plane — no LLM handle, no dream/summarize path (single-writer guarantee vs. the host), and keep `strictPort` semantics (busy configured port = hard error; the in-host sidecar keeps its hop-and-fallback recovery).
## Issue Reporting Requirements (read this first)
diff --git a/README.md b/README.md
index 12126317..a8664900 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -149,12 +149,22 @@ dsh web
> **旧挂载兼容**:已部署的 `dsh-mneme-mcp` + `DSH_MNEME_TOKEN` 写法继续有效(bin 与 env 变量均保留,无需迁移)。未全局安装 npm 包时,把 `command` 换成 `npx` 并追加参数 `-p mneme-memory mneme-mcp`(Claude Code/OpenCode 写进 args 数组,Codex 写 `args = ["-p", "mneme-memory", "mneme-mcp"]`)。配置细节与安全注意事项见[完整文档](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入)。
+## 不开 DSH 也能服务(独立服务 daemon)
+
+`dsh-mneme-serve` 把记忆库跑成常驻数据面——DSH 关着,第三方集成(网页端桥接、脚本、自有面板)照样读写同一份记忆:token 与 DSH 面板/CLI 共用,路由与「外部访问 API」同源,端口被占直接报错(与 DSH 外部访问二选一)。第一期无 LLM(巩固/蒸馏仍属 DSH 宿主),检索为关键词 + BM25,向量接入在后续版本。
+
+```bash
+npm i -g @modusensus/dsh-mneme
+dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790
+```
+
## 文档
| 文档 | 路径 |
|------|------|
| 插件完整文档(功能 / 安装 / 配置 / 架构) | [dsh-mneme/README.md](dsh-mneme/README.md) |
| stdio MCP server——Claude Code / Cursor 等任意 MCP 客户端接入记忆六件套 | [dsh-mneme/README.md · MCP Server](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入) |
+| 独立服务 daemon(不开 DSH 常驻数据面) | [dsh-mneme/docs/DAEMON.md](dsh-mneme/docs/DAEMON.md) |
| 配置说明(全键参考) | [dsh-mneme/docs/CONFIGURATION.md](dsh-mneme/docs/CONFIGURATION.md) |
| 实体结构化设计 | [dsh-mneme/docs/ENTITIES.md](dsh-mneme/docs/ENTITIES.md) |
| 语义架构 | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) |
@@ -183,7 +193,7 @@ dsh web
```bash
cd dsh-mneme && npm install
-npm test # 1529 个测试
+npm test # 1535 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
@@ -332,12 +342,22 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m
> **Legacy mounts keep working**: `dsh-mneme-mcp` + `DSH_MNEME_TOKEN` remain supported (both the bin and env vars are preserved; no migration needed). If the npm package is not installed globally, use `npx` as the command with args `-p mneme-memory mneme-mcp` (an args array in Claude Code/OpenCode; `args = ["-p", "mneme-memory", "mneme-mcp"]` in Codex). Full config details and security notes: [full docs](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入) (Chinese).
+## Serve memories without DSH (standalone daemon)
+
+`dsh-mneme-serve` runs the memory store as a long-lived data plane — with DSH closed, third-party integrations (web-bridge tools, scripts, your own panels) still read and write the same memories: the Bearer token is shared with the DSH panel/CLI, routes mirror the external API, and a busy port is a hard error (pick either the daemon or DSH's external API, not both). Phase 1 is LLM-free (consolidation/distillation stay with the DSH host); retrieval is keyword + BM25, with vector search arriving in a later release.
+
+```bash
+npm i -g @modusensus/dsh-mneme
+dsh-mneme-serve # defaults: ~/.dsh/memory + 127.0.0.1:8790
+```
+
## Docs
| Doc | Path |
|-----|------|
| Full plugin docs (features / install / config / architecture) | [dsh-mneme/README.md](dsh-mneme/README.md)(中文) |
| stdio MCP server — plug the six memory tools into any MCP client (Claude Code / Cursor / …) | [dsh-mneme/README.md · MCP Server](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入)(中文) |
+| Standalone daemon (serve memories without DSH) | [dsh-mneme/docs/DAEMON.md](dsh-mneme/docs/DAEMON.md)(中文) |
| Configuration reference (all keys) | [dsh-mneme/docs/CONFIGURATION.md](dsh-mneme/docs/CONFIGURATION.md)(中文) |
| Entity structure design | [dsh-mneme/docs/ENTITIES.md](dsh-mneme/docs/ENTITIES.md) |
| Semantic architecture | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) |
@@ -366,7 +386,7 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m
```bash
cd dsh-mneme && npm install
-npm test # 1529 tests
+npm test # 1535 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index dc0ce968..a0ed6dc3 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -2,6 +2,9 @@
## [Unreleased]
+## 🆕 新增
+
+- **独立服务 daemon(`dsh-mneme-serve`,#363)**:mneme 现在能在 DSH 宿主之外常驻——`src/serve.js` 的 `createServeRuntime` 用最小装配(store → settings → mirror → service → maintenance → standalone API,每步锚定 index.js 装配行号)把数据面跑成独立进程,第三方集成(网页端桥接等)不必为挂载记忆库而保持 DSH 开机。第一期刻意无 LLM:巩固(autoDream)与蒸馏结构性不在 daemon 内,这是与宿主「单写者」的机械保证,不靠用户自觉;检索为关键词 + BM25(向量由后续 PR 抽取 semantic 装配后接入)。token 与 DSH 面板/CLI 共用同一 kv 凭证,端口/主机解析链与外部访问一致;`createStandaloneApi` 新增 `strictPort` 选项——daemon 的配置端口被占即报错退出而非顺延(第三方把 URL 写死,静默换端口等于坏),不传该选项的宿主旁路行为不变。`/search` 照常落 recall_runs,第三方检索的复用统计不缺数。多进程共存(daemon 与宿主同库互写互读)有专门回归锁;已知限制(双进程去重竞态、镜像双写、版本偏斜)见 docs/DAEMON.md。
## 🧹 工程
- **发布准备脚本在 CRLF 检出上不再假成功(`scripts/release-prep.mjs`)**:该脚本用 `/^(# Changelog\n\n)/` 匹配 CHANGELOG 文件头,而 Windows 检出是 CRLF——正则命中不了,`replace` 退化成空操作,**脚本却照样打印 `✓ … 占位节`**,`git status` 里看不出任何异常(CI 跑在 ubuntu 是 LF,所以只有本机发版会中招,v0.8.13 那次即如此、最后靠人工补的占位节)。规则抽成 `dsh-mneme/scripts/changelog-prep.mjs` 的纯函数:行尾两种都吃、插入内容跟随原文件行尾、带 BOM 也认;匹配不上则如实回报 `header-not-found`,入口**报错退出(exit 1)**而不是假打印成功。配 6 条回归测试(LF / CRLF / BOM / 幂等 / 回报契约 / detectEol)。
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index 61b53a28..fd026b15 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,7 +5,7 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -515,6 +515,22 @@ dsh-mneme config show # 查看当前配置(toke
> 所有读取/写入命令支持 `--json` 输出原始 JSON;`config path` 打印配置文件路径(`~/.dsh-mneme/cli.json`)。
+### 独立服务(daemon,`dsh-mneme-serve`)
+
+不启动 DSH 也能让记忆库对外服务:`dsh-mneme-serve` 在宿主之外组装同一套 lib,把数据面跑成常驻进程(路由与鉴权与「独立外部 API」完全同源)。适用场景:第三方集成(如 [Mneme Bridge](https://github.com/slow-stack/mneme/discussions/363) 这类网页端桥接)需要长期挂载记忆库,而 DSH 不必一直开着。
+
+```bash
+dsh-mneme-serve # 默认 ~/.dsh/memory + 8790
+dsh-mneme-serve --memory-dir "D:\my mem" --port 8790 --host 127.0.0.1
+```
+
+- **鉴权与端口**:Bearer token 与 DSH 面板 / CLI 共用同一份(kv `external_api`,首次启动自动生成并持久化到 `memory.db`);端口/主机解析链与「外部访问」一致(显式参数 > 持久值 > 默认 8790/127.0.0.1)。配置端口被占会**直接报错退出**(不做端口顺延)——第三方把 URL 写死,静默换端口等于坏。因此 **daemon 与 DSH 的「外部访问」二选一**,不要同端口同开。
+- **安全**:daemon 使用明文 HTTP,不提供原生 TLS。指定非回环 `--host` 时,请勿直接把服务暴露给不可信网络;远程访问请走 TLS 终止代理或 SSH 隧道。
+- **能力边界(第一期,无 LLM)**:存储 / 检索(关键词 + BM25)/ 镜像同步与人改合并 / `POST /maintenance/reclaim` / `/bootstrap` 全可用;巩固(autoDream)与蒸馏不在 daemon 内——巩固只属于 DSH 宿主进程,这是与宿主「单写者」的机械保证。向量检索暂缺(后续版本接入),`/search` 退化为关键词 + BM25 属预期。
+- **检索回执**:daemon 的 `/search` 同样落 `recall_runs`,第三方检索的复用统计不缺数。
+- **生命周期**:stdout 仅就绪时打一行 `dsh-mneme-serve listening on http://host:port (pid N)`(供脚本解析实际端口),日志走 stderr;SIGINT/SIGTERM 优雅收库,Windows 强杀由 WAL 回放兜底。
+- **已知限制**:与 DSH 同时运行属设计内场景(WAL 多进程并发),但去重是先查后写、库层无 UNIQUE 约束,双进程并发写同一 `(type, title, scope)` 有极小概率产生重复;daemon 与插件请同版本升级。细节与坑清单见 [docs/DAEMON.md](docs/DAEMON.md)。
+
### MCP Server(任意 MCP 客户端接入)
> Claude Code / Codex / Hermes / OpenCode / OpenClaw 等各客户端的最小挂载配置速查表见[根 README](../README.md#用在其他-ai-工具里mcp);本节是完整配置与安全说明。规划中的独立分发包 `mneme-memory` 落地后,挂载命令将保持兼容(详见仓库 Discussions #300)。
@@ -584,7 +600,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 1529 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 1535 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```
@@ -593,7 +609,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 1529 个测试
+npm test # 运行 1535 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/bin/dsh-mneme-serve.mjs b/dsh-mneme/bin/dsh-mneme-serve.mjs
new file mode 100644
index 00000000..8424b624
--- /dev/null
+++ b/dsh-mneme/bin/dsh-mneme-serve.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+// bin/dsh-mneme-serve.mjs —— mneme 独立服务(daemon)入口。
+//
+// 为什么独立成 bin 而不是 cli.mjs 的子命令:CONTRIBUTING「The CLI is dependency-free
+// by contract」禁止给 bin/cli.mjs 加 import,而 serve 必须挂载 lib/serve.js;命名循
+// dsh-mneme-mcp 先例。数据面 = src/api-standalone.js 同一工厂,路由与鉴权零新面。
+//
+// 生命周期:bind 失败(含 strictPort 下端口被占)→ stderr 清错 + exit 1;
+// SIGINT/SIGTERM → dispose + exit 0。Windows 下 kill() 是硬终止、handler 不跑:
+// 已提交事务由 WAL 回放兜底(node:sqlite 未 finalize 语句的 close 抛错同理),不丢数据。
+//
+// 日志纪律:进度/错误全走 stderr;stdout 只在就绪时打一行机器可读的 listening 行
+// (脚本/测试从中解析实际端口),其余时刻保持干净——serve 是常驻进程,stdout 常被
+// 重定向进监控管道。
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join, resolve } from "node:path";
+import { createServeRuntime } from "../lib/serve.js";
+
+const BIN_NAME = "dsh-mneme-serve";
+const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
+
+const USAGE = `${BIN_NAME} — run the mneme data plane as a standalone service (no DSH required)
+
+Usage: dsh-mneme-serve [--memory-dir ] [--port ] [--host ]
+
+Options:
+ --memory-dir data directory (default: ~/.dsh/memory, same as the plugin)
+ --port HTTP port (default: persisted external_api port, else 8790)
+ --host bind address (default: persisted external_api host, else 127.0.0.1)
+ -h, --help show this help
+ -V, --version print version
+
+Auth: Bearer token is shared with the DSH panel / CLI (kv "external_api" in
+memory.db); it is generated on first boot. A busy configured port is a hard
+error — the DSH external API and this daemon must not share a port (pick one).`;
+
+/** 极简 argv 解析(--k=v / --k v / 旗标);够用即可,完整 CLI 在 bin/cli.mjs。 */
+function parseArgv(argv) {
+ const out = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "-h" || a === "--help") out.help = true;
+ else if (a === "-V" || a === "--version") out.version = true;
+ else if (a.startsWith("--")) {
+ const eq = a.indexOf("=");
+ if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1);
+ else {
+ const v = argv[i + 1];
+ // 本 bin 的旗标全部取值(无泛用布尔旗标):值缺失或下一个 token 是旗标
+ // 都按「缺值」报错退出——不能落成 true(Number(true)=1 会把 --port 变成
+ // 绑端口 1,报 EACCES 让用户查错方向)。帮助/版本在上方分支已提前返回。
+ if (v !== undefined && !v.startsWith("-")) { out[a.slice(2)] = v; i++; }
+ else fail(`${a} 缺少参数值`);
+ }
+ } else {
+ fail(`未知参数: ${a}\n运行 \`${BIN_NAME} --help\` 查看用法。`);
+ }
+ }
+ return out;
+}
+
+function fail(msg) {
+ console.error(`[${BIN_NAME}] ${msg}`);
+ process.exit(1);
+}
+
+// api-standalone 的 logger 契约:info/warn/error 收单字符串。全走 stderr(见头部纪律)。
+const logger = {
+ info: (msg) => console.error(`[dsh-mneme] ${msg}`),
+ warn: (msg) => console.error(`[dsh-mneme] warn: ${msg}`),
+ error: (msg) => console.error(`[dsh-mneme] error: ${msg}`)
+};
+
+async function main(argv) {
+ const args = parseArgv(argv);
+ if (args.help) { process.stdout.write(USAGE + "\n"); return; }
+ if (args.version) { console.log(PKG.version); return; }
+
+ // memoryDir:CLI > env > serve.js 缺省(~/.dsh/memory)。相对路径按 CWD 解;
+ // 前导 ~ 留给 serve.js 展开(与宿主 index.js:192-194 同一口径)。
+ const rawDir = typeof args["memory-dir"] === "string" ? args["memory-dir"]
+ : (typeof process.env.DSH_MNEME_MEMORY_DIR === "string" && process.env.DSH_MNEME_MEMORY_DIR ? process.env.DSH_MNEME_MEMORY_DIR : undefined);
+ const memoryDir = rawDir && !rawDir.startsWith("~") ? resolve(rawDir) : rawDir;
+
+ let port;
+ if (args.port !== undefined) {
+ port = Number(args.port);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ fail(`--port 需要合法端口(0-65535),收到: ${args.port}`);
+ }
+ }
+ const host = typeof args.host === "string" && args.host ? args.host : undefined;
+
+ const rt = createServeRuntime({ memoryDir, port, host, logger });
+ try {
+ await rt.api.ready;
+ } catch (err) {
+ fail(`数据面启动失败: ${err?.message ?? err}\n` +
+ ` 端口 ${rt.api.port} 被占通常意味着另一个 mneme 数据面正在运行` +
+ `(DSH 的「外部访问」或另一份 ${BIN_NAME})——二选一,或用 --port 换端口。`);
+ }
+ const addr = rt.api.server.address();
+ // stdout 唯一一行:机器可读就绪行(测试/脚本解析端口用)。
+ console.log(`${BIN_NAME} listening on http://${addr?.address ?? rt.api.host}:${addr?.port ?? rt.api.port} (pid ${process.pid})`);
+ if (!rt.tokenExisted) {
+ console.error(`[dsh-mneme] 首次启动已生成 Bearer token(已持久化,与 DSH 面板/CLI 共用):\n` +
+ ` ${rt.api.token}`);
+ }
+
+ let closing = false;
+ const shutdown = async (signal) => {
+ // 第二次信号 = 强制退出(dispose 里 server.close 等 in-flight 收尾,极端情况下会挂)
+ if (closing) process.exit(0);
+ closing = true;
+ console.error(`[dsh-mneme] ${signal} received, closing...`);
+ try { await rt.dispose(); } catch { /* dispose 各步自吞 */ }
+ process.exit(0);
+ };
+ process.on("SIGINT", () => shutdown("SIGINT"));
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
+}
+
+main(process.argv.slice(2)).catch((err) => {
+ console.error(err?.stack ?? String(err));
+ process.exit(1);
+});
diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md
new file mode 100644
index 00000000..a5e4acc3
--- /dev/null
+++ b/dsh-mneme/docs/DAEMON.md
@@ -0,0 +1,44 @@
+# dsh-mneme 独立服务(daemon)
+
+`dsh-mneme-serve`:在 DSH 宿主之外把 mneme 跑成一个常驻数据面。#363(Mneme Bridge)确立的方向——第三方集成需要长期挂载,而 DSH 不必一直开着;这是官方推荐姿势,lib 直挂的 embedded 模式降级为无网兜底。
+
+## 1. 职责边界
+
+daemon 是**数据面**,不是第二个宿主:
+
+- **有**:存储(SQLite)、检索(关键词 + BM25,`/search` 统一召回)、镜像同步与人改合并、`/maintenance/reclaim`、`/bootstrap`、recall_runs 检索回执。
+- **没有(第一期,无 LLM)**:巩固(autoDream)、蒸馏(autoSummarize)、实体抽取、sleep、注入/工具/面板路由。前两者是**结构性缺失**而非开关——daemon 装配里没有 LLM 句柄,巩固只属于 DSH 宿主进程。这就是 daemon 与宿主「单写者」的机械保证(AGENTS.md externalApi/autoDream 单侧纪律的 daemon 版),不依赖用户自觉。
+
+与宿主装配(`src/index.js` apply)的关系:`src/serve.js` 只搬数据面那一半,每步注释锚定 index.js 来源行号;刻意不抽公共装配函数(apply 其余环节与宿主 ctx 纠缠,防御段纪律「最后动或不动」)。装配漂移风险由 `test/serve-bin.test.js` 的多进程共存用例兜底(两进程真开同一个库互写互读)。
+
+## 2. 对外接口
+
+路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/search/maintenance/bootstrap),鉴权同源(Bearer + timingSafeEqual,`GET /health` 免鉴权)。**零新路由**;唯一新选项是 `strictPort`(见 §4)。
+
+CLI:
+
+```bash
+dsh-mneme-serve [--memory-dir ] [--port ] [--host ]
+```
+
+- `memoryDir`:CLI > env `DSH_MNEME_MEMORY_DIR` > `~/.dsh/memory`(与宿主 config.js 同默认,支持前导 `~`)。
+- port/host 解析链与宿主「外部访问」一致:显式参数 > kv `external_api` 持久值 > 默认 8790 / 127.0.0.1。
+- token 与 DSH 面板 / CLI **共用同一份**(kv `external_api`,首次启动自动生成并持久化)——三方零配置互通。
+- 安全:daemon 使用明文 HTTP,不提供原生 TLS。指定非回环 `--host` 时,请勿直接把服务暴露给不可信网络;远程访问请走 TLS 终止代理或 SSH 隧道。
+- stdout 只在就绪时打一行 `dsh-mneme-serve listening on http://host:port (pid N)`(机器可读,脚本/测试解析端口用);日志全走 stderr。
+- SIGINT/SIGTERM 优雅收库后 exit 0;Windows 强杀由 WAL 回放兜底。
+
+## 3. 内部文件
+
+- `src/serve.js` — `createServeRuntime({memoryDir, port, host, logger, strictPort})`:装配链 createStore → createSettings → createMirror → createService(最小 config)→ recoverMirror → 人改镜像合并 → recall recorder → createMaintenance → createStandaloneApi,每步锚定 index.js 行号。返回 `{api, store, service, settings, maintenance, tokenExisted, dispose}`;第三方可 import 它自行托管生命周期(bin 只是薄壳)。
+- `bin/dsh-mneme-serve.mjs` — CLI 壳。独立成 bin 而非 cli.mjs 子命令:CONTRIBUTING 禁止给 cli.mjs 加 import;命名循 dsh-mneme-mcp 先例。
+- `src/api-standalone.js` 的 `strictPort` 选项 — 唯一的数据面改动,默认关闭。
+
+## 4. 已知坑
+
+1. **端口互斥,二选一**:daemon 与 DSH 的「外部访问」抢同一个默认端口。daemon 侧 strictPort 报错退出;反方向( daemon 先占 8790,DSH 后开外部访问)宿主侧会**静默顺延**到下一端口(宿主旁路的多实例恢复语义,api-standalone.js listenWithRetry)——面板显示的端口会变,别当 bug 报。
+2. **双进程写并发**:与 DSH 同时运行是设计内场景(WAL + busy_timeout 先序,store.js createStore)。但 `saveWithDedupe` 的 (type,title,scope) 去重是先查后写、库层无 UNIQUE 约束,两进程并发写同一三元组有极小概率产生重复条目——已知限制,勿当强保证宣传(要不要加 UNIQUE 索引属 schema 防御段,单独决策)。
+3. **镜像双写竞态**:daemon 与宿主都会渲镜像 .md;可再生物,失败由 recoverMirror 自愈,极端并发下单文件可能短暂脏,下次同步覆盖。
+4. **版本偏斜**:库迁移是幂等加法式(PRAGMA 检查 + ALTER),旧代码读新 schema 一般无碍,但该组合无人测过——daemon 与插件请同版本升级。
+5. **第一期不吃宿主配置**:daemon 不加载 config schema(schemastery 是宿主 peer 依赖),面板/feature_flags 对它不生效;它只有 CLI 参数 + 上述固定最小 config(`language: zh`、document 子系统关闭)。向量检索(依赖 embedder/reranker 装配)由后续 PR 接入,接入前 `/search` 退化关键词 + BM25 属预期。
+6. **验收锚点**:`test/serve.test.js`(in-process 全链路 + token 复用 + recall_runs 回执)、`test/serve-bin.test.js`(真子进程 + 多进程互写互读)、`test/standalone-api.test.js` 的 strictPort 用例(busy → reject,默认路径仍顺延)。
diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js
index 01191af5..6bccac82 100644
--- a/dsh-mneme/lib/api-standalone.js
+++ b/dsh-mneme/lib/api-standalone.js
@@ -30,8 +30,13 @@ const MAX_PORT_ATTEMPTS = 20;
* the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
* OS assigns a free port. Multiple DSH profiles/instances sharing the default
* port no longer leave the standalone API permanently unavailable.
+ *
+ * strictPort opts out of that recovery (dsh-mneme-serve daemon, #363): a
+ * long-lived third-party integration pins the URL, so silently hopping ports
+ * would make clients talk to nothing (or, worse, to a future different data
+ * plane). A busy configured port is a configuration error there — fail loud.
*/
-function listenWithRetry(server, startPort, host, logger) {
+function listenWithRetry(server, startPort, host, logger, strictPort = false) {
return new Promise((resolve, reject) => {
let attempt = 0;
const tryListen = (port) => {
@@ -42,6 +47,10 @@ function listenWithRetry(server, startPort, host, logger) {
};
const onError = (error) => {
server.off("listening", onListening);
+ if (error?.code === "EADDRINUSE" && strictPort) {
+ reject(error);
+ return;
+ }
if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
attempt++;
const next = startPort + attempt;
@@ -193,11 +202,13 @@ async function handlePutBody(res, service, logger, id, text) {
* (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
* - port: explicit arg > persisted settings > config.externalApiPort > 8790.
* - host: explicit arg > config.externalApiHost > "127.0.0.1".
+ * - strictPort: EADDRINUSE rejects instead of hopping ports (daemon mode;
+ * default false keeps the in-host sidecar recovery described above).
* Returns { server, port, host, token, ready }: `port` is the effective bound
* port (updated to the OS-assigned one after `ready` resolves when asked to
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
*/
-export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance }) {
+export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance, strictPort = false }) {
const persisted = settings?.getExternalApi?.() ?? {};
let token = typeof persisted.token === "string" ? persisted.token : "";
@@ -552,7 +563,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
});
const ready = new Promise((resolve, reject) => {
// listening handled by listenWithRetry
- listenWithRetry(server, boundPort, boundHost, logger).then(resolve, reject);
+ listenWithRetry(server, boundPort, boundHost, logger, strictPort).then(resolve, reject);
});
// server.listen is called inside listenWithRetry
ready.then(() => {
diff --git a/dsh-mneme/lib/serve.js b/dsh-mneme/lib/serve.js
new file mode 100644
index 00000000..82ffba94
--- /dev/null
+++ b/dsh-mneme/lib/serve.js
@@ -0,0 +1,113 @@
+// src/serve.js —— 独立服务(daemon)运行时:mneme 在 DSH 宿主之外常驻的最小装配面
+// (#363 承诺的「官方推荐第三方挂载姿势」)。bin/dsh-mneme-serve.mjs 是它的 CLI 壳,
+// Mneme Bridge 这类第三方也可直接 import 本模块自行托管生命周期。
+//
+// 与宿主装配(src/index.js apply)的关系:只搬数据面那一半,每步注释锚定 index.js
+// 来源行号。刻意不抽公共装配函数——apply 的其余环节(注入/工具/dream)与宿主 ctx
+// 纠缠,防御段纪律是「最后动或不动」;这 60 行的漂移风险由 serve-bin 测试的多进程
+// 共存用例兜底(两侧真开同一个库互写互读)。
+//
+// 第一期无 LLM:巩固(autoDream)与蒸馏(autoSummarize)结构上不在这里——巩固只
+// 属于 DSH 宿主进程,这就是 daemon 与宿主「单写者」的机械保证(AGENTS.md 的
+// externalApi/autoDream 单侧纪律),不依赖用户自觉。检索是关键词 + BM25(service
+// 内建);向量管线由 PR2 的 semantic 抽取接入。
+import { mkdirSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { createStore } from "./store.js";
+import { createSettings } from "./settings.js";
+import { createMirror, TYPE_FILE } from "./mirror.js";
+import { langOf } from "./lang.js";
+import { createService } from "./service.js";
+import { createMaintenance } from "./maintenance.js";
+import { createStandaloneApi } from "./api-standalone.js";
+
+/**
+ * 组装并启动一个独立数据面。
+ * @param {object} opts
+ * memoryDir — 数据目录;缺省与宿主同默认 ~/.dsh/memory(config.js:9),支持前导 ~。
+ * port/host — 透传 createStandaloneApi;缺省走 kv external_api 持久值 > 8790/127.0.0.1。
+ * logger — console 形状(info/warn/error,收单字符串);缺省 null(全链路容缺)。
+ * strictPort — 默认 true:配置端口被占即失败(第三方把 URL 写死,顺延=静默打到
+ * 错误端口)。显式 port=0(测试/OS 分配)不受影响。
+ * @returns {api, store, service, settings, maintenance, tokenExisted, dispose}
+ * tokenExisted — 启动前 kv 里是否已有 token;false 时本次为首次生成,入口层可提示。
+ */
+export function createServeRuntime({ memoryDir, port, host, logger = null, strictPort = true } = {}) {
+ // index.js:192-195:~ 展开只认前导;目录不存在时 node:sqlite 直接抛,先 mkdir。
+ const dir = String(memoryDir || join(homedir(), ".dsh", "memory")).replace(/^~(?=$|[\\/])/, homedir());
+ mkdirSync(dir, { recursive: true });
+
+ // 装配顺序照 index.js:197-292:store → settings → mirror → service → recoverMirror。
+ const store = createStore(join(dir, "memory.db"));
+ const settings = createSettings(store.db);
+ // 第一期不吃宿主 config schema(schemastery 是宿主 peer 依赖,独立进程装不装随缘),
+ // 配最小集,与第三方 embedded 挂载实测同形:language 参与镜像渲染与去重标题;
+ // documentMemoryEnabled 关掉 document 子系统(daemon 不建 documentIndex,service
+ // 内部 if-guard 安全,index.js:261 的 documentIndex 仅宿主装配)。
+ const cfg = { language: "zh", documentMemoryEnabled: false };
+ const mirror = createMirror(dir, langOf(cfg));
+ const service = createService({ store, mirror, config: cfg, logger });
+
+ // index.js:292:上次镜像同步失败留下的 dirty 状态,启动时安全重渲一次(内部自吞)。
+ service.recoverMirror();
+
+ // index.js:319-332:人改镜像先合并——镜像文件里的手工编辑每次启动都赢。
+ // readHumanEdits 全类型一次读齐:mergeHumanEdits 成功会重渲全部镜像,逐类型读改
+ // 循环会拿没读到的类型覆盖掉未合并的编辑(index.js:324-327 注释同款坑)。
+ const humanEdits = new Map();
+ for (const type of Object.keys(TYPE_FILE)) humanEdits.set(type, mirror.readHumanEdits(type));
+ for (const [type, edits] of humanEdits) {
+ if (edits.length) service.mergeHumanEdits(type, edits);
+ }
+
+ // index.js:294-309:检索回执落 recall_runs(searchMemories 的 recordRecall 默认开,
+ // 第三方检索统计因此不缺数)。best-effort:回执写失败绝不影响检索本身。
+ service.setRecallRecorder((recall) => {
+ try {
+ store.saveRecallRun({
+ query: recall.query,
+ mode: recall.mode,
+ topK: recall.topK,
+ threshold: recall.threshold ?? null,
+ candidates: recall.candidates ?? [],
+ created_at: recall.createdAt
+ });
+ } catch { /* non-fatal: recall recording is bookkeeping */ }
+ });
+
+ // index.js:636:#275 无损回收。daemon 带上它,POST /maintenance/reclaim 才有后端
+ // (api-standalone 缺 maintenance 时该路由 503);同样不挂启动路径、不接定时器。
+ const maintenance = createMaintenance({ store, config: cfg, logger });
+
+ // index.js:643-649 同一工厂;差别只有 strictPort 默认开(daemon 语义,见上)。
+ // token 与宿主共用同一 kv 键 external_api:首次启动自动生成并持久化
+ // (api-standalone.js:203-212),DSH 面板 / CLI / daemon 三方零配置共享凭证。
+ const tokenExisted = Boolean(settings.getExternalApi?.()?.token);
+ const api = createStandaloneApi({ service, store, config: cfg, logger, settings, port, host, maintenance, strictPort });
+
+ return {
+ api,
+ store,
+ service,
+ settings,
+ maintenance,
+ tokenExisted,
+ /**
+ * 收尾:先停收新请求、等在途请求跑完,再关库——直接同步关库会让在途的
+ * PUT/POST 撞上已关的 store(500 或丢写)。closeIdleConnections 排干
+ * keep-alive 空闲连接(node ≥18.2,旧版无此 API 则跳过),否则 server.close
+ * 的回调要等 keep-alive 超时才触发。node:sqlite 对未 finalize 语句可能抛,
+ * 吞掉——WAL 会在下次打开时回放,已提交事务不丢。
+ */
+ async dispose() {
+ await new Promise((resolve) => {
+ try {
+ api.server.close(() => resolve());
+ api.server.closeIdleConnections?.();
+ } catch { resolve(); }
+ });
+ try { store.close(); } catch { /* 同上 */ }
+ }
+ };
+}
diff --git a/dsh-mneme/package.json b/dsh-mneme/package.json
index da8f88db..6502c468 100644
--- a/dsh-mneme/package.json
+++ b/dsh-mneme/package.json
@@ -16,12 +16,16 @@
"icon": "assets/icon.svg",
"bin": {
"dsh-mneme": "bin/cli.mjs",
- "dsh-mneme-mcp": "bin/dsh-mneme-mcp.mjs"
+ "dsh-mneme-mcp": "bin/dsh-mneme-mcp.mjs",
+ "dsh-mneme-serve": "bin/dsh-mneme-serve.mjs"
},
"exports": {
".": {
"default": "./lib/index.js"
},
+ "./serve": {
+ "default": "./lib/serve.js"
+ },
"./client": {
"default": "./lib/client.js"
},
diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js
index 01191af5..6bccac82 100644
--- a/dsh-mneme/src/api-standalone.js
+++ b/dsh-mneme/src/api-standalone.js
@@ -30,8 +30,13 @@ const MAX_PORT_ATTEMPTS = 20;
* the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
* OS assigns a free port. Multiple DSH profiles/instances sharing the default
* port no longer leave the standalone API permanently unavailable.
+ *
+ * strictPort opts out of that recovery (dsh-mneme-serve daemon, #363): a
+ * long-lived third-party integration pins the URL, so silently hopping ports
+ * would make clients talk to nothing (or, worse, to a future different data
+ * plane). A busy configured port is a configuration error there — fail loud.
*/
-function listenWithRetry(server, startPort, host, logger) {
+function listenWithRetry(server, startPort, host, logger, strictPort = false) {
return new Promise((resolve, reject) => {
let attempt = 0;
const tryListen = (port) => {
@@ -42,6 +47,10 @@ function listenWithRetry(server, startPort, host, logger) {
};
const onError = (error) => {
server.off("listening", onListening);
+ if (error?.code === "EADDRINUSE" && strictPort) {
+ reject(error);
+ return;
+ }
if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
attempt++;
const next = startPort + attempt;
@@ -193,11 +202,13 @@ async function handlePutBody(res, service, logger, id, text) {
* (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
* - port: explicit arg > persisted settings > config.externalApiPort > 8790.
* - host: explicit arg > config.externalApiHost > "127.0.0.1".
+ * - strictPort: EADDRINUSE rejects instead of hopping ports (daemon mode;
+ * default false keeps the in-host sidecar recovery described above).
* Returns { server, port, host, token, ready }: `port` is the effective bound
* port (updated to the OS-assigned one after `ready` resolves when asked to
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
*/
-export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance }) {
+export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance, strictPort = false }) {
const persisted = settings?.getExternalApi?.() ?? {};
let token = typeof persisted.token === "string" ? persisted.token : "";
@@ -552,7 +563,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
});
const ready = new Promise((resolve, reject) => {
// listening handled by listenWithRetry
- listenWithRetry(server, boundPort, boundHost, logger).then(resolve, reject);
+ listenWithRetry(server, boundPort, boundHost, logger, strictPort).then(resolve, reject);
});
// server.listen is called inside listenWithRetry
ready.then(() => {
diff --git a/dsh-mneme/src/serve.js b/dsh-mneme/src/serve.js
new file mode 100644
index 00000000..82ffba94
--- /dev/null
+++ b/dsh-mneme/src/serve.js
@@ -0,0 +1,113 @@
+// src/serve.js —— 独立服务(daemon)运行时:mneme 在 DSH 宿主之外常驻的最小装配面
+// (#363 承诺的「官方推荐第三方挂载姿势」)。bin/dsh-mneme-serve.mjs 是它的 CLI 壳,
+// Mneme Bridge 这类第三方也可直接 import 本模块自行托管生命周期。
+//
+// 与宿主装配(src/index.js apply)的关系:只搬数据面那一半,每步注释锚定 index.js
+// 来源行号。刻意不抽公共装配函数——apply 的其余环节(注入/工具/dream)与宿主 ctx
+// 纠缠,防御段纪律是「最后动或不动」;这 60 行的漂移风险由 serve-bin 测试的多进程
+// 共存用例兜底(两侧真开同一个库互写互读)。
+//
+// 第一期无 LLM:巩固(autoDream)与蒸馏(autoSummarize)结构上不在这里——巩固只
+// 属于 DSH 宿主进程,这就是 daemon 与宿主「单写者」的机械保证(AGENTS.md 的
+// externalApi/autoDream 单侧纪律),不依赖用户自觉。检索是关键词 + BM25(service
+// 内建);向量管线由 PR2 的 semantic 抽取接入。
+import { mkdirSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { createStore } from "./store.js";
+import { createSettings } from "./settings.js";
+import { createMirror, TYPE_FILE } from "./mirror.js";
+import { langOf } from "./lang.js";
+import { createService } from "./service.js";
+import { createMaintenance } from "./maintenance.js";
+import { createStandaloneApi } from "./api-standalone.js";
+
+/**
+ * 组装并启动一个独立数据面。
+ * @param {object} opts
+ * memoryDir — 数据目录;缺省与宿主同默认 ~/.dsh/memory(config.js:9),支持前导 ~。
+ * port/host — 透传 createStandaloneApi;缺省走 kv external_api 持久值 > 8790/127.0.0.1。
+ * logger — console 形状(info/warn/error,收单字符串);缺省 null(全链路容缺)。
+ * strictPort — 默认 true:配置端口被占即失败(第三方把 URL 写死,顺延=静默打到
+ * 错误端口)。显式 port=0(测试/OS 分配)不受影响。
+ * @returns {api, store, service, settings, maintenance, tokenExisted, dispose}
+ * tokenExisted — 启动前 kv 里是否已有 token;false 时本次为首次生成,入口层可提示。
+ */
+export function createServeRuntime({ memoryDir, port, host, logger = null, strictPort = true } = {}) {
+ // index.js:192-195:~ 展开只认前导;目录不存在时 node:sqlite 直接抛,先 mkdir。
+ const dir = String(memoryDir || join(homedir(), ".dsh", "memory")).replace(/^~(?=$|[\\/])/, homedir());
+ mkdirSync(dir, { recursive: true });
+
+ // 装配顺序照 index.js:197-292:store → settings → mirror → service → recoverMirror。
+ const store = createStore(join(dir, "memory.db"));
+ const settings = createSettings(store.db);
+ // 第一期不吃宿主 config schema(schemastery 是宿主 peer 依赖,独立进程装不装随缘),
+ // 配最小集,与第三方 embedded 挂载实测同形:language 参与镜像渲染与去重标题;
+ // documentMemoryEnabled 关掉 document 子系统(daemon 不建 documentIndex,service
+ // 内部 if-guard 安全,index.js:261 的 documentIndex 仅宿主装配)。
+ const cfg = { language: "zh", documentMemoryEnabled: false };
+ const mirror = createMirror(dir, langOf(cfg));
+ const service = createService({ store, mirror, config: cfg, logger });
+
+ // index.js:292:上次镜像同步失败留下的 dirty 状态,启动时安全重渲一次(内部自吞)。
+ service.recoverMirror();
+
+ // index.js:319-332:人改镜像先合并——镜像文件里的手工编辑每次启动都赢。
+ // readHumanEdits 全类型一次读齐:mergeHumanEdits 成功会重渲全部镜像,逐类型读改
+ // 循环会拿没读到的类型覆盖掉未合并的编辑(index.js:324-327 注释同款坑)。
+ const humanEdits = new Map();
+ for (const type of Object.keys(TYPE_FILE)) humanEdits.set(type, mirror.readHumanEdits(type));
+ for (const [type, edits] of humanEdits) {
+ if (edits.length) service.mergeHumanEdits(type, edits);
+ }
+
+ // index.js:294-309:检索回执落 recall_runs(searchMemories 的 recordRecall 默认开,
+ // 第三方检索统计因此不缺数)。best-effort:回执写失败绝不影响检索本身。
+ service.setRecallRecorder((recall) => {
+ try {
+ store.saveRecallRun({
+ query: recall.query,
+ mode: recall.mode,
+ topK: recall.topK,
+ threshold: recall.threshold ?? null,
+ candidates: recall.candidates ?? [],
+ created_at: recall.createdAt
+ });
+ } catch { /* non-fatal: recall recording is bookkeeping */ }
+ });
+
+ // index.js:636:#275 无损回收。daemon 带上它,POST /maintenance/reclaim 才有后端
+ // (api-standalone 缺 maintenance 时该路由 503);同样不挂启动路径、不接定时器。
+ const maintenance = createMaintenance({ store, config: cfg, logger });
+
+ // index.js:643-649 同一工厂;差别只有 strictPort 默认开(daemon 语义,见上)。
+ // token 与宿主共用同一 kv 键 external_api:首次启动自动生成并持久化
+ // (api-standalone.js:203-212),DSH 面板 / CLI / daemon 三方零配置共享凭证。
+ const tokenExisted = Boolean(settings.getExternalApi?.()?.token);
+ const api = createStandaloneApi({ service, store, config: cfg, logger, settings, port, host, maintenance, strictPort });
+
+ return {
+ api,
+ store,
+ service,
+ settings,
+ maintenance,
+ tokenExisted,
+ /**
+ * 收尾:先停收新请求、等在途请求跑完,再关库——直接同步关库会让在途的
+ * PUT/POST 撞上已关的 store(500 或丢写)。closeIdleConnections 排干
+ * keep-alive 空闲连接(node ≥18.2,旧版无此 API 则跳过),否则 server.close
+ * 的回调要等 keep-alive 超时才触发。node:sqlite 对未 finalize 语句可能抛,
+ * 吞掉——WAL 会在下次打开时回放,已提交事务不丢。
+ */
+ async dispose() {
+ await new Promise((resolve) => {
+ try {
+ api.server.close(() => resolve());
+ api.server.closeIdleConnections?.();
+ } catch { resolve(); }
+ });
+ try { store.close(); } catch { /* 同上 */ }
+ }
+ };
+}
diff --git a/dsh-mneme/test/serve-bin.test.js b/dsh-mneme/test/serve-bin.test.js
new file mode 100644
index 00000000..01c77d09
--- /dev/null
+++ b/dsh-mneme/test/serve-bin.test.js
@@ -0,0 +1,120 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createStore } from "../src/store.js";
+import { createService } from "../src/service.js";
+import { createSettings } from "../src/settings.js";
+
+// serve bin 冒烟 + 多进程共存锁(daemon 唯一的真新风险点):daemon 子进程与测试进程
+// 同时打开同一 memory.db(WAL + busy_timeout 本就为此设计),互写互读。spawn 必须
+// 异步(maintenance.test.js 的教训:同步 spawn 会堵住本进程事件循环);Windows 下
+// child.kill() 是硬终止,不断言信号路径,只断言可达性与退出。
+
+const BIN = fileURLToPath(new URL("../bin/dsh-mneme-serve.mjs", import.meta.url));
+
+function wait(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+async function waitFor(fn, timeoutMs, what) {
+ const deadline = Date.now() + timeoutMs;
+ let lastErr;
+ while (Date.now() < deadline) {
+ try {
+ return await fn();
+ } catch (err) {
+ lastErr = err;
+ }
+ await wait(150);
+ }
+ throw new Error(`timeout waiting for ${what}: ${lastErr?.message ?? lastErr}`);
+}
+
+test("serve bin: 值旗标缺值直接报错退出(--port 后无值不能落成 Number(true)=1)", { timeout: 30000 }, async () => {
+ // CodeRabbit on #364:--port 紧跟另一个旗标或结束时,旧解析把它存成 true,
+ // Number(true)=1 通过校验 → 静默改绑端口 1(EACCES 误导排错方向)。
+ const child = spawn(process.execPath, [BIN, "--memory-dir", mkdtempSync(join(tmpdir(), "mneme-serve-arg-")), "--port"], {
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+ let stderr = "";
+ child.stderr.on("data", (d) => { stderr += d; });
+ const [code] = await new Promise((resolve) => {
+ child.on("exit", (c) => resolve([c]));
+ });
+ assert.notEqual(code, 0, "missing value must exit non-zero");
+ assert.ok(stderr.includes("缺少参数值"), `stderr should name the missing value, got: ${stderr.slice(-200)}`);
+});
+
+test("serve bin: spawn 冒烟;daemon 与宿主进程同库互写互读", { timeout: 120000 }, async () => {
+ const dir = mkdtempSync(join(tmpdir(), "mneme-serve-bin-"));
+
+ // 预置 token:测试进程先开一次库写进 kv external_api,daemon 复用之 —— 同时验证
+ // 「与宿主面板/CLI 共用同一凭证」这条零配置承诺在真子进程里成立。
+ const seedStore = createStore(join(dir, "memory.db"));
+ const seedSettings = createSettings(seedStore.db);
+ const TOKEN = "serve-bin-test-token-0123456789abcdef";
+ seedSettings.setExternalApi({ token: TOKEN });
+ const peer = createService({ store: seedStore, mirror: null, config: {} });
+ peer.saveWithDedupe({ type: "project", title: "peer 进程直写", content: "测试进程经 createStore 写入", importance: 3 });
+
+ const child = spawn(process.execPath, [BIN, "--memory-dir", dir, "--port", "0"], {
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (d) => { stdout += d; });
+ child.stderr.on("data", (d) => { stderr += d; });
+
+ try {
+ // stdout 唯一机器可读行:listening 行(解析实际端口)
+ await waitFor(() => {
+ if (child.exitCode !== null) {
+ throw new Error(`daemon exited early (code ${child.exitCode}): ${stderr.slice(-400)}`);
+ }
+ if (!stdout.includes("listening on http://")) throw new Error("no listening line yet");
+ return true;
+ }, 20000, "listening line");
+ const port = Number(stdout.match(/:(\d+)/)[1]);
+ const base = `http://127.0.0.1:${port}`;
+ const auth = { authorization: `Bearer ${TOKEN}` };
+
+ await waitFor(async () => {
+ const res = await fetch(`${base}/health`);
+ assert.equal(res.status, 200);
+ return true;
+ }, 10000, "daemon /health");
+
+ // 双向可见性 ①:测试进程直写 → daemon HTTP 检索可见
+ await waitFor(async () => {
+ const res = await fetch(`${base}/search?q=${encodeURIComponent("peer 进程直写")}`, { headers: auth });
+ assert.equal(res.status, 200);
+ const body = await res.json();
+ assert.ok((body.items ?? []).some((m) => m.title === "peer 进程直写"));
+ return true;
+ }, 10000, "peer write visible via daemon");
+
+ // 双向可见性 ②:daemon HTTP 写 → 测试进程 service 同步检索可见
+ const save = await fetch(`${base}/memories`, {
+ method: "POST",
+ headers: { ...auth, "content-type": "application/json" },
+ body: JSON.stringify({ type: "project", title: "daemon HTTP 写入", content: "serve 进程经数据面写入", importance: 3 })
+ });
+ assert.ok(save.status === 201 || save.status === 200, `daemon save status ${save.status}`);
+ assert.ok(
+ peer.search("daemon HTTP 写入", { limit: 5 }).some((m) => m.title === "daemon HTTP 写入"),
+ "daemon-side write must be visible to the other process immediately"
+ );
+ } finally {
+ child.kill();
+ await new Promise((r) => {
+ if (child.exitCode !== null) r();
+ else child.on("exit", r);
+ });
+ try { seedStore.close(); } catch { /* 同上 */ }
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发,残留临时目录无害 */ }
+ }
+});
diff --git a/dsh-mneme/test/serve.test.js b/dsh-mneme/test/serve.test.js
new file mode 100644
index 00000000..5d29b987
--- /dev/null
+++ b/dsh-mneme/test/serve.test.js
@@ -0,0 +1,105 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createServeRuntime } from "../src/serve.js";
+
+// daemon 装配面锁(test/serve-bin.test.js 另有真子进程 + 多进程共存):
+// in-process 起真 HTTP(port 0,OS 分配),fetch 走 health / 401 / save / search /
+// token 持久化复用全链路。锁的是「daemon 数据面 = api-standalone 同一工厂」这一契约。
+
+function tmpDir() {
+ return mkdtempSync(join(tmpdir(), "mneme-serve-"));
+}
+
+test("serve: /health 免鉴权,业务路由无 token 401", async () => {
+ const dir = tmpDir();
+ const rt = createServeRuntime({ memoryDir: dir, port: 0 });
+ await rt.api.ready;
+ try {
+ const base = `http://127.0.0.1:${rt.api.port}`;
+ const health = await fetch(`${base}/health`);
+ assert.equal(health.status, 200);
+ assert.deepEqual(await health.json(), { ok: true });
+
+ const noAuth = await fetch(`${base}/search?q=x`);
+ assert.equal(noAuth.status, 401);
+ assert.deepEqual(await noAuth.json(), { error: "unauthorized" });
+ } finally {
+ rt.dispose();
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
+ }
+});
+
+test("serve: save → search 走通;token 持久化 kv;二次启动复用同一 token;检索回执落 recall_runs", async () => {
+ const dir = tmpDir();
+ const rt = createServeRuntime({ memoryDir: dir, port: 0 });
+ await rt.api.ready;
+ const base = `http://127.0.0.1:${rt.api.port}`;
+ const auth = { authorization: `Bearer ${rt.api.token}` };
+
+ // 全新目录:首次启动生成 token
+ assert.equal(rt.tokenExisted, false);
+
+ const save = await fetch(`${base}/memories`, {
+ method: "POST",
+ headers: { ...auth, "content-type": "application/json" },
+ body: JSON.stringify({ type: "preference", title: "serve 冒烟偏好", content: "回复保持简短", importance: 4 })
+ });
+ assert.equal(save.status, 201);
+
+ const search = await fetch(`${base}/search?q=${encodeURIComponent("简短")}`, { headers: auth });
+ assert.equal(search.status, 200);
+ const searchBody = await search.json();
+ assert.ok((searchBody.items ?? []).some((m) => m.title === "serve 冒烟偏好"));
+
+ // token 持久化在 kv external_api(与 DSH 面板/CLI 共用同一凭证通道)
+ assert.equal(rt.settings.getExternalApi().token, rt.api.token);
+
+ // 检索回执:recorder 已接(index.js:294-309 同款),recall_runs 不缺数 —— #363 回帖
+ // 向 bridge 承诺过「第三方检索的复用统计不受影响」,这条就是该承诺的回归锁。
+ const recallRows = rt.store.db.prepare("SELECT COUNT(*) AS n FROM recall_runs").get();
+ assert.ok(recallRows.n >= 1, "recall_runs should record the search above");
+
+ rt.dispose();
+
+ // 二次启动同目录:token 复用不重新生成(firstBoot 提示只在真正首次出现)
+ const rt2 = createServeRuntime({ memoryDir: dir, port: 0 });
+ await rt2.api.ready;
+ try {
+ assert.equal(rt2.tokenExisted, true);
+ assert.equal(rt2.api.token, rt.api.token);
+ } finally {
+ rt2.dispose();
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
+ }
+});
+
+test("serve: dispose 后端口可复用(同端口连起两轮不踩 strictPort)", async () => {
+ const dir = tmpDir();
+ const rt = createServeRuntime({ memoryDir: dir, port: 0 });
+ await rt.api.ready;
+ const port = rt.api.port;
+ // dispose 现为 async:等 server.close 回调(在途请求排干)后再重绑
+ await rt.dispose();
+ // 端口释放可能有内核级迟滞,重试绑定而不是假设立即可用
+ let rebound = null;
+ for (let i = 0; i < 10 && !rebound; i++) {
+ try {
+ const rt2 = createServeRuntime({ memoryDir: dir, port });
+ await rt2.api.ready;
+ rebound = rt2;
+ } catch (err) {
+ if (err?.code !== "EADDRINUSE") throw err;
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ }
+ try {
+ assert.ok(rebound, "port should be rebindable after dispose");
+ assert.equal(rebound.api.port, port);
+ } finally {
+ await rebound?.dispose();
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
+ }
+});
diff --git a/dsh-mneme/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js
index 0e24ba99..30d47d63 100644
--- a/dsh-mneme/test/standalone-api.test.js
+++ b/dsh-mneme/test/standalone-api.test.js
@@ -542,3 +542,30 @@ test("GET /search honors the occurred_at window", async () => {
close();
}
});
+
+test("strictPort: busy port rejects instead of hopping; default path still hops", async () => {
+ // daemon 语义锁(#363):strictPort 下配置端口被占 = 配置错误,明确失败 ——
+ // 顺延会让把 URL 写死的第三方客户端静默打到错误端口。占口用裸 net server
+ // (OS 分配,不与并行测试抢固定号)。
+ const { default: net } = await import("node:net");
+ const blocker = net.createServer();
+ await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve));
+ const busyPort = blocker.address().port;
+
+ const store = createStore(":memory:");
+ const service = createService({ store, mirror: null, config: {} });
+ const settings = createSettings(store.db);
+ try {
+ const strict = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort, strictPort: true });
+ await assert.rejects(strict.ready, (err) => err?.code === "EADDRINUSE");
+
+ // 默认路径(不传 strictPort)行为不变:顺延成功;具体端口不断言(避免抢号)。
+ const hopping = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort });
+ await hopping.ready;
+ assert.notEqual(hopping.port, busyPort, "default policy hops off a busy port");
+ hopping.server.close();
+ } finally {
+ blocker.close();
+ store.close();
+ }
+});