@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.
Files changed (196) hide show
  1. package/DASHBOARD.md +129 -0
  2. package/HYBRID_SEARCH.md +204 -0
  3. package/IMPLEMENTATION.md +159 -0
  4. package/README.md +175 -0
  5. package/dist/capabilities.d.ts +22 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +23 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/dashboard/dashboard.test.d.ts +2 -0
  10. package/dist/dashboard/dashboard.test.d.ts.map +1 -0
  11. package/dist/dashboard/dashboard.test.js +362 -0
  12. package/dist/dashboard/dashboard.test.js.map +1 -0
  13. package/dist/dashboard/public/app.js +1187 -0
  14. package/dist/dashboard/public/chart.js +0 -0
  15. package/dist/dashboard/public/index.html +967 -0
  16. package/dist/dashboard/server.d.ts +3 -0
  17. package/dist/dashboard/server.d.ts.map +1 -0
  18. package/dist/dashboard/server.js +297 -0
  19. package/dist/dashboard/server.js.map +1 -0
  20. package/dist/mcp/client.d.ts +34 -0
  21. package/dist/mcp/client.d.ts.map +1 -0
  22. package/dist/mcp/client.js +181 -0
  23. package/dist/mcp/client.js.map +1 -0
  24. package/dist/mcp/client.test.d.ts +2 -0
  25. package/dist/mcp/client.test.d.ts.map +1 -0
  26. package/dist/mcp/client.test.js +130 -0
  27. package/dist/mcp/client.test.js.map +1 -0
  28. package/dist/prompts/registry.d.ts +39 -0
  29. package/dist/prompts/registry.d.ts.map +1 -0
  30. package/dist/prompts/registry.js +90 -0
  31. package/dist/prompts/registry.js.map +1 -0
  32. package/dist/resources/index.d.ts +17 -0
  33. package/dist/resources/index.d.ts.map +1 -0
  34. package/dist/resources/index.js +100 -0
  35. package/dist/resources/index.js.map +1 -0
  36. package/dist/resources/index.test.d.ts +2 -0
  37. package/dist/resources/index.test.d.ts.map +1 -0
  38. package/dist/resources/index.test.js +96 -0
  39. package/dist/resources/index.test.js.map +1 -0
  40. package/dist/router.d.ts +4 -0
  41. package/dist/router.d.ts.map +1 -0
  42. package/dist/router.js +60 -0
  43. package/dist/router.js.map +1 -0
  44. package/dist/router.test.d.ts +2 -0
  45. package/dist/router.test.d.ts.map +1 -0
  46. package/dist/router.test.js +113 -0
  47. package/dist/router.test.js.map +1 -0
  48. package/dist/search_memory_example.d.ts +3 -0
  49. package/dist/search_memory_example.d.ts.map +1 -0
  50. package/dist/search_memory_example.js +56 -0
  51. package/dist/search_memory_example.js.map +1 -0
  52. package/dist/server.d.ts +3 -0
  53. package/dist/server.d.ts.map +1 -0
  54. package/dist/server.js +91 -0
  55. package/dist/server.js.map +1 -0
  56. package/dist/storage/sqlite.d.ts +95 -0
  57. package/dist/storage/sqlite.d.ts.map +1 -0
  58. package/dist/storage/sqlite.js +537 -0
  59. package/dist/storage/sqlite.js.map +1 -0
  60. package/dist/storage/sqlite.test.d.ts +2 -0
  61. package/dist/storage/sqlite.test.d.ts.map +1 -0
  62. package/dist/storage/sqlite.test.js +358 -0
  63. package/dist/storage/sqlite.test.js.map +1 -0
  64. package/dist/storage/vectors.stub.d.ts +12 -0
  65. package/dist/storage/vectors.stub.d.ts.map +1 -0
  66. package/dist/storage/vectors.stub.js +88 -0
  67. package/dist/storage/vectors.stub.js.map +1 -0
  68. package/dist/store_memory_example.d.ts +3 -0
  69. package/dist/store_memory_example.d.ts.map +1 -0
  70. package/dist/store_memory_example.js +69 -0
  71. package/dist/store_memory_example.js.map +1 -0
  72. package/dist/test_quotes_client.d.ts +3 -0
  73. package/dist/test_quotes_client.d.ts.map +1 -0
  74. package/dist/test_quotes_client.js +72 -0
  75. package/dist/test_quotes_client.js.map +1 -0
  76. package/dist/tools/memory.delete.d.ts +9 -0
  77. package/dist/tools/memory.delete.d.ts.map +1 -0
  78. package/dist/tools/memory.delete.js +22 -0
  79. package/dist/tools/memory.delete.js.map +1 -0
  80. package/dist/tools/memory.recap.d.ts +4 -0
  81. package/dist/tools/memory.recap.d.ts.map +1 -0
  82. package/dist/tools/memory.recap.js +42 -0
  83. package/dist/tools/memory.recap.js.map +1 -0
  84. package/dist/tools/memory.search.d.ts +5 -0
  85. package/dist/tools/memory.search.d.ts.map +1 -0
  86. package/dist/tools/memory.search.js +192 -0
  87. package/dist/tools/memory.search.js.map +1 -0
  88. package/dist/tools/memory.search.test.d.ts +2 -0
  89. package/dist/tools/memory.search.test.d.ts.map +1 -0
  90. package/dist/tools/memory.search.test.js +181 -0
  91. package/dist/tools/memory.search.test.js.map +1 -0
  92. package/dist/tools/memory.store.d.ts +5 -0
  93. package/dist/tools/memory.store.d.ts.map +1 -0
  94. package/dist/tools/memory.store.js +41 -0
  95. package/dist/tools/memory.store.js.map +1 -0
  96. package/dist/tools/memory.summarize.d.ts +4 -0
  97. package/dist/tools/memory.summarize.d.ts.map +1 -0
  98. package/dist/tools/memory.summarize.js +13 -0
  99. package/dist/tools/memory.summarize.js.map +1 -0
  100. package/dist/tools/memory.update.d.ts +5 -0
  101. package/dist/tools/memory.update.d.ts.map +1 -0
  102. package/dist/tools/memory.update.js +31 -0
  103. package/dist/tools/memory.update.js.map +1 -0
  104. package/dist/tools/schemas.d.ts +334 -0
  105. package/dist/tools/schemas.d.ts.map +1 -0
  106. package/dist/tools/schemas.js +251 -0
  107. package/dist/tools/schemas.js.map +1 -0
  108. package/dist/types.d.ts +31 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/types.js +3 -0
  111. package/dist/types.js.map +1 -0
  112. package/dist/utils/git-scope.d.ts +8 -0
  113. package/dist/utils/git-scope.d.ts.map +1 -0
  114. package/dist/utils/git-scope.js +38 -0
  115. package/dist/utils/git-scope.js.map +1 -0
  116. package/dist/utils/logger.d.ts +7 -0
  117. package/dist/utils/logger.d.ts.map +1 -0
  118. package/dist/utils/logger.js +40 -0
  119. package/dist/utils/logger.js.map +1 -0
  120. package/dist/utils/logger.test.d.ts +2 -0
  121. package/dist/utils/logger.test.d.ts.map +1 -0
  122. package/dist/utils/logger.test.js +84 -0
  123. package/dist/utils/logger.test.js.map +1 -0
  124. package/dist/utils/mcp-response.d.ts +44 -0
  125. package/dist/utils/mcp-response.d.ts.map +1 -0
  126. package/dist/utils/mcp-response.js +81 -0
  127. package/dist/utils/mcp-response.js.map +1 -0
  128. package/dist/utils/normalize.d.ts +4 -0
  129. package/dist/utils/normalize.d.ts.map +1 -0
  130. package/dist/utils/normalize.js +51 -0
  131. package/dist/utils/normalize.js.map +1 -0
  132. package/dist/utils/normalize.test.d.ts +2 -0
  133. package/dist/utils/normalize.test.d.ts.map +1 -0
  134. package/dist/utils/normalize.test.js +159 -0
  135. package/dist/utils/normalize.test.js.map +1 -0
  136. package/dist/utils/query-expander.d.ts +2 -0
  137. package/dist/utils/query-expander.d.ts.map +1 -0
  138. package/dist/utils/query-expander.js +50 -0
  139. package/dist/utils/query-expander.js.map +1 -0
  140. package/dist/utils/query-expander.test.d.ts +2 -0
  141. package/dist/utils/query-expander.test.d.ts.map +1 -0
  142. package/dist/utils/query-expander.test.js +35 -0
  143. package/dist/utils/query-expander.test.js.map +1 -0
  144. package/docs/PRD.md +199 -0
  145. package/docs/PROMPT-agent.md +139 -0
  146. package/docs/SPEC-git-scope.md +172 -0
  147. package/docs/SPEC-heuristics.md +199 -0
  148. package/docs/SPEC-server.md +243 -0
  149. package/docs/SPEC-skeleton.md +255 -0
  150. package/docs/SPEC-sqlite-schema.md +183 -0
  151. package/docs/SPEC-tool-schema.md +201 -0
  152. package/docs/SPEC-vector-search.md +198 -0
  153. package/docs/TEST-scenarios.md +179 -0
  154. package/package.json +43 -0
  155. package/scripts/update-null-titles-ai.mjs +272 -0
  156. package/scripts/update-titles-batch.mjs +71 -0
  157. package/scripts/update-titles.mjs +66 -0
  158. package/seed-data.mjs +151 -0
  159. package/src/capabilities.ts +22 -0
  160. package/src/dashboard/dashboard.test.ts +546 -0
  161. package/src/dashboard/public/app.js +1187 -0
  162. package/src/dashboard/public/chart.js +0 -0
  163. package/src/dashboard/public/index.html +967 -0
  164. package/src/dashboard/server.ts +347 -0
  165. package/src/mcp/client.test.ts +164 -0
  166. package/src/mcp/client.ts +212 -0
  167. package/src/prompts/registry.ts +89 -0
  168. package/src/resources/index.test.ts +132 -0
  169. package/src/resources/index.ts +113 -0
  170. package/src/router.test.ts +145 -0
  171. package/src/router.ts +80 -0
  172. package/src/server.ts +99 -0
  173. package/src/storage/sqlite.test.ts +504 -0
  174. package/src/storage/sqlite.ts +688 -0
  175. package/src/storage/vectors.stub.ts +101 -0
  176. package/src/tools/memory.delete.ts +37 -0
  177. package/src/tools/memory.recap.ts +61 -0
  178. package/src/tools/memory.search.test.ts +276 -0
  179. package/src/tools/memory.search.ts +244 -0
  180. package/src/tools/memory.store.ts +56 -0
  181. package/src/tools/memory.summarize.ts +23 -0
  182. package/src/tools/memory.update.ts +46 -0
  183. package/src/tools/schemas.ts +261 -0
  184. package/src/types.ts +36 -0
  185. package/src/utils/git-scope.ts +42 -0
  186. package/src/utils/logger.test.ts +125 -0
  187. package/src/utils/logger.ts +53 -0
  188. package/src/utils/mcp-response.ts +116 -0
  189. package/src/utils/normalize.test.ts +203 -0
  190. package/src/utils/normalize.ts +53 -0
  191. package/src/utils/query-expander.test.ts +40 -0
  192. package/src/utils/query-expander.ts +60 -0
  193. package/storage/.gitkeep +5 -0
  194. package/test.sh +48 -0
  195. package/tsconfig.json +21 -0
  196. package/vitest.config.ts +10 -0
