@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/src/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/src/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/src/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/src/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/test/api.test.js
CHANGED
|
@@ -191,6 +191,50 @@ test("PUT /api/dsh-mneme/rules saves rules", async () => {
|
|
|
191
191
|
assert.deepEqual(settings.getRules(), ["a", "b"]);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
// --- panel mode (light/standard) ---
|
|
195
|
+
|
|
196
|
+
test("GET /api/dsh-mneme/mode defaults to standard", async () => {
|
|
197
|
+
const { routes } = setup();
|
|
198
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
199
|
+
const res = new FakeRes();
|
|
200
|
+
await route.handler(req("/api/dsh-mneme/mode"), res);
|
|
201
|
+
assert.equal(res.statusCode, 200);
|
|
202
|
+
assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
|
|
206
|
+
const { routes, settings } = setup();
|
|
207
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
208
|
+
|
|
209
|
+
const put = new FakeRes();
|
|
210
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
|
|
211
|
+
assert.equal(put.statusCode, 200);
|
|
212
|
+
assert.deepEqual(JSON.parse(put.body), { mode: "light" });
|
|
213
|
+
assert.equal(settings.getPanelMode(), "light");
|
|
214
|
+
|
|
215
|
+
const back = new FakeRes();
|
|
216
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
|
|
217
|
+
assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
|
|
218
|
+
|
|
219
|
+
const bad = new FakeRes();
|
|
220
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
|
|
221
|
+
assert.equal(bad.statusCode, 400);
|
|
222
|
+
assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
|
|
226
|
+
const { routes } = setup(undefined, "secret-token");
|
|
227
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
228
|
+
const res = new FakeRes();
|
|
229
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
|
|
230
|
+
assert.equal(res.statusCode, 401);
|
|
231
|
+
const ok = new FakeRes();
|
|
232
|
+
const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
|
|
233
|
+
authed.headers = { authorization: "Bearer secret-token" };
|
|
234
|
+
await route.handler(authed, ok);
|
|
235
|
+
assert.equal(ok.statusCode, 200);
|
|
236
|
+
});
|
|
237
|
+
|
|
194
238
|
test("GET /api/dsh-mneme/commands lists commands", async () => {
|
|
195
239
|
const { routes, settings } = setup();
|
|
196
240
|
settings.addCommand({ name: "agenda", instruction: "x" });
|
package/test/settings.test.js
CHANGED
|
@@ -99,3 +99,27 @@ test("vector config disabled value is stored as false", () => {
|
|
|
99
99
|
assert.equal(settings.getVectorConfig().enabled, false);
|
|
100
100
|
store.close();
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("panel mode defaults to standard and round-trips", () => {
|
|
104
|
+
const { store, settings } = setup();
|
|
105
|
+
assert.equal(settings.getPanelMode(), "standard");
|
|
106
|
+
settings.setPanelMode("light");
|
|
107
|
+
assert.equal(settings.getPanelMode(), "light");
|
|
108
|
+
settings.setPanelMode("standard");
|
|
109
|
+
assert.equal(settings.getPanelMode(), "standard");
|
|
110
|
+
store.close();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("external api settings persist token and merge partial writes", () => {
|
|
114
|
+
const { store, settings } = setup();
|
|
115
|
+
assert.equal(settings.getExternalApi(), undefined);
|
|
116
|
+
settings.setExternalApi({ enabled: true, port: 9000, token: "tok-1" });
|
|
117
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "127.0.0.1", token: "tok-1" });
|
|
118
|
+
// Token-only write preserves the other keys.
|
|
119
|
+
settings.setExternalApi({ token: "tok-2" });
|
|
120
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "127.0.0.1", token: "tok-2" });
|
|
121
|
+
// Host write persists and survives the next partial merge.
|
|
122
|
+
settings.setExternalApi({ host: "0.0.0.0" });
|
|
123
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "0.0.0.0", token: "tok-2" });
|
|
124
|
+
store.close();
|
|
125
|
+
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createSettings } from "../src/settings.js";
|
|
6
|
+
import { createStandaloneApi } from "../src/api-standalone.js";
|
|
7
|
+
import { Config, applyLightModePreset } from "../src/config.js";
|
|
8
|
+
|
|
9
|
+
// Real HTTP server on an OS-assigned port (port: 0), driven with fetch.
|
|
10
|
+
async function setup() {
|
|
11
|
+
const store = createStore(":memory:");
|
|
12
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
13
|
+
const settings = createSettings(store.db);
|
|
14
|
+
const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
|
|
15
|
+
await api.ready;
|
|
16
|
+
const base = `http://127.0.0.1:${api.port}`;
|
|
17
|
+
const auth = { authorization: `Bearer ${api.token}` };
|
|
18
|
+
return {
|
|
19
|
+
store,
|
|
20
|
+
service,
|
|
21
|
+
settings,
|
|
22
|
+
api,
|
|
23
|
+
base,
|
|
24
|
+
auth,
|
|
25
|
+
close: () => {
|
|
26
|
+
// Drop pooled keep-alive sockets so node --test can drain the loop.
|
|
27
|
+
api.server.closeIdleConnections?.();
|
|
28
|
+
api.server.close();
|
|
29
|
+
store.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- auth ---------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test("GET /health is open without a token; every other route 401s", async () => {
|
|
37
|
+
const { base, close } = await setup();
|
|
38
|
+
try {
|
|
39
|
+
const health = await fetch(`${base}/health`);
|
|
40
|
+
assert.equal(health.status, 200);
|
|
41
|
+
assert.deepEqual(await health.json(), { ok: true });
|
|
42
|
+
|
|
43
|
+
for (const path of ["/status", "/memories", "/memories/x", "/search?q=x"]) {
|
|
44
|
+
const res = await fetch(`${base}${path}`);
|
|
45
|
+
assert.equal(res.status, 401, `${path} requires a token`);
|
|
46
|
+
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bad = await fetch(`${base}/status`, { headers: { authorization: "Bearer wrong-token" } });
|
|
50
|
+
assert.equal(bad.status, 401, "wrong token rejected");
|
|
51
|
+
} finally {
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("token is persisted into settings kv and reused across restarts", async () => {
|
|
57
|
+
const store = createStore(":memory:");
|
|
58
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
59
|
+
const settings = createSettings(store.db);
|
|
60
|
+
const first = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
61
|
+
await first.ready;
|
|
62
|
+
try {
|
|
63
|
+
assert.equal(settings.getExternalApi().token, first.token, "generated token persisted");
|
|
64
|
+
assert.ok(first.token.length >= 20, "token is a real random value");
|
|
65
|
+
const second = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
66
|
+
await second.ready;
|
|
67
|
+
assert.equal(second.token, first.token, "restart reuses the persisted token");
|
|
68
|
+
second.server.closeIdleConnections?.();
|
|
69
|
+
second.server.close();
|
|
70
|
+
} finally {
|
|
71
|
+
first.server.closeIdleConnections?.();
|
|
72
|
+
first.server.close();
|
|
73
|
+
store.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- CRUD loop ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test("POST/GET/DELETE /memories round trip", async () => {
|
|
80
|
+
const { base, auth, close } = await setup();
|
|
81
|
+
try {
|
|
82
|
+
const post = await fetch(`${base}/memories`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ type: "preference", title: "语言", content: "始终用中文回复", importance: 4, tags: ["交流"] })
|
|
86
|
+
});
|
|
87
|
+
assert.equal(post.status, 201);
|
|
88
|
+
const created = await post.json();
|
|
89
|
+
assert.ok(created.id, "row id returned");
|
|
90
|
+
assert.equal(created.title, "语言");
|
|
91
|
+
assert.equal(created.importance, 4);
|
|
92
|
+
|
|
93
|
+
const list = await fetch(`${base}/memories?limit=10`, { headers: auth });
|
|
94
|
+
assert.equal(list.status, 200);
|
|
95
|
+
const listBody = await list.json();
|
|
96
|
+
assert.equal(listBody.total, 1);
|
|
97
|
+
assert.equal(listBody.items.length, 1);
|
|
98
|
+
|
|
99
|
+
const got = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
100
|
+
assert.equal(got.status, 200);
|
|
101
|
+
assert.equal((await got.json()).content, "始终用中文回复");
|
|
102
|
+
|
|
103
|
+
const del = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
104
|
+
assert.equal(del.status, 200);
|
|
105
|
+
assert.deepEqual(await del.json(), { ok: true });
|
|
106
|
+
|
|
107
|
+
const gone = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
108
|
+
assert.equal(gone.status, 404, "deleted row reads back as 404");
|
|
109
|
+
const delAgain = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
110
|
+
assert.equal(delAgain.status, 404, "deleting a missing row is 404, not a fake ok");
|
|
111
|
+
} finally {
|
|
112
|
+
close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("POST /memories dedupes same title within a type (200 + merged content)", async () => {
|
|
117
|
+
const { base, auth, close } = await setup();
|
|
118
|
+
try {
|
|
119
|
+
const first = await fetch(`${base}/memories`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v1 内容" })
|
|
123
|
+
});
|
|
124
|
+
assert.equal(first.status, 201);
|
|
125
|
+
const row1 = await first.json();
|
|
126
|
+
|
|
127
|
+
const second = await fetch(`${base}/memories`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v2 追加" })
|
|
131
|
+
});
|
|
132
|
+
assert.equal(second.status, 200, "merge, not a second creation");
|
|
133
|
+
const row2 = await second.json();
|
|
134
|
+
assert.equal(row2.id, row1.id, "same row reused");
|
|
135
|
+
assert.ok(row2.content.includes("v1 内容") && row2.content.includes("v2 追加"), "content appended");
|
|
136
|
+
|
|
137
|
+
const list = await fetch(`${base}/memories`, { headers: auth });
|
|
138
|
+
assert.equal((await list.json()).total, 1, "no duplicate row created");
|
|
139
|
+
} finally {
|
|
140
|
+
close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("POST /memories validation: invalid type, bad JSON, non-array tags → 400", async () => {
|
|
145
|
+
const { base, auth, close } = await setup();
|
|
146
|
+
try {
|
|
147
|
+
const badType = await fetch(`${base}/memories`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ type: "diary", title: "t", content: "c" })
|
|
151
|
+
});
|
|
152
|
+
assert.equal(badType.status, 400);
|
|
153
|
+
assert.deepEqual(await badType.json(), { error: "invalid-type" });
|
|
154
|
+
|
|
155
|
+
const badJson = await fetch(`${base}/memories`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
158
|
+
body: "{not json"
|
|
159
|
+
});
|
|
160
|
+
assert.equal(badJson.status, 400);
|
|
161
|
+
assert.deepEqual(await badJson.json(), { error: "invalid-json" });
|
|
162
|
+
|
|
163
|
+
const badTags = await fetch(`${base}/memories`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ type: "project", title: "t", content: "c", tags: "x" })
|
|
167
|
+
});
|
|
168
|
+
assert.equal(badTags.status, 400);
|
|
169
|
+
assert.deepEqual(await badTags.json(), { error: "tags-must-be-an-array" });
|
|
170
|
+
|
|
171
|
+
const noTitle = await fetch(`${base}/memories`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ type: "project", content: "c" })
|
|
175
|
+
});
|
|
176
|
+
assert.equal(noTitle.status, 400);
|
|
177
|
+
assert.deepEqual(await noTitle.json(), { error: "missing-title" });
|
|
178
|
+
} finally {
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- list filters / status / search ---------------------------------------------
|
|
184
|
+
|
|
185
|
+
test("GET /memories honors type + minImportance filters and paging", async () => {
|
|
186
|
+
const { base, auth, service, close } = await setup();
|
|
187
|
+
try {
|
|
188
|
+
service.saveWithDedupe({ type: "preference", title: "低", content: "i2", importance: 2 });
|
|
189
|
+
service.saveWithDedupe({ type: "preference", title: "高", content: "i5", importance: 5 });
|
|
190
|
+
service.saveWithDedupe({ type: "project", title: "项目", content: "i4", importance: 4 });
|
|
191
|
+
|
|
192
|
+
const byType = await fetch(`${base}/memories?type=preference`, { headers: auth });
|
|
193
|
+
assert.equal((await byType.json()).total, 2);
|
|
194
|
+
|
|
195
|
+
const byImportance = await fetch(`${base}/memories?minImportance=4`, { headers: auth });
|
|
196
|
+
const filtered = await byImportance.json();
|
|
197
|
+
assert.equal(filtered.total, 2);
|
|
198
|
+
assert.deepEqual(filtered.items.map((m) => m.title).sort(), ["项目", "高"]);
|
|
199
|
+
|
|
200
|
+
const paged = await fetch(`${base}/memories?limit=2&offset=0&order=chrono`, { headers: auth });
|
|
201
|
+
const page = await paged.json();
|
|
202
|
+
assert.equal(page.items.length, 2);
|
|
203
|
+
assert.equal(page.total, 3);
|
|
204
|
+
} finally {
|
|
205
|
+
close();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("GET /status reports version, per-type totals, entities and uptime", async () => {
|
|
210
|
+
const { base, auth, service, close } = await setup();
|
|
211
|
+
try {
|
|
212
|
+
service.saveWithDedupe({ type: "preference", title: "p", content: "c" });
|
|
213
|
+
service.saveWithDedupe({ type: "project", title: "j", content: "c" });
|
|
214
|
+
service.saveWithDedupe({ type: "project", title: "j2", content: "c" });
|
|
215
|
+
|
|
216
|
+
const res = await fetch(`${base}/status`, { headers: auth });
|
|
217
|
+
assert.equal(res.status, 200);
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
assert.equal(body.version, "0.7.12");
|
|
220
|
+
assert.equal(body.memories.total, 3);
|
|
221
|
+
assert.equal(body.memories.byType.preference, 1);
|
|
222
|
+
assert.equal(body.memories.byType.project, 2);
|
|
223
|
+
assert.equal(body.entities, 0, "no extraction wired → empty entity table");
|
|
224
|
+
assert.ok(Number.isInteger(body.uptime_s));
|
|
225
|
+
} finally {
|
|
226
|
+
close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("GET /search finds saved memories; empty q returns empty keyword result", async () => {
|
|
231
|
+
const { base, auth, close } = await setup();
|
|
232
|
+
try {
|
|
233
|
+
await fetch(`${base}/memories`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ type: "project", title: "记忆插件", content: "SQLite 全文检索" })
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const res = await fetch(`${base}/search?q=${encodeURIComponent("全文检索")}&mode=keyword`, { headers: auth });
|
|
240
|
+
assert.equal(res.status, 200);
|
|
241
|
+
const body = await res.json();
|
|
242
|
+
assert.equal(body.mode, "keyword");
|
|
243
|
+
assert.equal(body.items.length, 1);
|
|
244
|
+
assert.equal(body.items[0].title, "记忆插件");
|
|
245
|
+
|
|
246
|
+
const empty = await fetch(`${base}/search?q=`, { headers: auth });
|
|
247
|
+
assert.deepEqual(await empty.json(), { items: [], mode: "keyword" });
|
|
248
|
+
} finally {
|
|
249
|
+
close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("unknown path returns 404 json", async () => {
|
|
254
|
+
const { base, auth, close } = await setup();
|
|
255
|
+
try {
|
|
256
|
+
const res = await fetch(`${base}/nope`, { headers: auth });
|
|
257
|
+
assert.equal(res.status, 404);
|
|
258
|
+
assert.deepEqual(await res.json(), { error: "not-found" });
|
|
259
|
+
} finally {
|
|
260
|
+
close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- light-mode preset (applyLightModePreset) ------------------------------------
|
|
265
|
+
|
|
266
|
+
const LIGHT_OFF_FIELDS = [
|
|
267
|
+
"entityExtractionEnabled",
|
|
268
|
+
"autoDream",
|
|
269
|
+
"sleepModeEnabled",
|
|
270
|
+
"rerankEnabled",
|
|
271
|
+
"autoReindexOnBoot",
|
|
272
|
+
"hybridInject",
|
|
273
|
+
"searchSemanticDedup",
|
|
274
|
+
"selectiveInjectEnabled",
|
|
275
|
+
"bm25SearchEnabled"
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
test("applyLightModePreset turns heavy features off and keeps the core loop", () => {
|
|
279
|
+
const cfg = applyLightModePreset(Config({ lightMode: true }));
|
|
280
|
+
for (const field of LIGHT_OFF_FIELDS) {
|
|
281
|
+
assert.equal(cfg[field], false, `${field} forced off in light mode`);
|
|
282
|
+
}
|
|
283
|
+
// Core loop preserved.
|
|
284
|
+
assert.equal(cfg.autoInject, true);
|
|
285
|
+
assert.equal(cfg.autoSummarize, true);
|
|
286
|
+
assert.equal(cfg.hotMemoryEnabled, true);
|
|
287
|
+
assert.equal(cfg.memoryQualityFilter.enabled, true);
|
|
288
|
+
// Unrelated knobs untouched.
|
|
289
|
+
assert.equal(cfg.dreamThresholdCount, 10);
|
|
290
|
+
assert.equal(cfg.dreamDelayMs, 2000);
|
|
291
|
+
assert.equal(cfg.maxInjectedItems, 5);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("applyLightModePreset keeps explicit non-preset values and is a no-op without lightMode", () => {
|
|
295
|
+
const tuned = applyLightModePreset(Config({ lightMode: true, dreamThresholdCount: 30, maxInjectedItems: 8 }));
|
|
296
|
+
assert.equal(tuned.dreamThresholdCount, 30, "operator values survive the preset");
|
|
297
|
+
assert.equal(tuned.maxInjectedItems, 8);
|
|
298
|
+
|
|
299
|
+
const plain = Config({});
|
|
300
|
+
assert.equal(applyLightModePreset(plain), plain, "identity when lightMode is unset");
|
|
301
|
+
const off = Config({ lightMode: false });
|
|
302
|
+
assert.equal(applyLightModePreset(off), off, "identity when lightMode is false");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("persisted panel_mode=light wins over a bundle config that did not ask for it", () => {
|
|
306
|
+
const store = createStore(":memory:");
|
|
307
|
+
const settings = createSettings(store.db);
|
|
308
|
+
try {
|
|
309
|
+
// Panel switched to light in a previous session; bundle config says nothing.
|
|
310
|
+
settings.setPanelMode("light");
|
|
311
|
+
const rawCfg = Config({});
|
|
312
|
+
const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
|
|
313
|
+
const cfg = applyLightModePreset({ ...rawCfg, lightMode });
|
|
314
|
+
assert.equal(lightMode, true, "persisted mode forces light");
|
|
315
|
+
assert.equal(cfg.autoDream, false);
|
|
316
|
+
assert.equal(cfg.entityExtractionEnabled, false);
|
|
317
|
+
assert.equal(cfg.autoInject, true, "core injection stays on");
|
|
318
|
+
|
|
319
|
+
// standard (default) never forces the preset even with a light bundle flag off.
|
|
320
|
+
settings.setPanelMode("standard");
|
|
321
|
+
const raw2 = Config({});
|
|
322
|
+
assert.equal(raw2.lightMode === true || settings.getPanelMode() === "light", false);
|
|
323
|
+
} finally {
|
|
324
|
+
store.close();
|
|
325
|
+
}
|
|
326
|
+
});
|