coding-friend-cli 1.16.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/{chunk-D4EWPGBL.js → chunk-C5LYVVEI.js} +1 -1
- package/dist/{chunk-X5WEODUD.js → chunk-CYQU33FY.js} +1 -0
- package/dist/{chunk-QNLL3ZDF.js → chunk-G6CEEMAR.js} +3 -3
- package/dist/{chunk-4DB4XTSL.js → chunk-KTX4MGMR.js} +15 -1
- package/dist/{chunk-KJUGTLPQ.js → chunk-YO6JKGR3.js} +38 -2
- package/dist/{config-AIZJJ5D2.js → config-LZFXXOI4.js} +276 -14
- package/dist/{dev-WJ5QQ35B.js → dev-R3IYWZ3M.js} +2 -2
- package/dist/{disable-JDVOQNZG.js → disable-R6K5YJN4.js} +2 -2
- package/dist/{enable-JBJ4Q2S7.js → enable-HF4PYVJN.js} +2 -2
- package/dist/{host-NA7LZ4HX.js → host-SYZH3FVC.js} +4 -4
- package/dist/index.js +78 -18
- package/dist/{init-FZ3GG53E.js → init-MF7ISADJ.js} +102 -6
- package/dist/{install-I3GOS56Q.js → install-Q4PWEU43.js} +4 -4
- package/dist/{mcp-DLS3J6QJ.js → mcp-TBEDYELW.js} +4 -4
- package/dist/memory-RGLM35HC.js +647 -0
- package/dist/postinstall.js +1 -1
- package/dist/{session-E3CZJJZQ.js → session-H4XW2WXH.js} +1 -1
- package/dist/{statusline-6HQCDWBD.js → statusline-6Y2EBAFQ.js} +1 -1
- package/dist/{uninstall-JN5YIKKM.js → uninstall-3PSUDGI4.js} +3 -3
- package/dist/{update-OWS4IJTG.js → update-WL6SFGGO.js} +4 -4
- package/lib/cf-memory/CHANGELOG.md +25 -0
- package/lib/cf-memory/README.md +284 -0
- package/lib/cf-memory/package-lock.json +2790 -0
- package/lib/cf-memory/package.json +31 -0
- package/lib/cf-memory/scripts/migrate-frontmatter.ts +134 -0
- package/lib/cf-memory/src/__tests__/daemon-e2e.test.ts +223 -0
- package/lib/cf-memory/src/__tests__/daemon.test.ts +407 -0
- package/lib/cf-memory/src/__tests__/dedup.test.ts +103 -0
- package/lib/cf-memory/src/__tests__/embeddings.test.ts +292 -0
- package/lib/cf-memory/src/__tests__/lazy-install.test.ts +210 -0
- package/lib/cf-memory/src/__tests__/markdown-backend.test.ts +410 -0
- package/lib/cf-memory/src/__tests__/migration.test.ts +255 -0
- package/lib/cf-memory/src/__tests__/migrations.test.ts +288 -0
- package/lib/cf-memory/src/__tests__/minisearch-backend.test.ts +262 -0
- package/lib/cf-memory/src/__tests__/ollama.test.ts +48 -0
- package/lib/cf-memory/src/__tests__/schema.test.ts +128 -0
- package/lib/cf-memory/src/__tests__/search.test.ts +115 -0
- package/lib/cf-memory/src/__tests__/temporal-decay.test.ts +54 -0
- package/lib/cf-memory/src/__tests__/tier.test.ts +293 -0
- package/lib/cf-memory/src/__tests__/tools.test.ts +83 -0
- package/lib/cf-memory/src/backends/markdown.ts +318 -0
- package/lib/cf-memory/src/backends/minisearch.ts +203 -0
- package/lib/cf-memory/src/backends/sqlite/embeddings.ts +286 -0
- package/lib/cf-memory/src/backends/sqlite/index.ts +549 -0
- package/lib/cf-memory/src/backends/sqlite/migrations.ts +188 -0
- package/lib/cf-memory/src/backends/sqlite/schema.ts +120 -0
- package/lib/cf-memory/src/backends/sqlite/search.ts +296 -0
- package/lib/cf-memory/src/bin/cf-memory.ts +2 -0
- package/lib/cf-memory/src/daemon/entry.ts +99 -0
- package/lib/cf-memory/src/daemon/process.ts +271 -0
- package/lib/cf-memory/src/daemon/server.ts +166 -0
- package/lib/cf-memory/src/daemon/watcher.ts +90 -0
- package/lib/cf-memory/src/index.ts +53 -0
- package/lib/cf-memory/src/lib/backend.ts +23 -0
- package/lib/cf-memory/src/lib/daemon-client.ts +163 -0
- package/lib/cf-memory/src/lib/dedup.ts +80 -0
- package/lib/cf-memory/src/lib/lazy-install.ts +274 -0
- package/lib/cf-memory/src/lib/ollama.ts +76 -0
- package/lib/cf-memory/src/lib/temporal-decay.ts +19 -0
- package/lib/cf-memory/src/lib/tier.ts +107 -0
- package/lib/cf-memory/src/lib/types.ts +109 -0
- package/lib/cf-memory/src/resources/index.ts +62 -0
- package/lib/cf-memory/src/server.ts +20 -0
- package/lib/cf-memory/src/tools/delete.ts +38 -0
- package/lib/cf-memory/src/tools/list.ts +38 -0
- package/lib/cf-memory/src/tools/retrieve.ts +52 -0
- package/lib/cf-memory/src/tools/search.ts +47 -0
- package/lib/cf-memory/src/tools/store.ts +70 -0
- package/lib/cf-memory/src/tools/update.ts +62 -0
- package/lib/cf-memory/tsconfig.json +15 -0
- package/lib/cf-memory/vitest.config.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { getRequestListener } from "@hono/node-server";
|
|
6
|
+
import { createDaemonApp } from "./server.js";
|
|
7
|
+
import type { MemoryBackend } from "../lib/backend.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
10
|
+
|
|
11
|
+
export interface DaemonPaths {
|
|
12
|
+
socketPath: string;
|
|
13
|
+
pidFile: string;
|
|
14
|
+
logFile: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getDaemonPaths(): DaemonPaths {
|
|
18
|
+
const baseDir = path.join(
|
|
19
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
|
20
|
+
".coding-friend",
|
|
21
|
+
"memory",
|
|
22
|
+
);
|
|
23
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
24
|
+
return {
|
|
25
|
+
socketPath: path.join(baseDir, "daemon.sock"),
|
|
26
|
+
pidFile: path.join(baseDir, "daemon.pid"),
|
|
27
|
+
logFile: path.join(baseDir, "daemon.log"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if the daemon is running by trying to connect to the socket.
|
|
33
|
+
*/
|
|
34
|
+
export async function isDaemonRunning(paths?: DaemonPaths): Promise<boolean> {
|
|
35
|
+
const { socketPath, pidFile } = paths ?? getDaemonPaths();
|
|
36
|
+
|
|
37
|
+
// Check PID file first
|
|
38
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
39
|
+
|
|
40
|
+
const pid = parseInt(
|
|
41
|
+
fs.readFileSync(pidFile, "utf-8").trim().split("\n")[0],
|
|
42
|
+
10,
|
|
43
|
+
);
|
|
44
|
+
if (isNaN(pid)) return false;
|
|
45
|
+
|
|
46
|
+
// Check if process is alive
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
} catch {
|
|
50
|
+
// Process not running, clean up stale files
|
|
51
|
+
cleanupDaemonFiles(paths);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try to connect to socket
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const client = net.createConnection({ path: socketPath }, () => {
|
|
58
|
+
client.end();
|
|
59
|
+
resolve(true);
|
|
60
|
+
});
|
|
61
|
+
client.on("error", () => {
|
|
62
|
+
resolve(false);
|
|
63
|
+
});
|
|
64
|
+
client.setTimeout(1000, () => {
|
|
65
|
+
client.destroy();
|
|
66
|
+
resolve(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get daemon info from PID file.
|
|
73
|
+
*/
|
|
74
|
+
export function getDaemonInfo(
|
|
75
|
+
paths?: DaemonPaths,
|
|
76
|
+
): { pid: number; startedAt: number } | null {
|
|
77
|
+
const { pidFile } = paths ?? getDaemonPaths();
|
|
78
|
+
if (!fs.existsSync(pidFile)) return null;
|
|
79
|
+
|
|
80
|
+
const content = fs.readFileSync(pidFile, "utf-8").trim();
|
|
81
|
+
const lines = content.split("\n");
|
|
82
|
+
const pid = parseInt(lines[0], 10);
|
|
83
|
+
if (isNaN(pid)) return null;
|
|
84
|
+
|
|
85
|
+
const startedAt = lines[1] ? parseInt(lines[1], 10) : Date.now();
|
|
86
|
+
return { pid, startedAt };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanupDaemonFiles(paths?: DaemonPaths): void {
|
|
90
|
+
const { socketPath, pidFile } = paths ?? getDaemonPaths();
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(socketPath);
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
fs.unlinkSync(pidFile);
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DaemonHandle {
|
|
104
|
+
close: () => void;
|
|
105
|
+
server: http.Server;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Start the daemon HTTP server on a Unix Domain Socket.
|
|
110
|
+
*
|
|
111
|
+
* Signal handling is NOT registered here — the caller owns that.
|
|
112
|
+
* Use the returned `close()` to trigger graceful shutdown.
|
|
113
|
+
*/
|
|
114
|
+
export function startDaemonServer(
|
|
115
|
+
backend: MemoryBackend,
|
|
116
|
+
opts?: {
|
|
117
|
+
idleTimeoutMs?: number;
|
|
118
|
+
paths?: DaemonPaths;
|
|
119
|
+
onShutdown?: () => void;
|
|
120
|
+
},
|
|
121
|
+
): DaemonHandle {
|
|
122
|
+
const paths = opts?.paths ?? getDaemonPaths();
|
|
123
|
+
const idleTimeoutMs = opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
124
|
+
const { socketPath, pidFile } = paths;
|
|
125
|
+
|
|
126
|
+
// Clean up stale socket (catch ENOENT instead of TOCTOU check-then-act)
|
|
127
|
+
try {
|
|
128
|
+
fs.unlinkSync(socketPath);
|
|
129
|
+
} catch {
|
|
130
|
+
// No stale socket — fine
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const app = createDaemonApp(backend);
|
|
134
|
+
const listener = getRequestListener(app.fetch);
|
|
135
|
+
|
|
136
|
+
const server = http.createServer(listener);
|
|
137
|
+
|
|
138
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
139
|
+
let shuttingDown = false;
|
|
140
|
+
|
|
141
|
+
function resetIdleTimer() {
|
|
142
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
143
|
+
if (idleTimeoutMs > 0) {
|
|
144
|
+
idleTimer = setTimeout(() => {
|
|
145
|
+
shutdown();
|
|
146
|
+
}, idleTimeoutMs);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reset idle timer on each request
|
|
151
|
+
server.on("request", () => {
|
|
152
|
+
resetIdleTimer();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
server.listen(socketPath, () => {
|
|
156
|
+
// Write PID file
|
|
157
|
+
fs.writeFileSync(pidFile, `${process.pid}\n${Date.now()}`, "utf-8");
|
|
158
|
+
resetIdleTimer();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function shutdown() {
|
|
162
|
+
if (shuttingDown) return;
|
|
163
|
+
shuttingDown = true;
|
|
164
|
+
|
|
165
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
166
|
+
opts?.onShutdown?.();
|
|
167
|
+
backend.close().catch(() => {});
|
|
168
|
+
server.close(() => {
|
|
169
|
+
cleanupDaemonFiles(paths);
|
|
170
|
+
process.exit(0);
|
|
171
|
+
});
|
|
172
|
+
// Force exit after 5 seconds
|
|
173
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { close: shutdown, server };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn the daemon as a detached background process (if not already running).
|
|
181
|
+
*
|
|
182
|
+
* Resolves `daemon/entry.js` relative to this file so it works from both
|
|
183
|
+
* the MCP server and the CLI without hardcoding paths.
|
|
184
|
+
*/
|
|
185
|
+
export async function spawnDaemon(
|
|
186
|
+
docsDir: string,
|
|
187
|
+
embeddingConfig?: {
|
|
188
|
+
provider?: string;
|
|
189
|
+
model?: string;
|
|
190
|
+
ollamaUrl?: string;
|
|
191
|
+
},
|
|
192
|
+
): Promise<{ pid: number } | null> {
|
|
193
|
+
if (await isDaemonRunning()) return null;
|
|
194
|
+
|
|
195
|
+
const { spawn } = await import("node:child_process");
|
|
196
|
+
const { fileURLToPath } = await import("node:url");
|
|
197
|
+
|
|
198
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
199
|
+
const entryPath = path.join(thisDir, "entry.js");
|
|
200
|
+
|
|
201
|
+
if (!fs.existsSync(entryPath)) return null;
|
|
202
|
+
|
|
203
|
+
const args = [entryPath, docsDir];
|
|
204
|
+
if (embeddingConfig?.provider)
|
|
205
|
+
args.push(`--embedding-provider=${embeddingConfig.provider}`);
|
|
206
|
+
if (embeddingConfig?.model)
|
|
207
|
+
args.push(`--embedding-model=${embeddingConfig.model}`);
|
|
208
|
+
if (embeddingConfig?.ollamaUrl)
|
|
209
|
+
args.push(`--embedding-ollama-url=${embeddingConfig.ollamaUrl}`);
|
|
210
|
+
|
|
211
|
+
const child = spawn("node", args, {
|
|
212
|
+
detached: true,
|
|
213
|
+
stdio: "ignore",
|
|
214
|
+
env: { ...process.env },
|
|
215
|
+
});
|
|
216
|
+
child.unref();
|
|
217
|
+
|
|
218
|
+
// Wait for daemon to be ready (max 3 seconds)
|
|
219
|
+
for (let i = 0; i < 30; i++) {
|
|
220
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
221
|
+
if (await isDaemonRunning()) {
|
|
222
|
+
const info = getDaemonInfo();
|
|
223
|
+
return info ? { pid: info.pid } : null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stop the daemon by sending SIGTERM.
|
|
232
|
+
*/
|
|
233
|
+
export async function stopDaemon(paths?: DaemonPaths): Promise<boolean> {
|
|
234
|
+
const { pidFile } = paths ?? getDaemonPaths();
|
|
235
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
236
|
+
|
|
237
|
+
const pid = parseInt(
|
|
238
|
+
fs.readFileSync(pidFile, "utf-8").trim().split("\n")[0],
|
|
239
|
+
10,
|
|
240
|
+
);
|
|
241
|
+
if (isNaN(pid)) return false;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
process.kill(pid, "SIGTERM");
|
|
245
|
+
} catch {
|
|
246
|
+
// Already dead
|
|
247
|
+
cleanupDaemonFiles(paths);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Wait for process to exit (max 5 seconds)
|
|
252
|
+
for (let i = 0; i < 50; i++) {
|
|
253
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, 0);
|
|
256
|
+
} catch {
|
|
257
|
+
// Process exited
|
|
258
|
+
cleanupDaemonFiles(paths);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Force kill
|
|
264
|
+
try {
|
|
265
|
+
process.kill(pid, "SIGKILL");
|
|
266
|
+
} catch {
|
|
267
|
+
// Already dead
|
|
268
|
+
}
|
|
269
|
+
cleanupDaemonFiles(paths);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { MemoryBackend } from "../lib/backend.js";
|
|
4
|
+
import {
|
|
5
|
+
MEMORY_TYPES,
|
|
6
|
+
type MemoryType,
|
|
7
|
+
type SearchInput,
|
|
8
|
+
} from "../lib/types.js";
|
|
9
|
+
|
|
10
|
+
const memoryTypeSchema = z.enum(MEMORY_TYPES);
|
|
11
|
+
|
|
12
|
+
const storeSchema = z.object({
|
|
13
|
+
title: z.string().min(1),
|
|
14
|
+
description: z.string(),
|
|
15
|
+
type: memoryTypeSchema,
|
|
16
|
+
tags: z.array(z.string()),
|
|
17
|
+
content: z.string(),
|
|
18
|
+
importance: z.number().min(1).max(5).optional(),
|
|
19
|
+
source: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const updateSchema = z.object({
|
|
23
|
+
title: z.string().min(1).optional(),
|
|
24
|
+
description: z.string().optional(),
|
|
25
|
+
tags: z.array(z.string()).optional(),
|
|
26
|
+
content: z.string().optional(),
|
|
27
|
+
importance: z.number().min(1).max(5).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function parseType(raw: string | undefined): MemoryType | undefined {
|
|
31
|
+
if (!raw) return undefined;
|
|
32
|
+
const result = memoryTypeSchema.safeParse(raw);
|
|
33
|
+
return result.success ? result.data : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDaemonApp(backend: MemoryBackend): Hono {
|
|
37
|
+
const app = new Hono();
|
|
38
|
+
|
|
39
|
+
// Global error handler
|
|
40
|
+
app.onError((err, c) => {
|
|
41
|
+
return c.json(
|
|
42
|
+
{ error: err instanceof Error ? err.message : "Internal server error" },
|
|
43
|
+
500,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Health check
|
|
48
|
+
app.get("/health", (c) => {
|
|
49
|
+
return c.json({
|
|
50
|
+
status: "ok",
|
|
51
|
+
uptime: process.uptime(),
|
|
52
|
+
pid: process.pid,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Stats
|
|
57
|
+
app.get("/stats", async (c) => {
|
|
58
|
+
const stats = await backend.stats();
|
|
59
|
+
return c.json(stats);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Store a new memory
|
|
63
|
+
app.post("/memory", async (c) => {
|
|
64
|
+
const raw = await c.req.json();
|
|
65
|
+
const parsed = storeSchema.safeParse(raw);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
return c.json(
|
|
68
|
+
{ error: "Validation failed", details: parsed.error.issues },
|
|
69
|
+
400,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const memory = await backend.store(parsed.data);
|
|
73
|
+
return c.json(
|
|
74
|
+
{ id: memory.id, title: memory.frontmatter.title, stored: true },
|
|
75
|
+
201,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Search memories
|
|
80
|
+
app.get("/memory/search", async (c) => {
|
|
81
|
+
const query = c.req.query("query") ?? "";
|
|
82
|
+
const type = parseType(c.req.query("type"));
|
|
83
|
+
const tagsRaw = c.req.query("tags");
|
|
84
|
+
const limitRaw = c.req.query("limit");
|
|
85
|
+
|
|
86
|
+
const input: SearchInput = {
|
|
87
|
+
query,
|
|
88
|
+
type,
|
|
89
|
+
tags: tagsRaw ? tagsRaw.split(",") : undefined,
|
|
90
|
+
limit: limitRaw ? parseInt(limitRaw, 10) : undefined,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const results = await backend.search(input);
|
|
94
|
+
return c.json(results);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Retrieve a memory by ID (category/slug)
|
|
98
|
+
app.get("/memory/:category/:slug", async (c) => {
|
|
99
|
+
const id = `${c.req.param("category")}/${c.req.param("slug")}`;
|
|
100
|
+
const memory = await backend.retrieve(id);
|
|
101
|
+
if (!memory) {
|
|
102
|
+
return c.json({ error: "Not found" }, 404);
|
|
103
|
+
}
|
|
104
|
+
return c.json(memory);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// List memories
|
|
108
|
+
app.get("/memory", async (c) => {
|
|
109
|
+
const type = parseType(c.req.query("type"));
|
|
110
|
+
const category = c.req.query("category");
|
|
111
|
+
const limitRaw = c.req.query("limit");
|
|
112
|
+
|
|
113
|
+
const metas = await backend.list({
|
|
114
|
+
type,
|
|
115
|
+
category: category || undefined,
|
|
116
|
+
limit: limitRaw ? parseInt(limitRaw, 10) : undefined,
|
|
117
|
+
});
|
|
118
|
+
return c.json(metas);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Update a memory
|
|
122
|
+
app.patch("/memory/:category/:slug", async (c) => {
|
|
123
|
+
const id = `${c.req.param("category")}/${c.req.param("slug")}`;
|
|
124
|
+
const raw = await c.req.json();
|
|
125
|
+
const parsed = updateSchema.safeParse(raw);
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
return c.json(
|
|
128
|
+
{ error: "Validation failed", details: parsed.error.issues },
|
|
129
|
+
400,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const memory = await backend.update({ id, ...parsed.data });
|
|
133
|
+
if (!memory) {
|
|
134
|
+
return c.json({ error: "Not found" }, 404);
|
|
135
|
+
}
|
|
136
|
+
return c.json({
|
|
137
|
+
id: memory.id,
|
|
138
|
+
title: memory.frontmatter.title,
|
|
139
|
+
updated: true,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Delete a memory
|
|
144
|
+
app.delete("/memory/:category/:slug", async (c) => {
|
|
145
|
+
const id = `${c.req.param("category")}/${c.req.param("slug")}`;
|
|
146
|
+
const deleted = await backend.delete(id);
|
|
147
|
+
if (!deleted) {
|
|
148
|
+
return c.json({ error: "Not found" }, 404);
|
|
149
|
+
}
|
|
150
|
+
return c.json({ id, deleted: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Rebuild index (for MiniSearchBackend)
|
|
154
|
+
app.post("/rebuild", async (c) => {
|
|
155
|
+
if ("rebuild" in backend && typeof backend.rebuild === "function") {
|
|
156
|
+
await (backend as MemoryBackend & { rebuild(): Promise<void> }).rebuild();
|
|
157
|
+
return c.json({ rebuilt: true });
|
|
158
|
+
}
|
|
159
|
+
return c.json({
|
|
160
|
+
rebuilt: false,
|
|
161
|
+
reason: "Backend does not support rebuild",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return app;
|
|
166
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { MemoryBackend } from "../lib/backend.js";
|
|
4
|
+
|
|
5
|
+
export interface WatcherHandle {
|
|
6
|
+
close: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watch docs/memory/ for changes and trigger index rebuild (debounced).
|
|
11
|
+
*/
|
|
12
|
+
export function setupWatcher(
|
|
13
|
+
docsDir: string,
|
|
14
|
+
backend: Required<Pick<MemoryBackend, "rebuild">>,
|
|
15
|
+
debounceMs = 500,
|
|
16
|
+
): WatcherHandle {
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
const watchers: fs.FSWatcher[] = [];
|
|
19
|
+
const watchedDirs = new Set<string>();
|
|
20
|
+
|
|
21
|
+
function scheduleRebuild() {
|
|
22
|
+
if (timer) clearTimeout(timer);
|
|
23
|
+
timer = setTimeout(() => {
|
|
24
|
+
backend.rebuild().catch(() => {});
|
|
25
|
+
}, debounceMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function watchSubdir(dirPath: string) {
|
|
29
|
+
if (watchedDirs.has(dirPath)) return;
|
|
30
|
+
watchedDirs.add(dirPath);
|
|
31
|
+
try {
|
|
32
|
+
const w = fs.watch(dirPath, { persistent: false }, (_, filename) => {
|
|
33
|
+
if (filename && filename.endsWith(".md")) {
|
|
34
|
+
scheduleRebuild();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
watchers.push(w);
|
|
38
|
+
} catch {
|
|
39
|
+
watchedDirs.delete(dirPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Watch the root docs dir
|
|
44
|
+
if (fs.existsSync(docsDir)) {
|
|
45
|
+
try {
|
|
46
|
+
const rootWatcher = fs.watch(
|
|
47
|
+
docsDir,
|
|
48
|
+
{ persistent: false },
|
|
49
|
+
(_, filename) => {
|
|
50
|
+
if (filename && !filename.startsWith(".")) {
|
|
51
|
+
scheduleRebuild();
|
|
52
|
+
// If a new directory appeared, watch it too
|
|
53
|
+
const subPath = path.join(docsDir, filename);
|
|
54
|
+
try {
|
|
55
|
+
if (fs.statSync(subPath).isDirectory()) {
|
|
56
|
+
watchSubdir(subPath);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// File may have been deleted between event and stat
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
watchers.push(rootWatcher);
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore watch errors (e.g., directory doesn't exist)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Watch existing subdirectories
|
|
70
|
+
try {
|
|
71
|
+
const entries = fs.readdirSync(docsDir, { withFileTypes: true });
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
74
|
+
watchSubdir(path.join(docsDir, entry.name));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Ignore
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
close() {
|
|
84
|
+
if (timer) clearTimeout(timer);
|
|
85
|
+
for (const w of watchers) {
|
|
86
|
+
w.close();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerAllTools } from "./server.js";
|
|
5
|
+
import { registerAllResources } from "./resources/index.js";
|
|
6
|
+
import { createBackendForTier } from "./lib/tier.js";
|
|
7
|
+
import type { TierConfig } from "./lib/tier.js";
|
|
8
|
+
import type { EmbeddingConfig } from "./backends/sqlite/embeddings.js";
|
|
9
|
+
|
|
10
|
+
const rawDir =
|
|
11
|
+
process.argv[2] ?? process.env.MEMORY_DOCS_DIR ?? "./docs/memory";
|
|
12
|
+
const docsDir = path.resolve(rawDir);
|
|
13
|
+
const tierConfig = (process.env.MEMORY_TIER ?? "auto") as TierConfig;
|
|
14
|
+
|
|
15
|
+
// Embedding config from environment variables
|
|
16
|
+
const embeddingConfig: Partial<EmbeddingConfig> | undefined = (() => {
|
|
17
|
+
const provider = process.env.MEMORY_EMBEDDING_PROVIDER as
|
|
18
|
+
| EmbeddingConfig["provider"]
|
|
19
|
+
| undefined;
|
|
20
|
+
const model = process.env.MEMORY_EMBEDDING_MODEL;
|
|
21
|
+
const ollamaUrl = process.env.MEMORY_EMBEDDING_OLLAMA_URL;
|
|
22
|
+
if (!provider && !model && !ollamaUrl) return undefined;
|
|
23
|
+
return {
|
|
24
|
+
...(provider && { provider }),
|
|
25
|
+
...(model && { model }),
|
|
26
|
+
...(ollamaUrl && { ollamaUrl }),
|
|
27
|
+
};
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
const { backend, tier } = await createBackendForTier(
|
|
31
|
+
docsDir,
|
|
32
|
+
tierConfig,
|
|
33
|
+
embeddingConfig,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Auto-start daemon for file watching (Tier 1 & 2)
|
|
37
|
+
// Daemon watches docs/memory/ for external changes (git pull, manual edits)
|
|
38
|
+
// and rebuilds the search index automatically.
|
|
39
|
+
if (tier.name !== "markdown") {
|
|
40
|
+
const { spawnDaemon } = await import("./daemon/process.js");
|
|
41
|
+
spawnDaemon(docsDir, embeddingConfig).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = new McpServer({
|
|
45
|
+
name: "coding-friend-memory",
|
|
46
|
+
version: "0.0.1",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
registerAllTools(server, backend);
|
|
50
|
+
registerAllResources(server, backend);
|
|
51
|
+
|
|
52
|
+
const transport = new StdioServerTransport();
|
|
53
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ListInput,
|
|
3
|
+
Memory,
|
|
4
|
+
MemoryMeta,
|
|
5
|
+
MemoryStats,
|
|
6
|
+
SearchInput,
|
|
7
|
+
SearchResult,
|
|
8
|
+
StoreInput,
|
|
9
|
+
UpdateInput,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface MemoryBackend {
|
|
13
|
+
store(input: StoreInput): Promise<Memory>;
|
|
14
|
+
search(input: SearchInput): Promise<SearchResult[]>;
|
|
15
|
+
retrieve(id: string): Promise<Memory | null>;
|
|
16
|
+
list(input: ListInput): Promise<MemoryMeta[]>;
|
|
17
|
+
update(input: UpdateInput): Promise<Memory | null>;
|
|
18
|
+
delete(id: string): Promise<boolean>;
|
|
19
|
+
stats(): Promise<MemoryStats>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
/** Rebuild the search index from source files. Optional — not all backends support it. */
|
|
22
|
+
rebuild?(): Promise<void>;
|
|
23
|
+
}
|