@modusensus/dsh-mneme 0.8.13 → 0.8.14

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-1523%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1547%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -405,7 +405,7 @@ dsh web
405
405
  | `bm25SearchEnabled` | `true` | BM25 稀疏第三路召回(v0.5.0):ASCII 词元 + CJK bigram,IDF 加权,散词/ID/代码片段查询不再依赖子串命中 |
406
406
  | `adaptiveThresholdEnabled` | `true` | 自适应相似度阈值(v0.5.0):按查询形态动态截断(前缀 0.5 / 短查询 0.7 / 长查询 0.6 / 头部分差大放宽 0.5),显式传 `threshold` 走旧行为 |
407
407
  | `hotMemoryEnabled` | `true` | 会话级短期热记忆总开关(v0.5.0):关闭后热记忆块不再注入(长期召回不受影响) |
408
- | `heatEnabled` | `false` | 热度模型总开关(v0.7.0 / v0.7.20 回归,**默认关**——v0.7.12 起用户已习惯无 heat 行为):开启后提供热度字段 / sleep 热联合降级保护 / 前端热度投影,并在注入排序的优先级层内乘热度(#218;`order=chrono` 分页序与召回融合序不动);关闭则跳过 heat 计算与热度触达,sleep 降级退回纯时间分层。也走 feature_flags 白名单(面板可启停=回滚开关),lightMode 预设强制关 |
408
+ | `heatEnabled` | `false` | 热度模型总开关(v0.7.0 / v0.7.20 回归,**默认关**——v0.7.12 起用户已习惯无 heat 行为):开启后提供热度字段 / sleep 热联合降级保护 / 前端热度投影;注入排序乘热度已拆为独立开关 `injectHeatEnabled`(#218 / E5 考卷:heat 乘进注入排序在真实年龄混合下饿死老约束,默认关,显式开启恢复旧序)。关闭则跳过 heat 计算与热度触达,sleep 降级退回纯时间分层。也走 feature_flags 白名单(面板可启停=回滚开关),lightMode 预设强制关 |
409
409
  | `heatGlobalBeta` | `1.0` | 广义指数形状参数 β(`H=exp(-λ·Δt^β)`,issue #218 拍板):β=1 纯指数;以 Δt>1 小时为准,β<1 衰减更慢(亚线性长尾)、β>1 衰减更快(超线性);0<Δt<1 的首小时内方向相反(Δt^β 随 β 增大而变小) |
410
410
  | `heatTypeDecay` | 内置 TYPE_DECAY | per-type 衰减因子 λ;λ=0 的类型免疫(preference/pattern/summary 热度恒 1.0,sleep 永不降级) |
411
411
  | `sleepHeatThreshold` | `0.05` | sleep 降级联合判定热度下限:heat<该值 **且** importance<5 才允许降级 |
@@ -465,7 +465,8 @@ dsh web
465
465
  | `POST` | `/memories` | 新增记忆 `{type,title,content,importance?,tags?,source?,sensitivity?,occurred_at?,agent_scope?,workspace_scope?}`;响应含 `action: created\|merged` |
466
466
  | `PUT` | `/memories/:id` | 局部更新 `{title?,content?,type?,importance?,tags?,reason?,agent_scope?,workspace_scope?}`;content 改写按 human_override 入档 |
467
467
  | `DELETE` | `/memories/:id` | 删除记忆 |
468
- | `GET` | `/search?q&mode=keyword\|vector\|hybrid\|auto&topK&occurred_from&occurred_to` | 搜索(关键词 / 向量 / hybrid=向量领位关键词补位 / 自动) |
468
+ | `GET` | `/search?q&mode=keyword\|vector\|hybrid\|auto&topK&occurred_from&occurred_to&agent_scope&workspace_scope` | 搜索(关键词 / 向量 / hybrid=向量领位关键词补位 / 自动)。scope 两参声明调用方身份(不传 = 不解析,行为与旧版一致;任一给出则 strictScope 硬过滤与 A2 软加权随之生效) |
469
+ | `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) |
469
470
  | `POST` | `/bootstrap` | 冷启动(v0.8.4):从仓库目录反向构建初始记忆,body `{dir}` 必填;幂等((type,title,scope) 去重 + `_overwrite` 原地刷新),零 LLM、必有产出 |
470
471
 
471
472
  ### curl 示例
@@ -515,6 +516,23 @@ dsh-mneme config show # 查看当前配置(toke
515
516
 
516
517
  > 所有读取/写入命令支持 `--json` 输出原始 JSON;`config path` 打印配置文件路径(`~/.dsh-mneme/cli.json`)。
517
518
 
519
+ ### 独立服务(daemon,`dsh-mneme-serve`)
520
+
521
+ 不启动 DSH 也能让记忆库对外服务:`dsh-mneme-serve` 在宿主之外组装同一套 lib,把数据面跑成常驻进程(路由与鉴权与「独立外部 API」完全同源)。适用场景:第三方集成(如 [Mneme-Bridge](https://github.com/SternChiri/Mneme-Bridge) 这类网页端桥接,见下方[社区适配器](#社区适配器))需要长期挂载记忆库,而 DSH 不必一直开着。
522
+
523
+ ```bash
524
+ dsh-mneme-serve # 默认 ~/.dsh/memory + 8790
525
+ dsh-mneme-serve --memory-dir "D:\my mem" --port 8790 --host 127.0.0.1
526
+ dsh-mneme-serve --embed off # 纯关键词 + BM25(不取件模型)
527
+ ```
528
+
529
+ - **鉴权与端口**:Bearer token 与 DSH 面板 / CLI 共用同一份(kv `external_api`,首次启动自动生成并持久化到 `memory.db`);端口/主机解析链与「外部访问」一致(显式参数 > 持久值 > 默认 8790/127.0.0.1)。配置端口被占会**直接报错退出**(不做端口顺延)——第三方把 URL 写死,静默换端口等于坏。因此 **daemon 与 DSH 的「外部访问」二选一**,不要同端口同开。
530
+ - **安全**:daemon 使用明文 HTTP,不提供原生 TLS。指定非回环 `--host` 时,请勿直接把服务暴露给不可信网络;远程访问请走 TLS 终止代理或 SSH 隧道。
531
+ - **能力边界(第一期,无 LLM)**:存储 / 检索(关键词 + BM25 + 向量)/ 镜像同步与人改合并 / `POST /maintenance/reclaim` / `/bootstrap` 全可用;巩固(autoDream)与蒸馏不在 daemon 内——巩固只属于 DSH 宿主进程,这是与宿主「单写者」的机械保证。`--embed` 默认 `local`(自管 runtime 与嵌入模型缺失时自动取件,约 200MB;失败降级关键词并打日志),也可选 `ollama` / `openai`(读宿主面板的 vector-config)/ `off`。
532
+ - **检索回执**:daemon 的 `/search` 同样落 `recall_runs`,第三方检索的复用统计不缺数。
533
+ - **生命周期**:stdout 仅就绪时打一行 `dsh-mneme-serve listening on http://host:port (pid N)`(供脚本解析实际端口),日志走 stderr;SIGINT/SIGTERM 优雅收库,Windows 强杀由 WAL 回放兜底。
534
+ - **已知限制**:与 DSH 同时运行属设计内场景(WAL 多进程并发),但去重是先查后写、库层无 UNIQUE 约束,双进程并发写同一 `(type, title, scope)` 有极小概率产生重复;daemon 与插件请同版本升级。细节与坑清单见 [docs/DAEMON.md](docs/DAEMON.md)。
535
+
518
536
  ### MCP Server(任意 MCP 客户端接入)
519
537
 
520
538
  > Claude Code / Codex / Hermes / OpenCode / OpenClaw 等各客户端的最小挂载配置速查表见[根 README](../README.md#用在其他-ai-工具里mcp);本节是完整配置与安全说明。规划中的独立分发包 `mneme-memory` 落地后,挂载命令将保持兼容(详见仓库 Discussions #300)。
@@ -540,6 +558,16 @@ Claude Code 挂载示例(项目根 `.mcp.json`;token 在面板「设置 →
540
558
 
541
559
  未全局安装 npm 包时,把 `command` 换成 `npx`、加 `args: ["-p", "@modusensus/dsh-mneme", "dsh-mneme-mcp"]` 即可。
542
560
 
561
+ ## 社区适配器
562
+
563
+ 以下项目由社区维护,把 mneme 接入第三方平台或宿主。它们不是本仓库的一部分,兼容性由各自维护者跟进——升级 mneme 后如遇接口变更,请优先查看对应项目的 release note。
564
+
565
+ | 项目 | 平台 | 说明 |
566
+ |------|------|------|
567
+ | [Mneme-Bridge](https://github.com/SternChiri/Mneme-Bridge) | DeepSeek 网页端 | 浏览器扩展(MV3)+ 本地 Node 服务:网页端会话蒸馏进 mneme 记忆库,DSH 侧记忆按话题回注网页对话,两侧共享同一份记忆。embedded(直接挂载 mneme lib)与 remote(standalone API,推荐配合 [`dsh-mneme-serve`](docs/DAEMON.md))双模式 |
568
+
569
+ > 想被收录?在 [Discussions](https://github.com/slow-stack/mneme/discussions) 开帖介绍你的项目,或直接提 PR 补充本表。
570
+
543
571
  ## 🏗️ 架构
544
572
 
545
573
  ```
@@ -584,7 +612,7 @@ src/
584
612
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
585
613
  └── index.js # 插件接线
586
614
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
587
- test/ # 1523 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
615
+ test/ # 1547 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
588
616
  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 运行时清单
589
617
  ```
590
618
 
@@ -593,7 +621,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
593
621
  ```bash
594
622
  cd dsh-mneme
595
623
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
596
- npm test # 运行 1523 个测试
624
+ npm test # 运行 1547 个测试
597
625
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
598
626
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
599
627
  ```
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // bin/dsh-mneme-serve.mjs —— mneme 独立服务(daemon)入口。
3
+ //
4
+ // 为什么独立成 bin 而不是 cli.mjs 的子命令:CONTRIBUTING「The CLI is dependency-free
5
+ // by contract」禁止给 bin/cli.mjs 加 import,而 serve 必须挂载 lib/serve.js;命名循
6
+ // dsh-mneme-mcp 先例。数据面 = src/api-standalone.js 同一工厂,路由与鉴权零新面。
7
+ //
8
+ // 生命周期:bind 失败(含 strictPort 下端口被占)→ stderr 清错 + exit 1;
9
+ // SIGINT/SIGTERM → dispose + exit 0。Windows 下 kill() 是硬终止、handler 不跑:
10
+ // 已提交事务由 WAL 回放兜底(node:sqlite 未 finalize 语句的 close 抛错同理),不丢数据。
11
+ //
12
+ // 日志纪律:进度/错误全走 stderr;stdout 只在就绪时打一行机器可读的 listening 行
13
+ // (脚本/测试从中解析实际端口),其余时刻保持干净——serve 是常驻进程,stdout 常被
14
+ // 重定向进监控管道。
15
+ import { readFileSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { createServeRuntime } from "../lib/serve.js";
19
+
20
+ const BIN_NAME = "dsh-mneme-serve";
21
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22
+ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
23
+
24
+ const USAGE = `${BIN_NAME} — run the mneme data plane as a standalone service (no DSH required)
25
+
26
+ Usage: dsh-mneme-serve [--memory-dir <dir>] [--port <n>] [--host <addr>] [--embed <provider>]
27
+
28
+ Options:
29
+ --memory-dir <dir> data directory (default: ~/.dsh/memory, same as the plugin)
30
+ --port <n> HTTP port (default: persisted external_api port, else 8790)
31
+ --host <addr> bind address (default: persisted external_api host, else 127.0.0.1)
32
+ --embed <provider> semantic retrieval: local (default, downloads the ONNX runtime
33
+ + embedding model on first boot) | ollama | openai (uses the
34
+ vector-config saved by the DSH panel) | off (keyword + BM25 only)
35
+ -h, --help show this help
36
+ -V, --version print version
37
+
38
+ Auth: Bearer token is shared with the DSH panel / CLI (kv "external_api" in
39
+ memory.db); it is generated on first boot. A busy configured port is a hard
40
+ error — the DSH external API and this daemon must not share a port (pick one).
41
+
42
+ Environment (runtime provisioning, local provider only):
43
+ DSH_MNEME_MEMORY_DIR data directory override
44
+ DSH_MNEME_RUNTIME_DIR self-managed runtime dir (default ~/.dsh/mneme/runtime)
45
+ DSH_MNEME_RUNTIME_TARBALL_DIR offline .tgz dir preferred over the network
46
+ DSH_MNEME_RUNTIME_MIRROR npm registry mirror prefix (e.g. npmmirror)`;
47
+
48
+ /** 极简 argv 解析(--k=v / --k v / 旗标);够用即可,完整 CLI 在 bin/cli.mjs。 */
49
+ function parseArgv(argv) {
50
+ const out = {};
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === "-h" || a === "--help") out.help = true;
54
+ else if (a === "-V" || a === "--version") out.version = true;
55
+ else if (a.startsWith("--")) {
56
+ const eq = a.indexOf("=");
57
+ if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1);
58
+ else {
59
+ const v = argv[i + 1];
60
+ // 本 bin 的旗标全部取值(无泛用布尔旗标):值缺失或下一个 token 是旗标
61
+ // 都按「缺值」报错退出——不能落成 true(Number(true)=1 会把 --port 变成
62
+ // 绑端口 1,报 EACCES 让用户查错方向)。帮助/版本在上方分支已提前返回。
63
+ if (v !== undefined && !v.startsWith("-")) { out[a.slice(2)] = v; i++; }
64
+ else fail(`${a} 缺少参数值`);
65
+ }
66
+ } else {
67
+ fail(`未知参数: ${a}\n运行 \`${BIN_NAME} --help\` 查看用法。`);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function fail(msg) {
74
+ console.error(`[${BIN_NAME}] ${msg}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ // api-standalone 的 logger 契约:info/warn/error 收单字符串。全走 stderr(见头部纪律)。
79
+ const logger = {
80
+ info: (msg) => console.error(`[dsh-mneme] ${msg}`),
81
+ warn: (msg) => console.error(`[dsh-mneme] warn: ${msg}`),
82
+ error: (msg) => console.error(`[dsh-mneme] error: ${msg}`)
83
+ };
84
+
85
+ async function main(argv) {
86
+ const args = parseArgv(argv);
87
+ if (args.help) { process.stdout.write(USAGE + "\n"); return; }
88
+ if (args.version) { console.log(PKG.version); return; }
89
+
90
+ // memoryDir:CLI > env > serve.js 缺省(~/.dsh/memory)。相对路径按 CWD 解;
91
+ // 前导 ~ 留给 serve.js 展开(与宿主 index.js:192-194 同一口径)。
92
+ const rawDir = typeof args["memory-dir"] === "string" ? args["memory-dir"]
93
+ : (typeof process.env.DSH_MNEME_MEMORY_DIR === "string" && process.env.DSH_MNEME_MEMORY_DIR ? process.env.DSH_MNEME_MEMORY_DIR : undefined);
94
+ const memoryDir = rawDir && !rawDir.startsWith("~") ? resolve(rawDir) : rawDir;
95
+
96
+ let port;
97
+ if (args.port !== undefined) {
98
+ port = Number(args.port);
99
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
100
+ fail(`--port 需要合法端口(0-65535),收到: ${args.port}`);
101
+ }
102
+ }
103
+ const host = typeof args.host === "string" && args.host ? args.host : undefined;
104
+
105
+ let embed = "local";
106
+ if (args.embed !== undefined) {
107
+ if (typeof args.embed !== "string" || !["off", "local", "ollama", "openai"].includes(args.embed)) {
108
+ fail(`--embed 需要 off|local|ollama|openai,收到: ${String(args.embed)}`);
109
+ }
110
+ embed = args.embed;
111
+ }
112
+
113
+ let rt;
114
+ try {
115
+ // 装配是异步的:embed=local 时可能要先取件 runtime(download 档,失败内部降级)
116
+ rt = await createServeRuntime({ memoryDir, port, host, logger, embed });
117
+ } catch (err) {
118
+ fail(`启动失败: ${err?.message ?? err}`);
119
+ }
120
+ try {
121
+ await rt.api.ready;
122
+ } catch (err) {
123
+ fail(`数据面启动失败: ${err?.message ?? err}\n` +
124
+ ` 端口 ${rt.api.port} 被占通常意味着另一个 mneme 数据面正在运行` +
125
+ `(DSH 的「外部访问」或另一份 ${BIN_NAME})——二选一,或用 --port 换端口。`);
126
+ }
127
+ const addr = rt.api.server.address();
128
+ // stdout 唯一一行:机器可读就绪行(测试/脚本解析端口用)。
129
+ console.log(`${BIN_NAME} listening on http://${addr?.address ?? rt.api.host}:${addr?.port ?? rt.api.port} (pid ${process.pid})`);
130
+ if (!rt.tokenExisted) {
131
+ console.error(`[dsh-mneme] 首次启动已生成 Bearer token(已持久化,与 DSH 面板/CLI 共用):\n` +
132
+ ` ${rt.api.token}`);
133
+ }
134
+
135
+ let closing = false;
136
+ const shutdown = async (signal) => {
137
+ // 第二次信号 = 强制退出(dispose 里 server.close 等 in-flight 收尾,极端情况下会挂)
138
+ if (closing) process.exit(0);
139
+ closing = true;
140
+ console.error(`[dsh-mneme] ${signal} received, closing...`);
141
+ try { await rt.dispose(); } catch { /* dispose 各步自吞 */ }
142
+ process.exit(0);
143
+ };
144
+ process.on("SIGINT", () => shutdown("SIGINT"));
145
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
146
+ }
147
+
148
+ main(process.argv.slice(2)).catch((err) => {
149
+ console.error(err?.stack ?? String(err));
150
+ process.exit(1);
151
+ });
@@ -1,4 +1,4 @@
1
- // Standalone HTTP API (v0.7.12): a plain node:http server for ecosystem
1
+ // Standalone HTTP API: a plain node:http server for ecosystem
2
2
  // integrations that live outside the DSH host and cannot reach the plugin's
3
3
  // internal webServer routes (/api/dsh-mneme/*). Mirrors the JSON semantics of
4
4
  // those routes but with mandatory Bearer-token auth on everything except
@@ -17,12 +17,10 @@ import { createServer } from "node:http";
17
17
  import { randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { TYPES } from "./store.js";
19
19
  import { bootstrapFromDirectory } from "./bootstrap.js";
20
+ import { PACKAGE_VERSION } from "./version-check.js";
20
21
 
21
22
  const DEFAULT_PORT = 8790;
22
23
  const DEFAULT_HOST = "127.0.0.1";
23
- // Hardcoded release version (package.json is bumped at publish time and may
24
- // lag the code that ships in between).
25
- const VERSION = "0.7.12";
26
24
  const MAX_PORT_ATTEMPTS = 20;
27
25
 
28
26
  /**
@@ -30,8 +28,13 @@ const MAX_PORT_ATTEMPTS = 20;
30
28
  * the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
31
29
  * OS assigns a free port. Multiple DSH profiles/instances sharing the default
32
30
  * port no longer leave the standalone API permanently unavailable.
31
+ *
32
+ * strictPort opts out of that recovery (dsh-mneme-serve daemon, #363): a
33
+ * long-lived third-party integration pins the URL, so silently hopping ports
34
+ * would make clients talk to nothing (or, worse, to a future different data
35
+ * plane). A busy configured port is a configuration error there — fail loud.
33
36
  */
34
- function listenWithRetry(server, startPort, host, logger) {
37
+ function listenWithRetry(server, startPort, host, logger, strictPort = false) {
35
38
  return new Promise((resolve, reject) => {
36
39
  let attempt = 0;
37
40
  const tryListen = (port) => {
@@ -42,6 +45,10 @@ function listenWithRetry(server, startPort, host, logger) {
42
45
  };
43
46
  const onError = (error) => {
44
47
  server.off("listening", onListening);
48
+ if (error?.code === "EADDRINUSE" && strictPort) {
49
+ reject(error);
50
+ return;
51
+ }
45
52
  if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
46
53
  attempt++;
47
54
  const next = startPort + attempt;
@@ -186,18 +193,70 @@ async function handlePutBody(res, service, logger, id, text) {
186
193
  sendJson(res, 200, { memory: service.toApiList([service.getById(id)])[0] });
187
194
  }
188
195
 
196
+ /**
197
+ * /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款
198
+ * (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。
199
+ * 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。
200
+ */
201
+ async function embedQueryVector(embedder, q) {
202
+ const embedSingle = typeof embedder.embedSingle === "function"
203
+ ? embedder.embedSingle.bind(embedder)
204
+ : typeof embedder.embed === "function"
205
+ ? embedder.embed.bind(embedder)
206
+ : null;
207
+ if (!embedSingle) return undefined;
208
+ const vector = await embedSingle(q);
209
+ return Array.isArray(vector) && vector.length ? vector : undefined;
210
+ }
211
+
212
+ /**
213
+ * /context 查询嵌入的超时护栏(CodeRabbit on #371):embedder 挂起(不抛错,
214
+ * 典型如第三方 ollama/openai 端点无响应)时不能让 HTTP 连接被无限占住——超时
215
+ * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃,
216
+ * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器
217
+ * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。
218
+ */
219
+ function withEmbedTimeout(promise, timeoutMs) {
220
+ let timer;
221
+ const timeout = new Promise((resolve) => {
222
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
223
+ });
224
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
225
+ }
226
+
227
+ /**
228
+ * scope 查询参数(/context 与 /search 同口径,issue #370):两参全缺 = 返回
229
+ * undefined(调用方不传 scope 走各自默认,行为与既往逐字节一致);任一给出则
230
+ * 缺的一维按「解析不到」(null)走 isVisibleInScope 的 fail-closed——身份不明
231
+ * 的维度只见全局,不冒认,与宿主会话 scope 解析器同构。
232
+ */
233
+ function scopeFromSearchParams(url) {
234
+ const agentScope = url.searchParams.get("agent_scope");
235
+ const workspaceScope = url.searchParams.get("workspace_scope");
236
+ if (agentScope === null && workspaceScope === null) return undefined;
237
+ return { agent_scope: agentScope, workspace_scope: workspaceScope };
238
+ }
239
+
189
240
  /**
190
241
  * Create (and start) the standalone API server.
191
- * Accepts { service, store, config, logger, settings, port, host }:
242
+ * Accepts { service, store, config, logger, settings, port, host, embedder }:
192
243
  * - token: persisted settings kv "external_api" wins; auto-generated
193
244
  * (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
194
245
  * - port: explicit arg > persisted settings > config.externalApiPort > 8790.
195
246
  * - host: explicit arg > config.externalApiHost > "127.0.0.1".
247
+ * - strictPort: EADDRINUSE rejects instead of hopping ports (daemon mode;
248
+ * default false keeps the in-host sidecar recovery described above).
249
+ * - embedder: query-embedding handle for GET /context (issue #370) — same
250
+ * instance the caller handed to service.setEmbedder; null degrades /context
251
+ * to the rule + BM25 tier. Best-effort: embed failures never fail the route.
252
+ * - embedTimeoutMs: /context query-embed guard (default 3000) — a hung
253
+ * embedder must not hold the HTTP connection; timeout degrades to the rule
254
+ * tier. Factory option (like strictPort), not a user config key.
196
255
  * Returns { server, port, host, token, ready }: `port` is the effective bound
197
256
  * port (updated to the OS-assigned one after `ready` resolves when asked to
198
257
  * bind port 0), `ready` resolves once listening and rejects if the bind fails.
199
258
  */
200
- export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance }) {
259
+ export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance, strictPort = false, embedder = null, embedTimeoutMs = 3000 }) {
201
260
  const persisted = settings?.getExternalApi?.() ?? {};
202
261
 
203
262
  let token = typeof persisted.token === "string" ? persisted.token : "";
@@ -249,7 +308,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
249
308
  entities = store.db.prepare("SELECT count(*) AS c FROM entities").get().c;
250
309
  } catch { /* entities storage unavailable → 0 */ }
251
310
  sendJson(res, 200, {
252
- version: VERSION,
311
+ version: PACKAGE_VERSION,
253
312
  memories: { total: service.count(), byType },
254
313
  entities,
255
314
  uptime_s: Math.floor(process.uptime())
@@ -517,6 +576,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
517
576
  const rerank = url.searchParams.get("rerank") !== "false";
518
577
  const occurredFrom = url.searchParams.get("occurred_from") ?? null;
519
578
  const occurredTo = url.searchParams.get("occurred_to") ?? null;
579
+ // scope 透传(issue #370):searchMemories 本就收 scope,此前 8790 没有
580
+ // 入口——第三方(daemon 桥接侧)声明身份后,A2 软加权与 strictScope 硬
581
+ // 过滤才在检索面同样成立;与 /context 同口径(scopeFromSearchParams)。
582
+ const scope = scopeFromSearchParams(url);
520
583
  const query = q.trim();
521
584
  if (!query) {
522
585
  sendJson(res, 200, { items: [], mode: "keyword" });
@@ -528,6 +591,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
528
591
  mode,
529
592
  topK: limit,
530
593
  useRerank: rerank,
594
+ ...(scope ? { scope } : {}),
531
595
  ...(occurredFrom !== null || occurredTo !== null ? { occurredFrom, occurredTo } : {})
532
596
  })
533
597
  ).then((rows) => {
@@ -539,6 +603,43 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
539
603
  return;
540
604
  }
541
605
 
606
+ // --- GET /context: 注入候选一站式聚合(issue #370,#363 承诺)------------
607
+ // 给定话题返回「宿主此刻会注入什么」的数据等价物:injectCandidates 全语义
608
+ // (优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置,
609
+ // service.js:1494)+ 画像 + 规则。结构化条目、不含渲染文本——语言 / 时间
610
+ // 前缀 / 热记忆是宿主会话概念,调用方(Mneme Bridge 等网页端桥接)自行渲染。
611
+ // 与宿主注入的两点差异都是 HTTP 面的有意为之:① 首次调用即走语义路(宿主
612
+ // 渲染必须同步,查询向量只能异步 prefetch 给下一轮,inject.js Bug4;这里
613
+ // 没有该约束);② 不做跨轮轮换(daemon 无会话状态,调用方自管)。
614
+ // recall_runs 记账随 injectCandidates 内建(#217:注入是曝光型访问事件,
615
+ // mode='inject' 与检索命中同表分账)——宿主注入同样记,/context 自动同口径。
616
+ if (req.method === "GET" && pathname === "/context") {
617
+ const q = (url.searchParams.get("q") ?? "").trim();
618
+ const maxItemsRaw = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 5);
619
+ const maxItems = Number.isInteger(maxItemsRaw) && maxItemsRaw > 0 ? maxItemsRaw : 5;
620
+ // threshold 是 importance 阈值(injectCandidates 语义),与 /search 的
621
+ // 相似度 threshold 同名不同义——文档已点名,勿混。
622
+ const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);
623
+ const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3;
624
+ const scope = scopeFromSearchParams(url);
625
+ void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined)
626
+ .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
627
+ .then((queryVector) => {
628
+ const pinnedStats = {};
629
+ const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats });
630
+ sendJson(res, 200, {
631
+ profile: (settings?.getProfile?.() ?? "").trim(),
632
+ rules: settings?.getRules?.() ?? [],
633
+ items: service.toApiList(items),
634
+ pinnedCount: pinnedStats.shown ?? 0
635
+ });
636
+ })
637
+ .catch(() => {
638
+ sendJson(res, 500, { error: "internal" });
639
+ });
640
+ return;
641
+ }
642
+
542
643
  sendJson(res, 404, { error: "not-found" });
543
644
  } catch {
544
645
  sendJson(res, 500, { error: "internal" });
@@ -552,7 +653,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
552
653
  });
553
654
  const ready = new Promise((resolve, reject) => {
554
655
  // listening handled by listenWithRetry
555
- listenWithRetry(server, boundPort, boundHost, logger).then(resolve, reject);
656
+ listenWithRetry(server, boundPort, boundHost, logger, strictPort).then(resolve, reject);
556
657
  });
557
658
  // server.listen is called inside listenWithRetry
558
659
  ready.then(() => {
package/lib/config.js CHANGED
@@ -708,6 +708,13 @@ export const Config = z.object({
708
708
  // 恒 1,sleep 降级退回纯时间分层。
709
709
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
710
710
  heatEnabled: z.boolean().default(false),
711
+ // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束
712
+ // (现行量级 ≡ 拟合参数,注入集逐条相同;importance-only 遵从 +12.7pp),
713
+ // 故注入侧 heat 改为独立 opt-in:默认关 = heatEnabled 开启后热度字段/sleep
714
+ // 保护/前端投影照常,但注入排序乘数恒 1(与 heat 关闭逐字节一致);
715
+ // 显式开启才恢复「层内乘 heat」的旧序。召回侧时钟(touchLastAccess)与
716
+ // sleep 降级联判不受本键影响——它们不参与注入排序。
717
+ injectHeatEnabled: z.boolean().default(false),
711
718
  // 广义指数形状参数 β(heat = exp(-λ·Δt^β),issue #218 拍板):β=1 纯指数。
712
719
  // 快慢以 Δt>1 小时为准——β<1 衰减更慢(亚线性长尾)、β>1 更快(超线性);
713
720
  // 0<Δt<1 的首小时内方向相反(Δt^β 随 β 增大而变小)。专家调优项,不进面板白名单。