@modusensus/dsh-mneme 0.7.11 → 0.7.12
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.en.md +69 -0
- package/README.md +69 -0
- package/bin/cli.mjs +603 -0
- package/lib/api-standalone.js +264 -0
- package/lib/api.js +91 -2
- package/lib/client.js +243 -1
- package/lib/config.js +49 -0
- package/lib/index.js +43 -12
- package/lib/settings.js +40 -0
- package/lib/store.js +4 -1
- package/package.json +5 -1
- package/src/api-standalone.js +264 -0
- package/src/api.js +91 -2
- package/src/config.js +49 -0
- package/src/index.js +43 -12
- package/src/settings.js +40 -0
- package/src/store.js +4 -1
- package/test/api.test.js +44 -0
- package/test/settings.test.js +24 -0
- package/test/standalone-api.test.js +326 -0
package/lib/config.js
CHANGED
|
@@ -249,4 +249,53 @@ export const Config = z.object({
|
|
|
249
249
|
// audits to recall_runs and NEVER touches recall_evals, regardless of this
|
|
250
250
|
// flag (production isolation is unconditional).
|
|
251
251
|
evalPersistTestResults: z.boolean().default(false),
|
|
252
|
+
|
|
253
|
+
// --- standalone external API (v0.7.12) ------------------------------------
|
|
254
|
+
// A plain node:http server for ecosystem integrations that cannot reach the
|
|
255
|
+
// DSH-internal webServer. Disabled by default; when enabled the Bearer token
|
|
256
|
+
// is persisted in the settings kv ("external_api"), auto-generated on first
|
|
257
|
+
// boot. Bind host: keep the loopback default — moving it to a non-loopback
|
|
258
|
+
// address exposes the whole memory store to the network and is the
|
|
259
|
+
// operator's responsibility.
|
|
260
|
+
externalApiEnabled: z.boolean().default(false),
|
|
261
|
+
externalApiPort: z.natural().default(8790),
|
|
262
|
+
externalApiHost: z.string().default("127.0.0.1"),
|
|
263
|
+
|
|
264
|
+
// --- light mode preset (v0.7.12) -------------------------------------------
|
|
265
|
+
// One switch for low-resource setups: turns off every background/semantic
|
|
266
|
+
// heavy path (dream consolidation, entity extraction, vector pipeline,
|
|
267
|
+
// reranker, BM25, semantic dedup / selective inject, sleep mode) while
|
|
268
|
+
// keeping the core loop (autoInject, autoSummarize, hot memory, quality
|
|
269
|
+
// filter, keyword search). Applied by applyLightModePreset before the config
|
|
270
|
+
// reaches any service; a persisted panel_mode="light" (settings kv) counts
|
|
271
|
+
// as lightMode=true too and wins over the bundle config.
|
|
272
|
+
lightMode: z.boolean().default(false),
|
|
252
273
|
});
|
|
274
|
+
|
|
275
|
+
// Fields forced to false by the light-mode preset. Everything not listed here
|
|
276
|
+
// (autoInject, autoSummarize, hotMemory*, memoryQualityFilter, dream
|
|
277
|
+
// thresholds/delays, ...) is left untouched — those are the core loop.
|
|
278
|
+
const LIGHT_MODE_OFF = [
|
|
279
|
+
"entityExtractionEnabled",
|
|
280
|
+
"autoDream",
|
|
281
|
+
"sleepModeEnabled",
|
|
282
|
+
"rerankEnabled",
|
|
283
|
+
"autoReindexOnBoot",
|
|
284
|
+
"hybridInject",
|
|
285
|
+
"searchSemanticDedup",
|
|
286
|
+
"selectiveInjectEnabled",
|
|
287
|
+
"bm25SearchEnabled"
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Apply the light-mode preset to a resolved config object (pure function,
|
|
292
|
+
* exported for tests). When cfg.lightMode is not exactly true the config is
|
|
293
|
+
* returned unchanged; otherwise a shallow copy carries false for every heavy
|
|
294
|
+
* feature. Idempotent and side-effect free.
|
|
295
|
+
*/
|
|
296
|
+
export function applyLightModePreset(cfg) {
|
|
297
|
+
if (cfg?.lightMode !== true) return cfg;
|
|
298
|
+
const preset = { ...cfg, lightMode: true };
|
|
299
|
+
for (const key of LIGHT_MODE_OFF) preset[key] = false;
|
|
300
|
+
return preset;
|
|
301
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -7,13 +7,14 @@ import { createSummarizer } from "./summarize.js";
|
|
|
7
7
|
import { createDreamScheduler } from "./dream.js";
|
|
8
8
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
9
9
|
import { createApi } from "./api.js";
|
|
10
|
+
import { createStandaloneApi } from "./api-standalone.js";
|
|
10
11
|
import { createSettings } from "./settings.js";
|
|
11
12
|
import { createCommandManager } from "./commands.js";
|
|
12
13
|
import { createEmbedder } from "./embedding.js";
|
|
13
14
|
import { createEmbedderByProvider } from "./local-embedder.js";
|
|
14
15
|
import { LocalReranker } from "./reranker.js";
|
|
15
16
|
import { createVectorIndex } from "./vector-index.js";
|
|
16
|
-
import { Config } from "./config.js";
|
|
17
|
+
import { Config, applyLightModePreset } from "./config.js";
|
|
17
18
|
import { extractEntities } from "./entities/extractor.js";
|
|
18
19
|
import { mkdirSync } from "node:fs";
|
|
19
20
|
import { join } from "node:path";
|
|
@@ -29,12 +30,12 @@ export { Config };
|
|
|
29
30
|
// has no prototype, is called normally, and its returned disposer is collected
|
|
30
31
|
// and run by the fiber on unload.
|
|
31
32
|
export const apply = (ctx, config) => {
|
|
32
|
-
const
|
|
33
|
+
const rawCfg = Config(config);
|
|
33
34
|
|
|
34
35
|
// Resolve memoryDir: expand leading "~"
|
|
35
|
-
const memoryDir =
|
|
36
|
-
? join(homedir(),
|
|
37
|
-
:
|
|
36
|
+
const memoryDir = rawCfg.memoryDir.startsWith("~")
|
|
37
|
+
? join(homedir(), rawCfg.memoryDir.slice(1))
|
|
38
|
+
: rawCfg.memoryDir;
|
|
38
39
|
mkdirSync(memoryDir, { recursive: true });
|
|
39
40
|
|
|
40
41
|
const store = createStore(join(memoryDir, "memory.db"));
|
|
@@ -47,11 +48,26 @@ export const apply = (ctx, config) => {
|
|
|
47
48
|
// default 90). Best-effort like the failure prune — the audit trail is
|
|
48
49
|
// bookkeeping and a failed purge must never block plugin boot.
|
|
49
50
|
try {
|
|
50
|
-
if (
|
|
51
|
-
const retentionMs = Number.isInteger(
|
|
51
|
+
if (rawCfg.llmAudit?.enabled !== false) {
|
|
52
|
+
const retentionMs = Number.isInteger(rawCfg.llmAudit?.retentionDays) ? rawCfg.llmAudit.retentionDays : 90;
|
|
52
53
|
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
53
54
|
}
|
|
54
55
|
} catch { /* non-fatal */ }
|
|
56
|
+
|
|
57
|
+
// User-configurable settings (profile, rules, panel mode, standalone API
|
|
58
|
+
// token) share the same SQLite file in dedicated tables, isolated from
|
|
59
|
+
// memories. Created before the config is finalized: the persisted
|
|
60
|
+
// panel_mode participates in light-mode resolution below.
|
|
61
|
+
const settings = createSettings(store.db);
|
|
62
|
+
|
|
63
|
+
// Light mode (v0.7.12): the bundle config flag OR a persisted panel_mode of
|
|
64
|
+
// "light" (the panel switch wins over the bundle config so it survives
|
|
65
|
+
// config redeploys). applyLightModePreset turns every heavy background /
|
|
66
|
+
// semantic feature off and keeps the core loop (autoInject, autoSummarize,
|
|
67
|
+
// hot memory, quality filter).
|
|
68
|
+
const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
|
|
69
|
+
const cfg = applyLightModePreset({ ...rawCfg, lightMode });
|
|
70
|
+
|
|
55
71
|
const mirror = createMirror(memoryDir);
|
|
56
72
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
|
|
57
73
|
|
|
@@ -78,10 +94,6 @@ export const apply = (ctx, config) => {
|
|
|
78
94
|
} catch { /* non-fatal: recall recording is bookkeeping */ }
|
|
79
95
|
});
|
|
80
96
|
|
|
81
|
-
// User-configurable settings (profile, rules) and custom commands share the
|
|
82
|
-
// same SQLite file but live in dedicated tables, isolated from memories.
|
|
83
|
-
const settings = createSettings(store.db);
|
|
84
|
-
|
|
85
97
|
// Semantic pipeline: a local/ollama embedder when configured, otherwise the
|
|
86
98
|
// legacy OpenAI-compatible embedder (settings-driven). The vector index wraps
|
|
87
99
|
// the store's embedding column and tracks the active model fingerprint. A
|
|
@@ -107,7 +119,13 @@ export const apply = (ctx, config) => {
|
|
|
107
119
|
|
|
108
120
|
let embedder = null;
|
|
109
121
|
let reranker = null;
|
|
110
|
-
if (
|
|
122
|
+
if (lightMode) {
|
|
123
|
+
// Light mode: the whole vector pipeline stays off — no embedder (nothing
|
|
124
|
+
// pulls in ONNX/transformers), no reranker, no boot backfill (the preset
|
|
125
|
+
// also cleared autoReindexOnBoot). Recall degrades to keyword search and
|
|
126
|
+
// human mirror edits still merge on boot.
|
|
127
|
+
applyHumanEdits();
|
|
128
|
+
} else if (cfg.embedProvider === "openai") {
|
|
111
129
|
// vectorIndex is passed so the legacy OpenAI embedder records the producing
|
|
112
130
|
// model fingerprint after each successful embed (Bug3).
|
|
113
131
|
embedder = createEmbedder({ store, settings, logger: ctx.logger, vectorIndex });
|
|
@@ -326,6 +344,19 @@ export const apply = (ctx, config) => {
|
|
|
326
344
|
disposers.push(api.dispose);
|
|
327
345
|
}
|
|
328
346
|
|
|
347
|
+
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
|
348
|
+
// integrations outside the DSH host. Persisted external_api settings win
|
|
349
|
+
// over the bundle config (enabled/port); the Bearer token lives in the same
|
|
350
|
+
// kv and is auto-generated on first boot by createStandaloneApi. Binding a
|
|
351
|
+
// non-loopback host is the operator's documented responsibility.
|
|
352
|
+
if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
|
|
353
|
+
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings });
|
|
354
|
+
disposers.push(() => standalone.server.close());
|
|
355
|
+
standalone.ready.catch((error) => {
|
|
356
|
+
ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
329
360
|
// Async disposer: cordis awaits the returned promise on unload (runDisposable),
|
|
330
361
|
// so an in-flight dream run is allowed to finish before the SQLite store is
|
|
331
362
|
// closed — dream.dispose() resolves only after its current run settles.
|
package/lib/settings.js
CHANGED
|
@@ -137,6 +137,46 @@ export function createSettings(db) {
|
|
|
137
137
|
};
|
|
138
138
|
setSetting("vector", JSON.stringify(cfg));
|
|
139
139
|
return cfg;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Standalone external API settings (kv "external_api"): {enabled, port,
|
|
144
|
+
* token}. The Bearer token is auto-generated on first boot and persisted
|
|
145
|
+
* here. Partial writes preserve the keys they don't mention.
|
|
146
|
+
*/
|
|
147
|
+
getExternalApi() {
|
|
148
|
+
const raw = getSetting("external_api");
|
|
149
|
+
if (!raw) return undefined;
|
|
150
|
+
try {
|
|
151
|
+
const cfg = JSON.parse(raw);
|
|
152
|
+
return typeof cfg === "object" && cfg !== null ? cfg : undefined;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
setExternalApi(patch = {}) {
|
|
158
|
+
const prev = this.getExternalApi() ?? {};
|
|
159
|
+
const port = Number(patch.port ?? prev.port);
|
|
160
|
+
const host = typeof patch.host === "string" && patch.host.trim() ? patch.host.trim() : (prev.host ?? "127.0.0.1");
|
|
161
|
+
const cfg = {
|
|
162
|
+
enabled: patch.enabled !== undefined ? patch.enabled === true : prev.enabled === true,
|
|
163
|
+
port: Number.isInteger(port) && port > 0 ? port : 8790,
|
|
164
|
+
host,
|
|
165
|
+
token: String(patch.token ?? prev.token ?? "")
|
|
166
|
+
};
|
|
167
|
+
setSetting("external_api", JSON.stringify(cfg));
|
|
168
|
+
return cfg;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Web panel mode (kv "panel_mode"): "light" (low-resource preset) or
|
|
173
|
+
* "standard" (full feature set). Unset reads as "standard".
|
|
174
|
+
*/
|
|
175
|
+
getPanelMode() {
|
|
176
|
+
return getSetting("panel_mode") === "light" ? "light" : "standard";
|
|
177
|
+
},
|
|
178
|
+
setPanelMode(mode) {
|
|
179
|
+
setSetting("panel_mode", mode === "light" ? "light" : "standard");
|
|
140
180
|
}
|
|
141
181
|
};
|
|
142
182
|
}
|
package/lib/store.js
CHANGED
|
@@ -239,7 +239,10 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
239
239
|
);
|
|
240
240
|
`;
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
// Exported for API-layer type validation (standalone API POST /memories and
|
|
243
|
+
// the /status byType breakdown); the set itself stays the single source of
|
|
244
|
+
// truth for what store.save accepts.
|
|
245
|
+
export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
|
|
243
246
|
|
|
244
247
|
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
245
248
|
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
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.
|
|
4
|
+
"version": "0.7.12",
|
|
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",
|
|
@@ -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
|
+
}
|
package/src/api.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
function sendJson(res, status, payload) {
|
|
5
5
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
6
6
|
res.end(JSON.stringify(payload));
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Defaults for the standalone external API — keep in step with the schema
|
|
10
|
+
// defaults in config.js (externalApiPort / externalApiHost).
|
|
11
|
+
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
12
|
+
|
|
13
|
+
/** Merge persisted external-api kv over the defaults for client display. */
|
|
14
|
+
function fullExternalConfig(kv = {}) {
|
|
15
|
+
return {
|
|
16
|
+
enabled: kv.enabled === true,
|
|
17
|
+
port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
|
|
18
|
+
host: kv.host || EXTERNAL_API_DEFAULTS.host,
|
|
19
|
+
token: kv.token || ""
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
11
25
|
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
@@ -535,6 +549,81 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
535
549
|
}
|
|
536
550
|
});
|
|
537
551
|
|
|
552
|
+
// --- panel mode (v0.7.12): light / standard -------------------------------
|
|
553
|
+
// Persists the Web panel's feature preset into the settings kv
|
|
554
|
+
// ("panel_mode"). PUT requires auth like every other settings write; the
|
|
555
|
+
// value is validated against the enum. index.js applies the light preset on
|
|
556
|
+
// the next boot (persisted mode wins over the bundle config).
|
|
557
|
+
register({
|
|
558
|
+
kind: "exact",
|
|
559
|
+
path: "/api/dsh-mneme/mode",
|
|
560
|
+
handler(req, res) {
|
|
561
|
+
try {
|
|
562
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
563
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
564
|
+
return readBody(req).then((text) => {
|
|
565
|
+
const body = parseBody(text);
|
|
566
|
+
if (body.mode !== "light" && body.mode !== "standard") {
|
|
567
|
+
sendJson(res, 400, { error: "invalid-mode" });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
settings.setPanelMode(body.mode);
|
|
571
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
575
|
+
} catch {
|
|
576
|
+
sendJson(res, 500, { error: "internal" });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// --- external API settings (the standalone server's panel-facing config) ---
|
|
582
|
+
register({
|
|
583
|
+
kind: "exact",
|
|
584
|
+
path: "/api/dsh-mneme/external-api",
|
|
585
|
+
handler(req, res) {
|
|
586
|
+
try {
|
|
587
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
588
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
589
|
+
return readBody(req).then((text) => {
|
|
590
|
+
const body = parseBody(text);
|
|
591
|
+
const patch = {};
|
|
592
|
+
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
593
|
+
if (body.port !== undefined) {
|
|
594
|
+
const port = Number(body.port);
|
|
595
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
596
|
+
sendJson(res, 400, { error: "invalid-port" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
patch.port = port;
|
|
600
|
+
}
|
|
601
|
+
if (body.host !== undefined) {
|
|
602
|
+
const host = String(body.host).trim();
|
|
603
|
+
if (!host || host.includes("://")) {
|
|
604
|
+
sendJson(res, 400, { error: "invalid-host" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
patch.host = host;
|
|
608
|
+
}
|
|
609
|
+
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// GET: always hand back a token — the standalone server generates its
|
|
613
|
+
// own on first enabled boot, but the panel displays it before that,
|
|
614
|
+
// so materialize and persist one here.
|
|
615
|
+
const kv = settings.getExternalApi() ?? {};
|
|
616
|
+
if (!kv.token) {
|
|
617
|
+
kv.token = randomBytes(24).toString("base64url");
|
|
618
|
+
settings.setExternalApi({ token: kv.token });
|
|
619
|
+
}
|
|
620
|
+
sendJson(res, 200, { config: fullExternalConfig(kv) });
|
|
621
|
+
} catch {
|
|
622
|
+
sendJson(res, 500, { error: "internal" });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
538
627
|
// --- custom commands ---
|
|
539
628
|
register({
|
|
540
629
|
kind: "exact",
|
|
@@ -573,7 +662,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
573
662
|
});
|
|
574
663
|
|
|
575
664
|
return {
|
|
576
|
-
routes:
|
|
665
|
+
routes: 17,
|
|
577
666
|
dispose: () => {
|
|
578
667
|
for (const dispose of disposers) dispose();
|
|
579
668
|
}
|