ralph-hero-knowledge-index 0.1.21 → 0.1.23
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/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +109 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +75 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +17 -0
- package/dist/db.js.map +1 -1
- package/dist/file-scanner.d.ts +13 -1
- package/dist/file-scanner.js +30 -3
- package/dist/file-scanner.js.map +1 -1
- package/dist/hybrid-search.d.ts +12 -0
- package/dist/hybrid-search.js +74 -5
- package/dist/hybrid-search.js.map +1 -1
- package/dist/ignore.d.ts +29 -0
- package/dist/ignore.js +65 -0
- package/dist/ignore.js.map +1 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +166 -6
- package/dist/index.js.map +1 -1
- package/dist/llm-client.d.ts +41 -0
- package/dist/llm-client.js +98 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/reindex.d.ts +22 -3
- package/dist/reindex.js +60 -8
- package/dist/reindex.js.map +1 -1
- package/dist/search.d.ts +12 -0
- package/dist/search.js +15 -1
- package/dist/search.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/config.test.ts +173 -0
- package/src/__tests__/file-scanner.test.ts +88 -0
- package/src/__tests__/hybrid-search.test.ts +107 -0
- package/src/__tests__/ignore.test.ts +86 -0
- package/src/__tests__/index.test.ts +450 -0
- package/src/__tests__/llm-client.test.ts +349 -0
- package/src/__tests__/memory-stats.test.ts +204 -0
- package/src/__tests__/reindex.test.ts +148 -2
- package/src/__tests__/search.test.ts +37 -0
- package/src/config.ts +105 -0
- package/src/db.ts +17 -0
- package/src/file-scanner.ts +28 -3
- package/src/hybrid-search.ts +88 -5
- package/src/ignore.ts +82 -0
- package/src/index.ts +202 -7
- package/src/llm-client.ts +136 -0
- package/src/reindex.ts +80 -9
- package/src/search.ts +27 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createLlmClient } from "../llm-client.js";
|
|
3
|
+
|
|
4
|
+
type FetchFn = typeof globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
const originalFetch: FetchFn | undefined = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
function installFetch(mock: FetchFn): void {
|
|
9
|
+
globalThis.fetch = mock;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function restoreFetch(): void {
|
|
13
|
+
if (originalFetch) {
|
|
14
|
+
globalThis.fetch = originalFetch;
|
|
15
|
+
} else {
|
|
16
|
+
// @ts-expect-error runtime cleanup when no original existed
|
|
17
|
+
delete globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeResponse(
|
|
22
|
+
init: { status?: number; ok?: boolean; json?: unknown } = {},
|
|
23
|
+
): Response {
|
|
24
|
+
const status = init.status ?? 200;
|
|
25
|
+
const ok = init.ok ?? (status >= 200 && status < 300);
|
|
26
|
+
return {
|
|
27
|
+
status,
|
|
28
|
+
ok,
|
|
29
|
+
json: async () => init.json ?? {},
|
|
30
|
+
} as unknown as Response;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function abortError(): Error {
|
|
34
|
+
const err = new Error("aborted");
|
|
35
|
+
err.name = "AbortError";
|
|
36
|
+
return err;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function connectionRefused(): Error {
|
|
40
|
+
// Node fetch surfaces connection-refused as a TypeError whose cause has
|
|
41
|
+
// code `ECONNREFUSED`. Mimic the thrown error here.
|
|
42
|
+
const err = new TypeError("fetch failed");
|
|
43
|
+
(err as Error & { cause?: { code: string } }).cause = { code: "ECONNREFUSED" };
|
|
44
|
+
return err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("createLlmClient", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
delete process.env.RALPH_LLM_URL;
|
|
50
|
+
delete process.env.RALPH_LLM_MODEL;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
restoreFetch();
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("available()", () => {
|
|
60
|
+
it("returns true when /v1/models responds with status 200", async () => {
|
|
61
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(makeResponse({ status: 200 }));
|
|
62
|
+
installFetch(fetchMock);
|
|
63
|
+
|
|
64
|
+
const client = createLlmClient();
|
|
65
|
+
const result = await client.available();
|
|
66
|
+
|
|
67
|
+
expect(result).toBe(true);
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
69
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
70
|
+
expect(url).toBe("http://localhost:8000/v1/models");
|
|
71
|
+
expect(init?.method).toBe("GET");
|
|
72
|
+
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false when fetch rejects with AbortError (timeout)", async () => {
|
|
76
|
+
const fetchMock = vi.fn<FetchFn>().mockRejectedValue(abortError());
|
|
77
|
+
installFetch(fetchMock);
|
|
78
|
+
|
|
79
|
+
const client = createLlmClient();
|
|
80
|
+
const result = await client.available();
|
|
81
|
+
|
|
82
|
+
expect(result).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns false when fetch returns status 404", async () => {
|
|
86
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(makeResponse({ status: 404 }));
|
|
87
|
+
installFetch(fetchMock);
|
|
88
|
+
|
|
89
|
+
const client = createLlmClient();
|
|
90
|
+
const result = await client.available();
|
|
91
|
+
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns false when fetch returns status 500", async () => {
|
|
96
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(makeResponse({ status: 500 }));
|
|
97
|
+
installFetch(fetchMock);
|
|
98
|
+
|
|
99
|
+
const client = createLlmClient();
|
|
100
|
+
const result = await client.available();
|
|
101
|
+
|
|
102
|
+
expect(result).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns false when fetch throws ECONNREFUSED", async () => {
|
|
106
|
+
const fetchMock = vi.fn<FetchFn>().mockRejectedValue(connectionRefused());
|
|
107
|
+
installFetch(fetchMock);
|
|
108
|
+
|
|
109
|
+
const client = createLlmClient();
|
|
110
|
+
const result = await client.available();
|
|
111
|
+
|
|
112
|
+
expect(result).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("aborts the probe after 2000ms", async () => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
let capturedSignal: AbortSignal | undefined;
|
|
118
|
+
const fetchMock = vi.fn<FetchFn>().mockImplementation((_input, init) => {
|
|
119
|
+
capturedSignal = init?.signal as AbortSignal | undefined;
|
|
120
|
+
return new Promise((_resolve, reject) => {
|
|
121
|
+
capturedSignal?.addEventListener("abort", () => reject(abortError()));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
installFetch(fetchMock);
|
|
125
|
+
|
|
126
|
+
const client = createLlmClient();
|
|
127
|
+
const probe = client.available();
|
|
128
|
+
|
|
129
|
+
// Advance timers past the 2000ms probe timeout.
|
|
130
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
131
|
+
|
|
132
|
+
const result = await probe;
|
|
133
|
+
expect(result).toBe(false);
|
|
134
|
+
expect(capturedSignal?.aborted).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("contextualize()", () => {
|
|
139
|
+
it("returns mocked content on happy path (trimmed)", async () => {
|
|
140
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
141
|
+
makeResponse({
|
|
142
|
+
status: 200,
|
|
143
|
+
json: {
|
|
144
|
+
choices: [{ message: { content: " This chunk discusses X. " } }],
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
installFetch(fetchMock);
|
|
149
|
+
|
|
150
|
+
const client = createLlmClient();
|
|
151
|
+
const result = await client.contextualize("doc body", "chunk");
|
|
152
|
+
|
|
153
|
+
expect(result).toBe("This chunk discusses X.");
|
|
154
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
155
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
156
|
+
expect(url).toBe("http://localhost:8000/v1/chat/completions");
|
|
157
|
+
expect(init?.method).toBe("POST");
|
|
158
|
+
const headers = init?.headers as Record<string, string> | undefined;
|
|
159
|
+
expect(headers?.["Content-Type"]).toBe("application/json");
|
|
160
|
+
|
|
161
|
+
const body = JSON.parse(init?.body as string) as {
|
|
162
|
+
model: string;
|
|
163
|
+
messages: Array<{ role: string; content: string }>;
|
|
164
|
+
max_tokens: number;
|
|
165
|
+
};
|
|
166
|
+
expect(body.model).toBe("mlx-community/gemma-4-26b-a4b-it-mxfp8");
|
|
167
|
+
expect(body.max_tokens).toBe(120);
|
|
168
|
+
expect(body.messages).toHaveLength(1);
|
|
169
|
+
expect(body.messages[0]!.role).toBe("user");
|
|
170
|
+
// Prompt should embed both the document and the chunk verbatim in the
|
|
171
|
+
// Anthropic Contextual Retrieval format.
|
|
172
|
+
expect(body.messages[0]!.content).toContain("<document>\ndoc body\n</document>");
|
|
173
|
+
expect(body.messages[0]!.content).toContain("<chunk>\nchunk\n</chunk>");
|
|
174
|
+
expect(body.messages[0]!.content).toContain(
|
|
175
|
+
"Please give a short succinct context",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns empty string on timeout (AbortError)", async () => {
|
|
180
|
+
const fetchMock = vi.fn<FetchFn>().mockRejectedValue(abortError());
|
|
181
|
+
installFetch(fetchMock);
|
|
182
|
+
|
|
183
|
+
const client = createLlmClient();
|
|
184
|
+
const result = await client.contextualize("doc", "chunk");
|
|
185
|
+
|
|
186
|
+
expect(result).toBe("");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns empty string on malformed response (no choices key)", async () => {
|
|
190
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
191
|
+
makeResponse({ status: 200, json: { unexpected: "shape" } }),
|
|
192
|
+
);
|
|
193
|
+
installFetch(fetchMock);
|
|
194
|
+
|
|
195
|
+
const client = createLlmClient();
|
|
196
|
+
const result = await client.contextualize("doc", "chunk");
|
|
197
|
+
|
|
198
|
+
expect(result).toBe("");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns empty string when choices array is empty", async () => {
|
|
202
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
203
|
+
makeResponse({ status: 200, json: { choices: [] } }),
|
|
204
|
+
);
|
|
205
|
+
installFetch(fetchMock);
|
|
206
|
+
|
|
207
|
+
const client = createLlmClient();
|
|
208
|
+
const result = await client.contextualize("doc", "chunk");
|
|
209
|
+
|
|
210
|
+
expect(result).toBe("");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns empty string when message.content is missing", async () => {
|
|
214
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
215
|
+
makeResponse({ status: 200, json: { choices: [{ message: {} }] } }),
|
|
216
|
+
);
|
|
217
|
+
installFetch(fetchMock);
|
|
218
|
+
|
|
219
|
+
const client = createLlmClient();
|
|
220
|
+
const result = await client.contextualize("doc", "chunk");
|
|
221
|
+
|
|
222
|
+
expect(result).toBe("");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns empty string on non-2xx response", async () => {
|
|
226
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
227
|
+
makeResponse({ status: 503, json: { error: "unavailable" } }),
|
|
228
|
+
);
|
|
229
|
+
installFetch(fetchMock);
|
|
230
|
+
|
|
231
|
+
const client = createLlmClient();
|
|
232
|
+
const result = await client.contextualize("doc", "chunk");
|
|
233
|
+
|
|
234
|
+
expect(result).toBe("");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns empty string on JSON parse error", async () => {
|
|
238
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue({
|
|
239
|
+
status: 200,
|
|
240
|
+
ok: true,
|
|
241
|
+
json: async () => {
|
|
242
|
+
throw new SyntaxError("Unexpected token in JSON");
|
|
243
|
+
},
|
|
244
|
+
} as unknown as Response);
|
|
245
|
+
installFetch(fetchMock);
|
|
246
|
+
|
|
247
|
+
const client = createLlmClient();
|
|
248
|
+
const result = await client.contextualize("doc", "chunk");
|
|
249
|
+
|
|
250
|
+
expect(result).toBe("");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("returns empty string on network failure", async () => {
|
|
254
|
+
const fetchMock = vi.fn<FetchFn>().mockRejectedValue(connectionRefused());
|
|
255
|
+
installFetch(fetchMock);
|
|
256
|
+
|
|
257
|
+
const client = createLlmClient();
|
|
258
|
+
const result = await client.contextualize("doc", "chunk");
|
|
259
|
+
|
|
260
|
+
expect(result).toBe("");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("options and env overrides", () => {
|
|
265
|
+
it("honors custom baseUrl option", async () => {
|
|
266
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(makeResponse({ status: 200 }));
|
|
267
|
+
installFetch(fetchMock);
|
|
268
|
+
|
|
269
|
+
const client = createLlmClient({ baseUrl: "http://example.test:9000" });
|
|
270
|
+
await client.available();
|
|
271
|
+
|
|
272
|
+
expect(fetchMock.mock.calls[0]![0]).toBe("http://example.test:9000/v1/models");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("honors custom model option in chat completion body", async () => {
|
|
276
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
277
|
+
makeResponse({
|
|
278
|
+
status: 200,
|
|
279
|
+
json: { choices: [{ message: { content: "ctx" } }] },
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
installFetch(fetchMock);
|
|
283
|
+
|
|
284
|
+
const client = createLlmClient({ model: "custom/model-v1" });
|
|
285
|
+
await client.contextualize("doc", "chunk");
|
|
286
|
+
|
|
287
|
+
const body = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string) as {
|
|
288
|
+
model: string;
|
|
289
|
+
};
|
|
290
|
+
expect(body.model).toBe("custom/model-v1");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("falls back to RALPH_LLM_URL env var", async () => {
|
|
294
|
+
process.env.RALPH_LLM_URL = "http://env.override:1234";
|
|
295
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(makeResponse({ status: 200 }));
|
|
296
|
+
installFetch(fetchMock);
|
|
297
|
+
|
|
298
|
+
const client = createLlmClient();
|
|
299
|
+
await client.available();
|
|
300
|
+
|
|
301
|
+
expect(fetchMock.mock.calls[0]![0]).toBe("http://env.override:1234/v1/models");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("falls back to RALPH_LLM_MODEL env var", async () => {
|
|
305
|
+
process.env.RALPH_LLM_MODEL = "env/model-v2";
|
|
306
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
307
|
+
makeResponse({
|
|
308
|
+
status: 200,
|
|
309
|
+
json: { choices: [{ message: { content: "ctx" } }] },
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
installFetch(fetchMock);
|
|
313
|
+
|
|
314
|
+
const client = createLlmClient();
|
|
315
|
+
await client.contextualize("doc", "chunk");
|
|
316
|
+
|
|
317
|
+
const body = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string) as {
|
|
318
|
+
model: string;
|
|
319
|
+
};
|
|
320
|
+
expect(body.model).toBe("env/model-v2");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("prefers explicit options over env vars", async () => {
|
|
324
|
+
process.env.RALPH_LLM_URL = "http://env.should-not-win:1234";
|
|
325
|
+
process.env.RALPH_LLM_MODEL = "env/should-not-win";
|
|
326
|
+
const fetchMock = vi.fn<FetchFn>().mockResolvedValue(
|
|
327
|
+
makeResponse({
|
|
328
|
+
status: 200,
|
|
329
|
+
json: { choices: [{ message: { content: "ctx" } }] },
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
installFetch(fetchMock);
|
|
333
|
+
|
|
334
|
+
const client = createLlmClient({
|
|
335
|
+
baseUrl: "http://explicit.wins:5000",
|
|
336
|
+
model: "explicit/model",
|
|
337
|
+
});
|
|
338
|
+
await client.contextualize("doc", "chunk");
|
|
339
|
+
|
|
340
|
+
expect(fetchMock.mock.calls[0]![0]).toBe(
|
|
341
|
+
"http://explicit.wins:5000/v1/chat/completions",
|
|
342
|
+
);
|
|
343
|
+
const body = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string) as {
|
|
344
|
+
model: string;
|
|
345
|
+
};
|
|
346
|
+
expect(body.model).toBe("explicit/model");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { KnowledgeDB } from "../db.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helper: call the MCP `knowledge_memory_stats` tool directly via the
|
|
7
|
+
* server's private `_registeredTools` map. Mirrors graph-tools.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
async function callStats(
|
|
10
|
+
server: McpServer,
|
|
11
|
+
args: Record<string, unknown> = {},
|
|
12
|
+
): Promise<Record<string, unknown>> {
|
|
13
|
+
const registered = (server as unknown as Record<string, unknown>)
|
|
14
|
+
._registeredTools as Record<
|
|
15
|
+
string,
|
|
16
|
+
{ handler: (args: Record<string, unknown>, extra: unknown) => Promise<unknown> }
|
|
17
|
+
>;
|
|
18
|
+
const tool = registered.knowledge_memory_stats;
|
|
19
|
+
if (!tool) throw new Error("knowledge_memory_stats not registered");
|
|
20
|
+
const result = (await tool.handler(args, {})) as {
|
|
21
|
+
content: Array<{ text: string }>;
|
|
22
|
+
isError?: boolean;
|
|
23
|
+
};
|
|
24
|
+
if (result.isError) {
|
|
25
|
+
throw new Error(`tool error: ${result.content[0]?.text}`);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure the v3 schema extensions (memory_tier column on documents, chunks
|
|
32
|
+
* table) exist on the test DB. Phase 1 (GH-762) owns the production schema
|
|
33
|
+
* migration; test fixtures add them so Phase 8 features can be exercised
|
|
34
|
+
* independently of Phase 1 merge order.
|
|
35
|
+
*/
|
|
36
|
+
function ensureV3Schema(db: KnowledgeDB): void {
|
|
37
|
+
const rows = db.db.prepare("PRAGMA table_info(documents)").all() as Array<{ name: string }>;
|
|
38
|
+
if (!rows.some((r) => r.name === "memory_tier")) {
|
|
39
|
+
db.db.exec(
|
|
40
|
+
"ALTER TABLE documents ADD COLUMN memory_tier TEXT NOT NULL DEFAULT 'doc' CHECK(memory_tier IN ('doc','raw','reflection'))",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
db.db.exec(
|
|
44
|
+
`CREATE TABLE IF NOT EXISTS chunks (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
47
|
+
chunk_index INTEGER NOT NULL,
|
|
48
|
+
content TEXT NOT NULL,
|
|
49
|
+
char_start INTEGER NOT NULL,
|
|
50
|
+
char_end INTEGER NOT NULL,
|
|
51
|
+
context_prefix TEXT NOT NULL DEFAULT '',
|
|
52
|
+
UNIQUE(document_id, chunk_index)
|
|
53
|
+
)`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function seedDoc(
|
|
58
|
+
db: KnowledgeDB,
|
|
59
|
+
id: string,
|
|
60
|
+
tier: "doc" | "raw" | "reflection",
|
|
61
|
+
date: string | null,
|
|
62
|
+
): void {
|
|
63
|
+
db.upsertDocument({
|
|
64
|
+
id,
|
|
65
|
+
path: `${id}.md`,
|
|
66
|
+
title: id,
|
|
67
|
+
date,
|
|
68
|
+
type: null,
|
|
69
|
+
status: null,
|
|
70
|
+
githubIssue: null,
|
|
71
|
+
content: "",
|
|
72
|
+
});
|
|
73
|
+
db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run(tier, id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function seedChunks(db: KnowledgeDB, docId: string, count: number): void {
|
|
77
|
+
const stmt = db.db.prepare(
|
|
78
|
+
`INSERT INTO chunks (id, document_id, chunk_index, content, char_start, char_end, context_prefix)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
80
|
+
);
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
stmt.run(`${docId}#c${i}`, docId, i, `chunk ${i}`, i * 100, (i + 1) * 100, "");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("knowledge_memory_stats", () => {
|
|
87
|
+
it("returns tier counts matching the fixture", async () => {
|
|
88
|
+
const mod = await import("../index.js");
|
|
89
|
+
const { server, db } = mod.createServer(":memory:");
|
|
90
|
+
ensureV3Schema(db);
|
|
91
|
+
|
|
92
|
+
// 2 doc, 3 raw, 1 reflection
|
|
93
|
+
seedDoc(db, "d1", "doc", "2026-04-01");
|
|
94
|
+
seedDoc(db, "d2", "doc", "2026-04-02");
|
|
95
|
+
seedDoc(db, "r1", "raw", "2026-04-03");
|
|
96
|
+
seedDoc(db, "r2", "raw", "2026-04-03");
|
|
97
|
+
seedDoc(db, "r3", "raw", "2026-04-04");
|
|
98
|
+
seedDoc(db, "f1", "reflection", "2026-04-05");
|
|
99
|
+
|
|
100
|
+
const out = await callStats(server, { since: "1970-01-01T00:00:00Z" });
|
|
101
|
+
expect(out.total_documents).toBe(6);
|
|
102
|
+
expect(out.by_tier).toEqual({ doc: 2, raw: 3, reflection: 1 });
|
|
103
|
+
expect(out.new_since).toEqual({ doc: 2, raw: 3, reflection: 1 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("computes chunks_per_doc_p50 and _p90 correctly on [1,2,3,4,5]", async () => {
|
|
107
|
+
const mod = await import("../index.js");
|
|
108
|
+
const { server, db } = mod.createServer(":memory:");
|
|
109
|
+
ensureV3Schema(db);
|
|
110
|
+
|
|
111
|
+
// Seed 5 docs each with 1,2,3,4,5 chunks respectively
|
|
112
|
+
const counts = [1, 2, 3, 4, 5];
|
|
113
|
+
for (let i = 0; i < counts.length; i++) {
|
|
114
|
+
const id = `chunked-${i}`;
|
|
115
|
+
seedDoc(db, id, "doc", "2026-04-10");
|
|
116
|
+
seedChunks(db, id, counts[i]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const out = await callStats(server, { since: "1970-01-01T00:00:00Z" });
|
|
120
|
+
// sorted counts: [1,2,3,4,5]. floor(5*0.5)=2 -> 3; floor(5*0.9)=4 -> 5.
|
|
121
|
+
expect(out.chunks_per_doc_p50).toBe(3);
|
|
122
|
+
expect(out.chunks_per_doc_p90).toBe(5);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns last_reflection_at as null when no reflection docs exist", async () => {
|
|
126
|
+
const mod = await import("../index.js");
|
|
127
|
+
const { server, db } = mod.createServer(":memory:");
|
|
128
|
+
ensureV3Schema(db);
|
|
129
|
+
|
|
130
|
+
seedDoc(db, "only-doc", "doc", "2026-04-01");
|
|
131
|
+
|
|
132
|
+
const out = await callStats(server, { since: "1970-01-01T00:00:00Z" });
|
|
133
|
+
expect(out.last_reflection_at).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns ISO timestamp of most recent reflection when present", async () => {
|
|
137
|
+
const mod = await import("../index.js");
|
|
138
|
+
const { server, db } = mod.createServer(":memory:");
|
|
139
|
+
ensureV3Schema(db);
|
|
140
|
+
|
|
141
|
+
seedDoc(db, "r-older", "reflection", "2026-03-01");
|
|
142
|
+
seedDoc(db, "r-newer", "reflection", "2026-04-10");
|
|
143
|
+
|
|
144
|
+
const out = await callStats(server, { since: "1970-01-01T00:00:00Z" });
|
|
145
|
+
expect(out.last_reflection_at).toBe("2026-04-10");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("counts new_since correctly when filtering by timestamp", async () => {
|
|
149
|
+
const mod = await import("../index.js");
|
|
150
|
+
const { server, db } = mod.createServer(":memory:");
|
|
151
|
+
ensureV3Schema(db);
|
|
152
|
+
|
|
153
|
+
seedDoc(db, "old-doc", "doc", "2026-01-01");
|
|
154
|
+
seedDoc(db, "new-doc", "doc", "2026-05-01");
|
|
155
|
+
seedDoc(db, "old-raw", "raw", "2026-01-15");
|
|
156
|
+
seedDoc(db, "new-raw", "raw", "2026-05-02");
|
|
157
|
+
|
|
158
|
+
const out = await callStats(server, { since: "2026-04-01T00:00:00Z" });
|
|
159
|
+
expect(out.total_documents).toBe(4);
|
|
160
|
+
expect(out.by_tier).toEqual({ doc: 2, raw: 2, reflection: 0 });
|
|
161
|
+
expect(out.new_since).toEqual({ doc: 1, raw: 1, reflection: 0 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("defaults `since` to ~24h ago when not provided", async () => {
|
|
165
|
+
const mod = await import("../index.js");
|
|
166
|
+
const { server, db } = mod.createServer(":memory:");
|
|
167
|
+
ensureV3Schema(db);
|
|
168
|
+
seedDoc(db, "d1", "doc", "2026-04-01");
|
|
169
|
+
|
|
170
|
+
const out = await callStats(server);
|
|
171
|
+
const since = out.since as string;
|
|
172
|
+
expect(since).toBeTruthy();
|
|
173
|
+
const sinceMs = Date.parse(since);
|
|
174
|
+
const nowMs = Date.now();
|
|
175
|
+
// Allow a wide window to accommodate slow test startup; the spec is 24h.
|
|
176
|
+
const ageMs = nowMs - sinceMs;
|
|
177
|
+
expect(ageMs).toBeGreaterThanOrEqual(23.5 * 3600 * 1000);
|
|
178
|
+
expect(ageMs).toBeLessThanOrEqual(24.5 * 3600 * 1000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports all documents as tier 'doc' on a v2 schema (column absent)", async () => {
|
|
182
|
+
const mod = await import("../index.js");
|
|
183
|
+
const { server, db } = mod.createServer(":memory:");
|
|
184
|
+
// Intentionally do NOT call ensureV3Schema — simulate v2 DB.
|
|
185
|
+
|
|
186
|
+
db.upsertDocument({
|
|
187
|
+
id: "legacy-doc",
|
|
188
|
+
path: "l.md",
|
|
189
|
+
title: "Legacy",
|
|
190
|
+
date: "2026-04-01",
|
|
191
|
+
type: null,
|
|
192
|
+
status: null,
|
|
193
|
+
githubIssue: null,
|
|
194
|
+
content: "",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const out = await callStats(server, { since: "1970-01-01T00:00:00Z" });
|
|
198
|
+
expect(out.total_documents).toBe(1);
|
|
199
|
+
expect(out.by_tier).toEqual({ doc: 1, raw: 0, reflection: 0 });
|
|
200
|
+
expect(out.chunks_per_doc_p50).toBe(0);
|
|
201
|
+
expect(out.chunks_per_doc_p90).toBe(0);
|
|
202
|
+
expect(out.last_reflection_at).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|