coding-friend-cli 1.16.0 → 1.17.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/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-YK6YRTOT.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-7RM67ZLS.js +668 -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 +15 -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 +220 -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 +45 -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/lib/learn-host/CHANGELOG.md +4 -0
- package/lib/learn-host/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
* Stop the daemon by sending SIGTERM.
|
|
181
|
+
*/
|
|
182
|
+
export async function stopDaemon(paths?: DaemonPaths): Promise<boolean> {
|
|
183
|
+
const { pidFile } = paths ?? getDaemonPaths();
|
|
184
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
185
|
+
|
|
186
|
+
const pid = parseInt(
|
|
187
|
+
fs.readFileSync(pidFile, "utf-8").trim().split("\n")[0],
|
|
188
|
+
10,
|
|
189
|
+
);
|
|
190
|
+
if (isNaN(pid)) return false;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
process.kill(pid, "SIGTERM");
|
|
194
|
+
} catch {
|
|
195
|
+
// Already dead
|
|
196
|
+
cleanupDaemonFiles(paths);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Wait for process to exit (max 5 seconds)
|
|
201
|
+
for (let i = 0; i < 50; i++) {
|
|
202
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
203
|
+
try {
|
|
204
|
+
process.kill(pid, 0);
|
|
205
|
+
} catch {
|
|
206
|
+
// Process exited
|
|
207
|
+
cleanupDaemonFiles(paths);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Force kill
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, "SIGKILL");
|
|
215
|
+
} catch {
|
|
216
|
+
// Already dead
|
|
217
|
+
}
|
|
218
|
+
cleanupDaemonFiles(paths);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
@@ -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,45 @@
|
|
|
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
|
+
const server = new McpServer({
|
|
37
|
+
name: "coding-friend-memory",
|
|
38
|
+
version: "0.0.1",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
registerAllTools(server, backend);
|
|
42
|
+
registerAllResources(server, backend);
|
|
43
|
+
|
|
44
|
+
const transport = new StdioServerTransport();
|
|
45
|
+
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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { MemoryBackend } from "./backend.js";
|
|
3
|
+
import type {
|
|
4
|
+
ListInput,
|
|
5
|
+
Memory,
|
|
6
|
+
MemoryMeta,
|
|
7
|
+
MemoryStats,
|
|
8
|
+
SearchInput,
|
|
9
|
+
SearchResult,
|
|
10
|
+
StoreInput,
|
|
11
|
+
UpdateInput,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MemoryBackend implementation that talks to the daemon over UDS.
|
|
16
|
+
*/
|
|
17
|
+
export class DaemonClient implements MemoryBackend {
|
|
18
|
+
constructor(private socketPath: string) {}
|
|
19
|
+
|
|
20
|
+
private request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const options: http.RequestOptions = {
|
|
23
|
+
socketPath: this.socketPath,
|
|
24
|
+
path,
|
|
25
|
+
method,
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const req = http.request(options, (res) => {
|
|
32
|
+
let data = "";
|
|
33
|
+
res.on("data", (chunk: Buffer) => {
|
|
34
|
+
data += chunk.toString();
|
|
35
|
+
});
|
|
36
|
+
res.on("end", () => {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(data) as T;
|
|
39
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
40
|
+
reject(
|
|
41
|
+
new Error(
|
|
42
|
+
(parsed as Record<string, string>).error ??
|
|
43
|
+
`HTTP ${res.statusCode}`,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
} else {
|
|
47
|
+
resolve(parsed);
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
reject(new Error(`Invalid JSON response: ${data}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
req.on("error", (err) => {
|
|
56
|
+
reject(err);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
req.setTimeout(10000, () => {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error("Request timeout"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (body) {
|
|
65
|
+
req.write(JSON.stringify(body));
|
|
66
|
+
}
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async store(input: StoreInput): Promise<Memory> {
|
|
72
|
+
const result = await this.request<{
|
|
73
|
+
id: string;
|
|
74
|
+
title: string;
|
|
75
|
+
stored: boolean;
|
|
76
|
+
}>("POST", "/memory", input);
|
|
77
|
+
// Retrieve full memory after store
|
|
78
|
+
const memory = await this.retrieve(result.id);
|
|
79
|
+
if (!memory) throw new Error("Store succeeded but retrieve failed");
|
|
80
|
+
return memory;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async search(input: SearchInput): Promise<SearchResult[]> {
|
|
84
|
+
const params = new URLSearchParams();
|
|
85
|
+
params.set("query", input.query);
|
|
86
|
+
if (input.type) params.set("type", input.type);
|
|
87
|
+
if (input.tags) params.set("tags", input.tags.join(","));
|
|
88
|
+
if (input.limit) params.set("limit", String(input.limit));
|
|
89
|
+
|
|
90
|
+
return this.request<SearchResult[]>(
|
|
91
|
+
"GET",
|
|
92
|
+
`/memory/search?${params.toString()}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async retrieve(id: string): Promise<Memory | null> {
|
|
97
|
+
try {
|
|
98
|
+
return await this.request<Memory>("GET", `/memory/${id}`);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async list(input: ListInput): Promise<MemoryMeta[]> {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
if (input.type) params.set("type", input.type);
|
|
107
|
+
if (input.category) params.set("category", input.category);
|
|
108
|
+
if (input.limit) params.set("limit", String(input.limit));
|
|
109
|
+
|
|
110
|
+
const qs = params.toString();
|
|
111
|
+
return this.request<MemoryMeta[]>("GET", qs ? `/memory?${qs}` : "/memory");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async update(input: UpdateInput): Promise<Memory | null> {
|
|
115
|
+
const { id, ...body } = input;
|
|
116
|
+
try {
|
|
117
|
+
await this.request("PATCH", `/memory/${id}`, body);
|
|
118
|
+
return this.retrieve(id);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async delete(id: string): Promise<boolean> {
|
|
125
|
+
try {
|
|
126
|
+
await this.request("DELETE", `/memory/${id}`);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async stats(): Promise<MemoryStats> {
|
|
134
|
+
return this.request<MemoryStats>("GET", "/stats");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async close(): Promise<void> {
|
|
138
|
+
// No-op — daemon keeps running
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if the daemon is reachable.
|
|
143
|
+
*/
|
|
144
|
+
async ping(): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
const result = await this.request<{ status: string }>("GET", "/health");
|
|
147
|
+
return result.status === "ok";
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Trigger index rebuild on the daemon.
|
|
155
|
+
*/
|
|
156
|
+
async rebuild(): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
await this.request<{ rebuilt: boolean }>("POST", "/rebuild");
|
|
159
|
+
} catch {
|
|
160
|
+
// Rebuild failed or not supported
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|