@vheins/local-memory-mcp 0.1.0
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/DASHBOARD.md +129 -0
- package/HYBRID_SEARCH.md +204 -0
- package/IMPLEMENTATION.md +159 -0
- package/README.md +175 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +23 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/dashboard/dashboard.test.d.ts +2 -0
- package/dist/dashboard/dashboard.test.d.ts.map +1 -0
- package/dist/dashboard/dashboard.test.js +362 -0
- package/dist/dashboard/dashboard.test.js.map +1 -0
- package/dist/dashboard/public/app.js +1187 -0
- package/dist/dashboard/public/chart.js +0 -0
- package/dist/dashboard/public/index.html +967 -0
- package/dist/dashboard/server.d.ts +3 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +297 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/mcp/client.d.ts +34 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +181 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/client.test.d.ts +2 -0
- package/dist/mcp/client.test.d.ts.map +1 -0
- package/dist/mcp/client.test.js +130 -0
- package/dist/mcp/client.test.js.map +1 -0
- package/dist/prompts/registry.d.ts +39 -0
- package/dist/prompts/registry.d.ts.map +1 -0
- package/dist/prompts/registry.js +90 -0
- package/dist/prompts/registry.js.map +1 -0
- package/dist/resources/index.d.ts +17 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +100 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/index.test.d.ts +2 -0
- package/dist/resources/index.test.d.ts.map +1 -0
- package/dist/resources/index.test.js +96 -0
- package/dist/resources/index.test.js.map +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +60 -0
- package/dist/router.js.map +1 -0
- package/dist/router.test.d.ts +2 -0
- package/dist/router.test.d.ts.map +1 -0
- package/dist/router.test.js +113 -0
- package/dist/router.test.js.map +1 -0
- package/dist/search_memory_example.d.ts +3 -0
- package/dist/search_memory_example.d.ts.map +1 -0
- package/dist/search_memory_example.js +56 -0
- package/dist/search_memory_example.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +91 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/sqlite.d.ts +95 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +537 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/storage/sqlite.test.d.ts +2 -0
- package/dist/storage/sqlite.test.d.ts.map +1 -0
- package/dist/storage/sqlite.test.js +358 -0
- package/dist/storage/sqlite.test.js.map +1 -0
- package/dist/storage/vectors.stub.d.ts +12 -0
- package/dist/storage/vectors.stub.d.ts.map +1 -0
- package/dist/storage/vectors.stub.js +88 -0
- package/dist/storage/vectors.stub.js.map +1 -0
- package/dist/store_memory_example.d.ts +3 -0
- package/dist/store_memory_example.d.ts.map +1 -0
- package/dist/store_memory_example.js +69 -0
- package/dist/store_memory_example.js.map +1 -0
- package/dist/test_quotes_client.d.ts +3 -0
- package/dist/test_quotes_client.d.ts.map +1 -0
- package/dist/test_quotes_client.js +72 -0
- package/dist/test_quotes_client.js.map +1 -0
- package/dist/tools/memory.delete.d.ts +9 -0
- package/dist/tools/memory.delete.d.ts.map +1 -0
- package/dist/tools/memory.delete.js +22 -0
- package/dist/tools/memory.delete.js.map +1 -0
- package/dist/tools/memory.recap.d.ts +4 -0
- package/dist/tools/memory.recap.d.ts.map +1 -0
- package/dist/tools/memory.recap.js +42 -0
- package/dist/tools/memory.recap.js.map +1 -0
- package/dist/tools/memory.search.d.ts +5 -0
- package/dist/tools/memory.search.d.ts.map +1 -0
- package/dist/tools/memory.search.js +192 -0
- package/dist/tools/memory.search.js.map +1 -0
- package/dist/tools/memory.search.test.d.ts +2 -0
- package/dist/tools/memory.search.test.d.ts.map +1 -0
- package/dist/tools/memory.search.test.js +181 -0
- package/dist/tools/memory.search.test.js.map +1 -0
- package/dist/tools/memory.store.d.ts +5 -0
- package/dist/tools/memory.store.d.ts.map +1 -0
- package/dist/tools/memory.store.js +41 -0
- package/dist/tools/memory.store.js.map +1 -0
- package/dist/tools/memory.summarize.d.ts +4 -0
- package/dist/tools/memory.summarize.d.ts.map +1 -0
- package/dist/tools/memory.summarize.js +13 -0
- package/dist/tools/memory.summarize.js.map +1 -0
- package/dist/tools/memory.update.d.ts +5 -0
- package/dist/tools/memory.update.d.ts.map +1 -0
- package/dist/tools/memory.update.js +31 -0
- package/dist/tools/memory.update.js.map +1 -0
- package/dist/tools/schemas.d.ts +334 -0
- package/dist/tools/schemas.d.ts.map +1 -0
- package/dist/tools/schemas.js +251 -0
- package/dist/tools/schemas.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/git-scope.d.ts +8 -0
- package/dist/utils/git-scope.d.ts.map +1 -0
- package/dist/utils/git-scope.js +38 -0
- package/dist/utils/git-scope.js.map +1 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +2 -0
- package/dist/utils/logger.test.d.ts.map +1 -0
- package/dist/utils/logger.test.js +84 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/mcp-response.d.ts +44 -0
- package/dist/utils/mcp-response.d.ts.map +1 -0
- package/dist/utils/mcp-response.js +81 -0
- package/dist/utils/mcp-response.js.map +1 -0
- package/dist/utils/normalize.d.ts +4 -0
- package/dist/utils/normalize.d.ts.map +1 -0
- package/dist/utils/normalize.js +51 -0
- package/dist/utils/normalize.js.map +1 -0
- package/dist/utils/normalize.test.d.ts +2 -0
- package/dist/utils/normalize.test.d.ts.map +1 -0
- package/dist/utils/normalize.test.js +159 -0
- package/dist/utils/normalize.test.js.map +1 -0
- package/dist/utils/query-expander.d.ts +2 -0
- package/dist/utils/query-expander.d.ts.map +1 -0
- package/dist/utils/query-expander.js +50 -0
- package/dist/utils/query-expander.js.map +1 -0
- package/dist/utils/query-expander.test.d.ts +2 -0
- package/dist/utils/query-expander.test.d.ts.map +1 -0
- package/dist/utils/query-expander.test.js +35 -0
- package/dist/utils/query-expander.test.js.map +1 -0
- package/docs/PRD.md +199 -0
- package/docs/PROMPT-agent.md +139 -0
- package/docs/SPEC-git-scope.md +172 -0
- package/docs/SPEC-heuristics.md +199 -0
- package/docs/SPEC-server.md +243 -0
- package/docs/SPEC-skeleton.md +255 -0
- package/docs/SPEC-sqlite-schema.md +183 -0
- package/docs/SPEC-tool-schema.md +201 -0
- package/docs/SPEC-vector-search.md +198 -0
- package/docs/TEST-scenarios.md +179 -0
- package/package.json +43 -0
- package/scripts/update-null-titles-ai.mjs +272 -0
- package/scripts/update-titles-batch.mjs +71 -0
- package/scripts/update-titles.mjs +66 -0
- package/seed-data.mjs +151 -0
- package/src/capabilities.ts +22 -0
- package/src/dashboard/dashboard.test.ts +546 -0
- package/src/dashboard/public/app.js +1187 -0
- package/src/dashboard/public/chart.js +0 -0
- package/src/dashboard/public/index.html +967 -0
- package/src/dashboard/server.ts +347 -0
- package/src/mcp/client.test.ts +164 -0
- package/src/mcp/client.ts +212 -0
- package/src/prompts/registry.ts +89 -0
- package/src/resources/index.test.ts +132 -0
- package/src/resources/index.ts +113 -0
- package/src/router.test.ts +145 -0
- package/src/router.ts +80 -0
- package/src/server.ts +99 -0
- package/src/storage/sqlite.test.ts +504 -0
- package/src/storage/sqlite.ts +688 -0
- package/src/storage/vectors.stub.ts +101 -0
- package/src/tools/memory.delete.ts +37 -0
- package/src/tools/memory.recap.ts +61 -0
- package/src/tools/memory.search.test.ts +276 -0
- package/src/tools/memory.search.ts +244 -0
- package/src/tools/memory.store.ts +56 -0
- package/src/tools/memory.summarize.ts +23 -0
- package/src/tools/memory.update.ts +46 -0
- package/src/tools/schemas.ts +261 -0
- package/src/types.ts +36 -0
- package/src/utils/git-scope.ts +42 -0
- package/src/utils/logger.test.ts +125 -0
- package/src/utils/logger.ts +53 -0
- package/src/utils/mcp-response.ts +116 -0
- package/src/utils/normalize.test.ts +203 -0
- package/src/utils/normalize.ts +53 -0
- package/src/utils/query-expander.test.ts +40 -0
- package/src/utils/query-expander.ts +60 -0
- package/storage/.gitkeep +5 -0
- package/test.sh +48 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Feature: memory-mcp-optimization
|
|
2
|
+
// Property 20: StructuredLogger output adalah JSON valid dengan field wajib
|
|
3
|
+
// Validates: Requirements 21.1, 21.2
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import * as fc from "fast-check";
|
|
7
|
+
|
|
8
|
+
// ─── Property 20: StructuredLogger output adalah JSON valid dengan field wajib ─
|
|
9
|
+
// Feature: memory-mcp-optimization, Property 20: StructuredLogger output JSON valid
|
|
10
|
+
|
|
11
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
12
|
+
|
|
13
|
+
describe("Property 20: StructuredLogger output adalah JSON valid dengan field wajib", () => {
|
|
14
|
+
let stderrOutput: string[] = [];
|
|
15
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
stderrOutput = [];
|
|
19
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
|
20
|
+
stderrOutput.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
21
|
+
return true;
|
|
22
|
+
});
|
|
23
|
+
// Reset LOG_LEVEL to ensure all levels are logged
|
|
24
|
+
process.env.LOG_LEVEL = "debug";
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
stderrSpy.mockRestore();
|
|
29
|
+
delete process.env.LOG_LEVEL;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("each log call produces valid JSON output with required fields", async () => {
|
|
33
|
+
// Re-import logger after setting LOG_LEVEL
|
|
34
|
+
const { logger } = await import("./logger.js?t=" + Date.now());
|
|
35
|
+
|
|
36
|
+
fc.assert(
|
|
37
|
+
fc.property(
|
|
38
|
+
fc.oneof(
|
|
39
|
+
fc.constant<LogLevel>("debug"),
|
|
40
|
+
fc.constant<LogLevel>("info"),
|
|
41
|
+
fc.constant<LogLevel>("warn"),
|
|
42
|
+
fc.constant<LogLevel>("error")
|
|
43
|
+
),
|
|
44
|
+
fc.string({ minLength: 1, maxLength: 100 }),
|
|
45
|
+
(level: LogLevel, message: string) => {
|
|
46
|
+
stderrOutput = [];
|
|
47
|
+
logger[level](message);
|
|
48
|
+
|
|
49
|
+
expect(stderrOutput.length).toBeGreaterThanOrEqual(1);
|
|
50
|
+
const lastOutput = stderrOutput[stderrOutput.length - 1];
|
|
51
|
+
|
|
52
|
+
// Must be parseable JSON
|
|
53
|
+
let parsed: Record<string, unknown>;
|
|
54
|
+
expect(() => {
|
|
55
|
+
parsed = JSON.parse(lastOutput.trim());
|
|
56
|
+
}).not.toThrow();
|
|
57
|
+
|
|
58
|
+
parsed = JSON.parse(lastOutput.trim());
|
|
59
|
+
|
|
60
|
+
// Must have required fields
|
|
61
|
+
expect(parsed).toHaveProperty("level");
|
|
62
|
+
expect(parsed).toHaveProperty("timestamp");
|
|
63
|
+
expect(parsed).toHaveProperty("message");
|
|
64
|
+
|
|
65
|
+
// level must match
|
|
66
|
+
expect(parsed.level).toBe(level);
|
|
67
|
+
|
|
68
|
+
// message must match
|
|
69
|
+
expect(parsed.message).toBe(message);
|
|
70
|
+
|
|
71
|
+
// timestamp must be ISO 8601
|
|
72
|
+
expect(typeof parsed.timestamp).toBe("string");
|
|
73
|
+
const ts = new Date(parsed.timestamp as string);
|
|
74
|
+
expect(isNaN(ts.getTime())).toBe(false);
|
|
75
|
+
}
|
|
76
|
+
),
|
|
77
|
+
{ numRuns: 100 }
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("log output with context includes context field", async () => {
|
|
82
|
+
const { logger } = await import("./logger.js?t=" + Date.now() + "ctx");
|
|
83
|
+
|
|
84
|
+
stderrOutput = [];
|
|
85
|
+
logger.info("test message", { key: "value", count: 42 });
|
|
86
|
+
|
|
87
|
+
expect(stderrOutput.length).toBeGreaterThanOrEqual(1);
|
|
88
|
+
const lastOutput = stderrOutput[stderrOutput.length - 1];
|
|
89
|
+
const parsed = JSON.parse(lastOutput.trim());
|
|
90
|
+
|
|
91
|
+
expect(parsed).toHaveProperty("context");
|
|
92
|
+
expect(parsed.context).toEqual({ key: "value", count: 42 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("log output without context does not include context field", async () => {
|
|
96
|
+
const { logger } = await import("./logger.js?t=" + Date.now() + "noctx");
|
|
97
|
+
|
|
98
|
+
stderrOutput = [];
|
|
99
|
+
logger.info("no context message");
|
|
100
|
+
|
|
101
|
+
expect(stderrOutput.length).toBeGreaterThanOrEqual(1);
|
|
102
|
+
const lastOutput = stderrOutput[stderrOutput.length - 1];
|
|
103
|
+
const parsed = JSON.parse(lastOutput.trim());
|
|
104
|
+
|
|
105
|
+
expect(parsed).not.toHaveProperty("context");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("all four log levels produce valid JSON with correct level field", async () => {
|
|
109
|
+
const { logger } = await import("./logger.js?t=" + Date.now() + "levels");
|
|
110
|
+
|
|
111
|
+
const levels: LogLevel[] = ["debug", "info", "warn", "error"];
|
|
112
|
+
for (const level of levels) {
|
|
113
|
+
stderrOutput = [];
|
|
114
|
+
logger[level](`test ${level} message`);
|
|
115
|
+
|
|
116
|
+
expect(stderrOutput.length).toBeGreaterThanOrEqual(1);
|
|
117
|
+
const lastOutput = stderrOutput[stderrOutput.length - 1];
|
|
118
|
+
const parsed = JSON.parse(lastOutput.trim());
|
|
119
|
+
|
|
120
|
+
expect(parsed.level).toBe(level);
|
|
121
|
+
expect(parsed.message).toBe(`test ${level} message`);
|
|
122
|
+
expect(typeof parsed.timestamp).toBe("string");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Structured logger — outputs JSON to stderr
|
|
2
|
+
// Requirements: 21.1, 21.2
|
|
3
|
+
|
|
4
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
interface LogEntry {
|
|
7
|
+
level: LogLevel;
|
|
8
|
+
timestamp: string; // ISO 8601
|
|
9
|
+
message: string;
|
|
10
|
+
context?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LEVELS: Record<LogLevel, number> = {
|
|
14
|
+
debug: 0,
|
|
15
|
+
info: 1,
|
|
16
|
+
warn: 2,
|
|
17
|
+
error: 3,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function parseLevel(raw: string | undefined): LogLevel {
|
|
21
|
+
if (raw && raw in LEVELS) return raw as LogLevel;
|
|
22
|
+
return "info";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const configuredLevel: LogLevel = parseLevel(process.env.LOG_LEVEL?.toLowerCase());
|
|
26
|
+
|
|
27
|
+
function log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
|
|
28
|
+
if (LEVELS[level] < LEVELS[configuredLevel]) return;
|
|
29
|
+
|
|
30
|
+
const entry: LogEntry = {
|
|
31
|
+
level,
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
message,
|
|
34
|
+
...(context !== undefined ? { context } : {}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const logger = {
|
|
41
|
+
debug(message: string, context?: Record<string, unknown>): void {
|
|
42
|
+
log("debug", message, context);
|
|
43
|
+
},
|
|
44
|
+
info(message: string, context?: Record<string, unknown>): void {
|
|
45
|
+
log("info", message, context);
|
|
46
|
+
},
|
|
47
|
+
warn(message: string, context?: Record<string, unknown>): void {
|
|
48
|
+
log("warn", message, context);
|
|
49
|
+
},
|
|
50
|
+
error(message: string, context?: Record<string, unknown>): void {
|
|
51
|
+
log("error", message, context);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const McpContentSchema = z.discriminatedUnion("type", [
|
|
4
|
+
z.object({
|
|
5
|
+
type: z.literal("text"),
|
|
6
|
+
text: z.string(),
|
|
7
|
+
}),
|
|
8
|
+
z.object({
|
|
9
|
+
type: z.literal("image"),
|
|
10
|
+
data: z.string(),
|
|
11
|
+
mimeType: z.string(),
|
|
12
|
+
}),
|
|
13
|
+
z.object({
|
|
14
|
+
type: z.literal("resource"),
|
|
15
|
+
resource: z.object({
|
|
16
|
+
uri: z.string(),
|
|
17
|
+
mimeType: z.string().optional(),
|
|
18
|
+
text: z.string().optional(),
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export type McpContent = z.infer<typeof McpContentSchema>;
|
|
24
|
+
|
|
25
|
+
export type McpResponse = {
|
|
26
|
+
content: McpContent[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function createMcpResponse(
|
|
30
|
+
data: unknown,
|
|
31
|
+
summary: string,
|
|
32
|
+
options?: {
|
|
33
|
+
query?: string;
|
|
34
|
+
results?: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
type: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
content: string;
|
|
39
|
+
importance?: number;
|
|
40
|
+
scope?: { repo: string; folder?: string; language?: string };
|
|
41
|
+
created_at?: string;
|
|
42
|
+
updated_at?: string;
|
|
43
|
+
hit_count?: number;
|
|
44
|
+
recall_count?: number;
|
|
45
|
+
last_used_at?: string | null;
|
|
46
|
+
expires_at?: string | null;
|
|
47
|
+
}>
|
|
48
|
+
}
|
|
49
|
+
): McpResponse {
|
|
50
|
+
const { query, results } = options || {};
|
|
51
|
+
|
|
52
|
+
const content: McpContent[] = [
|
|
53
|
+
{
|
|
54
|
+
type: "text",
|
|
55
|
+
text: summary,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Add resource for each result with embedded content
|
|
60
|
+
if (results && results.length > 0) {
|
|
61
|
+
for (const result of results) {
|
|
62
|
+
const resultId = Buffer.from(result.id).toString('base64').substring(0, 8);
|
|
63
|
+
|
|
64
|
+
// Generate title from content if not present
|
|
65
|
+
const title = result.title || (result.content.length > 50 ? result.content.substring(0, 50) + '...' : result.content);
|
|
66
|
+
|
|
67
|
+
content.push({
|
|
68
|
+
type: "resource",
|
|
69
|
+
resource: {
|
|
70
|
+
uri: `memory://${resultId}`,
|
|
71
|
+
mimeType: "application/json",
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
id: result.id,
|
|
74
|
+
type: result.type,
|
|
75
|
+
title: title,
|
|
76
|
+
content: result.content,
|
|
77
|
+
importance: result.importance,
|
|
78
|
+
scope: result.scope,
|
|
79
|
+
created_at: result.created_at,
|
|
80
|
+
updated_at: result.updated_at,
|
|
81
|
+
hit_count: result.hit_count,
|
|
82
|
+
recall_count: result.recall_count,
|
|
83
|
+
last_used_at: result.last_used_at,
|
|
84
|
+
expires_at: result.expires_at
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { content };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createTextOnlyResponse(text: string): McpResponse {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isMcpResponse(obj: unknown): obj is McpResponse {
|
|
106
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
107
|
+
const response = obj as Record<string, unknown>;
|
|
108
|
+
if (!Array.isArray(response.content)) return false;
|
|
109
|
+
return response.content.every(
|
|
110
|
+
(item) =>
|
|
111
|
+
typeof item === "object" &&
|
|
112
|
+
item !== null &&
|
|
113
|
+
"type" in item &&
|
|
114
|
+
typeof item.type === "string"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Feature: memory-mcp-optimization
|
|
2
|
+
// Unit + Property tests for normalize(), tokenize(), and STOPWORDS
|
|
3
|
+
// Requirements: 5.4, 5.5, 17.3, 17.4, 17.7
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import * as fc from "fast-check";
|
|
7
|
+
import { normalize, tokenize, STOPWORDS } from "./normalize.js";
|
|
8
|
+
|
|
9
|
+
// ─── Unit Tests: normalize() ─────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("normalize() — unit tests", () => {
|
|
12
|
+
it("converts text to lowercase", () => {
|
|
13
|
+
expect(normalize("Hello World")).toBe("hello world");
|
|
14
|
+
expect(normalize("TYPESCRIPT")).toBe("typescript");
|
|
15
|
+
expect(normalize("CamelCase")).toBe("camelcase");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("trims leading and trailing whitespace", () => {
|
|
19
|
+
expect(normalize(" hello ")).toBe("hello");
|
|
20
|
+
expect(normalize("\t text \n")).toBe("text");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("replaces special characters with spaces", () => {
|
|
24
|
+
const result = normalize("hello!world@test#value");
|
|
25
|
+
// Special chars replaced by spaces, then collapsed
|
|
26
|
+
expect(result).not.toContain("!");
|
|
27
|
+
expect(result).not.toContain("@");
|
|
28
|
+
expect(result).not.toContain("#");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("collapses multiple spaces into one", () => {
|
|
32
|
+
expect(normalize("hello world")).toBe("hello world");
|
|
33
|
+
expect(normalize("a b c")).toBe("a b c");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles empty string", () => {
|
|
37
|
+
expect(normalize("")).toBe("");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("handles string with only special characters", () => {
|
|
41
|
+
expect(normalize("!!!@@@###")).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("preserves alphanumeric characters", () => {
|
|
45
|
+
expect(normalize("abc123")).toBe("abc123");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ─── Unit Tests: tokenize() ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("tokenize() — unit tests", () => {
|
|
52
|
+
it("removes stopwords from output", () => {
|
|
53
|
+
const tokens = tokenize("the quick brown fox");
|
|
54
|
+
expect(tokens).not.toContain("the");
|
|
55
|
+
expect(tokens).toContain("quick");
|
|
56
|
+
expect(tokens).toContain("brown");
|
|
57
|
+
expect(tokens).toContain("fox");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("removes short words (length <= 0 after normalize)", () => {
|
|
61
|
+
// tokenize filters tokens with length > 0 and not in STOPWORDS
|
|
62
|
+
const tokens = tokenize("a b c hello");
|
|
63
|
+
// "a", "b", "c" are stopwords or single chars
|
|
64
|
+
expect(tokens).toContain("hello");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns empty array for all-stopword input", () => {
|
|
68
|
+
const tokens = tokenize("the and or but");
|
|
69
|
+
expect(tokens).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles empty string", () => {
|
|
73
|
+
expect(tokenize("")).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("normalizes before tokenizing (lowercase, trim)", () => {
|
|
77
|
+
const tokens1 = tokenize("TypeScript");
|
|
78
|
+
const tokens2 = tokenize("typescript");
|
|
79
|
+
expect(tokens1).toEqual(tokens2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("removes Indonesian stopwords", () => {
|
|
83
|
+
const tokens = tokenize("yang dan di ke dari ini itu coding");
|
|
84
|
+
expect(tokens).not.toContain("yang");
|
|
85
|
+
expect(tokens).not.toContain("dan");
|
|
86
|
+
expect(tokens).toContain("coding");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ─── Property 4: Tokenisasi konsisten antara SQLiteStore dan StubVectorStore ──
|
|
91
|
+
// Feature: memory-mcp-optimization, Property 4: Tokenisasi konsisten
|
|
92
|
+
// Validates: Requirements 4.4, 5.2, 5.3
|
|
93
|
+
|
|
94
|
+
describe("Property 4: Tokenisasi konsisten antara SQLiteStore dan StubVectorStore", () => {
|
|
95
|
+
it("tokenize() is deterministic — same input always produces same output", () => {
|
|
96
|
+
fc.assert(
|
|
97
|
+
fc.property(
|
|
98
|
+
fc.string({ minLength: 0, maxLength: 100 }),
|
|
99
|
+
(text: string) => {
|
|
100
|
+
// Both SQLiteStore and StubVectorStore use tokenize() from normalize.ts
|
|
101
|
+
// So testing tokenize() determinism validates consistency between them
|
|
102
|
+
const result1 = tokenize(text);
|
|
103
|
+
const result2 = tokenize(text);
|
|
104
|
+
expect(result1).toEqual(result2);
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
{ numRuns: 200 }
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("tokenize() output is a subset of normalize() tokens", () => {
|
|
112
|
+
fc.assert(
|
|
113
|
+
fc.property(
|
|
114
|
+
fc.string({ minLength: 0, maxLength: 100 }),
|
|
115
|
+
(text: string) => {
|
|
116
|
+
const normalized = normalize(text);
|
|
117
|
+
const allTokens = normalized.split(" ").filter((t) => t.length > 0);
|
|
118
|
+
const filtered = tokenize(text);
|
|
119
|
+
// Every token in filtered must appear in allTokens
|
|
120
|
+
for (const token of filtered) {
|
|
121
|
+
expect(allTokens).toContain(token);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
),
|
|
125
|
+
{ numRuns: 200 }
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── Property 5: normalize() idempoten ganda ─────────────────────────────────
|
|
131
|
+
// Feature: memory-mcp-optimization, Property 5: normalize() idempoten ganda
|
|
132
|
+
// Validates: Requirements 5.4, 5.5
|
|
133
|
+
|
|
134
|
+
describe("Property 5: normalize() idempoten ganda", () => {
|
|
135
|
+
it("normalize(normalize(text)) === normalize(text) for 100+ random inputs", () => {
|
|
136
|
+
fc.assert(
|
|
137
|
+
fc.property(
|
|
138
|
+
fc.string({ minLength: 0, maxLength: 200 }),
|
|
139
|
+
(text: string) => {
|
|
140
|
+
const once = normalize(text);
|
|
141
|
+
const twice = normalize(once);
|
|
142
|
+
expect(twice).toBe(once);
|
|
143
|
+
}
|
|
144
|
+
),
|
|
145
|
+
{ numRuns: 200 }
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("normalize is idempotent for specific edge cases", () => {
|
|
150
|
+
const cases = [
|
|
151
|
+
"",
|
|
152
|
+
" ",
|
|
153
|
+
"Hello World!",
|
|
154
|
+
"UPPERCASE",
|
|
155
|
+
"already normalized",
|
|
156
|
+
"multiple spaces",
|
|
157
|
+
"special!@#chars",
|
|
158
|
+
"123numbers456",
|
|
159
|
+
];
|
|
160
|
+
for (const text of cases) {
|
|
161
|
+
const once = normalize(text);
|
|
162
|
+
const twice = normalize(once);
|
|
163
|
+
expect(twice).toBe(once);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─── Property 21: Stopword list tidak mengandung duplikat ────────────────────
|
|
169
|
+
// Feature: memory-mcp-optimization, Property 21: Stopword no duplicates
|
|
170
|
+
// Validates: Requirement 17.4
|
|
171
|
+
|
|
172
|
+
describe("Property 21: Stopword list tidak mengandung duplikat", () => {
|
|
173
|
+
it("STOPWORDS has no duplicate entries", () => {
|
|
174
|
+
// Since STOPWORDS is a Set, duplicates are automatically removed.
|
|
175
|
+
// We verify the source array (before Set construction) has no duplicates
|
|
176
|
+
// by checking that Set size equals the number of unique entries.
|
|
177
|
+
// The Set itself guarantees uniqueness, so we verify the exported Set is consistent.
|
|
178
|
+
const stopwordsArray = Array.from(STOPWORDS);
|
|
179
|
+
const uniqueSet = new Set(stopwordsArray);
|
|
180
|
+
expect(uniqueSet.size).toBe(stopwordsArray.length);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("all STOPWORDS entries are lowercase strings", () => {
|
|
184
|
+
for (const word of STOPWORDS) {
|
|
185
|
+
expect(typeof word).toBe("string");
|
|
186
|
+
expect(word).toBe(word.toLowerCase());
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("STOPWORDS is non-empty", () => {
|
|
191
|
+
expect(STOPWORDS.size).toBeGreaterThan(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("property: no string appears more than once in STOPWORDS", () => {
|
|
195
|
+
// This is a structural property — verified once (not random inputs needed)
|
|
196
|
+
// since STOPWORDS is a constant Set
|
|
197
|
+
const seen = new Set<string>();
|
|
198
|
+
for (const word of STOPWORDS) {
|
|
199
|
+
expect(seen.has(word)).toBe(false);
|
|
200
|
+
seen.add(word);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Normalization layer for text processing
|
|
2
|
+
// Requirements: 4.1, 5.1, 17.4
|
|
3
|
+
|
|
4
|
+
// Combined English + Indonesian stopwords (all lowercase, no duplicates)
|
|
5
|
+
export const STOPWORDS: ReadonlySet<string> = new Set([
|
|
6
|
+
// English stopwords
|
|
7
|
+
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
|
8
|
+
"of", "with", "by", "from", "up", "about", "into", "through", "during",
|
|
9
|
+
"is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
|
|
10
|
+
"do", "does", "did", "will", "would", "could", "should", "may", "might",
|
|
11
|
+
"shall", "can", "need", "dare", "ought", "used",
|
|
12
|
+
"i", "me", "my", "myself", "we", "our", "ours", "ourselves",
|
|
13
|
+
"you", "your", "yours", "yourself", "yourselves",
|
|
14
|
+
"he", "him", "his", "himself", "she", "her", "hers", "herself",
|
|
15
|
+
"it", "its", "itself", "they", "them", "their", "theirs", "themselves",
|
|
16
|
+
"what", "which", "who", "whom", "this", "that", "these", "those",
|
|
17
|
+
"am", "if", "then", "else", "when", "where", "why", "how",
|
|
18
|
+
"all", "both", "each", "few", "more", "most", "other", "some", "such",
|
|
19
|
+
"no", "not", "only", "same", "so", "than", "too", "very",
|
|
20
|
+
"just", "because", "as", "until", "while", "although", "though",
|
|
21
|
+
"after", "before", "since", "between", "out", "off", "over", "under",
|
|
22
|
+
"again", "further", "once", "here", "there", "any", "also",
|
|
23
|
+
// Indonesian stopwords
|
|
24
|
+
"yang", "dan", "di", "ke", "dari", "ini", "itu", "dengan", "untuk",
|
|
25
|
+
"pada", "adalah", "dalam", "tidak", "akan", "juga", "ada", "saya",
|
|
26
|
+
"kita", "kami", "mereka", "dia", "ia", "anda", "kamu", "aku",
|
|
27
|
+
"bisa", "dapat", "harus", "sudah", "telah", "sedang", "masih",
|
|
28
|
+
"lebih", "sangat", "paling", "hanya", "jika", "kalau", "karena",
|
|
29
|
+
"sehingga", "namun", "tetapi", "tapi", "atau", "maupun", "bahwa",
|
|
30
|
+
"oleh", "seperti", "antara", "setelah", "sebelum", "ketika", "saat",
|
|
31
|
+
"semua", "setiap", "beberapa", "banyak", "sedikit", "lain", "lainnya",
|
|
32
|
+
"tersebut", "nya", "pun", "lah", "kah", "dah",
|
|
33
|
+
"agar", "supaya", "maka", "meski", "walaupun", "meskipun",
|
|
34
|
+
"selain", "selama", "sejak", "hingga", "sampai", "tentang",
|
|
35
|
+
"terhadap", "melalui", "tanpa", "bagi", "atas", "bawah",
|
|
36
|
+
"depan", "belakang", "kiri", "kanan", "sini", "sana", "situ",
|
|
37
|
+
"begitu", "begini", "demikian", "hal", "cara", "waktu", "tempat",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function normalize(text: string): string {
|
|
41
|
+
return text
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
// Keep alphanumeric characters and spaces, including Unicode letters (for Indonesian, etc.)
|
|
44
|
+
.replace(/[^\w\s\u00C0-\u017F]/g, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function tokenize(text: string): string[] {
|
|
50
|
+
return normalize(text)
|
|
51
|
+
.split(" ")
|
|
52
|
+
.filter((token) => token.length > 0 && !STOPWORDS.has(token));
|
|
53
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { expandQuery } from "../utils/query-expander.js";
|
|
3
|
+
|
|
4
|
+
describe("expandQuery", () => {
|
|
5
|
+
it("returns original query when no prompt provided", () => {
|
|
6
|
+
expect(expandQuery("database")).toBe("database");
|
|
7
|
+
expect(expandQuery("api endpoint")).toBe("api endpoint");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("expands query with prompt keywords", () => {
|
|
11
|
+
const result = expandQuery("database", "user authentication implementation");
|
|
12
|
+
expect(result).toContain("database");
|
|
13
|
+
expect(result).toContain("user");
|
|
14
|
+
expect(result).toContain("auth");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("expands known keywords", () => {
|
|
18
|
+
const result = expandQuery("auth", "login system");
|
|
19
|
+
expect(result).toContain("auth");
|
|
20
|
+
expect(result).toContain("login");
|
|
21
|
+
expect(result).toContain("password");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("limits to 10 keywords", () => {
|
|
25
|
+
const result = expandQuery("api", "building a rest endpoint with controller and route handling");
|
|
26
|
+
const words = result.split(" ");
|
|
27
|
+
expect(words.length).toBeLessThanOrEqual(10);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("removes duplicates", () => {
|
|
31
|
+
const result = expandQuery("database", "database query optimization");
|
|
32
|
+
const words = result.split(" ");
|
|
33
|
+
const unique = new Set(words);
|
|
34
|
+
expect(words.length).toBe(unique.size);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles empty prompt gracefully", () => {
|
|
38
|
+
expect(expandQuery("test", "")).toBe("test");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { tokenize, normalize } from "./normalize.js";
|
|
2
|
+
|
|
3
|
+
const KEYWORD_EXPANSION: Record<string, string[]> = {
|
|
4
|
+
database: ["sql", "orm", "query", "migration", "schema", "postgresql", "mysql"],
|
|
5
|
+
auth: ["authentication", "login", "password", "jwt", "token", "session", "oauth"],
|
|
6
|
+
login: ["auth", "authentication", "password", "jwt", "session", "credential"],
|
|
7
|
+
api: ["rest", "endpoint", "controller", "route", "request", "response"],
|
|
8
|
+
test: ["testing", "unit", "integration", "vitest", "jest", "mock"],
|
|
9
|
+
model: ["entity", "schema", "relation", "eloquent", "migration"],
|
|
10
|
+
frontend: ["ui", "react", "vue", "component", "tailwind", "css"],
|
|
11
|
+
backend: ["server", "api", "controller", "service", "database"],
|
|
12
|
+
security: ["encryption", "hash", "csrf", "xss", "sanitize", "validation"],
|
|
13
|
+
performance: ["cache", "optimization", "index", "query", "lazy", "eager"],
|
|
14
|
+
deployment: ["docker", "nginx", "ci", "cd", "pipeline", "deploy"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function expandKeyword(keyword: string): string[] {
|
|
18
|
+
const normalized = normalize(keyword).toLowerCase();
|
|
19
|
+
return KEYWORD_EXPANSION[normalized] || [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractIntentKeywords(prompt: string): string[] {
|
|
23
|
+
const tokens = tokenize(prompt);
|
|
24
|
+
|
|
25
|
+
const important = tokens.filter((t) => {
|
|
26
|
+
if (t.length < 3) return false;
|
|
27
|
+
const stopWords = [
|
|
28
|
+
"need", "want", "how", "what", "when", "where", "why",
|
|
29
|
+
"using", "with", "implement", "create", "build", "make",
|
|
30
|
+
"have", "get", "find", "look", "search", "trying",
|
|
31
|
+
];
|
|
32
|
+
return !stopWords.includes(t);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const expanded: string[] = [];
|
|
36
|
+
for (const token of important) {
|
|
37
|
+
expanded.push(token);
|
|
38
|
+
const expansions = expandKeyword(token);
|
|
39
|
+
expanded.push(...expansions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return [...new Set(expanded)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function expandQuery(query: string, prompt?: string): string {
|
|
46
|
+
const normalizedQuery = normalize(query);
|
|
47
|
+
const queryTokens = normalizedQuery.split(/\s+/).filter((t) => t.length > 0);
|
|
48
|
+
|
|
49
|
+
if (!prompt) {
|
|
50
|
+
return queryTokens.join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const intentKeywords = extractIntentKeywords(prompt);
|
|
54
|
+
|
|
55
|
+
const combined = new Set<string>([...queryTokens, ...intentKeywords]);
|
|
56
|
+
|
|
57
|
+
const result = Array.from(combined).slice(0, 10).join(" ");
|
|
58
|
+
|
|
59
|
+
return result.length > 3 ? result : queryTokens.join(" ");
|
|
60
|
+
}
|