@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
@@ -0,0 +1,212 @@
1
+ import { spawn, ChildProcess } from "child_process";
2
+ import { createInterface } from "readline";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { logger } from "../utils/logger.js";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Exponential backoff delays in ms: 1s, 2s, 4s
10
+ const RETRY_DELAYS = [1000, 2000, 4000];
11
+ const MAX_RETRIES = 3;
12
+ const MAX_RESTARTS = 3;
13
+ const REQUEST_TIMEOUT_MS = 10000;
14
+
15
+ export class MCPClient {
16
+ private process: ChildProcess | null = null;
17
+ private requestId = 0;
18
+ private pendingRequests = new Map<
19
+ number,
20
+ { resolve: (value: unknown) => void; reject: (reason: unknown) => void }
21
+ >();
22
+ private isInitialized = false;
23
+ private serverPathOverride?: string;
24
+ private restartCount = 0;
25
+
26
+ constructor(serverPath?: string) {
27
+ this.serverPathOverride = serverPath;
28
+ }
29
+
30
+ async start() {
31
+ if (this.process) return;
32
+
33
+ const serverPath =
34
+ this.serverPathOverride || path.join(__dirname, "../server.js");
35
+ this.process = spawn("node", [serverPath], {
36
+ stdio: ["pipe", "pipe", "inherit"],
37
+ });
38
+
39
+ if (!this.process.stdout || !this.process.stdin) {
40
+ throw new Error("Failed to spawn MCP server");
41
+ }
42
+
43
+ // Listen for unexpected process exit and auto-restart
44
+ this.process.on("close", (code) => {
45
+ if (this.process === null) {
46
+ // Intentional stop — do not restart
47
+ return;
48
+ }
49
+ logger.error("MCP server process closed unexpectedly", { code, restartCount: this.restartCount });
50
+ this.process = null;
51
+ this.isInitialized = false;
52
+
53
+ if (this.restartCount < MAX_RESTARTS) {
54
+ this.restartCount++;
55
+ logger.info("Attempting to restart MCP server", { attempt: this.restartCount });
56
+ this.start().catch((err) => {
57
+ logger.error("Failed to restart MCP server", { error: String(err) });
58
+ });
59
+ } else {
60
+ logger.error("Max restart attempts reached, giving up", { maxRestarts: MAX_RESTARTS });
61
+ }
62
+ });
63
+
64
+ const rl = createInterface({
65
+ input: this.process.stdout,
66
+ crlfDelay: Infinity,
67
+ });
68
+
69
+ rl.on("line", (line) => {
70
+ try {
71
+ const response = JSON.parse(line);
72
+
73
+ if (!response.id) return;
74
+
75
+ const pending = this.pendingRequests.get(response.id);
76
+ if (pending) {
77
+ this.pendingRequests.delete(response.id);
78
+ if (response.error) {
79
+ pending.reject(new Error(response.error.message));
80
+ } else {
81
+ pending.resolve(response.result);
82
+ }
83
+ }
84
+ } catch (err) {
85
+ logger.error("Failed to parse MCP response", { error: String(err) });
86
+ }
87
+ });
88
+
89
+ await this.callWithRetry("initialize", {
90
+ protocolVersion: "2024-11-05",
91
+ capabilities: {},
92
+ clientInfo: { name: "mcp-client", version: "1.0.0" },
93
+ });
94
+
95
+ this.isInitialized = true;
96
+ }
97
+
98
+ /**
99
+ * Send a single request without retry. Returns a Promise that rejects on timeout.
100
+ */
101
+ private callOnce(method: string, params: unknown = {}): Promise<unknown> {
102
+ if (!this.process || !this.process.stdin) {
103
+ return Promise.reject(new Error("MCP server not started"));
104
+ }
105
+
106
+ const id = ++this.requestId;
107
+ const request = { jsonrpc: "2.0", id, method, params };
108
+
109
+ return new Promise((resolve, reject) => {
110
+ this.pendingRequests.set(id, { resolve, reject });
111
+
112
+ const timer = setTimeout(() => {
113
+ if (this.pendingRequests.has(id)) {
114
+ // Delete entry to prevent memory leak (Req 18.4)
115
+ this.pendingRequests.delete(id);
116
+ reject(new Error("Request timeout"));
117
+ }
118
+ }, REQUEST_TIMEOUT_MS);
119
+
120
+ // Ensure timer doesn't keep the process alive
121
+ if (timer.unref) timer.unref();
122
+
123
+ this.process!.stdin!.write(JSON.stringify(request) + "\n");
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Call with retry on timeout — up to MAX_RETRIES retries with exponential backoff.
129
+ * Req 18.1: retry up to 3x with delays 1s, 2s, 4s.
130
+ */
131
+ private async callWithRetry(method: string, params: unknown = {}): Promise<unknown> {
132
+ let lastError: unknown;
133
+
134
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
135
+ try {
136
+ return await this.callOnce(method, params);
137
+ } catch (err) {
138
+ lastError = err;
139
+ const isTimeout =
140
+ err instanceof Error && err.message === "Request timeout";
141
+
142
+ if (!isTimeout || attempt === MAX_RETRIES) {
143
+ // Non-timeout error or exhausted retries — propagate
144
+ logger.error("MCP request failed", {
145
+ method,
146
+ attempt,
147
+ error: String(err),
148
+ });
149
+ throw err;
150
+ }
151
+
152
+ const delay = RETRY_DELAYS[attempt];
153
+ logger.warn("MCP request timed out, retrying", {
154
+ method,
155
+ attempt: attempt + 1,
156
+ delayMs: delay,
157
+ });
158
+ await sleep(delay);
159
+ }
160
+ }
161
+
162
+ throw lastError;
163
+ }
164
+
165
+ async call(method: string, params: unknown = {}): Promise<unknown> {
166
+ return this.callWithRetry(method, params);
167
+ }
168
+
169
+ async callTool(toolName: string, args: unknown): Promise<unknown> {
170
+ return this.call("tools/call", { name: toolName, arguments: args });
171
+ }
172
+
173
+ async readResource(uri: string): Promise<unknown> {
174
+ return this.call("resources/read", { uri });
175
+ }
176
+
177
+ /**
178
+ * Stop the client: reject all pending requests, then kill the process.
179
+ * Req 18.3: reject all pending requests with "Client stopped".
180
+ */
181
+ stop() {
182
+ // Reject all pending requests before killing the process
183
+ for (const [id, pending] of this.pendingRequests) {
184
+ pending.reject(new Error("Client stopped"));
185
+ this.pendingRequests.delete(id);
186
+ }
187
+
188
+ if (this.process) {
189
+ // Set to null BEFORE kill so the 'close' handler knows it was intentional
190
+ const proc = this.process;
191
+ this.process = null;
192
+ this.isInitialized = false;
193
+ proc.kill();
194
+ }
195
+ }
196
+
197
+ isConnected(): boolean {
198
+ return this.isInitialized && this.process !== null;
199
+ }
200
+
201
+ /**
202
+ * Returns the number of requests currently awaiting a response.
203
+ * Req 18.5
204
+ */
205
+ getPendingCount(): number {
206
+ return this.pendingRequests.size;
207
+ }
208
+ }
209
+
210
+ function sleep(ms: number): Promise<void> {
211
+ return new Promise((resolve) => setTimeout(resolve, ms));
212
+ }
@@ -0,0 +1,89 @@
1
+ export const PROMPTS = {
2
+ "memory-agent-core": {
3
+ name: "memory-agent-core",
4
+ description: "Core behavioral contract for memory-aware agents",
5
+ arguments: [],
6
+ messages: [
7
+ {
8
+ role: "user",
9
+ content: {
10
+ type: "text",
11
+ text: `You are a coding copilot agent working inside an active software project.
12
+
13
+ Your primary goal is to help write correct, maintainable, and consistent code.
14
+
15
+ You are memory-aware:
16
+ - Stored memory represents durable project knowledge
17
+ - Memory is a source of truth, not a suggestion
18
+ - You must respect stored decisions and constraints
19
+
20
+ Core Behavioral Rules:
21
+ 1. Never contradict stored decisions
22
+ 2. Never repeat known mistakes
23
+ 3. Never invent project rules
24
+ 4. Never use memory from another repository
25
+ 5. If memory conflicts with the user, ask for clarification
26
+
27
+ Memory Usage Policy:
28
+ Before generating code:
29
+ 1. Read project summary (if available)
30
+ 2. Search memory for relevant decisions, mistakes, or patterns
31
+ 3. Use memory ONLY if clearly relevant
32
+ 4. Prefer fewer, stronger memories over many weak ones
33
+
34
+ Auto-Memory Creation Policy:
35
+ You MAY store memory ONLY if:
36
+ - The information affects future behavior
37
+ - The scope (repository) is clear
38
+ - The knowledge is durable
39
+
40
+ Before storing memory:
41
+ - Explain briefly why it should be stored
42
+ - Ask for confirmation if unsure
43
+
44
+ Behave like a trusted senior engineer who remembers past decisions and protects the long-term health of the codebase.`
45
+ }
46
+ }
47
+ ]
48
+ },
49
+ "memory-index-policy": {
50
+ name: "memory-index-policy",
51
+ description: "Enforce strict memory discipline",
52
+ arguments: [],
53
+ messages: [
54
+ {
55
+ role: "user",
56
+ content: {
57
+ type: "text",
58
+ text: `Do not store:
59
+ - Temporary discussions
60
+ - Brainstorming
61
+ - Subjective opinions
62
+
63
+ Only store durable knowledge that affects future behavior.
64
+
65
+ Memory is a commit, not a log.`
66
+ }
67
+ }
68
+ ]
69
+ },
70
+ "tool-usage-guidelines": {
71
+ name: "tool-usage-guidelines",
72
+ description: "Prevent tool abuse",
73
+ arguments: [],
74
+ messages: [
75
+ {
76
+ role: "user",
77
+ content: {
78
+ type: "text",
79
+ text: `Only call memory.store when:
80
+ - The information affects future behavior
81
+ - The scope (repo) is clear
82
+ - The memory will still matter later
83
+
84
+ Better to not store anything than to store bad memory.`
85
+ }
86
+ }
87
+ ]
88
+ }
89
+ };
@@ -0,0 +1,132 @@
1
+ // Feature: memory-mcp-optimization, Property 19: memory://index filter repo
2
+ import { describe, it, expect } from "vitest";
3
+ import * as fc from "fast-check";
4
+ import { SQLiteStore } from "../storage/sqlite.js";
5
+ import { readResource } from "./index.js";
6
+ import { MemoryEntry } from "../types.js";
7
+
8
+ function makeEntry(id: string, repo: string): MemoryEntry {
9
+ return {
10
+ id,
11
+ type: "code_fact",
12
+ title: `Memory ${id}`,
13
+ content: `Content for memory ${id} in repo ${repo}`,
14
+ importance: 3,
15
+ scope: { repo },
16
+ created_at: new Date().toISOString(),
17
+ updated_at: new Date().toISOString(),
18
+ hit_count: 0,
19
+ recall_count: 0,
20
+ last_used_at: null,
21
+ expires_at: null,
22
+ };
23
+ }
24
+
25
+ describe("readResource memory://index", () => {
26
+ it("returns recent entries when no repo filter", () => {
27
+ const db = new SQLiteStore(":memory:");
28
+ db.insert(makeEntry("id-1", "repo-a"));
29
+ db.insert(makeEntry("id-2", "repo-b"));
30
+
31
+ const result = readResource("memory://index", db);
32
+ const entries = JSON.parse(result.contents[0].text);
33
+ expect(entries.length).toBeGreaterThan(0);
34
+ db.close();
35
+ });
36
+
37
+ it("returns only entries for the specified repo when ?repo=X is given", () => {
38
+ const db = new SQLiteStore(":memory:");
39
+ db.insert(makeEntry("id-a1", "repo-alpha"));
40
+ db.insert(makeEntry("id-a2", "repo-alpha"));
41
+ db.insert(makeEntry("id-b1", "repo-beta"));
42
+
43
+ const result = readResource("memory://index?repo=repo-alpha", db);
44
+ const entries: MemoryEntry[] = JSON.parse(result.contents[0].text);
45
+
46
+ expect(entries.length).toBeGreaterThan(0);
47
+ for (const entry of entries) {
48
+ expect(entry.scope.repo).toBe("repo-alpha");
49
+ }
50
+ db.close();
51
+ });
52
+
53
+ it("returns empty array when repo has no entries", () => {
54
+ const db = new SQLiteStore(":memory:");
55
+ db.insert(makeEntry("id-1", "repo-a"));
56
+
57
+ const result = readResource("memory://index?repo=nonexistent", db);
58
+ const entries = JSON.parse(result.contents[0].text);
59
+ expect(entries).toEqual([]);
60
+ db.close();
61
+ });
62
+
63
+ /**
64
+ * Property 19: memory://index dengan filter repo mengembalikan subset yang benar
65
+ * Validates: Requirements 19.1, 19.3
66
+ */
67
+ it("Property 19: all returned entries have repo === queried repo", () => {
68
+ fc.assert(
69
+ fc.property(
70
+ // Generate 2-4 distinct repo names
71
+ fc.uniqueArray(
72
+ fc.stringMatching(/^[a-z][a-z0-9-]{2,8}$/),
73
+ { minLength: 2, maxLength: 4 }
74
+ ),
75
+ // Generate 1-5 memories per repo
76
+ fc.integer({ min: 1, max: 5 }),
77
+ (repos: string[], memoriesPerRepo: number) => {
78
+ const db = new SQLiteStore(":memory:");
79
+
80
+ // Insert memories for each repo
81
+ let counter = 0;
82
+ for (const repo of repos) {
83
+ for (let i = 0; i < memoriesPerRepo; i++) {
84
+ db.insert(makeEntry(`id-${counter++}`, repo));
85
+ }
86
+ }
87
+
88
+ // Query with the first repo as filter
89
+ const targetRepo = repos[0];
90
+ const result = readResource(`memory://index?repo=${targetRepo}`, db);
91
+ const entries: MemoryEntry[] = JSON.parse(result.contents[0].text);
92
+
93
+ // All returned entries must belong to targetRepo
94
+ const allMatch = entries.every((e) => e.scope.repo === targetRepo);
95
+
96
+ db.close();
97
+ return allMatch;
98
+ }
99
+ ),
100
+ { numRuns: 100 }
101
+ );
102
+ });
103
+
104
+ it("Property 19 (no filter): returns entries from all repos", () => {
105
+ fc.assert(
106
+ fc.property(
107
+ fc.uniqueArray(
108
+ fc.stringMatching(/^[a-z][a-z0-9-]{2,8}$/),
109
+ { minLength: 2, maxLength: 3 }
110
+ ),
111
+ (repos: string[]) => {
112
+ const db = new SQLiteStore(":memory:");
113
+
114
+ for (const repo of repos) {
115
+ db.insert(makeEntry(`id-${repo}`, repo));
116
+ }
117
+
118
+ const result = readResource("memory://index", db);
119
+ const entries: Array<{ id: string; type: string; repo: string }> =
120
+ JSON.parse(result.contents[0].text);
121
+
122
+ // Without filter, should return entries (listRecent returns id/type/repo)
123
+ expect(entries.length).toBeGreaterThan(0);
124
+
125
+ db.close();
126
+ return true;
127
+ }
128
+ ),
129
+ { numRuns: 100 }
130
+ );
131
+ });
132
+ });
@@ -0,0 +1,113 @@
1
+ import { SQLiteStore } from "../storage/sqlite.js";
2
+
3
+ export function listResources() {
4
+ return {
5
+ resources: [
6
+ {
7
+ uri: "memory://index",
8
+ name: "Memory Index",
9
+ description: "Recent memory entries (metadata only)",
10
+ mimeType: "application/json"
11
+ },
12
+ {
13
+ uri: "memory://summary/{repo}",
14
+ name: "Project Summary",
15
+ description: "Antigravity summary for a repository",
16
+ mimeType: "text/plain"
17
+ },
18
+ {
19
+ uri: "memory://{id}",
20
+ name: "Memory by ID",
21
+ description: "View a specific memory by UUID",
22
+ mimeType: "application/json"
23
+ },
24
+ {
25
+ uri: "memory://{base64_query}",
26
+ name: "Search Memories",
27
+ description: "Search memories by query (base64 encoded)",
28
+ mimeType: "application/json"
29
+ }
30
+ ]
31
+ };
32
+ }
33
+
34
+ export function readResource(uri: string, db: SQLiteStore) {
35
+ if (uri === "memory://index" || uri.startsWith("memory://index?")) {
36
+ const parsed = new URL(uri.replace("memory://", "http://memory/"));
37
+ const repo = parsed.searchParams.get("repo");
38
+
39
+ const entries = repo
40
+ ? db.searchByRepo(repo, { limit: 20 })
41
+ : db.listRecent(20);
42
+
43
+ return {
44
+ contents: [
45
+ {
46
+ uri,
47
+ mimeType: "application/json",
48
+ text: JSON.stringify(entries, null, 2)
49
+ }
50
+ ]
51
+ };
52
+ }
53
+
54
+ if (uri.startsWith("memory://summary/")) {
55
+ const repo = uri.replace("memory://summary/", "");
56
+ const summary = db.getSummary(repo);
57
+
58
+ return {
59
+ contents: [
60
+ {
61
+ uri,
62
+ mimeType: "text/plain",
63
+ text: summary?.summary || "No summary available for this repository"
64
+ }
65
+ ]
66
+ };
67
+ }
68
+
69
+ // View memory by ID: memory://{uuid}
70
+ const idMatch = uri.match(/^memory:\/\/([0-9a-f-]{36})$/i);
71
+ if (idMatch) {
72
+ const id = idMatch[1];
73
+ const entry = db.getById(id);
74
+
75
+ if (!entry) {
76
+ throw new Error(`Memory not found: ${id}`);
77
+ }
78
+
79
+ return {
80
+ contents: [
81
+ {
82
+ uri,
83
+ mimeType: "application/json",
84
+ text: JSON.stringify(entry)
85
+ }
86
+ ]
87
+ };
88
+ }
89
+
90
+ // Search by query: memory://{base64_query}
91
+ if (uri.startsWith("memory://") && !uri.startsWith("memory://index") && !uri.startsWith("memory://summary")) {
92
+ const searchId = uri.replace("memory://", "");
93
+ const query = Buffer.from(searchId, 'base64').toString('utf-8');
94
+ const parsed = new URL(uri.replace("memory://", "http://memory/"));
95
+ const repo = parsed.searchParams.get("repo") || undefined;
96
+
97
+ const results = repo
98
+ ? db.searchBySimilarity(query, repo, 10)
99
+ : db.searchBySimilarity(query, "", 10);
100
+
101
+ return {
102
+ contents: [
103
+ {
104
+ uri,
105
+ mimeType: "application/json",
106
+ text: JSON.stringify({ query, repo, results })
107
+ }
108
+ ]
109
+ };
110
+ }
111
+
112
+ throw new Error(`Unknown resource URI: ${uri}`);
113
+ }
@@ -0,0 +1,145 @@
1
+ // Feature: memory-mcp-optimization, Property 11: createRouter() uses provided storage
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import * as fc from "fast-check";
4
+ import { createRouter } from "./router.js";
5
+ import { SQLiteStore } from "./storage/sqlite.js";
6
+ import { VectorStore } from "./types.js";
7
+
8
+ /**
9
+ * Property 11: createRouter() menggunakan storage yang diberikan
10
+ * Validates: Requirements 10.1, 10.4
11
+ *
12
+ * For any mock SQLiteStore given to createRouter(mockDb, mockVectors),
13
+ * all tool operations run through the router SHALL use mockDb and not access the real DB.
14
+ */
15
+ describe("createRouter() — Property 11: uses provided storage", () => {
16
+ function makeMockDb(): SQLiteStore {
17
+ return {
18
+ insert: vi.fn(),
19
+ update: vi.fn(),
20
+ delete: vi.fn(),
21
+ getById: vi.fn().mockReturnValue(null),
22
+ searchByRepo: vi.fn().mockReturnValue([]),
23
+ searchBySimilarity: vi.fn().mockReturnValue([]),
24
+ getRecentMemories: vi.fn().mockReturnValue([]),
25
+ getTotalCount: vi.fn().mockReturnValue(0),
26
+ getSummary: vi.fn().mockReturnValue(null),
27
+ upsertSummary: vi.fn(),
28
+ listRepos: vi.fn().mockReturnValue([]),
29
+ listRecent: vi.fn().mockReturnValue([]),
30
+ incrementHitCount: vi.fn(),
31
+ incrementRecallCount: vi.fn(),
32
+ getStats: vi.fn().mockReturnValue({ total: 0, byType: {}, unused: 0 }),
33
+ getAllMemoriesWithStats: vi.fn().mockReturnValue([]),
34
+ upsertVectorEmbedding: vi.fn(),
35
+ getVectorEmbedding: vi.fn().mockReturnValue(null),
36
+ archiveExpiredMemories: vi.fn().mockReturnValue(0),
37
+ logQuery: vi.fn(),
38
+ getRecentQueries: vi.fn().mockReturnValue([]),
39
+ close: vi.fn(),
40
+ } as unknown as SQLiteStore;
41
+ }
42
+
43
+ function makeMockVectors(): VectorStore {
44
+ return {
45
+ upsert: vi.fn().mockResolvedValue(undefined),
46
+ remove: vi.fn().mockResolvedValue(undefined),
47
+ search: vi.fn().mockResolvedValue([]),
48
+ };
49
+ }
50
+
51
+ it("memory-recap calls getRecentMemories on the provided mock db", async () => {
52
+ const mockDb = makeMockDb();
53
+ const mockVectors = makeMockVectors();
54
+ const router = createRouter(mockDb, mockVectors);
55
+
56
+ await router("tools/call", {
57
+ name: "memory-recap",
58
+ arguments: { repo: "test-repo", limit: 5 },
59
+ });
60
+
61
+ expect(mockDb.getRecentMemories).toHaveBeenCalledWith("test-repo", 5, 0);
62
+ expect(mockDb.getTotalCount).toHaveBeenCalledWith("test-repo");
63
+ });
64
+
65
+ it("memory-search calls searchBySimilarity on the provided mock db", async () => {
66
+ const mockDb = makeMockDb();
67
+ const mockVectors = makeMockVectors();
68
+ const router = createRouter(mockDb, mockVectors);
69
+
70
+ await router("tools/call", {
71
+ name: "memory-search",
72
+ arguments: { query: "test query", repo: "test-repo", limit: 5 },
73
+ });
74
+
75
+ expect(mockDb.searchBySimilarity).toHaveBeenCalled();
76
+ // Verify the first argument to searchBySimilarity contains the repo
77
+ const callArgs = (mockDb.searchBySimilarity as ReturnType<typeof vi.fn>).mock.calls[0];
78
+ expect(callArgs[1]).toBe("test-repo");
79
+ });
80
+
81
+ it("property: for any repo string, memory-recap always uses the injected db", async () => {
82
+ await fc.assert(
83
+ fc.asyncProperty(
84
+ fc.string({ minLength: 1, maxLength: 50 }).filter((s: string) => s.trim().length > 0),
85
+ fc.integer({ min: 1, max: 50 }),
86
+ async (repo: string, limit: number) => {
87
+ const mockDb = makeMockDb();
88
+ const mockVectors = makeMockVectors();
89
+ const router = createRouter(mockDb, mockVectors);
90
+
91
+ await router("tools/call", {
92
+ name: "memory-recap",
93
+ arguments: { repo, limit },
94
+ });
95
+
96
+ // The mock db methods must have been called (not a real DB)
97
+ expect(mockDb.getRecentMemories).toHaveBeenCalled();
98
+ expect(mockDb.getTotalCount).toHaveBeenCalled();
99
+ }
100
+ ),
101
+ { numRuns: 100 }
102
+ );
103
+ });
104
+
105
+ it("property: for any valid store args, memory-store uses the injected db", async () => {
106
+ await fc.assert(
107
+ fc.asyncProperty(
108
+ fc.record({
109
+ repo: fc.string({ minLength: 1, maxLength: 30 }).filter((s: string) => s.trim().length > 0),
110
+ content: fc.string({ minLength: 10, maxLength: 200 }),
111
+ importance: fc.integer({ min: 1, max: 5 }),
112
+ type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
113
+ }),
114
+ async ({ repo, content, importance, type }: { repo: string; content: string; importance: number; type: string }) => {
115
+ const mockDb = makeMockDb();
116
+ const mockVectors = makeMockVectors();
117
+ const router = createRouter(mockDb, mockVectors);
118
+
119
+ await router("tools/call", {
120
+ name: "memory-store",
121
+ arguments: { type, content, importance, scope: { repo } },
122
+ });
123
+
124
+ expect(mockDb.insert).toHaveBeenCalled();
125
+ }
126
+ ),
127
+ { numRuns: 100 }
128
+ );
129
+ });
130
+
131
+ it("different router instances use their own injected db independently", () => {
132
+ const mockDb1 = makeMockDb();
133
+ const mockDb2 = makeMockDb();
134
+ const mockVectors = makeMockVectors();
135
+
136
+ const router1 = createRouter(mockDb1, mockVectors);
137
+ const router2 = createRouter(mockDb2, mockVectors);
138
+
139
+ // Both routers are distinct functions
140
+ expect(router1).not.toBe(router2);
141
+
142
+ // Each router closes over its own db
143
+ // (verified by the property tests above that each mock is called independently)
144
+ });
145
+ });