package/src/router.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { listResources, readResource } from "./resources/index.js";
2
+ import { PROMPTS } from "./prompts/registry.js";
3
+ import { TOOL_DEFINITIONS } from "./tools/schemas.js";
4
+ import { SQLiteStore } from "./storage/sqlite.js";
5
+ import { VectorStore } from "./types.js";
6
+ import { handleMemoryStore } from "./tools/memory.store.js";
7
+ import { handleMemoryUpdate } from "./tools/memory.update.js";
8
+ import { handleMemorySearch } from "./tools/memory.search.js";
9
+ import { handleMemorySummarize } from "./tools/memory.summarize.js";
10
+ import { handleMemoryDelete } from "./tools/memory.delete.js";
11
+ import { handleMemoryRecap } from "./tools/memory.recap.js";
12
+
13
+ export function createRouter(
14
+ db: SQLiteStore,
15
+ vectors: VectorStore
16
+ ): (method: string, params: any) => Promise<any> {
17
+ async function handleMethod(method: string, params: any): Promise<any> {
18
+ switch (method) {
19
+ // ---- tools ----
20
+ case "tools/list":
21
+ return { tools: TOOL_DEFINITIONS };
22
+
23
+ case "tools/call":
24
+ return await handleToolCall(params);
25
+
26
+ // ---- resources ----
27
+ case "resources/list":
28
+ return listResources();
29
+
30
+ case "resources/read":
31
+ return readResource(params?.uri, db);
32
+
33
+ // ---- prompts ----
34
+ case "prompts/list":
35
+ return { prompts: Object.values(PROMPTS) };
36
+
37
+ case "prompts/get": {
38
+ const prompt = PROMPTS[params?.name as keyof typeof PROMPTS];
39
+ if (!prompt) {
40
+ throw new Error(`Unknown prompt: ${params?.name}`);
41
+ }
42
+ return prompt;
43
+ }
44
+
45
+ default:
46
+ throw new Error(`Unsupported method: ${method}`);
47
+ }
48
+ }
49
+
50
+ async function handleToolCall(params: any): Promise<any> {
51
+ const { name, arguments: args } = params;
52
+ // Normalize tool naming: accept both dot (memory.store) and hyphen (memory-store)
53
+ const toolName = String(name).replace(/\./g, "-");
54
+
55
+ switch (toolName) {
56
+ case "memory-store":
57
+ return await handleMemoryStore(args, db, vectors);
58
+
59
+ case "memory-update":
60
+ return await handleMemoryUpdate(args, db, vectors);
61
+
62
+ case "memory-recap":
63
+ return await handleMemoryRecap(args, db);
64
+
65
+ case "memory-search":
66
+ return await handleMemorySearch(args, db, vectors);
67
+
68
+ case "memory-summarize":
69
+ return await handleMemorySummarize(args, db);
70
+
71
+ case "memory-delete":
72
+ return await handleMemoryDelete(args, db, vectors);
73
+
74
+ default:
75
+ throw new Error(`Unknown tool: ${name}`);
76
+ }
77
+ }
78
+
79
+ return handleMethod;
80
+ }
package/src/server.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline";
3
+ import { createRouter } from "./router.js";
4
+ import { SQLiteStore } from "./storage/sqlite.js";
5
+ import { StubVectorStore } from "./storage/vectors.stub.js";
6
+ import { CAPABILITIES } from "./capabilities.js";
7
+ import { logger } from "./utils/logger.js";
8
+
9
+ // Create storage instances
10
+ const db = new SQLiteStore();
11
+ const vectors = new StubVectorStore(db);
12
+
13
+ // Wire router with injected storage
14
+ const handleMethod = createRouter(db, vectors);
15
+
16
+ // Cleanup on exit
17
+ process.on("SIGINT", () => {
18
+ db.close();
19
+ process.exit(0);
20
+ });
21
+
22
+ process.on("SIGTERM", () => {
23
+ db.close();
24
+ process.exit(0);
25
+ });
26
+
27
+ const rl = readline.createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout,
30
+ terminal: false
31
+ });
32
+
33
+ function reply(payload: unknown) {
34
+ try {
35
+ process.stdout.write(JSON.stringify(payload) + "\n");
36
+ } catch (err: any) {
37
+ // Ignore EPIPE errors (broken pipe when client disconnects)
38
+ if (err.code !== "EPIPE") {
39
+ throw err;
40
+ }
41
+ }
42
+ }
43
+
44
+ rl.on("line", async (line) => {
45
+ if (!line.trim()) return;
46
+
47
+ let msg;
48
+ try {
49
+ msg = JSON.parse(line);
50
+ } catch {
51
+ return;
52
+ }
53
+
54
+ const { id, method, params } = msg;
55
+
56
+ // --- initialize ---
57
+ if (method === "initialize") {
58
+ reply({
59
+ jsonrpc: "2.0",
60
+ id,
61
+ result: {
62
+ protocolVersion: "2024-11-05",
63
+ serverInfo: CAPABILITIES.serverInfo,
64
+ capabilities: CAPABILITIES.capabilities
65
+ }
66
+ });
67
+
68
+ reply({
69
+ jsonrpc: "2.0",
70
+ method: "notifications/initialized",
71
+ params: {}
72
+ });
73
+ return;
74
+ }
75
+
76
+ // --- ignore notification ---
77
+ if (method === "notifications/initialized") return;
78
+
79
+ // --- route method ---
80
+ try {
81
+ const result = await handleMethod(method, params);
82
+
83
+ reply({
84
+ jsonrpc: "2.0",
85
+ id,
86
+ result
87
+ });
88
+ } catch (err: any) {
89
+ logger.error("Method handler error", { method, id, message: err.message });
90
+ reply({
91
+ jsonrpc: "2.0",
92
+ id,
93
+ error: {
94
+ code: -32603,
95
+ message: err.message || "Internal error"
96
+ }
97
+ });
98
+ }
99
+ });
@@ -0,0 +1,504 @@
1
+ // Feature: memory-mcp-optimization
2
+ // Property tests for SQLiteStore — Properties 1, 6, 7, 8, 9, 10, 18
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import * as fc from "fast-check";
6
+ import { SQLiteStore } from "./sqlite.js";
7
+ import type { MemoryEntry } from "../types.js";
8
+
9
+ // ─── Helpers ────────────────────────────────────────────────────────────────
10
+
11
+ type MemoryType = "code_fact" | "decision" | "mistake" | "pattern";
12
+
13
+ function makeEntry(overrides: Partial<{
14
+ id: string;
15
+ repo: string;
16
+ type: MemoryType;
17
+ title: string;
18
+ content: string;
19
+ importance: number;
20
+ created_at: string;
21
+ expires_at: string | null;
22
+ }>): MemoryEntry {
23
+ const now = new Date().toISOString();
24
+ return {
25
+ id: overrides.id ?? `id-${Math.random().toString(36).slice(2)}`,
26
+ type: overrides.type ?? "code_fact",
27
+ title: overrides.title ?? "Test Memory Title",
28
+ content: overrides.content ?? "sample content for testing purposes",
29
+ importance: overrides.importance ?? 3,
30
+ scope: { repo: overrides.repo ?? "test-repo" },
31
+ created_at: overrides.created_at ?? now,
32
+ updated_at: now,
33
+ hit_count: 0,
34
+ recall_count: 0,
35
+ last_used_at: null,
36
+ expires_at: overrides.expires_at !== undefined ? overrides.expires_at : null,
37
+ };
38
+ }
39
+
40
+ function freshStore(): SQLiteStore {
41
+ return new SQLiteStore(":memory:");
42
+ }
43
+
44
+ // ─── Property 1: Pre-filter SQL membatasi kandidat searchBySimilarity ────────
45
+ // Validates: Requirements 1.1, 1.2, 1.3, 1.4
46
+
47
+ describe("Property 1: Pre-filter SQL membatasi kandidat searchBySimilarity", () => {
48
+ // Feature: memory-mcp-optimization, Property 1: Pre-filter SQL membatasi kandidat searchBySimilarity
49
+ it("result.length <= limit for any repo with N > limit memories", () => {
50
+ fc.assert(
51
+ fc.property(
52
+ fc.integer({ min: 5, max: 30 }), // N memories
53
+ fc.integer({ min: 1, max: 4 }), // limit < N
54
+ fc.string({ minLength: 5, maxLength: 30 }), // query
55
+ (n: number, limit: number, query: string) => {
56
+ const store = freshStore();
57
+ const repo = "repo-p1";
58
+
59
+ for (let i = 0; i < n; i++) {
60
+ store.insert(makeEntry({
61
+ id: `p1-${i}`,
62
+ repo,
63
+ content: `memory content item number ${i} about coding patterns`,
64
+ }));
65
+ }
66
+
67
+ const results = store.searchBySimilarity(query, repo, limit);
68
+ store.close();
69
+
70
+ return results.length <= limit;
71
+ }
72
+ ),
73
+ { numRuns: 50 }
74
+ );
75
+ });
76
+ });
77
+
78
+ // ─── Property 6: Inisialisasi SQLiteStore berulang tidak melempar exception ──
79
+ // Validates: Requirements 7.1, 7.3, 7.4
80
+
81
+ describe("Property 6: Inisialisasi SQLiteStore berulang tidak melempar exception", () => {
82
+ // Feature: memory-mcp-optimization, Property 6: Inisialisasi SQLiteStore berulang tidak melempar exception
83
+ it("constructing SQLiteStore twice on :memory: does not throw", () => {
84
+ fc.assert(
85
+ fc.property(
86
+ fc.constant(":memory:"),
87
+ (_path: string) => {
88
+ // Each :memory: DB is independent, so we test the migration logic
89
+ // by creating two stores (each runs migrate() on a fresh in-memory DB)
90
+ expect(() => {
91
+ const s1 = new SQLiteStore(":memory:");
92
+ s1.close();
93
+ }).not.toThrow();
94
+
95
+ expect(() => {
96
+ const s2 = new SQLiteStore(":memory:");
97
+ s2.close();
98
+ }).not.toThrow();
99
+
100
+ return true;
101
+ }
102
+ ),
103
+ { numRuns: 20 }
104
+ );
105
+ });
106
+
107
+ it("migrate() is idempotent — inserting then re-opening same store does not throw", () => {
108
+ // We simulate re-initialization by calling migrate logic twice via two stores
109
+ // on the same in-memory path (each :memory: is isolated, so we verify no throw)
110
+ const store1 = new SQLiteStore(":memory:");
111
+ store1.insert(makeEntry({ id: "m1", repo: "r1" }));
112
+ // A second store on :memory: is a fresh DB — migration runs again safely
113
+ const store2 = new SQLiteStore(":memory:");
114
+ store2.insert(makeEntry({ id: "m2", repo: "r2" }));
115
+ store1.close();
116
+ store2.close();
117
+ });
118
+ });
119
+
120
+ // ─── Property 7: Pagination non-overlapping ──────────────────────────────────
121
+ // Validates: Requirements 8.2, 8.4
122
+
123
+ describe("Property 7: Pagination non-overlapping", () => {
124
+ // Feature: memory-mcp-optimization, Property 7: Pagination non-overlapping
125
+ it("pages i and j (i ≠ j) share no memory ids", () => {
126
+ fc.assert(
127
+ fc.property(
128
+ fc.integer({ min: 10, max: 40 }), // total memories N
129
+ fc.integer({ min: 2, max: 5 }), // page size L
130
+ fc.integer({ min: 0, max: 3 }), // page index i
131
+ fc.integer({ min: 0, max: 3 }), // page index j
132
+ (n: number, pageSize: number, i: number, j: number) => {
133
+ fc.pre(i !== j);
134
+
135
+ const store = freshStore();
136
+ const repo = "repo-p7";
137
+
138
+ for (let k = 0; k < n; k++) {
139
+ store.insert(makeEntry({
140
+ id: `p7-${k}`,
141
+ repo,
142
+ content: `memory item ${k} for pagination test`,
143
+ }));
144
+ }
145
+
146
+ const pageI = store.getRecentMemories(repo, pageSize, i * pageSize);
147
+ const pageJ = store.getRecentMemories(repo, pageSize, j * pageSize);
148
+ store.close();
149
+
150
+ const idsI = new Set(pageI.map((m) => m.id));
151
+ const idsJ = new Set(pageJ.map((m) => m.id));
152
+
153
+ // No overlap
154
+ for (const id of idsI) {
155
+ if (idsJ.has(id)) return false;
156
+ }
157
+ return true;
158
+ }
159
+ ),
160
+ { numRuns: 100 }
161
+ );
162
+ });
163
+ });
164
+
165
+ // ─── Property 8: TTL menyimpan expires_at yang benar ─────────────────────────
166
+ // Validates: Requirement 9.2
167
+
168
+ describe("Property 8: TTL menyimpan expires_at yang benar", () => {
169
+ // Feature: memory-mcp-optimization, Property 8: TTL menyimpan expires_at yang benar
170
+ it("expires_at equals created_at + ttlDays * 86400 seconds", () => {
171
+ fc.assert(
172
+ fc.property(
173
+ fc.integer({ min: 1, max: 365 }), // ttlDays
174
+ (ttlDays: number) => {
175
+ const store = freshStore();
176
+ const createdAt = new Date("2025-01-01T00:00:00.000Z");
177
+ const expectedExpiresAt = new Date(
178
+ createdAt.getTime() + ttlDays * 86400 * 1000
179
+ ).toISOString();
180
+
181
+ const entry = makeEntry({
182
+ id: `p8-${ttlDays}`,
183
+ repo: "repo-p8",
184
+ created_at: createdAt.toISOString(),
185
+ expires_at: expectedExpiresAt,
186
+ });
187
+
188
+ store.insert(entry);
189
+ const retrieved = store.getById(entry.id);
190
+ store.close();
191
+
192
+ return retrieved?.expires_at === expectedExpiresAt;
193
+ }
194
+ ),
195
+ { numRuns: 100 }
196
+ );
197
+ });
198
+ });
199
+
200
+ // ─── Property 9: Expired memories dikecualikan dari hasil pencarian ───────────
201
+ // Validates: Requirements 9.3, 9.6
202
+
203
+ describe("Property 9: Expired memories dikecualikan dari hasil pencarian", () => {
204
+ // Feature: memory-mcp-optimization, Property 9: Expired memories dikecualikan dari hasil pencarian
205
+ it("searchBySimilarity does not return expired memories", () => {
206
+ fc.assert(
207
+ fc.property(
208
+ fc.string({ minLength: 5, maxLength: 20 }), // query
209
+ (query: string) => {
210
+ const store = freshStore();
211
+ const repo = "repo-p9-sim";
212
+ const pastDate = new Date(Date.now() - 86400 * 1000).toISOString(); // yesterday
213
+
214
+ store.insert(makeEntry({
215
+ id: "expired-sim",
216
+ repo,
217
+ content: `${query} expired content that should not appear`,
218
+ expires_at: pastDate,
219
+ }));
220
+
221
+ const results = store.searchBySimilarity(query, repo, 10);
222
+ store.close();
223
+
224
+ return !results.some((r) => r.id === "expired-sim");
225
+ }
226
+ ),
227
+ { numRuns: 50 }
228
+ );
229
+ });
230
+
231
+ it("searchByRepo does not return expired memories", () => {
232
+ fc.assert(
233
+ fc.property(
234
+ fc.integer({ min: 1, max: 5 }), // importance
235
+ (importance: number) => {
236
+ const store = freshStore();
237
+ const repo = "repo-p9-byrepo";
238
+ const pastDate = new Date(Date.now() - 86400 * 1000).toISOString();
239
+
240
+ store.insert(makeEntry({
241
+ id: "expired-byrepo",
242
+ repo,
243
+ importance,
244
+ expires_at: pastDate,
245
+ }));
246
+
247
+ const results = store.searchByRepo(repo);
248
+ store.close();
249
+
250
+ return !results.some((r) => r.id === "expired-byrepo");
251
+ }
252
+ ),
253
+ { numRuns: 50 }
254
+ );
255
+ });
256
+ });
257
+
258
+ // ─── Property 10: archiveExpiredMemories() idempoten ─────────────────────────
259
+ // Validates: Requirement 9.5
260
+
261
+ describe("Property 10: archiveExpiredMemories() idempoten", () => {
262
+ // Feature: memory-mcp-optimization, Property 10: archiveExpiredMemories() idempoten
263
+ it("second call returns 0 when no new memories expired between calls", () => {
264
+ fc.assert(
265
+ fc.property(
266
+ fc.integer({ min: 1, max: 10 }), // number of expired memories
267
+ (n: number) => {
268
+ const store = freshStore();
269
+ const repo = "repo-p10";
270
+ const pastDate = new Date(Date.now() - 86400 * 1000).toISOString();
271
+
272
+ for (let i = 0; i < n; i++) {
273
+ store.insert(makeEntry({
274
+ id: `p10-${i}`,
275
+ repo,
276
+ expires_at: pastDate,
277
+ }));
278
+ }
279
+
280
+ const first = store.archiveExpiredMemories();
281
+ const second = store.archiveExpiredMemories();
282
+ store.close();
283
+
284
+ return first === n && second === 0;
285
+ }
286
+ ),
287
+ { numRuns: 50 }
288
+ );
289
+ });
290
+ });
291
+
292
+ // ─── Property 18: listRepos() mengembalikan daftar unik dan terurut ───────────
293
+ // Validates: Requirements 19.5, 19.6
294
+
295
+ describe("Property 18: listRepos() mengembalikan daftar unik dan terurut", () => {
296
+ // Feature: memory-mcp-optimization, Property 18: listRepos() mengembalikan daftar unik dan terurut
297
+ it("no duplicates and sorted ascending", () => {
298
+ fc.assert(
299
+ fc.property(
300
+ fc.array(
301
+ fc.stringMatching(/^[a-z][a-z0-9-]{1,10}$/),
302
+ { minLength: 2, maxLength: 10 }
303
+ ),
304
+ (repos: string[]) => {
305
+ const store = freshStore();
306
+
307
+ repos.forEach((repo: string, i: number) => {
308
+ store.insert(makeEntry({
309
+ id: `p18-${i}-${repo}`,
310
+ repo,
311
+ }));
312
+ });
313
+
314
+ const result = store.listRepos();
315
+ store.close();
316
+
317
+ // No duplicates
318
+ const unique = new Set(result);
319
+ if (unique.size !== result.length) return false;
320
+
321
+ // Sorted ascending
322
+ for (let i = 1; i < result.length; i++) {
323
+ if (result[i] < result[i - 1]) return false;
324
+ }
325
+
326
+ return true;
327
+ }
328
+ ),
329
+ { numRuns: 100 }
330
+ );
331
+ });
332
+ });
333
+
334
+ // Property 19: Action Log functionality
335
+ describe("Property 19: Action Log stores and retrieves recent actions", () => {
336
+ it("logAction stores action with correct metadata", () => {
337
+ const store = freshStore();
338
+
339
+ store.logAction('search', "test-repo", { query: "test query", resultCount: 5 });
340
+
341
+ const actions = store.getRecentActions("test-repo", 10);
342
+
343
+ expect(actions).toHaveLength(1);
344
+ expect(actions[0].action).toBe("search");
345
+ expect(actions[0].query).toBe("test query");
346
+ });
347
+
348
+ it("getRecentActions returns actions in descending order by created_at", () => {
349
+ const store = freshStore();
350
+
351
+ store.logAction('search', "test-repo", { query: "first query", resultCount: 3 });
352
+ store.logAction('read', "test-repo", { memoryId: "id-1", resultCount: 2 });
353
+ store.logAction('write', "test-repo", { query: "third query", resultCount: 1 });
354
+
355
+ const actions = store.getRecentActions("test-repo", 10);
356
+
357
+ expect(actions[0].action).toBe("write");
358
+ expect(actions[1].action).toBe("read");
359
+ expect(actions[2].action).toBe("search");
360
+ });
361
+
362
+ it("getRecentActions limits results correctly", () => {
363
+ const store = freshStore();
364
+
365
+ for (let i = 0; i < 25; i++) {
366
+ store.logAction('search', "test-repo", { query: `query-${i}`, resultCount: i });
367
+ }
368
+
369
+ const actions = store.getRecentActions("test-repo", 10);
370
+
371
+ expect(actions).toHaveLength(10);
372
+ });
373
+
374
+ it("getRecentActions filters by repo correctly", () => {
375
+ const store = freshStore();
376
+
377
+ store.logAction('search', "repo1", { query: "repo1 query", resultCount: 1 });
378
+ store.logAction('search', "repo2", { query: "repo2 query", resultCount: 1 });
379
+ store.logAction('read', "repo1", { memoryId: "id-1", resultCount: 1 });
380
+
381
+ const repo1Actions = store.getRecentActions("repo1", 10);
382
+ const repo2Actions = store.getRecentActions("repo2", 10);
383
+
384
+ expect(repo1Actions).toHaveLength(2);
385
+ expect(repo2Actions).toHaveLength(1);
386
+ });
387
+
388
+ it("getRecentActions returns empty array when no actions exist", () => {
389
+ const store = freshStore();
390
+
391
+ const actions = store.getRecentActions("nonexistent-repo", 10);
392
+
393
+ expect(actions).toHaveLength(0);
394
+ });
395
+
396
+ it("getRecentActions without repo parameter returns all actions", () => {
397
+ const store = freshStore();
398
+
399
+ store.logAction('search', "repo1", { query: "query1", resultCount: 1 });
400
+ store.logAction('read', "repo2", { memoryId: "id-1", resultCount: 1 });
401
+
402
+ const allActions = store.getRecentActions(undefined, 10);
403
+
404
+ expect(allActions).toHaveLength(2);
405
+ });
406
+
407
+ it("logAction stores result_count correctly", () => {
408
+ const store = freshStore();
409
+
410
+ store.logAction('search', "test-repo", { query: "high results", resultCount: 100 });
411
+ store.logAction('search', "test-repo", { query: "low results", resultCount: 1 });
412
+
413
+ const actions = store.getRecentActions("test-repo", 10);
414
+
415
+ expect(actions[0].result_count).toBe(1);
416
+ expect(actions[1].result_count).toBe(100);
417
+ });
418
+
419
+ it("different action types are logged correctly", () => {
420
+ const store = freshStore();
421
+
422
+ store.logAction('search', "test-repo", { query: "search query", resultCount: 5 });
423
+ store.logAction('read', "test-repo", { memoryId: "mem-1", resultCount: 1 });
424
+ store.logAction('write', "test-repo", { query: "new memory", resultCount: 1 });
425
+ store.logAction('update', "test-repo", { memoryId: "mem-2", resultCount: 1 });
426
+ store.logAction('delete', "test-repo", { memoryId: "mem-3", resultCount: 1 });
427
+
428
+ const actions = store.getRecentActions("test-repo", 10);
429
+
430
+ expect(actions).toHaveLength(5);
431
+ expect(actions.map(a => a.action)).toEqual(['delete', 'update', 'write', 'read', 'search']);
432
+ });
433
+ });
434
+
435
+ describe("Dashboard memory queries", () => {
436
+ it("listMemoriesForDashboard paginates, searches, and sorts server-side", () => {
437
+ const store = freshStore();
438
+
439
+ store.insert(makeEntry({
440
+ id: "dash-1",
441
+ repo: "repo-dashboard",
442
+ title: "Alpha Memory",
443
+ content: "alpha content",
444
+ importance: 5,
445
+ }));
446
+ store.insert(makeEntry({
447
+ id: "dash-2",
448
+ repo: "repo-dashboard",
449
+ title: "Beta Memory",
450
+ content: "beta content",
451
+ importance: 3,
452
+ }));
453
+ store.insert(makeEntry({
454
+ id: "dash-3",
455
+ repo: "repo-dashboard",
456
+ title: "Gamma Memory",
457
+ content: "gamma content",
458
+ importance: 4,
459
+ }));
460
+
461
+ store.incrementHitCount("dash-2");
462
+ store.incrementHitCount("dash-2");
463
+ store.incrementRecallCount("dash-2");
464
+
465
+ const result = store.listMemoriesForDashboard({
466
+ repo: "repo-dashboard",
467
+ search: "memory",
468
+ sortBy: "title",
469
+ sortOrder: "asc",
470
+ limit: 2,
471
+ offset: 0,
472
+ });
473
+
474
+ expect(result.total).toBe(3);
475
+ expect(result.items).toHaveLength(2);
476
+ expect(result.items[0].title).toBe("Alpha Memory");
477
+ expect(result.items[1].title).toBe("Beta Memory");
478
+
479
+ store.close();
480
+ });
481
+
482
+ it("getByIdWithStats returns recall_rate for detail views", () => {
483
+ const store = freshStore();
484
+
485
+ store.insert(makeEntry({
486
+ id: "detail-1",
487
+ repo: "repo-detail",
488
+ title: "Detail Memory",
489
+ content: "detail content",
490
+ }));
491
+
492
+ store.incrementHitCount("detail-1");
493
+ store.incrementHitCount("detail-1");
494
+ store.incrementRecallCount("detail-1");
495
+
496
+ const memory = store.getByIdWithStats("detail-1");
497
+
498
+ expect(memory).not.toBeNull();
499
+ expect(memory?.title).toBe("Detail Memory");
500
+ expect(memory?.recall_rate).toBe(0.5);
501
+
502
+ store.close();
503
+ });
504
+ });