claude-code-openviking 0.2.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,185 @@
1
+ # openviking-claude-code
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
+ `openviking-claude-code` connects Claude Code to an [OpenViking](https://github.com/volcengine/OpenViking) server for long-term memory. Once installed:
18
+
19
+ - **Auto-recall**: Every message 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
+ ### v0.2.0 — Tiered Memory
24
+
25
+ The recall system now leverages OpenViking's native L0/L1/L2 context layers:
26
+
27
+ - **Split-space search** — Searches user preferences and agent experience memories separately, then merges results. Ensures both types surface.
28
+ - **L0/L1 tiered injection** — Top-3 highest-scoring memories inject L1 overview (~300 tokens each) for rich context. Remaining memories inject L0 abstract (~50 tokens each) for broad coverage.
29
+ - **3x coverage** — Same 2000-token budget now covers ~20 memories instead of 6.
30
+ - **Smart capture filtering** — Trivial conversations (< 2 turns or very short responses) are skipped, reducing noise in stored memories.
31
+
32
+ ### Quick Start
33
+
34
+ ```bash
35
+ npx claude-openviking
36
+ ```
37
+
38
+ You'll be asked for your OpenViking server URL and API key. That's it.
39
+
40
+ ### Non-Interactive
41
+
42
+ ```bash
43
+ npx claude-openviking --url http://your-server:1933 --key your-api-key
44
+ ```
45
+
46
+ ### What Gets Installed
47
+
48
+ | Component | Location | Purpose |
49
+ |---|---|---|
50
+ | `auto-recall.cjs` | `~/.claude/hooks/openviking/` | Search memories on every `UserPromptSubmit` |
51
+ | `auto-capture.cjs` | `~/.claude/hooks/openviking/` | Capture conversation on every `Stop` (async) |
52
+ | `mcp-server.cjs` | `~/.claude/hooks/openviking/` | MCP stdio server for memory tools |
53
+ | Hook config | `~/.claude/settings.json` | Registers hooks with Claude Code |
54
+ | MCP config | `.mcp.json` (current directory) | Registers MCP server |
55
+
56
+ ### How It Works
57
+
58
+ ```
59
+ You send a message
60
+
61
+ auto-recall hook fires
62
+ → searches user memories + agent memories + global (parallel)
63
+ → merges & deduplicates by URI
64
+ → top-3 inject L1 overview (rich), rest inject L0 abstract (compact)
65
+
66
+ Claude sees your message + tiered memories → responds
67
+
68
+ auto-capture hook fires (async)
69
+ → quality gate: skips if < 2 turns or assistant text < 50 chars
70
+ → sends conversation to OpenViking for memory extraction
71
+ ```
72
+
73
+ ### Configuration
74
+
75
+ All settings use environment variables (with defaults from setup):
76
+
77
+ | Variable | Description |
78
+ |---|---|
79
+ | `OPENVIKING_BASE_URL` | OpenViking server URL |
80
+ | `OPENVIKING_API_KEY` | API key for authentication |
81
+ | `OPENVIKING_AGENT_ID` | Agent identifier (default: `claude-code`) |
82
+
83
+ ### MCP Tools
84
+
85
+ | Tool | Description |
86
+ |---|---|
87
+ | `memory_recall` | Search memories by query |
88
+ | `memory_store` | Store text as memory |
89
+ | `memory_forget` | Delete a memory by URI or search query |
90
+
91
+ ### Uninstall
92
+
93
+ ```bash
94
+ npx claude-openviking --uninstall
95
+ ```
96
+
97
+ Removes hooks and settings entries. MCP config (`.mcp.json`) left for manual cleanup.
98
+
99
+ ### Author
100
+
101
+ **Bill Zhao** — [LinkedIn](https://www.linkedin.com/in/billzhaodi/) | [GitHub](https://github.com/billzhao9)
102
+
103
+ ### Credits
104
+
105
+ - [OpenViking](https://github.com/volcengine/OpenViking) — Long-term memory backend
106
+ - [Claude Code](https://claude.com/claude-code) — The AI coding assistant
107
+
108
+ ### License
109
+
110
+ MIT
111
+
112
+ ---
113
+
114
+ <a name="中文"></a>
115
+
116
+ ## 中文
117
+
118
+ ### 这是什么?
119
+
120
+ `openviking-claude-code` 为 Claude Code 接入 [OpenViking](https://github.com/volcengine/OpenViking) 长期记忆服务。安装后:
121
+
122
+ - **自动召回**:每条消息自动搜索记忆,注入相关上下文
123
+ - **自动捕获**:每次回复后静默发送对话到 OpenViking 提取记忆
124
+ - **MCP 工具**:`memory_recall`、`memory_store`、`memory_forget` 可手动调用
125
+
126
+ ### v0.2.0 — 分层记忆
127
+
128
+ 召回系统现在利用 OpenViking 原生的 L0/L1/L2 上下文分层:
129
+
130
+ - **分空间搜索** — 分别搜索用户偏好和 agent 经验记忆,合并结果,确保两类记忆都能浮现
131
+ - **L0/L1 分层注入** — 前 3 条高分记忆注入 L1 概要(详细),其余注入 L0 摘要(精简)
132
+ - **3 倍覆盖** — 同样 2000 token 预算,覆盖 ~20 条记忆(之前只有 6 条)
133
+ - **智能捕获过滤** — 跳过过于简短的对话(< 2 轮或回复太短),减少噪音记忆
134
+
135
+ ### 快速安装
136
+
137
+ ```bash
138
+ npx claude-openviking
139
+ ```
140
+
141
+ 输入 OpenViking 服务器地址和 API key 即可。
142
+
143
+ ### 非交互安装
144
+
145
+ ```bash
146
+ npx claude-openviking --url http://your-server:1933 --key your-api-key
147
+ ```
148
+
149
+ ### 安装了什么
150
+
151
+ | 组件 | 位置 | 功能 |
152
+ |---|---|---|
153
+ | `auto-recall.cjs` | `~/.claude/hooks/openviking/` | 每条消息自动搜索记忆 |
154
+ | `auto-capture.cjs` | `~/.claude/hooks/openviking/` | 每次回复后异步捕获对话 |
155
+ | `mcp-server.cjs` | `~/.claude/hooks/openviking/` | MCP 工具服务 |
156
+ | Hook 配置 | `~/.claude/settings.json` | 注册 hooks |
157
+ | MCP 配置 | `.mcp.json`(当前目录) | 注册 MCP server |
158
+
159
+ ### 工作流程
160
+
161
+ ```
162
+ 你发一条消息
163
+
164
+ auto-recall 触发 → 搜索 OpenViking → 注入相关记忆
165
+
166
+ Claude 看到你的消息 + 记忆 → 回复
167
+
168
+ auto-capture 触发(异步)→ 对话发到 OpenViking
169
+
170
+ OpenViking 提取并存储记忆,供未来召回
171
+ ```
172
+
173
+ ### 卸载
174
+
175
+ ```bash
176
+ npx claude-openviking --uninstall
177
+ ```
178
+
179
+ ### 作者
180
+
181
+ **Bill Zhao** — [LinkedIn](https://www.linkedin.com/in/billzhaodi/) | [GitHub](https://github.com/billzhao9)
182
+
183
+ ### 协议
184
+
185
+ 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,217 @@
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 || "";
14
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "";
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
+ const MIN_CAPTURE_TURNS = 2;
20
+ const MIN_ASSISTANT_CHARS = 50;
21
+
22
+ // Skip patterns
23
+ const COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
24
+ const NON_CONTENT_RE = /^[\p{P}\p{S}\s]+$/u;
25
+ const RELEVANT_MEMORIES_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
26
+ const METADATA_BLOCK_RE = /(?:^|\n)\s*(?:Conversation info|Conversation metadata)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
27
+ const QUESTION_ONLY_RE = /^[??\s]*$/;
28
+
29
+ // Memory trigger keywords for keyword mode (we use semantic mode by default)
30
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
31
+
32
+ function sanitize(text) {
33
+ return text
34
+ .replace(RELEVANT_MEMORIES_RE, " ")
35
+ .replace(METADATA_BLOCK_RE, " ")
36
+ .replace(/\u0000/g, "")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ }
40
+
41
+ function shouldCapture(text) {
42
+ if (!text || text.length < MIN_CAPTURE_LENGTH) return false;
43
+ if (text.length > CAPTURE_MAX_LENGTH) return false;
44
+ if (COMMAND_RE.test(text)) return false;
45
+ if (NON_CONTENT_RE.test(text)) return false;
46
+ if (QUESTION_ONLY_RE.test(text)) return false;
47
+ return true;
48
+ }
49
+
50
+ async function ovRequest(path, options = {}) {
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
53
+ try {
54
+ const res = await fetch(`${OPENVIKING_BASE_URL}${path}`, {
55
+ ...options,
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ "X-API-Key": OPENVIKING_API_KEY,
59
+ "X-OpenViking-Agent": OPENVIKING_AGENT_ID,
60
+ ...(options.headers || {}),
61
+ },
62
+ signal: controller.signal,
63
+ });
64
+ const payload = await res.json().catch(() => ({}));
65
+ return payload.result ?? payload;
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read the last N lines from a JSONL transcript file to extract
73
+ * the most recent user + assistant messages.
74
+ */
75
+ async function readRecentMessages(transcriptPath, maxLines = 50) {
76
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return [];
77
+
78
+ const messages = [];
79
+ const lines = [];
80
+
81
+ // Read last maxLines from file
82
+ const fileStream = fs.createReadStream(transcriptPath, { encoding: "utf8" });
83
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
84
+
85
+ for await (const line of rl) {
86
+ lines.push(line);
87
+ if (lines.length > maxLines * 2) {
88
+ lines.splice(0, lines.length - maxLines);
89
+ }
90
+ }
91
+
92
+ // Parse JSONL entries and extract user/assistant messages
93
+ for (const line of lines.slice(-maxLines)) {
94
+ if (!line.trim()) continue;
95
+ try {
96
+ const entry = JSON.parse(line);
97
+ // Claude Code transcript format: look for message entries
98
+ if (entry.type === "human" || entry.type === "user") {
99
+ const text = extractText(entry);
100
+ if (text) messages.push({ role: "user", text });
101
+ } else if (entry.type === "assistant") {
102
+ const text = extractText(entry);
103
+ if (text) messages.push({ role: "assistant", text });
104
+ } else if (entry.role === "user" || entry.role === "human") {
105
+ const text = extractText(entry);
106
+ if (text) messages.push({ role: "user", text });
107
+ } else if (entry.role === "assistant") {
108
+ const text = extractText(entry);
109
+ if (text) messages.push({ role: "assistant", text });
110
+ }
111
+ // Also handle message wrapper format
112
+ if (entry.message) {
113
+ const msg = entry.message;
114
+ const role = msg.role === "human" ? "user" : msg.role;
115
+ if (role === "user" || role === "assistant") {
116
+ const text = extractText(msg);
117
+ if (text) messages.push({ role, text });
118
+ }
119
+ }
120
+ } catch {
121
+ // skip unparseable lines
122
+ }
123
+ }
124
+
125
+ return messages;
126
+ }
127
+
128
+ function extractText(obj) {
129
+ if (typeof obj.content === "string") return obj.content.trim();
130
+ if (typeof obj.text === "string") return obj.text.trim();
131
+ if (Array.isArray(obj.content)) {
132
+ const parts = [];
133
+ for (const block of obj.content) {
134
+ if (typeof block === "string") parts.push(block);
135
+ else if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
136
+ }
137
+ return parts.join("\n").trim();
138
+ }
139
+ return "";
140
+ }
141
+
142
+ async function main() {
143
+ let input = "";
144
+ for await (const chunk of process.stdin) {
145
+ input += chunk;
146
+ }
147
+
148
+ let data;
149
+ try {
150
+ data = JSON.parse(input);
151
+ } catch {
152
+ process.exit(0);
153
+ }
154
+
155
+ const transcriptPath = data.transcript_path;
156
+ const sessionId = data.session_id || `claude-code-${Date.now()}`;
157
+
158
+ // Use session_id as the OV session ID for 1:1 mapping (like OpenClaw plugin)
159
+ const ovSessionId = `cc-${sessionId}`;
160
+
161
+ // Read recent messages from transcript
162
+ const messages = await readRecentMessages(transcriptPath);
163
+ if (messages.length === 0) {
164
+ process.exit(0);
165
+ }
166
+
167
+ // Take the last few messages (current turn)
168
+ const recentMessages = messages.slice(-6);
169
+
170
+ // Quality gate: skip trivial conversations
171
+ if (recentMessages.length < MIN_CAPTURE_TURNS) {
172
+ process.exit(0);
173
+ }
174
+
175
+ // Quality gate: skip if assistant responses are too short
176
+ const assistantText = recentMessages
177
+ .filter((m) => m.role === "assistant")
178
+ .map((m) => m.text)
179
+ .join("");
180
+ if (assistantText.length < MIN_ASSISTANT_CHARS) {
181
+ process.exit(0);
182
+ }
183
+
184
+ // Build turn text
185
+ const turnTexts = recentMessages.map((m) => `[${m.role}]: ${m.text}`);
186
+ const turnText = turnTexts.join("\n");
187
+ const sanitized = sanitize(turnText);
188
+
189
+ if (!shouldCapture(sanitized)) {
190
+ process.exit(0);
191
+ }
192
+
193
+ // Truncate if needed
194
+ const captureText = sanitized.length > CAPTURE_MAX_LENGTH
195
+ ? sanitized.slice(0, CAPTURE_MAX_LENGTH)
196
+ : sanitized;
197
+
198
+ try {
199
+ // Send to OpenViking session
200
+ await ovRequest(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, {
201
+ method: "POST",
202
+ body: JSON.stringify({ role: "user", content: captureText }),
203
+ });
204
+
205
+ // Commit session to extract memories
206
+ await ovRequest(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit?wait=false`, {
207
+ method: "POST",
208
+ body: JSON.stringify({}),
209
+ });
210
+ } catch {
211
+ // Silently fail - don't block the user
212
+ }
213
+
214
+ process.exit(0);
215
+ }
216
+
217
+ main().catch(() => process.exit(0));
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenViking Auto-Recall Hook for Claude Code (v2 — Tiered)
4
+ *
5
+ * Runs on UserPromptSubmit: searches user + agent memory spaces,
6
+ * merges results, injects top-3 as L1 overview + remainder as L0 abstract.
7
+ */
8
+
9
+ const OPENVIKING_BASE_URL = process.env.OPENVIKING_BASE_URL || "";
10
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "";
11
+ const OPENVIKING_AGENT_ID = process.env.OPENVIKING_AGENT_ID || "claude-code";
12
+
13
+ // Recall parameters
14
+ const RECALL_TOKEN_BUDGET = 2000;
15
+ const RECALL_SCORE_THRESHOLD = 0.15;
16
+ const SEARCH_LIMIT_PER_SPACE = 20;
17
+ const L1_SLOTS = 3;
18
+ const L1_MAX_CHARS = 1200;
19
+ const L0_MAX_CHARS = 200;
20
+ const TIMEOUT_MS = 5000;
21
+ const MIN_QUERY_LENGTH = 5;
22
+
23
+ // Skip patterns
24
+ const COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
25
+ const NON_CONTENT_RE = /^[\p{P}\p{S}\s]+$/u;
26
+
27
+ function clampScore(v) {
28
+ if (typeof v !== "number" || Number.isNaN(v)) return 0;
29
+ return Math.max(0, Math.min(1, v));
30
+ }
31
+
32
+ function estimateTokens(text) {
33
+ return Math.ceil((text || "").length / 4);
34
+ }
35
+
36
+ async function ovFind(query, targetUri) {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
39
+ try {
40
+ const res = await fetch(`${OPENVIKING_BASE_URL}/api/v1/search/find`, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "X-API-Key": OPENVIKING_API_KEY,
45
+ "X-OpenViking-Agent": OPENVIKING_AGENT_ID,
46
+ },
47
+ body: JSON.stringify({
48
+ query,
49
+ ...(targetUri ? { target_uri: targetUri } : {}),
50
+ limit: SEARCH_LIMIT_PER_SPACE,
51
+ score_threshold: 0,
52
+ }),
53
+ signal: controller.signal,
54
+ });
55
+ const payload = await res.json().catch(() => ({}));
56
+ if (!res.ok) return [];
57
+ return payload.result?.memories || payload.memories || [];
58
+ } catch {
59
+ return [];
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ async function main() {
66
+ // Read stdin
67
+ let input = "";
68
+ for await (const chunk of process.stdin) {
69
+ input += chunk;
70
+ }
71
+
72
+ let prompt;
73
+ try {
74
+ const data = JSON.parse(input);
75
+ prompt = data.prompt || "";
76
+ } catch {
77
+ process.exit(0);
78
+ }
79
+
80
+ // Clean up the prompt text
81
+ const queryText = prompt
82
+ .replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ")
83
+ .replace(/\s+/g, " ")
84
+ .trim();
85
+
86
+ // Skip if too short, is a command, or is pure punctuation
87
+ if (
88
+ queryText.length < MIN_QUERY_LENGTH ||
89
+ COMMAND_RE.test(queryText) ||
90
+ NON_CONTENT_RE.test(queryText)
91
+ ) {
92
+ process.exit(0);
93
+ }
94
+
95
+ // Split-space search: user memories + agent memories + global fallback
96
+ const [userMems, agentMems, globalMems] = await Promise.all([
97
+ ovFind(queryText, "viking://user/memories"),
98
+ ovFind(queryText, "viking://agent/memories"),
99
+ ovFind(queryText, null),
100
+ ]);
101
+
102
+ // Merge and deduplicate by URI (split-space results first, then global)
103
+ const uriSet = new Set();
104
+ const allMems = [];
105
+ for (const item of [...userMems, ...agentMems, ...globalMems]) {
106
+ if (uriSet.has(item.uri)) continue;
107
+ uriSet.add(item.uri);
108
+ allMems.push(item);
109
+ }
110
+
111
+ // Filter by score threshold and sort descending
112
+ const scored = allMems
113
+ .filter((m) => clampScore(m.score) >= RECALL_SCORE_THRESHOLD)
114
+ .sort((a, b) => clampScore(b.score) - clampScore(a.score));
115
+
116
+ // Deduplicate by abstract content
117
+ const seen = new Set();
118
+ const candidates = [];
119
+ for (const item of scored) {
120
+ const key = (item.abstract || item.overview || "").trim().toLowerCase() || item.uri;
121
+ if (seen.has(key)) continue;
122
+ seen.add(key);
123
+ candidates.push(item);
124
+ }
125
+
126
+ if (candidates.length === 0) {
127
+ process.exit(0);
128
+ }
129
+
130
+ // Build tiered injection lines within token budget
131
+ let budgetRemaining = RECALL_TOKEN_BUDGET;
132
+ const lines = [];
133
+
134
+ for (let i = 0; i < candidates.length; i++) {
135
+ if (budgetRemaining <= 0) break;
136
+ const item = candidates[i];
137
+
138
+ let content;
139
+ if (i < L1_SLOTS) {
140
+ // Top-N: use L1 overview for richer context
141
+ content = (item.overview || item.abstract || item.uri).trim();
142
+ if (content.length > L1_MAX_CHARS) {
143
+ content = content.slice(0, L1_MAX_CHARS) + "...";
144
+ }
145
+ } else {
146
+ // Remainder: use L0 abstract for broad coverage
147
+ content = (item.abstract || item.uri).trim();
148
+ if (content.length > L0_MAX_CHARS) {
149
+ content = content.slice(0, L0_MAX_CHARS) + "...";
150
+ }
151
+ }
152
+
153
+ const line = `- [${item.category || "memory"}] ${content}`;
154
+ const lineTokens = estimateTokens(line);
155
+
156
+ // First line always included even if over budget
157
+ if (lineTokens > budgetRemaining && lines.length > 0) break;
158
+
159
+ lines.push(line);
160
+ budgetRemaining -= lineTokens;
161
+ }
162
+
163
+ if (lines.length === 0) {
164
+ process.exit(0);
165
+ }
166
+
167
+ const memoryContext =
168
+ "[OpenViking Auto-Recall] The following long-term memories may be relevant to this conversation:\n" +
169
+ lines.join("\n");
170
+
171
+ const output = {
172
+ hookSpecificOutput: {
173
+ hookEventName: "UserPromptSubmit",
174
+ additionalContext: memoryContext,
175
+ },
176
+ };
177
+
178
+ process.stdout.write(JSON.stringify(output));
179
+ process.exit(0);
180
+ }
181
+
182
+ 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 || "";
12
+ const OPENVIKING_API_KEY = process.env.OPENVIKING_API_KEY || "";
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": "claude-code-openviking",
3
+ "version": "0.2.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
+ }