openviking-claude-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # claude-openviking
2
+
3
+ **OpenViking long-term memory integration for Claude Code.**
4
+
5
+ > Give Claude Code persistent memory across sessions — auto-recall past context, auto-capture new learnings.
6
+
7
+ [English](#english) | [中文](#中文)
8
+
9
+ ---
10
+
11
+ <a name="english"></a>
12
+
13
+ ## English
14
+
15
+ ### What is this?
16
+
17
+ `claude-openviking` connects Claude Code to an [OpenViking](https://github.com/nicepkg/openviking) server for long-term memory. Once installed:
18
+
19
+ - **Auto-recall**: Every message you send triggers a memory search — relevant past context is injected automatically
20
+ - **Auto-capture**: Every Claude response is silently sent to OpenViking for memory extraction
21
+ - **MCP tools**: `memory_recall`, `memory_store`, `memory_forget` available for manual use
22
+
23
+ ### Quick Start
24
+
25
+ ```bash
26
+ npx claude-openviking
27
+ ```
28
+
29
+ You'll be asked for your OpenViking server URL and API key. That's it.
30
+
31
+ ### Non-Interactive
32
+
33
+ ```bash
34
+ npx claude-openviking --url http://your-server:1933 --key your-api-key
35
+ ```
36
+
37
+ ### What Gets Installed
38
+
39
+ | Component | Location | Purpose |
40
+ |---|---|---|
41
+ | `auto-recall.cjs` | `~/.claude/hooks/openviking/` | Search memories on every `UserPromptSubmit` |
42
+ | `auto-capture.cjs` | `~/.claude/hooks/openviking/` | Capture conversation on every `Stop` (async) |
43
+ | `mcp-server.cjs` | `~/.claude/hooks/openviking/` | MCP stdio server for memory tools |
44
+ | Hook config | `~/.claude/settings.json` | Registers hooks with Claude Code |
45
+ | MCP config | `.mcp.json` (current directory) | Registers MCP server |
46
+
47
+ ### How It Works
48
+
49
+ ```
50
+ You send a message
51
+
52
+ auto-recall hook fires → searches OpenViking → injects relevant memories
53
+
54
+ Claude sees your message + memories → responds
55
+
56
+ auto-capture hook fires (async) → sends conversation to OpenViking
57
+
58
+ OpenViking extracts and stores memories for future recall
59
+ ```
60
+
61
+ ### Configuration
62
+
63
+ All settings use environment variables (with defaults from setup):
64
+
65
+ | Variable | Description |
66
+ |---|---|
67
+ | `OPENVIKING_BASE_URL` | OpenViking server URL |
68
+ | `OPENVIKING_API_KEY` | API key for authentication |
69
+ | `OPENVIKING_AGENT_ID` | Agent identifier (default: `claude-code`) |
70
+
71
+ ### MCP Tools
72
+
73
+ | Tool | Description |
74
+ |---|---|
75
+ | `memory_recall` | Search memories by query |
76
+ | `memory_store` | Store text as memory |
77
+ | `memory_forget` | Delete a memory by URI or search query |
78
+
79
+ ### Uninstall
80
+
81
+ ```bash
82
+ npx claude-openviking --uninstall
83
+ ```
84
+
85
+ Removes hooks and settings entries. MCP config (`.mcp.json`) left for manual cleanup.
86
+
87
+ ### Author
88
+
89
+ **Bill Zhao** — [LinkedIn](https://www.linkedin.com/in/billzhaodi/) | [GitHub](https://github.com/billzhao9)
90
+
91
+ ### Credits
92
+
93
+ - [OpenViking](https://github.com/nicepkg/openviking) — Long-term memory backend
94
+ - [Claude Code](https://claude.com/claude-code) — The AI coding assistant
95
+
96
+ ### License
97
+
98
+ MIT
99
+
100
+ ---
101
+
102
+ <a name="中文"></a>
103
+
104
+ ## 中文
105
+
106
+ ### 这是什么?
107
+
108
+ `claude-openviking` 为 Claude Code 接入 [OpenViking](https://github.com/nicepkg/openviking) 长期记忆服务。安装后:
109
+
110
+ - **自动召回**:每条消息自动搜索记忆,注入相关上下文
111
+ - **自动捕获**:每次回复后静默发送对话到 OpenViking 提取记忆
112
+ - **MCP 工具**:`memory_recall`、`memory_store`、`memory_forget` 可手动调用
113
+
114
+ ### 快速安装
115
+
116
+ ```bash
117
+ npx claude-openviking
118
+ ```
119
+
120
+ 输入 OpenViking 服务器地址和 API key 即可。
121
+
122
+ ### 非交互安装
123
+
124
+ ```bash
125
+ npx claude-openviking --url http://your-server:1933 --key your-api-key
126
+ ```
127
+
128
+ ### 安装了什么
129
+
130
+ | 组件 | 位置 | 功能 |
131
+ |---|---|---|
132
+ | `auto-recall.cjs` | `~/.claude/hooks/openviking/` | 每条消息自动搜索记忆 |
133
+ | `auto-capture.cjs` | `~/.claude/hooks/openviking/` | 每次回复后异步捕获对话 |
134
+ | `mcp-server.cjs` | `~/.claude/hooks/openviking/` | MCP 工具服务 |
135
+ | Hook 配置 | `~/.claude/settings.json` | 注册 hooks |
136
+ | MCP 配置 | `.mcp.json`(当前目录) | 注册 MCP server |
137
+
138
+ ### 工作流程
139
+
140
+ ```
141
+ 你发一条消息
142
+
143
+ auto-recall 触发 → 搜索 OpenViking → 注入相关记忆
144
+
145
+ Claude 看到你的消息 + 记忆 → 回复
146
+
147
+ auto-capture 触发(异步)→ 对话发到 OpenViking
148
+
149
+ OpenViking 提取并存储记忆,供未来召回
150
+ ```
151
+
152
+ ### 卸载
153
+
154
+ ```bash
155
+ npx claude-openviking --uninstall
156
+ ```
157
+
158
+ ### 作者
159
+
160
+ **Bill Zhao** — [LinkedIn](https://www.linkedin.com/in/billzhaodi/) | [GitHub](https://github.com/billzhao9)
161
+
162
+ ### 协议
163
+
164
+ MIT
package/bin/setup.cjs ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-openviking setup script
4
+ *
5
+ * Installs hooks + MCP server config for Claude Code.
6
+ * Usage:
7
+ * npx claude-openviking # interactive setup
8
+ * npx claude-openviking --url http://... --key xxx # non-interactive
9
+ * npx claude-openviking --uninstall # remove
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ const readline = require("readline");
16
+
17
+ const CLAUDE_DIR = path.join(os.homedir(), ".claude");
18
+ const HOOKS_DIR = path.join(CLAUDE_DIR, "hooks", "openviking");
19
+ const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
20
+ const PKG_HOOKS_DIR = path.join(__dirname, "..", "hooks");
21
+
22
+ const args = process.argv.slice(2);
23
+ const isUninstall = args.includes("--uninstall");
24
+ const urlArg = args[args.indexOf("--url") + 1];
25
+ const keyArg = args[args.indexOf("--key") + 1];
26
+ const agentArg = args[args.indexOf("--agent") + 1];
27
+
28
+ async function ask(question) {
29
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
31
+ }
32
+
33
+ function readJson(filePath) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writeJson(filePath, data) {
42
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
43
+ }
44
+
45
+ // --- Uninstall ---
46
+
47
+ function uninstall() {
48
+ console.log("🗑️ Removing claude-openviking...\n");
49
+
50
+ // Remove hooks dir
51
+ if (fs.existsSync(HOOKS_DIR)) {
52
+ fs.rmSync(HOOKS_DIR, { recursive: true });
53
+ console.log(" ✅ Removed hooks directory");
54
+ }
55
+
56
+ // Remove hook entries from settings.json
57
+ const settings = readJson(SETTINGS_PATH);
58
+ if (settings?.hooks) {
59
+ let changed = false;
60
+
61
+ for (const event of ["UserPromptSubmit", "Stop"]) {
62
+ if (Array.isArray(settings.hooks[event])) {
63
+ const before = settings.hooks[event].length;
64
+ settings.hooks[event] = settings.hooks[event].filter(
65
+ (entry) => !JSON.stringify(entry).includes("openviking")
66
+ );
67
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
68
+ if (settings.hooks[event]?.length !== before) changed = true;
69
+ }
70
+ }
71
+
72
+ if (changed) {
73
+ writeJson(SETTINGS_PATH, settings);
74
+ console.log(" ✅ Removed hooks from settings.json");
75
+ }
76
+ }
77
+
78
+ console.log("\n✅ Uninstalled. MCP server config (.mcp.json) left untouched — remove manually if needed.");
79
+ process.exit(0);
80
+ }
81
+
82
+ // --- Install ---
83
+
84
+ async function install() {
85
+ console.log("🦞 claude-openviking setup\n");
86
+ console.log("This will install OpenViking memory integration for Claude Code:\n");
87
+ console.log(" • Auto-recall: search memories before every message");
88
+ console.log(" • Auto-capture: save conversation to OpenViking after every reply");
89
+ console.log(" • MCP tools: memory_recall, memory_store, memory_forget\n");
90
+
91
+ // Get config
92
+ let baseUrl = urlArg;
93
+ let apiKey = keyArg;
94
+ let agentId = agentArg || "claude-code";
95
+
96
+ if (!baseUrl) {
97
+ baseUrl = await ask("OpenViking server URL (e.g. http://127.0.0.1:1933): ");
98
+ }
99
+ if (!baseUrl) {
100
+ console.log("❌ URL is required. Aborting.");
101
+ process.exit(1);
102
+ }
103
+
104
+ if (!apiKey) {
105
+ apiKey = await ask("OpenViking API key (press Enter to skip): ");
106
+ }
107
+
108
+ if (!agentArg) {
109
+ const ans = await ask(`Agent ID [${agentId}]: `);
110
+ if (ans) agentId = ans;
111
+ }
112
+
113
+ console.log("\n📦 Installing...\n");
114
+
115
+ // 1. Copy hooks
116
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
117
+ for (const file of ["auto-recall.cjs", "auto-capture.cjs", "mcp-server.cjs"]) {
118
+ const src = path.join(PKG_HOOKS_DIR, file);
119
+ const dst = path.join(HOOKS_DIR, file);
120
+ fs.copyFileSync(src, dst);
121
+ console.log(` ✅ Copied ${file}`);
122
+ }
123
+
124
+ // 2. Patch hooks files with user's config
125
+ for (const file of ["auto-recall.cjs", "auto-capture.cjs", "mcp-server.cjs"]) {
126
+ const filePath = path.join(HOOKS_DIR, file);
127
+ let content = fs.readFileSync(filePath, "utf8");
128
+ // Replace default values
129
+ content = content.replace(
130
+ /const OPENVIKING_BASE_URL = process\.env\.OPENVIKING_BASE_URL \|\| "[^"]*"/,
131
+ `const OPENVIKING_BASE_URL = process.env.OPENVIKING_BASE_URL || ${JSON.stringify(baseUrl)}`
132
+ );
133
+ content = content.replace(
134
+ /const OPENVIKING_API_KEY = process\.env\.OPENVIKING_API_KEY \|\| "[^"]*"/,
135
+ `const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || ${JSON.stringify(apiKey || "")}`
136
+ );
137
+ content = content.replace(
138
+ /const OPENVIKING_AGENT_ID = process\.env\.OPENVIKING_AGENT_ID \|\| "[^"]*"/,
139
+ `const OPENVIKING_AGENT_ID = process.env.OPENVIKING_AGENT_ID || ${JSON.stringify(agentId)}`
140
+ );
141
+ fs.writeFileSync(filePath, content, "utf8");
142
+ }
143
+ console.log(" ✅ Configured OpenViking connection");
144
+
145
+ // 3. Update settings.json (add hooks)
146
+ const settings = readJson(SETTINGS_PATH) || {};
147
+ settings.hooks = settings.hooks || {};
148
+
149
+ // UserPromptSubmit hook
150
+ const recallHook = {
151
+ matcher: "",
152
+ hooks: [{
153
+ type: "command",
154
+ command: `node "${path.join(HOOKS_DIR, "auto-recall.cjs")}"`,
155
+ timeout: 6000,
156
+ statusMessage: "Recalling OpenViking memories..."
157
+ }]
158
+ };
159
+
160
+ // Stop hook
161
+ const captureHook = {
162
+ matcher: "",
163
+ hooks: [{
164
+ type: "command",
165
+ command: `node "${path.join(HOOKS_DIR, "auto-capture.cjs")}"`,
166
+ timeout: 12000,
167
+ async: true,
168
+ statusMessage: "Capturing memories to OpenViking..."
169
+ }]
170
+ };
171
+
172
+ // Add if not already present
173
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit || [];
174
+ if (!JSON.stringify(settings.hooks.UserPromptSubmit).includes("openviking")) {
175
+ settings.hooks.UserPromptSubmit.push(recallHook);
176
+ }
177
+
178
+ settings.hooks.Stop = settings.hooks.Stop || [];
179
+ if (!JSON.stringify(settings.hooks.Stop).includes("openviking")) {
180
+ settings.hooks.Stop.push(captureHook);
181
+ }
182
+
183
+ writeJson(SETTINGS_PATH, settings);
184
+ console.log(" ✅ Added hooks to settings.json");
185
+
186
+ // 4. Create .mcp.json in cwd (user can move it)
187
+ const mcpConfig = {
188
+ mcpServers: {
189
+ openviking: {
190
+ command: "node",
191
+ args: [path.join(HOOKS_DIR, "mcp-server.cjs")],
192
+ env: {
193
+ OPENVIKING_BASE_URL: baseUrl,
194
+ OPENVIKING_API_KEY: apiKey || "",
195
+ OPENVIKING_AGENT_ID: agentId,
196
+ }
197
+ }
198
+ }
199
+ };
200
+
201
+ const mcpPath = path.join(process.cwd(), ".mcp.json");
202
+ if (!fs.existsSync(mcpPath)) {
203
+ writeJson(mcpPath, mcpConfig);
204
+ console.log(` ✅ Created ${mcpPath}`);
205
+ } else {
206
+ const existing = readJson(mcpPath) || {};
207
+ existing.mcpServers = existing.mcpServers || {};
208
+ if (!existing.mcpServers.openviking) {
209
+ existing.mcpServers.openviking = mcpConfig.mcpServers.openviking;
210
+ writeJson(mcpPath, existing);
211
+ console.log(` ✅ Added openviking to existing ${mcpPath}`);
212
+ } else {
213
+ console.log(` ⏭️ openviking already in ${mcpPath} — skipped`);
214
+ }
215
+ }
216
+
217
+ console.log(`
218
+ ✅ Setup complete!
219
+
220
+ Restart Claude Code to activate. You'll see:
221
+ • "[OpenViking Auto-Recall]" context on every message
222
+ • Conversations auto-saved to OpenViking
223
+ • memory_recall / memory_store / memory_forget tools available
224
+
225
+ To uninstall: npx claude-openviking --uninstall
226
+ `);
227
+ }
228
+
229
+ // --- Main ---
230
+
231
+ if (isUninstall) {
232
+ uninstall();
233
+ } else {
234
+ install().catch((err) => {
235
+ console.error("Setup failed:", err.message);
236
+ process.exit(1);
237
+ });
238
+ }
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenViking Auto-Capture Hook for Claude Code
4
+ *
5
+ * Runs on Stop: reads the latest conversation turn from the transcript,
6
+ * sends it to OpenViking for memory extraction.
7
+ * Mirrors the afterTurn auto-capture behavior of the OpenClaw plugin.
8
+ */
9
+
10
+ const fs = require("fs");
11
+ const readline = require("readline");
12
+
13
+ const OPENVIKING_BASE_URL = process.env.OPENVIKING_BASE_URL || "http://69.5.7.190:1933";
14
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "fb56a300af1a93e580aa2ef16952cb7663fc3963f1f1f0ec26dfec4f0900f790";
15
+ const OPENVIKING_AGENT_ID = process.env.OPENVIKING_AGENT_ID || "claude-code";
16
+ const TIMEOUT_MS = 10000;
17
+ const CAPTURE_MAX_LENGTH = 24000;
18
+ const MIN_CAPTURE_LENGTH = 10;
19
+
20
+ // Skip patterns
21
+ const COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
22
+ const NON_CONTENT_RE = /^[\p{P}\p{S}\s]+$/u;
23
+ const RELEVANT_MEMORIES_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
24
+ const METADATA_BLOCK_RE = /(?:^|\n)\s*(?:Conversation info|Conversation metadata)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
25
+ const QUESTION_ONLY_RE = /^[??\s]*$/;
26
+
27
+ // Memory trigger keywords for keyword mode (we use semantic mode by default)
28
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
29
+
30
+ function sanitize(text) {
31
+ return text
32
+ .replace(RELEVANT_MEMORIES_RE, " ")
33
+ .replace(METADATA_BLOCK_RE, " ")
34
+ .replace(/\u0000/g, "")
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ }
38
+
39
+ function shouldCapture(text) {
40
+ if (!text || text.length < MIN_CAPTURE_LENGTH) return false;
41
+ if (text.length > CAPTURE_MAX_LENGTH) return false;
42
+ if (COMMAND_RE.test(text)) return false;
43
+ if (NON_CONTENT_RE.test(text)) return false;
44
+ if (QUESTION_ONLY_RE.test(text)) return false;
45
+ return true;
46
+ }
47
+
48
+ async function ovRequest(path, options = {}) {
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
51
+ try {
52
+ const res = await fetch(`${OPENVIKING_BASE_URL}${path}`, {
53
+ ...options,
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ "X-API-Key": OPENVIKING_API_KEY,
57
+ "X-OpenViking-Agent": OPENVIKING_AGENT_ID,
58
+ ...(options.headers || {}),
59
+ },
60
+ signal: controller.signal,
61
+ });
62
+ const payload = await res.json().catch(() => ({}));
63
+ return payload.result ?? payload;
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Read the last N lines from a JSONL transcript file to extract
71
+ * the most recent user + assistant messages.
72
+ */
73
+ async function readRecentMessages(transcriptPath, maxLines = 50) {
74
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return [];
75
+
76
+ const messages = [];
77
+ const lines = [];
78
+
79
+ // Read last maxLines from file
80
+ const fileStream = fs.createReadStream(transcriptPath, { encoding: "utf8" });
81
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
82
+
83
+ for await (const line of rl) {
84
+ lines.push(line);
85
+ if (lines.length > maxLines * 2) {
86
+ lines.splice(0, lines.length - maxLines);
87
+ }
88
+ }
89
+
90
+ // Parse JSONL entries and extract user/assistant messages
91
+ for (const line of lines.slice(-maxLines)) {
92
+ if (!line.trim()) continue;
93
+ try {
94
+ const entry = JSON.parse(line);
95
+ // Claude Code transcript format: look for message entries
96
+ if (entry.type === "human" || entry.type === "user") {
97
+ const text = extractText(entry);
98
+ if (text) messages.push({ role: "user", text });
99
+ } else if (entry.type === "assistant") {
100
+ const text = extractText(entry);
101
+ if (text) messages.push({ role: "assistant", text });
102
+ } else if (entry.role === "user" || entry.role === "human") {
103
+ const text = extractText(entry);
104
+ if (text) messages.push({ role: "user", text });
105
+ } else if (entry.role === "assistant") {
106
+ const text = extractText(entry);
107
+ if (text) messages.push({ role: "assistant", text });
108
+ }
109
+ // Also handle message wrapper format
110
+ if (entry.message) {
111
+ const msg = entry.message;
112
+ const role = msg.role === "human" ? "user" : msg.role;
113
+ if (role === "user" || role === "assistant") {
114
+ const text = extractText(msg);
115
+ if (text) messages.push({ role, text });
116
+ }
117
+ }
118
+ } catch {
119
+ // skip unparseable lines
120
+ }
121
+ }
122
+
123
+ return messages;
124
+ }
125
+
126
+ function extractText(obj) {
127
+ if (typeof obj.content === "string") return obj.content.trim();
128
+ if (typeof obj.text === "string") return obj.text.trim();
129
+ if (Array.isArray(obj.content)) {
130
+ const parts = [];
131
+ for (const block of obj.content) {
132
+ if (typeof block === "string") parts.push(block);
133
+ else if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
134
+ }
135
+ return parts.join("\n").trim();
136
+ }
137
+ return "";
138
+ }
139
+
140
+ async function main() {
141
+ let input = "";
142
+ for await (const chunk of process.stdin) {
143
+ input += chunk;
144
+ }
145
+
146
+ let data;
147
+ try {
148
+ data = JSON.parse(input);
149
+ } catch {
150
+ process.exit(0);
151
+ }
152
+
153
+ const transcriptPath = data.transcript_path;
154
+ const sessionId = data.session_id || `claude-code-${Date.now()}`;
155
+
156
+ // Use session_id as the OV session ID for 1:1 mapping (like OpenClaw plugin)
157
+ const ovSessionId = `cc-${sessionId}`;
158
+
159
+ // Read recent messages from transcript
160
+ const messages = await readRecentMessages(transcriptPath);
161
+ if (messages.length === 0) {
162
+ process.exit(0);
163
+ }
164
+
165
+ // Take the last few messages (current turn)
166
+ const recentMessages = messages.slice(-6);
167
+
168
+ // Build turn text
169
+ const turnTexts = recentMessages.map((m) => `[${m.role}]: ${m.text}`);
170
+ const turnText = turnTexts.join("\n");
171
+ const sanitized = sanitize(turnText);
172
+
173
+ if (!shouldCapture(sanitized)) {
174
+ process.exit(0);
175
+ }
176
+
177
+ // Truncate if needed
178
+ const captureText = sanitized.length > CAPTURE_MAX_LENGTH
179
+ ? sanitized.slice(0, CAPTURE_MAX_LENGTH)
180
+ : sanitized;
181
+
182
+ try {
183
+ // Send to OpenViking session
184
+ await ovRequest(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, {
185
+ method: "POST",
186
+ body: JSON.stringify({ role: "user", content: captureText }),
187
+ });
188
+
189
+ // Commit session to extract memories
190
+ await ovRequest(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit?wait=false`, {
191
+ method: "POST",
192
+ body: JSON.stringify({}),
193
+ });
194
+ } catch {
195
+ // Silently fail - don't block the user
196
+ }
197
+
198
+ process.exit(0);
199
+ }
200
+
201
+ main().catch(() => process.exit(0));
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenViking Auto-Recall Hook for Claude Code
4
+ *
5
+ * Runs on UserPromptSubmit: takes the user's message, searches OpenViking
6
+ * for relevant memories, and injects them as additional context.
7
+ */
8
+
9
+ const OPENVIKING_BASE_URL = process.env.OPENVIKING_BASE_URL || "http://69.5.7.190:1933";
10
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "fb56a300af1a93e580aa2ef16952cb7663fc3963f1f1f0ec26dfec4f0900f790";
11
+ const OPENVIKING_AGENT_ID = process.env.OPENVIKING_AGENT_ID || "claude-code";
12
+ const RECALL_LIMIT = 6;
13
+ const RECALL_SCORE_THRESHOLD = 0.15;
14
+ const RECALL_MAX_CONTENT_CHARS = 500;
15
+ const RECALL_TOKEN_BUDGET = 2000;
16
+ const TIMEOUT_MS = 5000;
17
+ const MIN_QUERY_LENGTH = 5;
18
+
19
+ // Skip patterns (commands, very short, pure punctuation)
20
+ const COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
21
+ const NON_CONTENT_RE = /^[\p{P}\p{S}\s]+$/u;
22
+
23
+ function clampScore(v) {
24
+ if (typeof v !== "number" || Number.isNaN(v)) return 0;
25
+ return Math.max(0, Math.min(1, v));
26
+ }
27
+
28
+ function estimateTokens(text) {
29
+ return Math.ceil((text || "").length / 4);
30
+ }
31
+
32
+ async function ovFind(query, targetUri) {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
35
+ try {
36
+ const res = await fetch(`${OPENVIKING_BASE_URL}/api/v1/search/find`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "X-API-Key": OPENVIKING_API_KEY,
41
+ "X-OpenViking-Agent": OPENVIKING_AGENT_ID,
42
+ },
43
+ body: JSON.stringify({
44
+ query,
45
+ ...(targetUri ? { target_uri: targetUri } : {}),
46
+ limit: Math.max(RECALL_LIMIT * 4, 20),
47
+ score_threshold: 0,
48
+ }),
49
+ signal: controller.signal,
50
+ });
51
+ const payload = await res.json().catch(() => ({}));
52
+ if (!res.ok) return [];
53
+ return (payload.result?.memories || payload.memories || []);
54
+ } catch {
55
+ return [];
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ async function ovRead(uri) {
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
64
+ try {
65
+ const res = await fetch(
66
+ `${OPENVIKING_BASE_URL}/api/v1/content/read?uri=${encodeURIComponent(uri)}`,
67
+ {
68
+ headers: {
69
+ "X-API-Key": OPENVIKING_API_KEY,
70
+ "X-OpenViking-Agent": OPENVIKING_AGENT_ID,
71
+ },
72
+ signal: controller.signal,
73
+ },
74
+ );
75
+ const payload = await res.json().catch(() => ({}));
76
+ if (!res.ok) return null;
77
+ const result = payload.result ?? payload;
78
+ return typeof result === "string" ? result.trim() : null;
79
+ } catch {
80
+ return null;
81
+ } finally {
82
+ clearTimeout(timer);
83
+ }
84
+ }
85
+
86
+ async function main() {
87
+ // Read stdin
88
+ let input = "";
89
+ for await (const chunk of process.stdin) {
90
+ input += chunk;
91
+ }
92
+
93
+ let prompt;
94
+ try {
95
+ const data = JSON.parse(input);
96
+ prompt = data.prompt || "";
97
+ } catch {
98
+ process.exit(0);
99
+ }
100
+
101
+ // Clean up the prompt text
102
+ const queryText = prompt
103
+ .replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ")
104
+ .replace(/\s+/g, " ")
105
+ .trim();
106
+
107
+ // Skip if too short, is a command, or is pure punctuation
108
+ if (
109
+ queryText.length < MIN_QUERY_LENGTH ||
110
+ COMMAND_RE.test(queryText) ||
111
+ NON_CONTENT_RE.test(queryText)
112
+ ) {
113
+ process.exit(0);
114
+ }
115
+
116
+ // Search ALL memory spaces (no target_uri filter)
117
+ const allMems = await ovFind(queryText, null);
118
+
119
+ // Deduplicate
120
+ const uriSet = new Set();
121
+ const unique = allMems.filter((m) => {
122
+ if (uriSet.has(m.uri)) return false;
123
+ uriSet.add(m.uri);
124
+ return true;
125
+ });
126
+
127
+ // Filter: leaf only, score threshold, dedup by abstract
128
+ const sorted = unique
129
+ .filter((m) => m.level === 2 && clampScore(m.score) >= RECALL_SCORE_THRESHOLD)
130
+ .sort((a, b) => clampScore(b.score) - clampScore(a.score));
131
+
132
+ const seen = new Set();
133
+ const memories = [];
134
+ for (const item of sorted) {
135
+ const key = (item.abstract || item.overview || "").trim().toLowerCase() || item.uri;
136
+ if (seen.has(key)) continue;
137
+ seen.add(key);
138
+ memories.push(item);
139
+ if (memories.length >= RECALL_LIMIT) break;
140
+ }
141
+
142
+ if (memories.length === 0) {
143
+ process.exit(0);
144
+ }
145
+
146
+ // Build memory lines with token budget
147
+ let budgetRemaining = RECALL_TOKEN_BUDGET;
148
+ const lines = [];
149
+ for (const item of memories) {
150
+ if (budgetRemaining <= 0) break;
151
+
152
+ let content = item.abstract?.trim() || item.uri;
153
+ // Try to read full content for leaf memories
154
+ if (item.level === 2) {
155
+ const full = await ovRead(item.uri);
156
+ if (full) content = full;
157
+ }
158
+ if (content.length > RECALL_MAX_CONTENT_CHARS) {
159
+ content = content.slice(0, RECALL_MAX_CONTENT_CHARS) + "...";
160
+ }
161
+
162
+ const line = `- [${item.category || "memory"}] ${content}`;
163
+ const lineTokens = estimateTokens(line);
164
+
165
+ // First line always included even if over budget
166
+ if (lineTokens > budgetRemaining && lines.length > 0) break;
167
+
168
+ lines.push(line);
169
+ budgetRemaining -= lineTokens;
170
+ }
171
+
172
+ if (lines.length === 0) {
173
+ process.exit(0);
174
+ }
175
+
176
+ const memoryContext =
177
+ "[OpenViking Auto-Recall] The following long-term memories may be relevant to this conversation:\n" +
178
+ lines.join("\n");
179
+
180
+ // Output as additionalContext JSON
181
+ const output = {
182
+ hookSpecificOutput: {
183
+ hookEventName: "UserPromptSubmit",
184
+ additionalContext: memoryContext,
185
+ },
186
+ };
187
+
188
+ process.stdout.write(JSON.stringify(output));
189
+ process.exit(0);
190
+ }
191
+
192
+ main().catch(() => process.exit(0));
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenViking MCP Server for Claude Code
4
+ *
5
+ * Provides memory_recall, memory_store, memory_forget tools
6
+ * that connect to the same OpenViking backend used by the OpenClaw plugin.
7
+ *
8
+ * Protocol: MCP stdio (JSON-RPC 2.0 over stdin/stdout)
9
+ */
10
+
11
+ const OPENVIKING_BASE_URL = process.env.OPENVIKING_BASE_URL || "http://69.5.7.190:1933";
12
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "fb56a300af1a93e580aa2ef16952cb7663fc3963f1f1f0ec26dfec4f0900f790";
13
+ const OPENVIKING_AGENT_ID = process.env.OPENVIKING_AGENT_ID || "claude-code";
14
+ const OPENVIKING_TIMEOUT_MS = 15000;
15
+ const RECALL_LIMIT = 6;
16
+ const RECALL_SCORE_THRESHOLD = 0.15;
17
+ const RECALL_MAX_CONTENT_CHARS = 500;
18
+
19
+ // --- HTTP client ---
20
+
21
+ async function ovRequest(path, options = {}, agentId) {
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), OPENVIKING_TIMEOUT_MS);
24
+ try {
25
+ const headers = {
26
+ "Content-Type": "application/json",
27
+ "X-API-Key": OPENVIKING_API_KEY,
28
+ "X-OpenViking-Agent": agentId || OPENVIKING_AGENT_ID,
29
+ };
30
+ const res = await fetch(`${OPENVIKING_BASE_URL}${path}`, {
31
+ ...options,
32
+ headers: { ...headers, ...(options.headers || {}) },
33
+ signal: controller.signal,
34
+ });
35
+ const payload = await res.json().catch(() => ({}));
36
+ if (!res.ok || payload.status === "error") {
37
+ const code = payload.error?.code ? ` [${payload.error.code}]` : "";
38
+ const message = payload.error?.message || `HTTP ${res.status}`;
39
+ throw new Error(`OpenViking${code}: ${message}`);
40
+ }
41
+ return payload.result ?? payload;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ // --- Memory helpers ---
48
+
49
+ function clampScore(v) {
50
+ if (typeof v !== "number" || Number.isNaN(v)) return 0;
51
+ return Math.max(0, Math.min(1, v));
52
+ }
53
+
54
+ function dedupeMemories(items, limit, scoreThreshold) {
55
+ const seen = new Set();
56
+ const result = [];
57
+ const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score));
58
+ for (const item of sorted) {
59
+ if (clampScore(item.score) < scoreThreshold) continue;
60
+ if (item.level !== 2) continue;
61
+ const key = (item.abstract || item.overview || "").trim().toLowerCase() || item.uri;
62
+ if (seen.has(key)) continue;
63
+ seen.add(key);
64
+ result.push(item);
65
+ if (result.length >= limit) break;
66
+ }
67
+ return result;
68
+ }
69
+
70
+ const MEMORY_URI_RE = /^viking:\/\/(user|agent)\/(?:[^/]+\/)?memories(?:\/|$)/;
71
+ function isMemoryUri(uri) {
72
+ return MEMORY_URI_RE.test(uri);
73
+ }
74
+
75
+ // --- Tool implementations ---
76
+
77
+ async function memoryRecall(params) {
78
+ const { query } = params;
79
+ const limit = params.limit || RECALL_LIMIT;
80
+ const scoreThreshold = params.scoreThreshold ?? RECALL_SCORE_THRESHOLD;
81
+ const targetUri = params.targetUri;
82
+ const requestLimit = Math.max(limit * 4, 20);
83
+
84
+ let allMemories;
85
+ if (targetUri) {
86
+ const result = await ovRequest("/api/v1/search/find", {
87
+ method: "POST",
88
+ body: JSON.stringify({ query, target_uri: targetUri, limit: requestLimit, score_threshold: 0 }),
89
+ });
90
+ allMemories = result.memories || [];
91
+ } else {
92
+ // Search ALL spaces (no target_uri) to avoid space-resolution mismatch
93
+ const result = await ovRequest("/api/v1/search/find", {
94
+ method: "POST",
95
+ body: JSON.stringify({ query, limit: requestLimit, score_threshold: 0 }),
96
+ });
97
+ allMemories = result.memories || [];
98
+ }
99
+
100
+ const memories = dedupeMemories(allMemories, limit, scoreThreshold);
101
+ if (memories.length === 0) {
102
+ return { content: [{ type: "text", text: "No relevant OpenViking memories found." }] };
103
+ }
104
+
105
+ // Fetch full content for each memory
106
+ const lines = [];
107
+ for (const item of memories) {
108
+ let content = item.abstract?.trim() || item.uri;
109
+ if (item.level === 2) {
110
+ try {
111
+ const full = await ovRequest(`/api/v1/content/read?uri=${encodeURIComponent(item.uri)}`);
112
+ if (typeof full === "string" && full.trim()) {
113
+ content = full.trim();
114
+ }
115
+ } catch {}
116
+ }
117
+ if (content.length > RECALL_MAX_CONTENT_CHARS) {
118
+ content = content.slice(0, RECALL_MAX_CONTENT_CHARS) + "...";
119
+ }
120
+ const score = (clampScore(item.score) * 100).toFixed(0);
121
+ lines.push(`- [${item.category || "memory"}] ${content} (${score}%)`);
122
+ }
123
+
124
+ return {
125
+ content: [{ type: "text", text: `Found ${memories.length} memories:\n\n${lines.join("\n")}` }],
126
+ };
127
+ }
128
+
129
+ async function memoryStore(params) {
130
+ const { text, sessionId } = params;
131
+ const role = params.role || "user";
132
+ const sid = sessionId || `claude-code-${Date.now()}`;
133
+
134
+ await ovRequest(`/api/v1/sessions/${encodeURIComponent(sid)}/messages`, {
135
+ method: "POST",
136
+ body: JSON.stringify({ role, content: text }),
137
+ });
138
+ const commitResult = await ovRequest(`/api/v1/sessions/${encodeURIComponent(sid)}/commit?wait=true`, {
139
+ method: "POST",
140
+ body: JSON.stringify({}),
141
+ });
142
+ const count = commitResult.memories_extracted || 0;
143
+ return {
144
+ content: [{ type: "text", text: `Stored in OpenViking session ${sid}, extracted ${count} memories.` }],
145
+ };
146
+ }
147
+
148
+ async function memoryForget(params) {
149
+ const { uri, query } = params;
150
+
151
+ if (uri) {
152
+ if (!isMemoryUri(uri)) {
153
+ return { content: [{ type: "text", text: `Refusing to delete non-memory URI: ${uri}` }] };
154
+ }
155
+ await ovRequest(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { method: "DELETE" });
156
+ return { content: [{ type: "text", text: `Forgotten: ${uri}` }] };
157
+ }
158
+
159
+ if (!query) {
160
+ return { content: [{ type: "text", text: "Provide uri or query." }] };
161
+ }
162
+
163
+ const result = await ovRequest("/api/v1/search/find", {
164
+ method: "POST",
165
+ body: JSON.stringify({ query, limit: 20, score_threshold: 0 }),
166
+ });
167
+ const candidates = dedupeMemories(result.memories || [], 20, RECALL_SCORE_THRESHOLD)
168
+ .filter((m) => isMemoryUri(m.uri));
169
+
170
+ if (candidates.length === 0) {
171
+ return { content: [{ type: "text", text: "No matching memories found." }] };
172
+ }
173
+
174
+ const top = candidates[0];
175
+ if (candidates.length === 1 && clampScore(top.score) >= 0.85) {
176
+ await ovRequest(`/api/v1/fs?uri=${encodeURIComponent(top.uri)}&recursive=false`, { method: "DELETE" });
177
+ return { content: [{ type: "text", text: `Forgotten: ${top.uri}` }] };
178
+ }
179
+
180
+ const list = candidates
181
+ .map((item) => `- ${item.uri} (${(clampScore(item.score) * 100).toFixed(0)}%)`)
182
+ .join("\n");
183
+ return {
184
+ content: [{ type: "text", text: `Found ${candidates.length} candidates. Specify uri:\n${list}` }],
185
+ };
186
+ }
187
+
188
+ // --- MCP Protocol ---
189
+
190
+ const TOOLS = [
191
+ {
192
+ name: "memory_recall",
193
+ description: "Search long-term memories from OpenViking. Use when you need past user preferences, facts, decisions, or context from previous conversations.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ query: { type: "string", description: "Search query" },
198
+ limit: { type: "number", description: "Max results (default: 6)" },
199
+ scoreThreshold: { type: "number", description: "Minimum score 0-1 (default: 0.15)" },
200
+ targetUri: { type: "string", description: "Search scope URI (e.g. viking://user/memories)" },
201
+ },
202
+ required: ["query"],
203
+ },
204
+ },
205
+ {
206
+ name: "memory_store",
207
+ description: "Store information in OpenViking long-term memory. Use to save important facts, preferences, decisions, or context for future recall.",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ text: { type: "string", description: "Information to store" },
212
+ role: { type: "string", description: "Session role (default: user)" },
213
+ sessionId: { type: "string", description: "Session ID (auto-generated if omitted)" },
214
+ },
215
+ required: ["text"],
216
+ },
217
+ },
218
+ {
219
+ name: "memory_forget",
220
+ description: "Delete a memory by URI, or search then delete when a strong match is found.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ uri: { type: "string", description: "Exact memory URI to delete" },
225
+ query: { type: "string", description: "Search query to find memory" },
226
+ },
227
+ },
228
+ },
229
+ ];
230
+
231
+ const TOOL_HANDLERS = {
232
+ memory_recall: memoryRecall,
233
+ memory_store: memoryStore,
234
+ memory_forget: memoryForget,
235
+ };
236
+
237
+ function makeResponse(id, result) {
238
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
239
+ }
240
+
241
+ function makeError(id, code, message) {
242
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
243
+ }
244
+
245
+ async function handleMessage(msg) {
246
+ const { id, method, params } = msg;
247
+
248
+ switch (method) {
249
+ case "initialize":
250
+ return makeResponse(id, {
251
+ protocolVersion: "2024-11-05",
252
+ capabilities: { tools: {} },
253
+ serverInfo: { name: "openviking", version: "1.0.0" },
254
+ });
255
+
256
+ case "notifications/initialized":
257
+ return null; // no response needed
258
+
259
+ case "tools/list":
260
+ return makeResponse(id, { tools: TOOLS });
261
+
262
+ case "tools/call": {
263
+ const toolName = params?.name;
264
+ const handler = TOOL_HANDLERS[toolName];
265
+ if (!handler) {
266
+ return makeError(id, -32601, `Unknown tool: ${toolName}`);
267
+ }
268
+ try {
269
+ const result = await handler(params.arguments || {});
270
+ return makeResponse(id, result);
271
+ } catch (err) {
272
+ return makeResponse(id, {
273
+ content: [{ type: "text", text: `Error: ${err.message}` }],
274
+ isError: true,
275
+ });
276
+ }
277
+ }
278
+
279
+ case "ping":
280
+ return makeResponse(id, {});
281
+
282
+ default:
283
+ if (id != null) {
284
+ return makeError(id, -32601, `Method not found: ${method}`);
285
+ }
286
+ return null;
287
+ }
288
+ }
289
+
290
+ // --- stdio transport ---
291
+
292
+ let buffer = "";
293
+
294
+ process.stdin.setEncoding("utf8");
295
+ process.stdin.on("data", async (chunk) => {
296
+ buffer += chunk;
297
+ let newlineIdx;
298
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
299
+ const line = buffer.slice(0, newlineIdx).trim();
300
+ buffer = buffer.slice(newlineIdx + 1);
301
+ if (!line) continue;
302
+ try {
303
+ const msg = JSON.parse(line);
304
+ const response = await handleMessage(msg);
305
+ if (response) {
306
+ process.stdout.write(response + "\n");
307
+ }
308
+ } catch (err) {
309
+ process.stderr.write(`MCP parse error: ${err.message}\n`);
310
+ }
311
+ }
312
+ });
313
+
314
+ process.stdin.on("end", () => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "openviking-claude-code",
3
+ "version": "0.1.0",
4
+ "description": "OpenViking long-term memory integration for Claude Code — auto-recall, auto-capture, and MCP tools",
5
+ "author": {
6
+ "name": "Bill Zhao",
7
+ "email": "zhaodibill@gmail.com",
8
+ "url": "https://www.linkedin.com/in/billzhaodi/"
9
+ },
10
+ "license": "MIT",
11
+ "bin": {
12
+ "openviking-claude-code": "bin/setup.cjs"
13
+ },
14
+ "files": [
15
+ "hooks/*.cjs",
16
+ "bin/*.cjs",
17
+ "README.md"
18
+ ],
19
+ "keywords": ["claude-code", "openviking", "memory", "mcp", "auto-recall", "long-term-memory"],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/billzhao9/claude-openviking"
23
+ }
24
+ }