nlm-memory 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -34
- package/dist/cli/nlm.js +2 -1
- package/dist/cli/nlm.js.map +1 -1
- package/dist/http/app.js +2 -1
- package/dist/http/app.js.map +1 -1
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/ui/assets/{index-C8cpwbYJ.css → index-Beo8psd-.css} +1 -1
- package/dist/ui/assets/{index-CB50QnL-.js → index-CSPTTeeM.js} +8 -8
- package/dist/ui/index.html +2 -2
- package/package.json +26 -1
- package/.agents/plugins/marketplace.json +0 -20
- package/.github/workflows/ci.yml +0 -30
- package/docs/methodology/re-derivation-rate.md +0 -112
- package/docs/methodology/useful-hit-rate.md +0 -79
- package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
- package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
- package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
- package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
- package/docs/plans/desktop-product.md +0 -69
- package/docs/plans/factstore-design.md +0 -236
- package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1575
- package/logs/CHANGELOG/CHANGELOG.md +0 -209
- package/migrations/000_initial_schema.sql +0 -174
- package/migrations/001_entity_type_rename.sql +0 -17
- package/migrations/002_adapter_state_extend.sql +0 -12
- package/migrations/003_session_embeddings.sql +0 -11
- package/migrations/004_facts.sql +0 -46
- package/migrations/005_sources.sql +0 -31
- package/migrations/006_providers.sql +0 -33
- package/migrations/007_source_tokens.sql +0 -17
- package/migrations/008_fts_rebuild.sql +0 -9
- package/migrations/009_session_embedding_chunks.sql +0 -46
- package/migrations/010_sources_opencode.sql +0 -30
- package/migrations/011_sources_hermes_agent.sql +0 -30
- package/migrations/012_sources_aider.sql +0 -30
- package/migrations/013_adapter_state_failure_count.sql +0 -12
- package/migrations/014_sources_cursor.sql +0 -30
- package/migrations/015_sources_windsurf.sql +0 -30
- package/plugin-hermes-agent/README.md +0 -49
- package/plugin-hermes-agent/__init__.py +0 -75
- package/plugin-hermes-agent/plugin.yaml +0 -15
- package/scripts/backfill-citations.mjs +0 -0
- package/scripts/build-codex-plugin.mjs +0 -61
- package/scripts/deepseek-probe.mjs +0 -67
- package/scripts/extract-triples.mjs +0 -207
- package/scripts/longmemeval/embedding-cache.ts +0 -77
- package/scripts/longmemeval/fetch-dataset.sh +0 -25
- package/scripts/longmemeval/run-harness.ts +0 -315
- package/scripts/longmemeval/scorer.ts +0 -99
- package/scripts/longmemeval/tsconfig.json +0 -9
- package/scripts/longmemeval/types.ts +0 -35
- package/scripts/nlm-daily-digest.py +0 -239
- package/scripts/nlm-daily-digest.sh +0 -28
- package/src/cli/classify-parity.ts +0 -257
- package/src/cli/launchctl-helpers.ts +0 -49
- package/src/cli/nlm.ts +0 -1078
- package/src/core/actions/actions-log.ts +0 -118
- package/src/core/actions/overlay.ts +0 -117
- package/src/core/adapters/aider.ts +0 -205
- package/src/core/adapters/claude-code.ts +0 -293
- package/src/core/adapters/common.ts +0 -54
- package/src/core/adapters/cursor.ts +0 -486
- package/src/core/adapters/from-source.ts +0 -67
- package/src/core/adapters/hermes-agent.ts +0 -240
- package/src/core/adapters/hermes.ts +0 -277
- package/src/core/adapters/jsonl-generic.ts +0 -208
- package/src/core/adapters/opencode.ts +0 -281
- package/src/core/adapters/pi.ts +0 -264
- package/src/core/adapters/windsurf.ts +0 -386
- package/src/core/classifier/prompt.ts +0 -200
- package/src/core/dataset/build-dataset.ts +0 -463
- package/src/core/embedding/chunk-body.ts +0 -76
- package/src/core/embedding/embed-backfill.ts +0 -210
- package/src/core/embedding/embed-normalize.ts +0 -135
- package/src/core/facts/backfill-facts.ts +0 -254
- package/src/core/facts/extract-facts.ts +0 -50
- package/src/core/hook/citation-detect.ts +0 -124
- package/src/core/hook/cite-memo.ts +0 -68
- package/src/core/hook/claude-settings.ts +0 -187
- package/src/core/hook/gate.ts +0 -25
- package/src/core/hook/hook-log.ts +0 -41
- package/src/core/hook/memo-sweep.ts +0 -164
- package/src/core/hook/memo.ts +0 -67
- package/src/core/hook/pointer-block.ts +0 -26
- package/src/core/hook/select.ts +0 -32
- package/src/core/hook/transcript.ts +0 -121
- package/src/core/ingest/ingest-session.ts +0 -111
- package/src/core/providers/provider-models.ts +0 -100
- package/src/core/providers/provider-registry.ts +0 -196
- package/src/core/recall/citation-log.ts +0 -108
- package/src/core/recall/filter.ts +0 -27
- package/src/core/recall/index.ts +0 -6
- package/src/core/recall/match-fields.ts +0 -40
- package/src/core/recall/query-log.ts +0 -149
- package/src/core/recall/query-shape.ts +0 -66
- package/src/core/recall/recall-service.ts +0 -320
- package/src/core/recall/recent-log.ts +0 -59
- package/src/core/recall/tokenize.ts +0 -18
- package/src/core/recall/useful-scan.ts +0 -336
- package/src/core/recall-facts/fact-query-log.ts +0 -150
- package/src/core/recall-facts/fact-recall-service.ts +0 -327
- package/src/core/scheduler/scan-once.ts +0 -142
- package/src/core/scheduler/scheduler.ts +0 -225
- package/src/core/sources/source-registry.ts +0 -278
- package/src/core/storage/db-restore.ts +0 -133
- package/src/core/storage/live-status.ts +0 -45
- package/src/core/storage/migrate.ts +0 -72
- package/src/core/storage/sqlite-fact-store.ts +0 -304
- package/src/core/storage/sqlite-session-store.ts +0 -810
- package/src/hook/hook-auth.ts +0 -18
- package/src/hook/prompt-recall-hook.ts +0 -180
- package/src/hook/session-end-hook.ts +0 -81
- package/src/hook/session-start-hook.ts +0 -168
- package/src/hook/stop-hook.ts +0 -239
- package/src/http/app.ts +0 -1215
- package/src/install/claude-code.ts +0 -128
- package/src/install/codex.ts +0 -367
- package/src/install/cursor.ts +0 -68
- package/src/install/hermes-agent.ts +0 -76
- package/src/install/hermes.ts +0 -78
- package/src/install/nlm-dir-perms.ts +0 -55
- package/src/install/ollama.ts +0 -284
- package/src/install/setup.ts +0 -489
- package/src/install/windsurf.ts +0 -68
- package/src/llm/classifier-box.ts +0 -64
- package/src/llm/deepseek-client.ts +0 -150
- package/src/llm/env-autoload.ts +0 -55
- package/src/llm/ollama-client.ts +0 -189
- package/src/mcp/server.ts +0 -534
- package/src/ports/fact-store.ts +0 -102
- package/src/ports/llm-client.ts +0 -52
- package/src/ports/logger.ts +0 -16
- package/src/ports/session-store.ts +0 -45
- package/src/ports/transcript-adapter.ts +0 -55
- package/src/shared/types.ts +0 -149
- package/src/ui/App.tsx +0 -58
- package/src/ui/components/PromoteOpenButton.tsx +0 -65
- package/src/ui/components/SessionDrawer.tsx +0 -199
- package/src/ui/components/SideNav.tsx +0 -162
- package/src/ui/components/Skeleton.tsx +0 -107
- package/src/ui/index.html +0 -13
- package/src/ui/lib/actions.ts +0 -30
- package/src/ui/lib/api.ts +0 -92
- package/src/ui/lib/dataset.ts +0 -141
- package/src/ui/lib/registries.ts +0 -155
- package/src/ui/lib/view-settings.ts +0 -41
- package/src/ui/main.tsx +0 -15
- package/src/ui/pages/Live.tsx +0 -229
- package/src/ui/pages/Pulse.tsx +0 -415
- package/src/ui/pages/Recall.tsx +0 -190
- package/src/ui/pages/River.tsx +0 -354
- package/src/ui/pages/Search.tsx +0 -386
- package/src/ui/pages/Stub.tsx +0 -9
- package/src/ui/pages/Thread.tsx +0 -473
- package/src/ui/pages/settings/Classifier.tsx +0 -227
- package/src/ui/pages/settings/Data.tsx +0 -190
- package/src/ui/pages/settings/Index.tsx +0 -65
- package/src/ui/pages/settings/Labels.tsx +0 -224
- package/src/ui/pages/settings/Providers.tsx +0 -305
- package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
- package/src/ui/pages/settings/Sources.tsx +0 -326
- package/src/ui/pages/settings/Views.tsx +0 -96
- package/src/ui/styles.css +0 -1890
- package/src/ui/tsconfig.json +0 -21
- package/src/ui/vite.config.ts +0 -19
- package/tests/fixtures/claude_code/short_session.jsonl +0 -2
- package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
- package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
- package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
- package/tests/fixtures/facts.ts +0 -17
- package/tests/fixtures/golden-corpus.ts +0 -85
- package/tests/fixtures/hermes/paired_request_dump.json +0 -24
- package/tests/fixtures/hermes/paired_session.json +0 -23
- package/tests/fixtures/hermes/request_dump.json +0 -28
- package/tests/fixtures/hermes/session_iso.json +0 -38
- package/tests/fixtures/hermes/session_unix.json +0 -38
- package/tests/fixtures/hermes/system_only.json +0 -18
- package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
- package/tests/fixtures/pi/short-successful.jsonl +0 -5
- package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
- package/tests/fixtures/sessions.ts +0 -22
- package/tests/integration/backfill-facts.test.ts +0 -362
- package/tests/integration/citation-explicit.test.ts +0 -111
- package/tests/integration/cite-event.test.ts +0 -169
- package/tests/integration/cite-memo.test.ts +0 -87
- package/tests/integration/db-restore.test.ts +0 -153
- package/tests/integration/embed-backfill.test.ts +0 -176
- package/tests/integration/fact-supersedence.test.ts +0 -313
- package/tests/integration/fts-index.test.ts +0 -60
- package/tests/integration/getbyids-sqlite.test.ts +0 -100
- package/tests/integration/hermes-agent-hooks.test.ts +0 -248
- package/tests/integration/hook-claude-settings.test.ts +0 -218
- package/tests/integration/hook-log.test.ts +0 -54
- package/tests/integration/hook-memo.test.ts +0 -68
- package/tests/integration/hook-pre-compact.test.ts +0 -105
- package/tests/integration/hook-subagent-start.test.ts +0 -102
- package/tests/integration/http.test.ts +0 -401
- package/tests/integration/keyword-search-fts.test.ts +0 -66
- package/tests/integration/mcp-recall-logging.test.ts +0 -88
- package/tests/integration/mcp.test.ts +0 -260
- package/tests/integration/memo-sweep.test.ts +0 -91
- package/tests/integration/prompt-recall-hook.test.ts +0 -88
- package/tests/integration/provider-registry.test.ts +0 -107
- package/tests/integration/recall-golden.test.ts +0 -59
- package/tests/integration/recall-sqlite.test.ts +0 -169
- package/tests/integration/scheduler.test.ts +0 -391
- package/tests/integration/session-end-hook.test.ts +0 -48
- package/tests/integration/session-start-hook.test.ts +0 -126
- package/tests/integration/source-registry.test.ts +0 -122
- package/tests/integration/sqlite-fact-store.test.ts +0 -346
- package/tests/integration/stop-hook.test.ts +0 -560
- package/tests/integration/wal-checkpoint.test.ts +0 -49
- package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
- package/tests/unit/core/adapters/aider.test.ts +0 -230
- package/tests/unit/core/adapters/claude-code.test.ts +0 -118
- package/tests/unit/core/adapters/cursor.test.ts +0 -485
- package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
- package/tests/unit/core/adapters/hermes.test.ts +0 -81
- package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
- package/tests/unit/core/adapters/opencode.test.ts +0 -354
- package/tests/unit/core/adapters/pi.test.ts +0 -110
- package/tests/unit/core/adapters/windsurf.test.ts +0 -416
- package/tests/unit/core/classifier/prompt.test.ts +0 -126
- package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
- package/tests/unit/core/facts/extract-facts.test.ts +0 -117
- package/tests/unit/core/filter.test.ts +0 -40
- package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
- package/tests/unit/core/hook/citation-detect.test.ts +0 -124
- package/tests/unit/core/hook/gate.test.ts +0 -29
- package/tests/unit/core/hook/pointer-block.test.ts +0 -22
- package/tests/unit/core/hook/select.test.ts +0 -66
- package/tests/unit/core/match-fields.test.ts +0 -39
- package/tests/unit/core/mcp-cite-session.test.ts +0 -51
- package/tests/unit/core/providers/provider-models.test.ts +0 -101
- package/tests/unit/core/query-shape.test.ts +0 -92
- package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
- package/tests/unit/core/recall-service.test.ts +0 -200
- package/tests/unit/core/storage/live-status.test.ts +0 -54
- package/tests/unit/core/tokenize.test.ts +0 -32
- package/tests/unit/core/useful-scan.test.ts +0 -537
- package/tests/unit/llm/embed.test.ts +0 -93
- package/tests/unit/llm/ollama-client.test.ts +0 -124
- package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
- package/tsconfig.json +0 -31
- package/tsconfig.test.json +0 -11
- package/vitest.config.ts +0 -22
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The MCP recall handlers must write to the recall telemetry, the same way
|
|
3
|
-
* the HTTP /api/recall path does. Without this, every agent recall via MCP
|
|
4
|
-
* is invisible to query_log.jsonl / fact_query_log.jsonl and the Recall
|
|
5
|
-
* page — which is the path that actually matters for adoption telemetry.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
-
import {
|
|
13
|
-
recallFactsHandler,
|
|
14
|
-
recallSessionsHandler,
|
|
15
|
-
type McpDeps,
|
|
16
|
-
} from "../../src/mcp/server.js";
|
|
17
|
-
|
|
18
|
-
// logQuery is fire-and-forget (void) in the handler — poll for the line.
|
|
19
|
-
async function waitForLine(path: string): Promise<string> {
|
|
20
|
-
for (let i = 0; i < 60; i++) {
|
|
21
|
-
if (existsSync(path)) {
|
|
22
|
-
const txt = readFileSync(path, "utf8").trim();
|
|
23
|
-
if (txt) return txt;
|
|
24
|
-
}
|
|
25
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
26
|
-
}
|
|
27
|
-
throw new Error(`no log line written to ${path} within timeout`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("MCP recall handlers write telemetry", () => {
|
|
31
|
-
let tmp: string;
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-mcplog-"));
|
|
35
|
-
process.env["NLM_QUERY_LOG"] = join(tmp, "query_log.jsonl");
|
|
36
|
-
process.env["NLM_FACT_QUERY_LOG"] = join(tmp, "fact_query_log.jsonl");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
delete process.env["NLM_QUERY_LOG"];
|
|
41
|
-
delete process.env["NLM_FACT_QUERY_LOG"];
|
|
42
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("recall_sessions logs an mcp-source query", async () => {
|
|
46
|
-
const deps = {
|
|
47
|
-
recall: {
|
|
48
|
-
search: async () => ({
|
|
49
|
-
query: "pgvector",
|
|
50
|
-
entity: null,
|
|
51
|
-
kind: null,
|
|
52
|
-
mode: "keyword",
|
|
53
|
-
limit: 10,
|
|
54
|
-
total: 2,
|
|
55
|
-
results: [{ id: "s1" }, { id: "s2" }],
|
|
56
|
-
}),
|
|
57
|
-
},
|
|
58
|
-
} as unknown as McpDeps;
|
|
59
|
-
|
|
60
|
-
await recallSessionsHandler(deps, { query: "pgvector", mode: "keyword", limit: 10 });
|
|
61
|
-
|
|
62
|
-
const entry = JSON.parse(await waitForLine(process.env["NLM_QUERY_LOG"] as string));
|
|
63
|
-
expect(entry.source).toBe("mcp");
|
|
64
|
-
expect(entry.query).toBe("pgvector");
|
|
65
|
-
expect(entry.n_results).toBe(2);
|
|
66
|
-
expect(entry.returned_ids).toEqual(["s1", "s2"]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("recall_facts logs an mcp-source query", async () => {
|
|
70
|
-
const deps = {
|
|
71
|
-
factRecall: {
|
|
72
|
-
search: async () => ({
|
|
73
|
-
query: "routing",
|
|
74
|
-
total: 1,
|
|
75
|
-
results: [{ id: "fact_x" }],
|
|
76
|
-
}),
|
|
77
|
-
},
|
|
78
|
-
} as unknown as McpDeps;
|
|
79
|
-
|
|
80
|
-
await recallFactsHandler(deps, { query: "routing", mode: "keyword", limit: 10 });
|
|
81
|
-
|
|
82
|
-
const entry = JSON.parse(await waitForLine(process.env["NLM_FACT_QUERY_LOG"] as string));
|
|
83
|
-
expect(entry.source).toBe("mcp");
|
|
84
|
-
expect(entry.query).toBe("routing");
|
|
85
|
-
expect(entry.n_results).toBe(1);
|
|
86
|
-
expect(entry.returned_ids).toEqual(["fact_x"]);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP adapter integration. Exercises the tool handlers directly (no stdio
|
|
3
|
-
* transport) to prove the in-process binding to RecallService + SessionStore
|
|
4
|
-
* works end-to-end.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
9
|
-
import { join, resolve } from "node:path";
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
11
|
-
import { FactRecallService } from "../../src/core/recall-facts/fact-recall-service.js";
|
|
12
|
-
import { RecallService } from "../../src/core/recall/recall-service.js";
|
|
13
|
-
import { SqliteFactStore } from "../../src/core/storage/sqlite-fact-store.js";
|
|
14
|
-
import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
|
|
15
|
-
import {
|
|
16
|
-
createMcpServer,
|
|
17
|
-
getFactHistoryHandler,
|
|
18
|
-
getSessionHandler,
|
|
19
|
-
recallFactsHandler,
|
|
20
|
-
recallSessionsHandler,
|
|
21
|
-
} from "../../src/mcp/server.js";
|
|
22
|
-
import type { EmbedResult, LLMClient } from "../../src/ports/llm-client.js";
|
|
23
|
-
import type { Session } from "../../src/shared/types.js";
|
|
24
|
-
import { makeFact } from "../fixtures/facts.js";
|
|
25
|
-
import { makeSession } from "../fixtures/sessions.js";
|
|
26
|
-
|
|
27
|
-
const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
|
|
28
|
-
|
|
29
|
-
function unit(values: number[]): Float32Array {
|
|
30
|
-
const padded = new Float32Array(768);
|
|
31
|
-
values.forEach((v, i) => {
|
|
32
|
-
padded[i] = v;
|
|
33
|
-
});
|
|
34
|
-
let sum = 0;
|
|
35
|
-
for (const v of padded) sum += v * v;
|
|
36
|
-
const norm = Math.sqrt(sum) || 1;
|
|
37
|
-
for (let i = 0; i < padded.length; i++) padded[i] = (padded[i] ?? 0) / norm;
|
|
38
|
-
return padded;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class FixedEmbedder implements LLMClient {
|
|
42
|
-
constructor(private readonly vector: Float32Array) {}
|
|
43
|
-
async embed(): Promise<EmbedResult> {
|
|
44
|
-
return { vector: this.vector, model: "fixed-test" };
|
|
45
|
-
}
|
|
46
|
-
async classify(): Promise<never> {
|
|
47
|
-
throw new Error("not used");
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const seed: ReadonlyArray<{ session: Session; embedding: Float32Array }> = [
|
|
52
|
-
{
|
|
53
|
-
session: makeSession({
|
|
54
|
-
id: "sess_a",
|
|
55
|
-
label: "Hono router setup",
|
|
56
|
-
entities: ["NLM"],
|
|
57
|
-
decisions: ["chose Hono"],
|
|
58
|
-
}),
|
|
59
|
-
embedding: unit([1, 0, 0]),
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
session: makeSession({
|
|
63
|
-
id: "sess_b",
|
|
64
|
-
label: "pgvector migration plan",
|
|
65
|
-
entities: ["NLM", "Postgres"],
|
|
66
|
-
open: ["cutover timing"],
|
|
67
|
-
}),
|
|
68
|
-
embedding: unit([0, 1, 0]),
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
interface ParsedTool {
|
|
73
|
-
content: { type: string; text: string }[];
|
|
74
|
-
isError?: boolean;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function parsePayload(result: ParsedTool): unknown {
|
|
78
|
-
const first = result.content[0];
|
|
79
|
-
if (!first) throw new Error("empty tool result");
|
|
80
|
-
return JSON.parse(first.text);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
describe("MCP adapter", () => {
|
|
84
|
-
let tmp: string;
|
|
85
|
-
let store: SqliteSessionStore;
|
|
86
|
-
let recall: RecallService;
|
|
87
|
-
|
|
88
|
-
beforeEach(() => {
|
|
89
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-mcp-"));
|
|
90
|
-
store = new SqliteSessionStore({
|
|
91
|
-
dbPath: join(tmp, "canonical.sqlite"),
|
|
92
|
-
migrationsDir: MIGRATIONS_DIR,
|
|
93
|
-
});
|
|
94
|
-
for (const { session, embedding } of seed) {
|
|
95
|
-
store.insertSessionForTest(session);
|
|
96
|
-
store.insertEmbeddingForTest(session.id, embedding);
|
|
97
|
-
}
|
|
98
|
-
recall = new RecallService({
|
|
99
|
-
store,
|
|
100
|
-
llm: new FixedEmbedder(unit([0, 1, 0])),
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
afterEach(() => {
|
|
105
|
-
store.close();
|
|
106
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("recall_sessions returns the keyword hit", async () => {
|
|
110
|
-
const result = await recallSessionsHandler(
|
|
111
|
-
{ recall, store },
|
|
112
|
-
{ query: "pgvector", mode: "keyword" },
|
|
113
|
-
);
|
|
114
|
-
expect(result.isError).toBeUndefined();
|
|
115
|
-
const body = parsePayload(result) as { total: number; results: { id: string }[] };
|
|
116
|
-
expect(body.total).toBe(1);
|
|
117
|
-
expect(body.results[0]?.id).toBe("sess_b");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("recall_sessions defaults to hybrid mode + 10-item limit", async () => {
|
|
121
|
-
const result = await recallSessionsHandler({ recall, store }, { query: "hono" });
|
|
122
|
-
const body = parsePayload(result) as { mode: string; limit: number };
|
|
123
|
-
expect(body.mode).toBe("hybrid");
|
|
124
|
-
expect(body.limit).toBe(10);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("recall_sessions threads entity + kind filters into RecallService", async () => {
|
|
128
|
-
const result = await recallSessionsHandler(
|
|
129
|
-
{ recall, store },
|
|
130
|
-
{ query: "pgvector", entity: "NLM", kind: "open" },
|
|
131
|
-
);
|
|
132
|
-
const body = parsePayload(result) as {
|
|
133
|
-
entity: string;
|
|
134
|
-
kind: string;
|
|
135
|
-
results: { id: string }[];
|
|
136
|
-
};
|
|
137
|
-
expect(body.entity).toBe("NLM");
|
|
138
|
-
expect(body.kind).toBe("open");
|
|
139
|
-
expect(body.results.every((r) => r.id === "sess_b")).toBe(true);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("get_session returns the full session for a known id", async () => {
|
|
143
|
-
const result = await getSessionHandler({ recall, store }, { id: "sess_a" });
|
|
144
|
-
expect(result.isError).toBeUndefined();
|
|
145
|
-
const body = parsePayload(result) as { id: string; entities: string[] };
|
|
146
|
-
expect(body.id).toBe("sess_a");
|
|
147
|
-
expect(body.entities).toContain("NLM");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("get_session includes supersedence links when an edge exists", async () => {
|
|
151
|
-
store.insertEdgeForTest("sess_a", "sess_b", "supersedes");
|
|
152
|
-
const newer = await getSessionHandler({ recall, store }, { id: "sess_a" });
|
|
153
|
-
const older = await getSessionHandler({ recall, store }, { id: "sess_b" });
|
|
154
|
-
const newerBody = parsePayload(newer) as { supersedes: string[]; supersededBy: string | null };
|
|
155
|
-
const olderBody = parsePayload(older) as { supersedes: string[]; supersededBy: string | null };
|
|
156
|
-
expect(newerBody.supersedes).toEqual(["sess_b"]);
|
|
157
|
-
expect(newerBody.supersededBy).toBeNull();
|
|
158
|
-
expect(olderBody.supersededBy).toBe("sess_a");
|
|
159
|
-
expect(olderBody.supersedes).toEqual([]);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("get_session returns an error tool result on missing id", async () => {
|
|
163
|
-
const result = await getSessionHandler(
|
|
164
|
-
{ recall, store },
|
|
165
|
-
{ id: "does_not_exist" },
|
|
166
|
-
);
|
|
167
|
-
expect(result.isError).toBe(true);
|
|
168
|
-
expect(result.content[0]?.text).toContain("not found");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("createMcpServer registers both tools without throwing", () => {
|
|
172
|
-
const server = createMcpServer({ recall, store });
|
|
173
|
-
expect(server).toBeDefined();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe("fact tools (B.3)", () => {
|
|
177
|
-
let factStore: SqliteFactStore;
|
|
178
|
-
let factRecall: FactRecallService;
|
|
179
|
-
|
|
180
|
-
beforeEach(async () => {
|
|
181
|
-
factStore = new SqliteFactStore(store.rawDb());
|
|
182
|
-
factRecall = new FactRecallService({
|
|
183
|
-
factStore,
|
|
184
|
-
llm: new FixedEmbedder(unit([1, 0, 0])),
|
|
185
|
-
});
|
|
186
|
-
await factStore.insertMany([
|
|
187
|
-
makeFact({
|
|
188
|
-
id: "f_hono",
|
|
189
|
-
subject: "nlm-memory-ts",
|
|
190
|
-
predicate: "framework",
|
|
191
|
-
value: "Hono",
|
|
192
|
-
confidence: 0.9,
|
|
193
|
-
sourceSessionId: "sess_a",
|
|
194
|
-
}),
|
|
195
|
-
makeFact({
|
|
196
|
-
id: "f_endpoint",
|
|
197
|
-
kind: "attribute",
|
|
198
|
-
subject: "mac-pro-llm-host",
|
|
199
|
-
predicate: "endpoint",
|
|
200
|
-
value: "http://macpro:8080/v1",
|
|
201
|
-
confidence: 0.85,
|
|
202
|
-
sourceSessionId: "sess_b",
|
|
203
|
-
}),
|
|
204
|
-
]);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("recall_facts returns the current fact for an exact subject+predicate", async () => {
|
|
208
|
-
const result = await recallFactsHandler(
|
|
209
|
-
{ recall, store, factRecall, factStore },
|
|
210
|
-
{ subject: "nlm-memory-ts", predicate: "framework" },
|
|
211
|
-
);
|
|
212
|
-
expect(result.isError).toBeUndefined();
|
|
213
|
-
const body = parsePayload(result) as {
|
|
214
|
-
total: number;
|
|
215
|
-
results: { id: string; value: string }[];
|
|
216
|
-
};
|
|
217
|
-
expect(body.total).toBe(1);
|
|
218
|
-
expect(body.results[0]?.id).toBe("f_hono");
|
|
219
|
-
expect(body.results[0]?.value).toBe("Hono");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("recall_facts returns an error tool result when factRecall is missing", async () => {
|
|
223
|
-
const result = await recallFactsHandler(
|
|
224
|
-
{ recall, store },
|
|
225
|
-
{ subject: "x" },
|
|
226
|
-
);
|
|
227
|
-
expect(result.isError).toBe(true);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("get_fact_history returns chains ordered newest → oldest", async () => {
|
|
231
|
-
await factStore.insertMany([
|
|
232
|
-
makeFact({
|
|
233
|
-
id: "f_old",
|
|
234
|
-
subject: "nlm-memory-ts",
|
|
235
|
-
predicate: "framework",
|
|
236
|
-
value: "Fastify",
|
|
237
|
-
createdAt: "2026-05-18T00:00:00Z",
|
|
238
|
-
confidence: 0.9,
|
|
239
|
-
sourceSessionId: "sess_a",
|
|
240
|
-
}),
|
|
241
|
-
]);
|
|
242
|
-
await factStore.markSuperseded("f_old", "f_hono");
|
|
243
|
-
|
|
244
|
-
const result = await getFactHistoryHandler(
|
|
245
|
-
{ recall, store, factRecall, factStore },
|
|
246
|
-
{ subject: "nlm-memory-ts", predicate: "framework" },
|
|
247
|
-
);
|
|
248
|
-
const body = parsePayload(result) as {
|
|
249
|
-
chains: { history: { id: string }[] }[];
|
|
250
|
-
};
|
|
251
|
-
expect(body.chains).toHaveLength(1);
|
|
252
|
-
expect(body.chains[0]?.history.map((f) => f.id)).toEqual(["f_hono", "f_old"]);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("createMcpServer registers fact tools when factRecall + factStore wired", () => {
|
|
256
|
-
const server = createMcpServer({ recall, store, factRecall, factStore });
|
|
257
|
-
expect(server).toBeDefined();
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { MemoSweepScheduler, sweepMemoDir } from "../../src/core/hook/memo-sweep.js";
|
|
6
|
-
|
|
7
|
-
describe("memo sweep", () => {
|
|
8
|
-
let tmp: string;
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-memo-sweep-"));
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
15
|
-
|
|
16
|
-
function plantMemo(name: string, ageMs: number): string {
|
|
17
|
-
const path = join(tmp, name);
|
|
18
|
-
writeFileSync(path, "[]", "utf8");
|
|
19
|
-
if (ageMs > 0) {
|
|
20
|
-
const past = (Date.now() - ageMs) / 1000;
|
|
21
|
-
utimesSync(path, past, past);
|
|
22
|
-
}
|
|
23
|
-
return path;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
it("returns a zero-count report when state dir does not exist", () => {
|
|
27
|
-
const report = sweepMemoDir({ stateDir: join(tmp, "nope") });
|
|
28
|
-
expect(report).toEqual({ scanned: 0, deleted: 0, kept: 0, errors: 0 });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("deletes memos older than the dormant threshold and keeps fresh ones", () => {
|
|
32
|
-
plantMemo("fresh.json", 30 * 60 * 1000); // 30 min old — active
|
|
33
|
-
plantMemo("idle.json", 6 * 60 * 60 * 1000); // 6 hours — idle
|
|
34
|
-
plantMemo("dormant-a.json", 25 * 60 * 60 * 1000); // 25h — dormant
|
|
35
|
-
plantMemo("dormant-b.json", 30 * 24 * 60 * 60 * 1000); // 30 days — very dormant
|
|
36
|
-
|
|
37
|
-
const report = sweepMemoDir({ stateDir: tmp });
|
|
38
|
-
expect(report).toMatchObject({ scanned: 4, deleted: 2, kept: 2, errors: 0 });
|
|
39
|
-
|
|
40
|
-
const remaining = readdirSync(tmp).sort();
|
|
41
|
-
expect(remaining).toEqual(["fresh.json", "idle.json"]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("ignores non-json files in the state dir", () => {
|
|
45
|
-
plantMemo("dormant.json", 25 * 60 * 60 * 1000);
|
|
46
|
-
writeFileSync(join(tmp, "README.txt"), "not a memo");
|
|
47
|
-
writeFileSync(join(tmp, ".DS_Store"), "");
|
|
48
|
-
|
|
49
|
-
const report = sweepMemoDir({ stateDir: tmp });
|
|
50
|
-
expect(report.deleted).toBe(1);
|
|
51
|
-
expect(existsSync(join(tmp, "README.txt"))).toBe(true);
|
|
52
|
-
expect(existsSync(join(tmp, ".DS_Store"))).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("honors a custom dormantMs threshold", () => {
|
|
56
|
-
plantMemo("two-hour-old.json", 2 * 60 * 60 * 1000);
|
|
57
|
-
// Threshold of 1 hour — anything older than 1h is dormant.
|
|
58
|
-
const report = sweepMemoDir({ stateDir: tmp, dormantMs: 60 * 60 * 1000 });
|
|
59
|
-
expect(report.deleted).toBe(1);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("uses an injected `now` for deterministic time-window tests", () => {
|
|
63
|
-
const path = plantMemo("memo.json", 0);
|
|
64
|
-
// memo was just touched. Pretend "now" is 2 days in the future — it's dormant.
|
|
65
|
-
const future = Date.now() + 2 * 24 * 60 * 60 * 1000;
|
|
66
|
-
const report = sweepMemoDir({ stateDir: tmp, now: () => future });
|
|
67
|
-
expect(report.deleted).toBe(1);
|
|
68
|
-
expect(existsSync(path)).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("MemoSweepScheduler.tick performs the sweep without scheduling", () => {
|
|
72
|
-
plantMemo("dormant.json", 25 * 60 * 60 * 1000);
|
|
73
|
-
plantMemo("fresh.json", 60_000);
|
|
74
|
-
const sweeper = new MemoSweepScheduler({ stateDir: tmp, logger: () => {} });
|
|
75
|
-
const report = sweeper.tick();
|
|
76
|
-
expect(report.deleted).toBe(1);
|
|
77
|
-
expect(report.kept).toBe(1);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("MemoSweepScheduler.start does not throw and stop cleans up the timer", () => {
|
|
81
|
-
const sweeper = new MemoSweepScheduler({
|
|
82
|
-
stateDir: tmp,
|
|
83
|
-
intervalMs: 60_000,
|
|
84
|
-
logger: () => {},
|
|
85
|
-
});
|
|
86
|
-
expect(() => sweeper.start()).not.toThrow();
|
|
87
|
-
expect(() => sweeper.stop()).not.toThrow();
|
|
88
|
-
// Idempotent — double-stop is a no-op.
|
|
89
|
-
expect(() => sweeper.stop()).not.toThrow();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { runHook } from "../../src/hook/prompt-recall-hook.js";
|
|
6
|
-
import type { RecallHitInput } from "../../src/core/hook/select.js";
|
|
7
|
-
|
|
8
|
-
const hits = (...ids: string[]): ReadonlyArray<RecallHitInput> =>
|
|
9
|
-
ids.map((id, i) => ({
|
|
10
|
-
id,
|
|
11
|
-
label: `Session ${id}`,
|
|
12
|
-
startedAt: "2026-05-15T10:00:00.000Z",
|
|
13
|
-
matchScore: 0.9 - i * 0.01,
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
describe("runHook", () => {
|
|
17
|
-
let tmp: string;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-hook-"));
|
|
21
|
-
process.env["NLM_HOOK_STATE_DIR"] = join(tmp, "state");
|
|
22
|
-
process.env["NLM_HOOK_LOG"] = join(tmp, "hook-log.jsonl");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
delete process.env["NLM_HOOK_STATE_DIR"];
|
|
27
|
-
delete process.env["NLM_HOOK_LOG"];
|
|
28
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("shadow mode logs but returns no stdout", async () => {
|
|
32
|
-
const out = await runHook(
|
|
33
|
-
{ prompt: "what did we decide about pgvector", conversationId: "c1" },
|
|
34
|
-
{ mode: "shadow", recall: async () => hits("sess_a") },
|
|
35
|
-
);
|
|
36
|
-
expect(out).toBe("");
|
|
37
|
-
const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
|
|
38
|
-
expect(JSON.parse(log).wouldInject).toEqual(["sess_a"]);
|
|
39
|
-
expect(JSON.parse(log).mode).toBe("shadow");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("shadow mode does not write the memo", async () => {
|
|
43
|
-
await runHook(
|
|
44
|
-
{ prompt: "what did we decide", conversationId: "c1" },
|
|
45
|
-
{ mode: "shadow", recall: async () => hits("sess_a") },
|
|
46
|
-
);
|
|
47
|
-
expect(existsSync(join(tmp, "state", "c1.json"))).toBe(false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("live mode returns the pointer block and records the memo", async () => {
|
|
51
|
-
const out = await runHook(
|
|
52
|
-
{ prompt: "what did we decide about pgvector", conversationId: "c1" },
|
|
53
|
-
{ mode: "live", recall: async () => hits("sess_a", "sess_b") },
|
|
54
|
-
);
|
|
55
|
-
expect(out).toContain("## Possibly-relevant prior sessions (nlm-memory)");
|
|
56
|
-
expect(out).toContain("sess_a");
|
|
57
|
-
const memo = JSON.parse(readFileSync(join(tmp, "state", "c1.json"), "utf8"));
|
|
58
|
-
expect([...memo].sort()).toEqual(["sess_a", "sess_b"]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("live mode dedups: a second fire does not re-surface the same session", async () => {
|
|
62
|
-
const deps = { mode: "live" as const, recall: async () => hits("sess_a") };
|
|
63
|
-
const first = await runHook({ prompt: "what did we decide", conversationId: "c1" }, deps);
|
|
64
|
-
expect(first).toContain("sess_a");
|
|
65
|
-
const second = await runHook({ prompt: "and what else did we decide", conversationId: "c1" }, deps);
|
|
66
|
-
expect(second).toBe("");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("generative prompts skip recall entirely", async () => {
|
|
70
|
-
let called = false;
|
|
71
|
-
const out = await runHook(
|
|
72
|
-
{ prompt: "draft a blog post about FTS5", conversationId: "c1" },
|
|
73
|
-
{ mode: "live", recall: async () => { called = true; return hits("sess_a"); } },
|
|
74
|
-
);
|
|
75
|
-
expect(out).toBe("");
|
|
76
|
-
expect(called).toBe(false);
|
|
77
|
-
const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
|
|
78
|
-
expect(JSON.parse(log).gate).toBe("generative");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("returns empty and does not throw when recall rejects", async () => {
|
|
82
|
-
const out = await runHook(
|
|
83
|
-
{ prompt: "what did we decide", conversationId: "c1" },
|
|
84
|
-
{ mode: "live", recall: async () => { throw new Error("daemon down"); } },
|
|
85
|
-
);
|
|
86
|
-
expect(out).toBe("");
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 0 task 3 — ProviderRegistry integration. Real SQLite, seed
|
|
3
|
-
* defaults bridge from env, CRUD, secret redaction on list/get.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
-
import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
|
|
11
|
-
import { ProviderRegistry } from "../../src/core/providers/provider-registry.js";
|
|
12
|
-
|
|
13
|
-
const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
|
|
14
|
-
|
|
15
|
-
describe("ProviderRegistry", () => {
|
|
16
|
-
let tmp: string;
|
|
17
|
-
let store: SqliteSessionStore;
|
|
18
|
-
let registry: ProviderRegistry;
|
|
19
|
-
let savedKey: string | undefined;
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-providers-"));
|
|
23
|
-
store = new SqliteSessionStore({
|
|
24
|
-
dbPath: join(tmp, "canonical.sqlite"),
|
|
25
|
-
migrationsDir: MIGRATIONS_DIR,
|
|
26
|
-
});
|
|
27
|
-
registry = new ProviderRegistry(store.rawDb());
|
|
28
|
-
savedKey = process.env["DEEPSEEK_API_KEY"];
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
if (savedKey === undefined) delete process.env["DEEPSEEK_API_KEY"];
|
|
33
|
-
else process.env["DEEPSEEK_API_KEY"] = savedKey;
|
|
34
|
-
store.close();
|
|
35
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("seedDefaults inserts Ollama always, DeepSeek with key when present", () => {
|
|
39
|
-
process.env["DEEPSEEK_API_KEY"] = "sk-test-abc";
|
|
40
|
-
registry.seedDefaults();
|
|
41
|
-
const rows = registry.list();
|
|
42
|
-
expect(rows.map((r) => r.kind)).toEqual(["ollama", "deepseek"]);
|
|
43
|
-
const deepseek = rows.find((r) => r.kind === "deepseek");
|
|
44
|
-
expect(deepseek?.enabled).toBe(true);
|
|
45
|
-
expect(deepseek?.hasApiKey).toBe(true);
|
|
46
|
-
expect(deepseek?.apiKey).toBeNull(); // redacted
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("seedDefaults disables DeepSeek when key is absent", () => {
|
|
50
|
-
delete process.env["DEEPSEEK_API_KEY"];
|
|
51
|
-
registry.seedDefaults();
|
|
52
|
-
const deepseek = registry.getByName("DeepSeek");
|
|
53
|
-
expect(deepseek?.enabled).toBe(false);
|
|
54
|
-
expect(deepseek?.hasApiKey).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("seedDefaults is idempotent", () => {
|
|
58
|
-
registry.seedDefaults();
|
|
59
|
-
registry.seedDefaults();
|
|
60
|
-
expect(registry.list().length).toBe(2);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("inserts a custom provider with explicit base URL", () => {
|
|
64
|
-
const row = registry.insert({
|
|
65
|
-
kind: "openai-compatible",
|
|
66
|
-
name: "vLLM box",
|
|
67
|
-
baseUrl: "http://192.168.1.50:8000/v1",
|
|
68
|
-
defaultModel: "llama-3.1-70b",
|
|
69
|
-
apiKey: "secret-token",
|
|
70
|
-
});
|
|
71
|
-
expect(row.baseUrl).toBe("http://192.168.1.50:8000/v1");
|
|
72
|
-
expect(row.hasApiKey).toBe(true);
|
|
73
|
-
expect(row.apiKey).toBeNull();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("getSecret returns the unredacted key", () => {
|
|
77
|
-
const row = registry.insert({
|
|
78
|
-
kind: "openai", name: "OpenAI prod", apiKey: "sk-real",
|
|
79
|
-
});
|
|
80
|
-
expect(registry.getSecret(row.id)).toBe("sk-real");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("rejects duplicate names", () => {
|
|
84
|
-
registry.insert({ kind: "openai", name: "OpenAI", apiKey: "k" });
|
|
85
|
-
expect(() => registry.insert({ kind: "openai", name: "OpenAI", apiKey: "k2" }))
|
|
86
|
-
.toThrow();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("update patches only supplied fields", () => {
|
|
90
|
-
const row = registry.insert({ kind: "openai", name: "OAI", apiKey: "k1" });
|
|
91
|
-
const updated = registry.update(row.id, { apiKey: "k2", enabled: false });
|
|
92
|
-
expect(updated?.enabled).toBe(false);
|
|
93
|
-
expect(registry.getSecret(row.id)).toBe("k2");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("delete removes the row", () => {
|
|
97
|
-
const row = registry.insert({ kind: "openai", name: "Tmp", apiKey: "k" });
|
|
98
|
-
expect(registry.delete(row.id)).toBe(true);
|
|
99
|
-
expect(registry.get(row.id)).toBeNull();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("fills in default base URL and model when omitted", () => {
|
|
103
|
-
const row = registry.insert({ kind: "anthropic", name: "Claude", apiKey: "k" });
|
|
104
|
-
expect(row.baseUrl).toBe("https://api.anthropic.com");
|
|
105
|
-
expect(row.defaultModel).toBe("claude-haiku-4-5-20251001");
|
|
106
|
-
});
|
|
107
|
-
});
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Recall-quality regression gate. A fixed corpus + query/expectation pairs,
|
|
3
|
-
* run through RecallService against a real SqliteSessionStore. Assertions are
|
|
4
|
-
* tolerant (expected session within top 3) so they survive the swap from the
|
|
5
|
-
* token-overlap scorer to FTS5 BM25 ranking. This test must stay green from
|
|
6
|
-
* the current code through every task in this plan.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join, resolve } from "node:path";
|
|
12
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
-
import { RecallService } from "../../src/core/recall/recall-service.js";
|
|
14
|
-
import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
|
|
15
|
-
import type { EmbedResult, LLMClient } from "../../src/ports/llm-client.js";
|
|
16
|
-
import { LLMUnreachableError } from "../../src/ports/llm-client.js";
|
|
17
|
-
import { GOLDEN_CORPUS, GOLDEN_QUERIES } from "../fixtures/golden-corpus.js";
|
|
18
|
-
|
|
19
|
-
const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
|
|
20
|
-
|
|
21
|
-
// Keyword-only recall must never touch the embedder; this stub proves it.
|
|
22
|
-
class UnreachableEmbedder implements LLMClient {
|
|
23
|
-
async embed(): Promise<EmbedResult> {
|
|
24
|
-
throw new LLMUnreachableError("ollama");
|
|
25
|
-
}
|
|
26
|
-
async classify(): Promise<never> {
|
|
27
|
-
throw new Error("not used");
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("golden recall regression gate", () => {
|
|
32
|
-
let tmp: string;
|
|
33
|
-
let store: SqliteSessionStore;
|
|
34
|
-
|
|
35
|
-
beforeEach(() => {
|
|
36
|
-
tmp = mkdtempSync(join(tmpdir(), "nlm-golden-"));
|
|
37
|
-
store = new SqliteSessionStore({
|
|
38
|
-
dbPath: join(tmp, "canonical.sqlite"),
|
|
39
|
-
migrationsDir: MIGRATIONS_DIR,
|
|
40
|
-
});
|
|
41
|
-
for (const session of GOLDEN_CORPUS) {
|
|
42
|
-
store.insertSessionForTest(session);
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
store.close();
|
|
48
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
for (const { query, expectTop3 } of GOLDEN_QUERIES) {
|
|
52
|
-
it(`keyword recall surfaces "${expectTop3}" in the top 3 for "${query}"`, async () => {
|
|
53
|
-
const svc = new RecallService({ store, llm: new UnreachableEmbedder() });
|
|
54
|
-
const result = await svc.search({ query, mode: "keyword", limit: 10 });
|
|
55
|
-
const top3 = result.results.slice(0, 3).map((r) => r.id);
|
|
56
|
-
expect(top3).toContain(expectTop3);
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
});
|