@modusensus/dsh-mneme 0.7.11 → 0.7.13

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/lib/summarize.js CHANGED
@@ -1,9 +1,22 @@
1
1
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
 
3
- const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
3
+ const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。
4
+ 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
5
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
5
6
  不要输出任何其他文字。`;
6
7
 
8
+ // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
+ // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
+ // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
+ const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手思考/回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
12
+ 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
13
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|rejected_solution|pitfall|constraint","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
14
+ 若对话涉及编码/调试,可额外提取编码类记忆:
15
+ - rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)
16
+ - pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)
17
+ - constraint:项目工程约束(content 含约束描述 + 来源)
18
+ 普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。`;
19
+
7
20
  /** Extract a JSON array from LLM output that may contain prose around it. */
8
21
  export function parseSummaryJson(raw) {
9
22
  const text = String(raw ?? "");
@@ -17,7 +30,7 @@ export function parseSummaryJson(raw) {
17
30
  return [];
18
31
  }
19
32
  if (!Array.isArray(arr)) return [];
20
- const VALID = new Set(["preference", "project", "decision", "history"]);
33
+ const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
21
34
  return arr.filter(
22
35
  (item) =>
23
36
  item &&
@@ -76,22 +89,108 @@ function toProtocolChunk(chunk) {
76
89
  // events must not leak into the memory store. Events without a data payload
77
90
  // (minimal test doubles) pass the kind check and are handled by the content
78
91
  // check below.
79
- // DSH ≥0.1.2-rc removed the Session.events property; events are only reachable
80
- // via snapshotEvents(). Older DSH builds still expose .events, so fall back.
81
- function getSessionEvents(session) {
82
- return session?.snapshotEvents?.() ?? session?.events ?? [];
92
+ //
93
+ // codingRetrospect: the distill context is the FULL turn transcript
94
+ // user prompts plus assistant thinking/replies, tool calls + results and code
95
+ // dispatch output so the summarizer can see tool errors and extract pitfall
96
+ // root causes, not just what the user typed. The same filtering stays: only
97
+ // source.kind === "user" prompts enter (plugin/machine content is excluded).
98
+ // The result is a single text transcript passed to the LLM as one user message
99
+ // (SUMMARY_PROMPT already says "根据下面的会话内容").
100
+ function collectMessages(session, maxChars = 8000) {
101
+ // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
102
+ const events = session.snapshotEvents?.() ?? session.events ?? [];
103
+ const lines = [];
104
+ // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
105
+ // 直接传字符串数组)。text 之外按需抽 thinking/reasoning 块。
106
+ const textOf = (content) => {
107
+ if (typeof content === "string") return content;
108
+ if (!Array.isArray(content)) return "";
109
+ return content
110
+ .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
111
+ .filter((s) => s)
112
+ .join("\n");
113
+ };
114
+ const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
115
+ for (const event of events) {
116
+ const data = event?.data ?? {};
117
+ const kind = data?.source?.kind;
118
+ switch (event.type) {
119
+ case "user/message": {
120
+ if (kind !== undefined && kind !== "user") break;
121
+ const text = textOf(data?.content);
122
+ if (text.trim()) lines.push(`用户:${text}`);
123
+ break;
124
+ }
125
+ case "assistant/message": {
126
+ const msg = data?.message;
127
+ const blocks = Array.isArray(msg?.content) ? msg.content : [];
128
+ const text = textOf(blocks);
129
+ if (text.trim()) lines.push(`助手:${text}`);
130
+ const thinking = blocks
131
+ .map((b) => (typeof b === "string" ? "" : (b && b.type === "reasoning" && typeof b.text === "string" ? b.text : "")))
132
+ .filter((s) => s)
133
+ .join("\n");
134
+ if (thinking.trim()) lines.push(`助手思考:${thinking}`);
135
+ break;
136
+ }
137
+ case "tool/call": {
138
+ const args = typeof data.arguments === "string"
139
+ ? data.arguments
140
+ : data.arguments ? JSON.stringify(data.arguments) : "";
141
+ lines.push(`工具调用:${data.name ?? "?"}(${trim(args, 300)})`);
142
+ break;
143
+ }
144
+ case "tool/result": {
145
+ const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
146
+ const status = data.ok === false ? "失败" : "成功";
147
+ lines.push(`工具结果(${status}):${trim(out, 500)}`);
148
+ break;
149
+ }
150
+ case "tool/code-dispatch": {
151
+ const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
152
+ const status = data.ok === false ? "失败" : "成功";
153
+ lines.push(`代码执行(${status}):${trim(out, 500)}`);
154
+ break;
155
+ }
156
+ default:
157
+ break;
158
+ }
159
+ }
160
+ return lines.length ? [createUserMessage({ content: [{ type: "text", text: trim(lines.join("\n"), maxChars) }] })] : [];
83
161
  }
84
162
 
85
- function collectMessages(session) {
86
- const messages = [];
87
- for (const event of getSessionEvents(session)) {
88
- const kind = event.data?.source?.kind;
89
- if (event.type !== "user/message") continue;
90
- if (kind !== undefined && kind !== "user") continue;
91
- if (!event.data?.content?.length) continue; // nothing to summarize
92
- messages.push(createUserMessage({ content: event.data.content }));
93
- }
94
- return messages.slice(-20);
163
+ // ── 智能调速器(429 保护)─────────────────────────────────────────────
164
+ // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
165
+ // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
166
+ // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
167
+ // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
168
+ // 不把 429 错误码直接抛出去。
169
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
170
+
171
+ function isRateLimited(error) {
172
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
173
+ if (status === 429) return true;
174
+ const msg = String(error?.message ?? error ?? "");
175
+ return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
176
+ }
177
+
178
+ // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
179
+ // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
180
+ // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
181
+ let distillQueue = Promise.resolve();
182
+ let lastDistillEnd = 0;
183
+ function enqueueDistill(task, intervalMs = 0) {
184
+ const run = distillQueue.then(async () => {
185
+ if (intervalMs > 0) {
186
+ const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
187
+ if (wait > 0) await sleep(wait);
188
+ }
189
+ lastDistillEnd = Date.now();
190
+ return task();
191
+ });
192
+ distillQueue = run.catch(() => {});
193
+ return run;
95
194
  }
96
195
 
97
196
  export function createSummarizer(ctx, service, config) {
@@ -104,7 +203,7 @@ export function createSummarizer(ctx, service, config) {
104
203
  if (disposed || inFlight.has(session.id)) return;
105
204
  const controller = new AbortController();
106
205
  inFlight.set(session.id, controller);
107
- // Bug8: audit state for the compression call. null = no audit for this run
206
+ // audit state for the compression call. null = no audit for this run
108
207
  // (disabled, or no LLM call was actually made). The audit row is written in
109
208
  // the finally below — once, regardless of which exit path the call took —
110
209
  // so a failed/aborted stream still leaves a status='error' trail without
@@ -119,7 +218,9 @@ export function createSummarizer(ctx, service, config) {
119
218
  ? { provider: header.provider, model: header.model }
120
219
  : undefined;
121
220
  if (!route) return;
122
- const messages = collectMessages(session);
221
+ // 完整转录(Codex 式):蒸馏把整轮对话交给 LLM 提炼原子记忆,不再硬裁
222
+ // 8000 字截断语义;上限由 distillMaxChars 控制(默认 24000,可调大)。
223
+ const messages = collectMessages(session, config.distillMaxChars ?? 24000);
123
224
  if (!messages.length) return;
124
225
 
125
226
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
@@ -134,61 +235,98 @@ export function createSummarizer(ctx, service, config) {
134
235
  };
135
236
  }
136
237
 
137
- const assembler = new BlockAssembler();
138
- let text = "";
139
238
  const options = {
140
239
  provider: route.provider,
141
240
  model: route.model,
142
241
  purpose: "summarization",
143
242
  messages: [
144
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
243
+ { role: "system", content: [{ type: "text", text: config.codingRetrospect ? CODING_SUMMARY_PROMPT : SUMMARY_PROMPT }] },
145
244
  ...messages
146
245
  ],
147
246
  signal: controller.signal
148
247
  };
149
- try {
150
- for await (const chunk of ctx.llm.stream(options)) {
151
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
152
- if (chunk.type === "text-delta") {
153
- text += chunk.text ?? chunk.delta ?? "";
154
- }
155
- if (chunk.type === "usage" && audit) {
156
- const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
157
- const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
158
- if (Number.isFinite(i)) audit.inputTokens = i;
159
- if (Number.isFinite(o)) audit.outputTokens = o;
160
- }
161
- if (chunk.type === "finish") {
162
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
163
- if (reasonKind === "error" || reasonKind === "aborted") {
164
- if (audit) {
165
- audit.status = "error";
166
- audit.errorMessage = `llm stream ${reasonKind}`;
248
+ // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
249
+ // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
250
+ const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
251
+ const { text, assembledText, aborted } = await enqueueDistill(async () => {
252
+ const retries = config.distillRateLimitRetries ?? 3;
253
+ const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
254
+ for (let attempt = 0; ; attempt++) {
255
+ const assembler = new BlockAssembler();
256
+ let text = "";
257
+ let aborted = false;
258
+ try {
259
+ for await (const chunk of ctx.llm.stream(options)) {
260
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
261
+ if (chunk.type === "text-delta") {
262
+ text += chunk.text ?? chunk.delta ?? "";
263
+ }
264
+ if (chunk.type === "usage" && audit) {
265
+ const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
266
+ const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
267
+ if (Number.isFinite(i)) audit.inputTokens = i;
268
+ if (Number.isFinite(o)) audit.outputTokens = o;
167
269
  }
168
- return;
270
+ if (chunk.type === "finish") {
271
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
272
+ if (reasonKind === "error" || reasonKind === "aborted") {
273
+ // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
274
+ // 转抛错走指数退避重试。
275
+ if (isRateLimited(chunk.reason ?? chunk)) {
276
+ throw Object.assign(new Error("rate limited"), { status: 429 });
277
+ }
278
+ if (audit) {
279
+ audit.status = "error";
280
+ audit.errorMessage = `llm stream ${reasonKind}`;
281
+ }
282
+ aborted = true;
283
+ break;
284
+ }
285
+ }
286
+ }
287
+ // Direct delta accumulation is the primary extraction path (it works
288
+ // for real protocol chunks {index,text} and looser {delta} shapes
289
+ // alike); the assembler blocks are a fallback for streams that only
290
+ // deliver text inside block-end. This dsh-llm exposes no public
291
+ // no-arg assemble() — blocks() is the message-level API.
292
+ const blocks = assembler.blocks();
293
+ const assembledText = blocks
294
+ .filter((b) => b.type === "text")
295
+ .map((b) => b.text ?? "")
296
+ .join("");
297
+ return { text, assembledText, aborted };
298
+ } catch (error) {
299
+ if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
300
+ if (isRateLimited(error) && attempt < retries) {
301
+ const delay = baseDelayMs * 2 ** attempt;
302
+ ctx.logger?.warn?.(
303
+ `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
304
+ );
305
+ await sleep(delay);
306
+ continue;
307
+ }
308
+ // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
309
+ if (audit) {
310
+ audit.status = "error";
311
+ audit.errorMessage = String(error?.message ?? error);
169
312
  }
313
+ throw error;
170
314
  }
171
315
  }
172
- } catch (error) {
173
- if (audit) {
174
- audit.status = "error";
175
- audit.errorMessage = String(error?.message ?? error);
176
- }
177
- throw error; // caller's catch handles the failure; audit already staged
178
- }
179
- // Direct delta accumulation is the primary extraction path (it works
180
- // for real protocol chunks {index,text} and looser {delta} shapes
181
- // alike); the assembler blocks are a fallback for streams that only
182
- // deliver text inside block-end. This dsh-llm exposes no public
183
- // no-arg assemble() — blocks() is the message-level API.
184
- const blocks = assembler.blocks();
185
- const assembledText = blocks
186
- .filter((b) => b.type === "text")
187
- .map((b) => b.text ?? "")
188
- .join("");
316
+ }, intervalMs);
317
+ if (aborted) return;
189
318
  const entries = parseSummaryJson(text || assembledText);
190
319
  for (const entry of entries) {
191
- service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
320
+ // Provenance: the summarizer runs on a real session (turn/end hook), so
321
+ // session.id is always available here — it rides the human-readable
322
+ // source label.
323
+ // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
324
+ // (rejected_solution/pitfall/constraint)区分即可,tag 体系
325
+ // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
326
+ service.saveWithDedupe({
327
+ ...entry,
328
+ source: `session:${session.id}`
329
+ });
192
330
  }
193
331
  } finally {
194
332
  if (audit) {
package/lib/tools.js CHANGED
@@ -30,7 +30,7 @@ export function createTools(ctx, service, config, embedder) {
30
30
  "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
31
31
  "Merges into an existing entry of the same type when the title matches.",
32
32
  parameters: {
33
- type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
33
+ type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary; rejected_solution=rejected/abandoned implementation approach; pitfall=debugging lesson (symptom+root cause+fix); constraint=engineering constraint" },
34
34
  title: { type: "string", required: true, description: "Short unique title" },
35
35
  content: { type: "string", required: true, description: "Memory body" },
36
36
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
@@ -100,7 +100,7 @@ export function createTools(ctx, service, config, embedder) {
100
100
  name: "memory_list",
101
101
  description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
102
102
  parameters: {
103
- type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
103
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
104
104
  limit: { type: "integer", description: "Page size (default 50)" },
105
105
  offset: { type: "integer", description: "Page offset (default 0)" },
106
106
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
@@ -138,7 +138,7 @@ export function createTools(ctx, service, config, embedder) {
138
138
  id: { type: "string", required: true, description: "Memory id" },
139
139
  title: { type: "string" },
140
140
  content: { type: "string" },
141
- type: { type: "string", enum: ["preference", "project", "decision", "history"] },
141
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"] },
142
142
  tags: { type: "array", items: { type: "string" } },
143
143
  importance: { type: "integer", description: "1-5" },
144
144
  reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
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, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.7.11",
4
+ "version": "0.7.13",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "type": "module",
15
15
  "main": "lib/index.js",
16
+ "bin": {
17
+ "dsh-mneme": "bin/cli.mjs"
18
+ },
16
19
  "exports": {
17
20
  ".": {
18
21
  "default": "./lib/index.js"
@@ -23,6 +26,7 @@
23
26
  "./package.json": "./package.json"
24
27
  },
25
28
  "files": [
29
+ "bin",
26
30
  "lib",
27
31
  "src",
28
32
  "scripts",
@@ -58,12 +62,14 @@
58
62
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
59
63
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
64
  "@deepseek-ai/schemastery": "^3.18.1",
65
+ "c8": "^12.0.0",
61
66
  "lucide": "^1.42.0"
62
67
  },
63
68
  "scripts": {
64
69
  "sync": "node scripts/sync-lib.js",
65
70
  "prepack": "npm run sync",
66
71
  "test": "node --test test/*.test.js",
72
+ "test:coverage": "c8 node --test test/*.test.js",
67
73
  "e2e": "node scripts/e2e-dsh.js",
68
74
  "stress": "node scripts/stress-dsh.js"
69
75
  },
@@ -0,0 +1,264 @@
1
+ // Standalone HTTP API (v0.7.12): a plain node:http server for ecosystem
2
+ // integrations that live outside the DSH host and cannot reach the plugin's
3
+ // internal webServer routes (/api/dsh-mneme/*). Mirrors the JSON semantics of
4
+ // those routes but with mandatory Bearer-token auth on everything except
5
+ // GET /health, so the store can be exposed safely on loopback.
6
+ //
7
+ // Security: the default bind host is 127.0.0.1. Pointing externalApiHost at a
8
+ // non-loopback address exposes the whole memory store to the network — that is
9
+ // the operator's explicit responsibility (documented in README).
10
+ import { createServer } from "node:http";
11
+ import { randomBytes, timingSafeEqual } from "node:crypto";
12
+ import { TYPES } from "./store.js";
13
+
14
+ const DEFAULT_PORT = 8790;
15
+ const DEFAULT_HOST = "127.0.0.1";
16
+ // Hardcoded release version (package.json is bumped at publish time and may
17
+ // lag the code that ships in between).
18
+ const VERSION = "0.7.12";
19
+
20
+ function sendJson(res, status, payload) {
21
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
22
+ res.end(JSON.stringify(payload));
23
+ }
24
+
25
+ /** True when the request carries the configured token. */
26
+ function isAuthorized(req, apiToken) {
27
+ const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
28
+ const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
29
+ if (token === "" || token.length !== apiToken.length) return false;
30
+ // Constant-time comparison: no timing oracle on the token.
31
+ return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
32
+ }
33
+
34
+ /** Collect the request body as text (tolerant of transport errors). */
35
+ function readBody(req) {
36
+ return new Promise((resolve) => {
37
+ let body = "";
38
+ req.on("data", (chunk) => { body += chunk; });
39
+ req.on("end", () => resolve(body));
40
+ req.on("error", () => resolve(""));
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Create (and start) the standalone API server.
46
+ * Accepts { service, store, config, logger, settings, port, host }:
47
+ * - token: persisted settings kv "external_api" wins; auto-generated
48
+ * (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
49
+ * - port: explicit arg > persisted settings > config.externalApiPort > 8790.
50
+ * - host: explicit arg > config.externalApiHost > "127.0.0.1".
51
+ * Returns { server, port, host, token, ready }: `port` is the effective bound
52
+ * port (updated to the OS-assigned one after `ready` resolves when asked to
53
+ * bind port 0), `ready` resolves once listening and rejects if the bind fails.
54
+ */
55
+ export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host }) {
56
+ const persisted = settings?.getExternalApi?.() ?? {};
57
+
58
+ let token = typeof persisted.token === "string" ? persisted.token : "";
59
+ if (!token) {
60
+ token = randomBytes(24).toString("base64url");
61
+ // Persist only the token; enabled/port keys stay as they are (merge).
62
+ try {
63
+ settings?.setExternalApi?.({ token });
64
+ } catch (error) {
65
+ logger?.warn?.(`[dsh-mneme] standalone API token persistence failed: ${String(error)}`);
66
+ }
67
+ }
68
+
69
+ // Persisted host (set from the panel) wins over the bundle config, same
70
+ // precedence as port; the explicit argument still wins over both.
71
+ const boundHost = host
72
+ ?? (typeof persisted.host === "string" && persisted.host ? persisted.host : undefined)
73
+ ?? config.externalApiHost
74
+ ?? DEFAULT_HOST;
75
+ const persistedPort = Number(persisted.port);
76
+ const boundPort = port
77
+ ?? (Number.isInteger(persistedPort) && persistedPort > 0 ? persistedPort : undefined)
78
+ ?? config.externalApiPort
79
+ ?? DEFAULT_PORT;
80
+
81
+ const server = createServer((req, res) => {
82
+ try {
83
+ const url = new URL(req.url ?? "/", "http://localhost");
84
+ const pathname = url.pathname;
85
+
86
+ // Health is the single unauthenticated probe (monitor checks).
87
+ if (req.method === "GET" && pathname === "/health") {
88
+ sendJson(res, 200, { ok: true });
89
+ return;
90
+ }
91
+
92
+ // Everything else requires the Bearer token.
93
+ if (!isAuthorized(req, token)) {
94
+ sendJson(res, 401, { error: "unauthorized" });
95
+ return;
96
+ }
97
+
98
+ // --- GET /status: version + store shape + uptime -----------------------
99
+ if (req.method === "GET" && pathname === "/status") {
100
+ const byType = {};
101
+ for (const type of TYPES) byType[type] = service.count(type);
102
+ let entities = 0;
103
+ try {
104
+ entities = store.db.prepare("SELECT count(*) AS c FROM entities").get().c;
105
+ } catch { /* entities storage unavailable → 0 */ }
106
+ sendJson(res, 200, {
107
+ version: VERSION,
108
+ memories: { total: service.count(), byType },
109
+ entities,
110
+ uptime_s: Math.floor(process.uptime())
111
+ });
112
+ return;
113
+ }
114
+
115
+ // --- GET /memories: paged + filtered list (same semantics as the
116
+ // internal /api/dsh-mneme/list) ------------------------------------
117
+ if (req.method === "GET" && pathname === "/memories") {
118
+ const type = url.searchParams.get("type") ?? undefined;
119
+ const limit = Number(url.searchParams.get("limit") ?? 50);
120
+ const offset = Number(url.searchParams.get("offset") ?? 0);
121
+ const order = url.searchParams.get("order") ?? undefined;
122
+ const minRaw = url.searchParams.get("minImportance");
123
+ const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
124
+ ? Number(minRaw)
125
+ : undefined;
126
+ const source = url.searchParams.get("source") || undefined;
127
+ const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source }));
128
+ // Total honors the same filters so pager math stays correct.
129
+ sendJson(res, 200, { items, total: service.count(type, { minImportance, source }) });
130
+ return;
131
+ }
132
+
133
+ // --- GET/DELETE /memories/:id ------------------------------------------
134
+ const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
135
+ if (idMatch) {
136
+ let id = idMatch[1];
137
+ try { id = decodeURIComponent(id); } catch { /* keep raw */ }
138
+ if (req.method === "GET") {
139
+ const row = service.getById(id);
140
+ if (!row) {
141
+ sendJson(res, 404, { error: "not-found" });
142
+ return;
143
+ }
144
+ sendJson(res, 200, service.toApiList([row])[0]);
145
+ return;
146
+ }
147
+ if (req.method === "DELETE") {
148
+ // store.remove deletes silently — precheck for a distinguishable 404.
149
+ if (!service.getById(id)) {
150
+ sendJson(res, 404, { error: "not-found" });
151
+ return;
152
+ }
153
+ service.remove(id);
154
+ sendJson(res, 200, { ok: true });
155
+ return;
156
+ }
157
+ sendJson(res, 404, { error: "not-found" });
158
+ return;
159
+ }
160
+
161
+ // --- POST /memories: save with title-dedupe (safer than raw save) ------
162
+ if (req.method === "POST" && pathname === "/memories") {
163
+ void readBody(req).then((text) => {
164
+ let body;
165
+ try {
166
+ body = JSON.parse(text || "{}");
167
+ } catch {
168
+ sendJson(res, 400, { error: "invalid-json" });
169
+ return;
170
+ }
171
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
172
+ sendJson(res, 400, { error: "invalid-body" });
173
+ return;
174
+ }
175
+ // Pre-validate what store.save would throw on, so clients get a
176
+ // clean 400 instead of a leaked SQLite error.
177
+ if (!TYPES.has(body.type)) {
178
+ sendJson(res, 400, { error: "invalid-type" });
179
+ return;
180
+ }
181
+ if (typeof body.title !== "string" || !body.title.trim()) {
182
+ sendJson(res, 400, { error: "missing-title" });
183
+ return;
184
+ }
185
+ if (typeof body.content !== "string") {
186
+ sendJson(res, 400, { error: "missing-content" });
187
+ return;
188
+ }
189
+ if (body.tags !== undefined && !Array.isArray(body.tags)) {
190
+ sendJson(res, 400, { error: "tags-must-be-an-array" });
191
+ return;
192
+ }
193
+ try {
194
+ const { action, memory } = service.saveWithDedupe({
195
+ type: body.type,
196
+ title: body.title,
197
+ content: body.content,
198
+ importance: body.importance,
199
+ tags: body.tags,
200
+ source: body.source
201
+ });
202
+ sendJson(res, action === "created" ? 201 : 200, service.toApiList([memory])[0]);
203
+ } catch (error) {
204
+ logger?.warn?.(`[dsh-mneme] standalone API save failed: ${String(error)}`);
205
+ sendJson(res, 500, { error: "internal" });
206
+ }
207
+ });
208
+ return;
209
+ }
210
+
211
+ // --- GET /search: unified recall pipeline (keyword + vector + BM25) ----
212
+ if (req.method === "GET" && pathname === "/search") {
213
+ const q = url.searchParams.get("q") ?? "";
214
+ const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
215
+ const mode = url.searchParams.get("mode") ?? "auto";
216
+ const rerank = url.searchParams.get("rerank") !== "false";
217
+ const query = q.trim();
218
+ if (!query) {
219
+ sendJson(res, 200, { items: [], mode: "keyword" });
220
+ return;
221
+ }
222
+ // Any vector/rerank failure degrades to keyword inside searchMemories.
223
+ void Promise.resolve(
224
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
225
+ ).then((rows) => {
226
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
227
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
228
+ }).catch(() => {
229
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
230
+ });
231
+ return;
232
+ }
233
+
234
+ sendJson(res, 404, { error: "not-found" });
235
+ } catch {
236
+ sendJson(res, 500, { error: "internal" });
237
+ }
238
+ });
239
+
240
+ // A permanent error listener keeps an EADDRINUSE / runtime socket error from
241
+ // crashing the host process; `ready` still surfaces the first bind failure.
242
+ server.on("error", (error) => {
243
+ logger?.warn?.(`[dsh-mneme] standalone API error: ${String(error)}`);
244
+ });
245
+ const ready = new Promise((resolve, reject) => {
246
+ server.once("listening", resolve);
247
+ server.once("error", reject);
248
+ });
249
+ server.listen(boundPort, boundHost);
250
+ ready.then(() => {
251
+ const address = server.address();
252
+ if (address && typeof address === "object") {
253
+ logger?.info?.(`[dsh-mneme] standalone API listening on http://${address.address}:${address.port}`);
254
+ }
255
+ }).catch(() => { /* already logged by the error handler above */ });
256
+
257
+ const api = { server, port: boundPort, host: boundHost, token, ready };
258
+ // After the OS assigns the real port (bind port 0), reflect it for callers.
259
+ ready.then(() => {
260
+ const address = server.address();
261
+ if (address && typeof address === "object") api.port = address.port;
262
+ }).catch(() => { /* bind failed: port stays as configured */ });
263
+ return api;
264
+ }