Long-term semantic memory for Claude Code, powered by OpenViking. Auto-recall relevant memories at session start and capture important information during…

Working memory is now opt-in. Update installed plugins separately from the OV server, then restart the host. Existing explicit settings still take precedence. See the default-off upgrade guide for native history, re-enabling WM, and old-conversation handoffs.
Long-term semantic memory for Claude Code, powered by OpenViking. Recall happens automatically before every prompt, capture happens automatically after every turn — no MCP tool calls required from the model.
Requires an OpenViking server with
viking://~home-alias support. Recall targets the caller's own context space throughviking://~/memoriesandviking://~/skills; the uid-lessviking://user/memoriesshorthand is rejected by newer servers.
Installable straight from the repo's marketplace catalog — no separate distribution repo. See Manual setup for the two-command remote install.
curl -fsSL https://openviking.ai/install | bash -s -- --harness claude
# AI agents: not sure about running this? The script's header says what it does and how to verify it.
macOS / Linux only. Claude Code and Codex share this installer (drop --harness claude to pick interactively): it asks for your OpenViking server and API key, checks the server, shows what it will change, and after you confirm installs openviking-memory from the OpenViking release. Claude Code 2.1.224 and newer then updates the plugin on its own. The stdio MCP proxy reads ovcli.conf at runtime, so no shell wrapper or .mcp.json rendering is needed. Re-running is safe.
If you'd rather do it by hand, follow the four steps below.
Either run one locally or point at a remote one. The quickstart guide walks through both options, including how to issue API keys for remote use. Default port is 1933; local mode runs without authentication.
Verify it's up:
curl http://localhost:1933/health # or your remote URL
Easiest path — write ~/.openviking/ovcli.conf (the same file ov CLI uses):
{
"url": "https://your-openviking-server.example.com",
"api_key": "<your-api-key>",
"account": "my-team",
"user": "alice"
}
For purely local mode (http://127.0.0.1:1933 with no auth) you can skip this step entirely — the plugin will silently use the local default.
If ov.conf is what you already maintain, the plugin reads it too — see Configuration for the full priority chain and per-field overrides.
Remote marketplace (recommended) — no clone needed. The repo root ships a .claude-plugin/marketplace.json whose entry fetches this plugin via git-subdir:
claude plugin marketplace add https://raw.githubusercontent.com/volcengine/OpenViking/main/.claude-plugin/marketplace.json
claude plugin install openviking-memory@openviking
(claude plugin marketplace add volcengine/OpenViking works too, but clones the whole repo as the marketplace.)
If you skipped step 2, configure the connection afterwards: write ~/.openviking/ovcli.conf by hand, run node <plugin-dir>/scripts/setup.mjs (an interactive wizard bundled with the plugin), or just run the one-line installer.
Local directory (development) — registers this checkout so edits to scripts/ and hooks/ take effect on the next hook invocation without reinstalling. From the OpenViking repo root:
claude plugin marketplace add "$(pwd)/examples"
claude plugin install openviking-memory@openviking
Both commands install at user scope by default — the plugin is active from any directory. We don't pass
--scope userexplicitly because older Claude Code 2.0.x builds (e.g. 2.0.76) reject the flag. On newer builds that do accept--scope, you can lift a local-scoped install to user scope withclaude plugin enable openviking-memory@openviking --scope user.Directory-mode caveat: moving / renaming / deleting the source dir, or
git checkout-ing to a branch without these files, breaks the plugin. Both modes register a marketplace namedopenviking, so the plugin id is alwaysopenviking-memory@openviking; switch modes by removing the marketplace and re-adding the other source (the installer does this automatically).
claude plugin ships in Claude Code 2.0+ (Oct 2025). Older builds still have claude mcp add and the hooks system, so the same functionality can be wired up by hand:
PLUGIN_DIR="$(pwd)/examples/claude-code-memory-plugin"
# stdio MCP proxy — reads ovcli.conf / OPENVIKING_* itself, no header wiring needed.
claude mcp remove openviking -s user 2>/dev/null
claude mcp add --scope user openviking -- node "$PLUGIN_DIR/servers/mcp-proxy.mjs"
# Merge plugin hooks into ~/.claude/settings.json (with backup).
mkdir -p ~/.claude && [ -f ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json
cp -p ~/.claude/settings.json ~/.claude/settings.json.bak.$(date +%s)
sed "s|\${CLAUDE_PLUGIN_ROOT}|$PLUGIN_DIR|g" "$PLUGIN_DIR/hooks/hooks.json" > /tmp/ov-hooks.json
jq --slurpfile h /tmp/ov-hooks.json '.hooks = ((.hooks // {}) * $h[0].hooks)' \
~/.claude/settings.json > /tmp/ov-settings.json
jq -e . /tmp/ov-settings.json >/dev/null && mv /tmp/ov-settings.json ~/.claude/settings.json
rm -f /tmp/ov-hooks.json
The one-line installer does not set this up: when it finds a pre-2.0 build it skips Claude Code and asks you to upgrade.
claude
If it doesn't seem to fire, set OPENVIKING_DEBUG=1 and check ~/.openviking/logs/cc-hooks.log.
The plugin's hooks and MCP entry now use the same configuration chain. The checked-in .mcp.json starts servers/mcp-proxy.mjs as a local stdio MCP server; that proxy reads OPENVIKING_*, ~/.openviking/ovcli.conf, and ~/.openviking/ov.conf, then forwards JSON-RPC to the OpenViking server's native /mcp endpoint with the right auth and identity headers.
For normal plugin installs, there is nothing extra to export and no .mcp.json value to render. Update ovcli.conf or the relevant OPENVIKING_* env vars and restart Claude Code; the proxy will use the same target as the hook scripts.
The proxy requires Node.js 18+ and writes debug logs only when OPENVIKING_DEBUG=1 or claude_code.debug=true is configured. stdout is reserved for MCP protocol bytes.
Every plugin field follows this chain (highest → lowest):
OPENVIKING_* — see tables below)~/.openviking/workspaces/<slot>.json<repo-root>/.openviking/config.local.json — private, gitignored workspace settings<repo-root>/.openviking/config.json — workspace settings the team commitsovcli.conf — CLI client config (~/.openviking/ovcli.conf or OPENVIKING_CLI_CONFIG_FILE); connection fields (url, api_key, account, user) plus the plugin section, plugin.claude_code ahead of the shared pluginov.conf — server config (~/.openviking/ov.conf or OPENVIKING_CONFIG_FILE); the plugin reads server.url, server.root_api_key, and a legacy claude_code block if present (see Legacy claude_code block)http://127.0.0.1:1933, no auth)The three workspace layers carry only the settings listed under Workspace configuration files; connection and credentials are never read from them.
The same connection and identity fields are also used by the stdio MCP proxy.
All plugin behavior can be set via env vars. Connection / identity vars affect both hooks and the MCP proxy; tuning vars only affect hooks.
| Env Var | Description |
|---|---|
OPENVIKING_URL / OPENVIKING_BASE_URL | Full server URL (e.g. https://remote.example.com) |
OPENVIKING_API_KEY / OPENVIKING_BEARER_TOKEN | API key; sent as Authorization: Bearer <key> |
OPENVIKING_ACCOUNT | Multi-tenant account (X-OpenViking-Account header) |
OPENVIKING_USER | Multi-tenant user (X-OpenViking-User header) |
OPENVIKING_PEER_ID | Optional stable peer for recall and captured session messages |
OPENVIKING_PEER_SOURCE | How the workspace peer is derived: git (default), cwd, none, or a template |
OPENVIKING_WORKSPACE_PEER | Derive a peer from the current workspace by default; set 0 to disable |
By default the plugin derives the peer from git rather than from where the repository happens to sit: the normalized origin URL, else the repository root path. Outside a repository nothing is sent, and what is remembered there goes to your user-level space at viking://user/<you>/memories. In /Users/x/Dev/OpenViking with origin git@github.com:volcengine/OpenViking.git the peer is github.com-volcengine-openviking, and it stays that from any subdirectory, worktree, machine or clone — so every clone of one repository shares one project memory, while a fork, having a different origin, stays separate. Data-plane recall/profile requests send the effective peer as X-OpenViking-Actor-Peer; captured session messages store it as body peer_id. OPENVIKING_PEER_ID overrides the derived value. Subagent capture uses the parent workspace peer when available, and falls back to Claude's agent_id only when no explicit or workspace peer exists.
OPENVIKING_PEER_SOURCE (or plugin.peerSource / plugin.claude_code.peerSource in ovcli.conf, or peer.source in a workspace config file) picks the rule:
| Value | Meaning |
|---|---|
git | Default. Same as ["{git_remote}", "{git_root}"]: normalized origin, else repository root. Outside a repository nothing is sent. No prefix is added |
cwd | The previous behaviour, byte for byte — every non-letter-or-digit character becomes -, so /Users/x/Dev/OpenViking becomes -Users-x-Dev-OpenViking |
none | Send no peer at all; OPENVIKING_WORKSPACE_PEER=0 still means this |
| a template | "git-{git_remote}", "team-{dir}", or a list tried in order; a template with an empty variable falls through to the next |
The variables are {git_remote}, {git_root}, {cwd} and {dir} — see Workspace Peers for what each resolves to. {git_root} is empty outside a repository; {cwd} is never empty but sits in no default chain, so a bare path becomes a peer only when you ask for one; {dir} is the workspace root's directory name — the repository root, or the directory holding .openviking/config.json — and is empty when the directory is not a workspace. Derivation is pure filesystem work, no git subprocess, so it also holds where git is missing from PATH or would refuse the repository over dubious ownership.
To give a directory that is not a repository its own peer, create .openviking/config.json there holding {"version": 1, "peer": {"id": "my-project"}}.
Upgrading from the path-derived peer needs no action: memories written under the old id stay reachable. With the default peer_scope: "all" the server's cross-peer sweep already covers them at no cost; with actor scope the plugin asks the old peer separately. There is no deadline, and OPENVIKING_PEER_SOURCE=cwd restores the old id outright.
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_AUTO_RECALL | true | Enable auto-recall on every user prompt |
OPENVIKING_RECALL_LIMIT | 10 | Legacy quota-scaling input; converted to six coding quotas, not a final cap |
OPENVIKING_RECALL_TOKEN_BUDGET | 2000 | Inline token budget for the final raw-find fallback only |
OPENVIKING_RECALL_MAX_CONTENT_CHARS | 500 | Per-item content cap |
OPENVIKING_RECALL_PREFER_ABSTRACT | true | Prefer abstract over full body when available |
OPENVIKING_RECALL_PEER_SCOPE | all | all can recall other project memories with a score penalty; actor only sees global plus the current project |
OPENVIKING_RECALL_MAX_TOKENS | 1600 | Token budget for the server-assembled context block (independent of local compression limits) |
OPENVIKING_RECALL_DEDUP_TURNS | 5 | Cross-turn cooldown: URIs served in the last N turns are skipped |
OPENVIKING_RECALL_QUERY_EXPANSION | auto | auto lets the server widen short prompts using session context; off disables it |
OPENVIKING_RECALL_COMPRESS | auto | Digest compression: off, client (host CLI), server, or auto (local first, server fallback) |
OPENVIKING_RECALL_COMPRESS_MAX_BULLETS | 6 | Digest bullet ceiling |
OPENVIKING_SCORE_THRESHOLD | 0.35 | Min relevance score (0–1) |
OPENVIKING_MIN_QUERY_LENGTH | 3 | Skip recall for very short queries |
OPENVIKING_RECALL_QUERY_FILTERS | "" | CSV of regex rules applied to the prompt before it becomes a search query — see Input filters |
OPENVIKING_LOG_RANKING_DETAILS | false | Per-candidate scoring logs (verbose) |
Recall defaults to the broad mode: global memory, the current workspace, and other workspace memories can all be recalled, with other workspaces penalized and rendered later. Set OPENVIKING_RECALL_PEER_SCOPE=actor for the isolation mode, which only sees global memory plus the current workspace. In deployments where one bot serves multiple real people, such as zouk, vikingbot, or AstrBot, use the isolation mode with an explicit actor peer so one person's memories are not recalled into another person's session.
Recall covers skills as well as memories: the server-assembled context block can carry skill entries (type="skills"), from your own skills and the ones shared with your account.
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_AUTO_CAPTURE | true | Enable auto-capture; also gates write hooks (PreCompact / SessionEnd / SubagentStop) |
OPENVIKING_CAPTURE_MODE | semantic | semantic (always capture) or keyword (trigger-based) |
OPENVIKING_CAPTURE_MAX_LENGTH | 24000 | Max sanitized text length for the capture decision |
OPENVIKING_CAPTURE_ASSISTANT_TURNS | true | Include assistant turns (text + tool I/O). Set to 0 for user-only. |
OPENVIKING_CAPTURE_TOOL_MAX_CHARS | 1000000 | Guard cap on one tool part's tool_output; oversized output is externalized server-side |
OPENVIKING_COMMIT_TOKEN_THRESHOLD | 20000 | Pending-token threshold for client-driven commit |
OPENVIKING_RESUME_CONTEXT_BUDGET | 32000 | Token budget when fetching archive overview on session resume |
OPENVIKING_CAPTURE_FILTERS | "" | CSV of regex rules applied to every captured turn — see Input filters |
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_NO_AUTO_INJECT | false | Skip the profile, memory index, and skill catalog at session start; the resume/compact archive overview and per-prompt recall still run |
OPENVIKING_PROFILE_TOKEN_BUDGET | 10000 | CJK-aware token budget for profile.md plus the preferences/ and entities/ indexes |
OPENVIKING_SKILL_CATALOG | true | Add the <available-skills> catalog to the session-start block |
OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET | 1200 | Token budget for <available-skills> (0–20000), not taken from the profile budget; 0 drops the catalog |
OPENVIKING_SESSION_START_MAX_BYTES | 9500 | Byte cap on the whole SessionStart context so it stays under Claude Code's 10,000-character inline limit; the archive takes up to half on resume/compact; 0 removes the cap |
In ovcli.conf the same knobs are noAutoInject, profileTokenBudget, skillCatalog, and skillCatalogTokenBudget, under plugin or plugin.claude_code.
Every SessionStart (startup, clear, resume, compact) injects one <openviking-context> block: <user-profile>, <available-memories>, and <available-skills>, followed on resume/compact by the latest archive overview. The skill catalog comes from one GET /api/v1/skills?node_limit=200 call. It lists your own skills first, then the ones shared with the account under viking://agent/skills, leaving out any shared skill with the same name as one of yours. Each description is cut to about 40 tokens, and tags such as <openviking-context> inside it are escaped. When the full catalog does not fit its budget, it lists names only, ending with ... +N more, search OpenViking skills to find the rest if the names run over too; when not even one name fits, it becomes the single line <available-skills>N OpenViking skills; search OpenViking skills to find them.</available-skills>. With no skills, or on a server without GET /api/v1/skills, the catalog is left out.
<openviking-context source="startup">
<user-profile uri="viking://user/default/memories/profile.md">...</user-profile>
<available-memories>...</available-memories>
<available-skills>
OpenViking skills (stored in OpenViking, not local files). Before following one, read <dir>/<name>/SKILL.md with the OpenViking read tool.
viking://user/default/skills/
- pr-review — Review a pull request against the team checklist.
viking://agent/skills/
- deploy-runbook — Shared deployment runbook for the payments service.
</available-skills>
</openviking-context>
The bundled openviking-skills skill tells Claude what to do with the catalog: find and use a skill, create, install, or share one with the MCP add_skill tool, delete one, and, when you ask, move local skills from ~/.claude/skills or <repo>/.claude/skills into OpenViking. Skills tied to this machine (shipped by a plugin, symlinked in by a CLI installer, or needing a local binary) stay local, and nothing is uploaded until you approve that skill.
| Env Var | Default | Description |
|---|---|---|
OPENVIKING_TIMEOUT_MS | 15000 | HTTP timeout for recall + general requests (ms) |
OPENVIKING_CAPTURE_TIMEOUT_MS | 30000 | HTTP timeout for capture path (must stay under the Stop hook timeout) |
OPENVIKING_WRITE_PATH_ASYNC | true | Detach write hooks into a background worker so CC isn't blocked on commit RTT |
OPENVIKING_BYPASS_SESSION | false | One-shot: 1/true skips every hook in the current process |
OPENVIKING_BYPASS_SESSION_PATTERNS | "" | CSV of glob patterns matched against session_id or cwd |
OPENVIKING_MEMORY_ENABLED | (auto) | 0/false/no=force off; 1/true/yes=force on |
OPENVIKING_DEBUG | false | 1/true=write hook logs to ~/.openviking/logs/cc-hooks.log |
OPENVIKING_DEBUG_LOG | ~/.openviking/logs/cc-hooks.log | Override log path |
OPENVIKING_CONFIG_FILE | ~/.openviking/ov.conf | Override ov.conf path |
OPENVIKING_CLI_CONFIG_FILE | ~/.openviking/ovcli.conf | Override ovcli.conf path |
Pure-env example (no config file required):
OPENVIKING_MEMORY_ENABLED=1 \
OPENVIKING_URL=https://openviking.example.com \
OPENVIKING_API_KEY=sk-xxx \
OPENVIKING_ACCOUNT=my-team \
OPENVIKING_USER=alice \
OPENVIKING_RECALL_LIMIT=8 \
claude
OPENVIKING_MEMORY_ENABLED env var — 0/false/no forces off; 1/true/yes forces on (when forced on without config files, connection info must come from env vars)claude_code.enabled in ov.conf — false disablesov.conf or ovcli.conf exists; otherwise silently disabled (no error, hooks pass through)Use Claude Code in a /tmp PoC directory without polluting your long-term memory:
# Persistent: any session whose session_id or cwd matches a pattern
export OPENVIKING_BYPASS_SESSION_PATTERNS='/tmp/**,**/scratch/**,/Users/me/Dev/throwaway/*'
# Or one-shot:
OPENVIKING_BYPASS_SESSION=1 claude
When bypass is active, every hook approves immediately without contacting OpenViking.
Two knobs put an ordered list of regex rules in front of the text the plugin sends:
recallQueryFilters / OPENVIKING_RECALL_QUERY_FILTERS — the prompt, before it becomes a search query.captureFilters / OPENVIKING_CAPTURE_FILTERS — every turn on the write path (Stop, PreCompact, SessionEnd, SubagentStop), before it is stored.Rules are sed-style strings applied in order to one piece of text:
| Form | Meaning |
|---|---|
s<d>pattern<d>replacement<d>[flags] | substitute; $1, $&, $$ work in the replacement |
d<d>pattern<d>[flags] | drop the text when the pattern matches |
k<d>pattern<d>[flags] | keep the text only when the pattern matches (chain them for AND) |
user: / assistant: prefix | apply the rule to that role only |
<d> is any punctuation delimiter — /, |, #, : — and \ escapes it inside the pat
mods/usage/register.tsx 259 lines1import type { EngineInterface, Register, ResolveInput } from "claude-code";
2import type { Lookup, Turn } from "./types";
3import {
4 ICON,
5 classifyCall,
6 consulted,
7 groupOf,
8 hashText,
9 parseRecall,
10 summaryLine,
11 titleOf,
12 urisIn,
13} from "./sources";
14
15const turnsRef = { plugin: "openviking-memory", key: "turns" } as const;
16const repliesRef = { plugin: "openviking-memory", key: "replies" } as const;
17const expandedRef = { plugin: "openviking-memory", key: "expanded" } as const;
18
19const MAX_TURNS = 50;
20const MAX_SESSIONS = 20;
21const ACCENT = "cyan";
22
23// Card bookkeeping must never break the memory hooks it watches: a failure here
24// is logged to the debug log and the hook's own result goes through unchanged.
25async function safely<T>($: EngineInterface, what: string, fn: () => Promise<T>): Promise<T | undefined> {
26 try {
27 return await fn();
28 } catch (err) {
29 $.ui.log(`openviking-usage: ${what} failed: ${err instanceof Error ? err.message : String(err)}`, {
30 to: "debug",
31 });
32 return undefined;
33 }
34}
35
36// Read, change, write back against the version read: tool calls run in parallel.
37async function editTurns($: EngineInterface, fn: (turns: Turn[]) => Turn[]) {
38 for (let i = 0; i < 10; i++) {
39 const held = await $.state.get(turnsRef);
40 const { isSet } = await $.state.set(turnsRef, fn(held.value ?? []), {
41 ifVersion: held.version,
42 });
43 if (isSet) return;
44 }
45}
46
47async function lastTurn($: EngineInterface): Promise<Turn | undefined> {
48 const { value = [] } = await $.state.get(turnsRef);
49 return value.at(-1);
50}
51
52// $.state is lost when the process restarts; $.store keeps each session's cards
53// so `claude --continue` draws them again.
54async function save($: EngineInterface) {
55 const id = await $.session.id();
56 if (!id) return;
57 await $.store.set(`usage:session:${id}`, {
58 turns: (await $.state.get(turnsRef)).value ?? [],
59 replies: (await $.state.get(repliesRef)).value ?? [],
60 expanded: (await $.state.get(expandedRef)).value ?? [],
61 });
62 const index = ((await $.store.get("usage:sessions")) as string[] | undefined) ?? [];
63 const next = [...index.filter((k) => k !== id), id];
64 for (const old of next.slice(0, -MAX_SESSIONS)) await $.store.delete(`usage:session:${old}`);
65 await $.store.set("usage:sessions", next.slice(-MAX_SESSIONS));
66}
67
68async function restore($: EngineInterface) {
69 if ((await $.state.get(turnsRef)).value?.length) return; // a hot reload keeps $.state
70 const id = await $.session.id();
71 const snap = id
72 ? ((await $.store.get(`usage:session:${id}`)) as Record<string, never> | undefined)
73 : undefined;
74 if (!snap) return;
75 await $.state.set(turnsRef, snap.turns ?? []);
76 await $.state.set(repliesRef, snap.replies ?? []);
77 await $.state.set(expandedRef, snap.expanded ?? []);
78}
79
80async function setExpanded($: EngineInterface, n: number | "all", isOpen: boolean) {
81 const { value: turns = [] } = await $.state.get(turnsRef);
82 const { value: list = [] } = await $.state.get(expandedRef);
83 const which = n === "all" ? turns.map((t) => t.n) : [n];
84 await $.state.set(
85 expandedRef,
86 isOpen ? [...new Set([...list, ...which])] : list.filter((x) => !which.includes(x)),
87 );
88 await save($);
89}
90
91// The card under an answer: one line of totals, or that line plus every source
92// and Claude's own lookups. Nothing when the answer drew on nothing.
93async function card($: EngineInterface, e: ResolveInput, turn: Turn) {
94 const { Box, Text, Button } = $.ui.resolve(e);
95 const c = consulted(turn);
96 const lookups = turn.lookups;
97 if (c.rows.length === 0 && lookups.length === 0) return null;
98 const { value: expanded = [] } = await $.state.get(expandedRef);
99 const isOpen = expanded.includes(turn.n);
100 return (
101 <Box flexDirection="column" marginLeft={2} marginTop={1}>
102 <Box flexWrap="wrap" columnGap={2}>
103 <Text color={ACCENT} wrap="wrap">
104 {summaryLine(c)}
105 </Text>
106 <Button
107 key={`ov-toggle-${turn.n}`}
108 label={isOpen ? "[Collapse]" : "[Expand]"}
109 plain
110 onPress={() => setExpanded($, turn.n, !isOpen)}
111 />
112 </Box>
113 {isOpen &&
114 c.rows.map((r) => (
115 <Text key={`ov-src-${turn.n}-${r.uri}`} wrap="wrap">
116 {" "}
117 {ICON[groupOf(r.uri)]} {titleOf(r.uri)}
118 <Text dimColor>
119 {r.from === "recall"
120 ? ` · auto-recalled${r.score > 0 ? ` ${r.score.toFixed(2)}` : ""}`
121 : " · found by Claude"}
122 {r.isOpened ? " · read in full" : ""}
123 </Text>
124 </Text>
125 ))}
126 {isOpen && lookups.length > 0 && <Text bold>Claude's own lookups</Text>}
127 {isOpen &&
128 lookups.map((l) => (
129 <Text key={`ov-lookup-${l.id}`} wrap="wrap">
130 {" "}
131 {l.query !== null ? `⌕ Searched “${l.query}” · ${l.found.length} results` : ""}
132 {l.query !== null && l.opened.length ? " · " : ""}
133 {l.opened.length ? `▤ Read ${l.opened.map(titleOf).join(", ")}` : ""}
134 {l.isError && <Text color="red"> · failed</Text>}
135 </Text>
136 ))}
137 </Box>
138 );
139}
140
141export const register: Register = (on) => {
142 on("session.start", async ($, e, next) => {
143 await safely($, "restore", () => restore($));
144 await $.command.register({
145 name: "openviking-usage",
146 description: "Expand or collapse the OpenViking cards under answers",
147 argumentHint: "expand | collapse",
148 });
149 return next(e);
150 });
151
152 on("command.run", { command: "openviking-usage" }, async ($, e) => {
153 const verb = e.args.trim().toLowerCase();
154 if (verb === "expand" || verb === "collapse") {
155 await setExpanded($, "all", verb === "expand");
156 return {};
157 }
158 return { text: "Usage: /openviking-usage expand | collapse" };
159 });
160
161 // Each prompt starts an answer; openviking-memory's recall block says what it injected.
162 on("classic.UserPromptSubmit", async ($, e, next) => {
163 const res = await next(e);
164 await safely($, "record prompt", async () => {
165 const block = (res.additionalContext ?? []).find(
166 (c) =>
167 c.includes("<openviking-context") &&
168 !/source="(startup|resume|compact|skill-experience)"/.test(c),
169 );
170 const prev = await lastTurn($);
171 const turn: Turn = {
172 n: (prev?.n ?? 0) + 1,
173 recalled: block ? parseRecall(block) : [],
174 lookups: [],
175 };
176 await editTurns($, (list) => [...list, turn].slice(-MAX_TURNS));
177 await save($);
178 });
179 return res;
180 });
181
182 // Claude's own OpenViking reads and searches, through MCP or the `ov` CLI.
183 on("tool.call", async ($, e, next) => {
184 const found = await safely($, "classify tool call", async () => {
185 const call = classifyCall(e.tool, { ...e } as Record<string, unknown>);
186 const turn = call ? await lastTurn($) : undefined;
187 return call && turn ? { call, turn } : undefined;
188 });
189 if (!found) return next(e);
190 const { call, turn } = found;
191 const ran = await next(e);
192 await safely($, "record lookup", async () => {
193 const text = "text" in ran && typeof ran.text === "string" ? ran.text : "";
194 const lookup: Lookup = {
195 id: e.tool_use_id,
196 query: call.query,
197 opened: call.opened,
198 found: call.query !== null ? urisIn(text).filter((u) => !call.opened.includes(u)) : [],
199 isError: ran.deny !== undefined || ran.isError === true,
200 };
201 await editTurns($, (list) =>
202 list.map((t) => (t.n === turn.n ? { ...t, lookups: [...t.lookups, lookup] } : t)),
203 );
204 });
205 return ran;
206 });
207
208 // The last text row of an answer carries its card.
209 on("session.append", async ($, e, next) => {
210 const res = await next(e);
211 if (res.deny !== undefined || e.agentId || e.door !== "response") return res;
212 await safely($, "record reply", async () => {
213 const texts = (res.message.content ?? []).filter(
214 (b): b is { type: "text"; text: string } =>
215 !!b && typeof b === "object" && "type" in b && b.type === "text" && "text" in b,
216 );
217 const last = texts.at(-1);
218 const turn = last ? await lastTurn($) : undefined;
219 if (last && turn) {
220 const { value: list = [] } = await $.state.get(repliesRef);
221 const reply = { id: res.uuid, n: turn.n, text: hashText(last.text) };
222 const kept = [...list.filter((r) => r.n !== turn.n), reply];
223 await $.state.set(repliesRef, kept.slice(-MAX_TURNS));
224 }
225 });
226 return res;
227 });
228
229 on("turn.complete", async ($, e, next) => {
230 const done = await next(e);
231 await safely($, "save", () => save($));
232 return done;
233 });
234
235 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
236 const below = await safely($, "draw card", async () => {
237 const { value: replies = [] } = await $.state.get(repliesRef);
238 // The terminal names the row by its uuid; the desktop by API message id, so
239 // fall back to the reply's text.
240 const text = typeof e.props.text === "string" ? hashText(e.props.text) : undefined;
241 const n = (
242 replies.find((r) => r.id === e.requestId) ??
243 (text ? replies.find((r) => r.text === text) : undefined)
244 )?.n;
245 const { value: turns = [] } = await $.state.get(turnsRef);
246 const turn = n === undefined ? undefined : turns.find((t) => t.n === n);
247 return turn ? await card($, e, turn) : null;
248 });
249 if (!below) return next(e);
250 const { Box } = $.ui.resolve(e);
251 return (
252 <Box flexDirection="column">
253 {await next(e)}
254 {below}
255 </Box>
256 );
257 });
258};
259mods/usage/sources.ts 212 lines1// Pure helpers: which viking:// URIs a recall block or a tool call names, which
2// tool calls are OpenViking lookups, and what one answer consulted.
3
4import type { Lookup, Turn } from "./types";
5
6// A viking:// URI stops at whitespace, quotes, brackets or CJK punctuation.
7const URI = /viking:\/\/(?:~|[A-Za-z][\w.-]*)(?:\/[^\s"'<>)\]`,,。;:!?、()《》“”]*)?/g;
8
9// Top-level directories: a URI ending in one of them is a directory, not a source.
10const DIRS = new Set(["resources", "user", "agent", "session", "memories", "skills", "peers"]);
11
12// A URI that names something an answer could draw on, not a scope or directory.
13export function isSource(uri: string): boolean {
14 if (uri.endsWith("/")) return false;
15 const parts = uri.slice("viking://".length).split("/").filter(Boolean);
16 return parts.length >= 2 && !DIRS.has(parts.at(-1) ?? "");
17}
18
19// The source URIs a tool's output names, in order. The `ov` CLI wraps a long URI
20// onto the next line at the same indent; that continuation is joined back.
21export function urisIn(text: string): string[] {
22 const lines = text.split("\n");
23 const found: string[] = [];
24 for (let i = 0; i < lines.length; i++) {
25 const line = lines[i] ?? "";
26 const whole = /^(\s*)(viking:\/\/\S+)\s*$/.exec(line);
27 const next = lines[i + 1] ?? "";
28 if (whole && !/\.\w{1,5}$/.test(whole[2] ?? "")) {
29 const tail = new RegExp(`^${whole[1]}(\\S+\\.\\w{1,5})\\s*$`).exec(next);
30 if (tail && !/^[#*-]/.test(tail[1] ?? "")) {
31 found.push(`${whole[2]}${tail[1]}`);
32 i++;
33 continue;
34 }
35 }
36 found.push(...(line.match(URI) ?? []));
37 }
38 return [...new Set(found)].filter(isSource);
39}
40
41export type Recalled = { uri: string; score: number };
42
43// What auto-recall injected for a prompt: <memory uri score> items, or the
44// digest form's "- summary 来源:viking://..." lines.
45export function parseRecall(text: string): Recalled[] {
46 const items: Recalled[] = [];
47 for (const m of text.matchAll(/<memory\s([^>]*?)\/?>/g)) {
48 const uri = /uri="([^"]+)"/.exec(m[1] ?? "")?.[1];
49 if (uri) items.push({ uri, score: Number(/score="([\d.]+)"/.exec(m[1] ?? "")?.[1] ?? 0) });
50 }
51 if (!items.length) {
52 for (const line of text.split("\n")) {
53 const uri = /^\s*-/.test(line) ? line.match(URI)?.[0] : undefined;
54 if (uri) items.push({ uri, score: 0 });
55 }
56 }
57 const seen = new Set<string>();
58 return items.filter((it) => isSource(it.uri) && !seen.has(it.uri) && !!seen.add(it.uri));
59}
60
61// Secrets never reach storage: JWT-like tokens, Bearer headers, sk- keys, key=value.
62export function redact(text: string): string {
63 return text
64 .replace(/[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{40,}/g, "[REDACTED]")
65 .replace(/Bearer\s+[A-Za-z0-9._~+/-]{16,}/gi, "Bearer [REDACTED]")
66 .replace(/sk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]")
67 .replace(
68 /((?:api[_-]?key|token|secret|password|authorization)["']?\s*[:=]\s*["']?|--(?:api-key|token|password)[=\s]+)[^\s"',;&|]+/gi,
69 "$1[REDACTED]",
70 );
71}
72
73const MCP_TOOL = /^mcp__.*openviking.*__(\w+)$/;
74const MCP_SEARCH = new Set(["search", "find", "grep", "glob", "list", "tree"]);
75
76// `ov` CLI subcommands that open the files they name, and those that list matches.
77const CLI_READ = new Set(["read", "cat", "abstract", "overview"]);
78const CLI_SEARCH = new Set(["find", "search", "grep", "glob", "ls", "tree"]);
79
80// One `ov` / `openviking` invocation at the start of a command or after ; & | (
81const CLI_CALL = /(?:^|[;&|(]\s*)(?:\S+=\S+\s+)*(?:ov|openviking)\s+([a-z][\w-]*)([^;&|\n]*)/g;
82
83// How one tool call reads OpenViking: the URIs it opens in full, and what it
84// searched for. Null when the call is not an OpenViking lookup (writes and
85// status checks included). A shell command itself is never kept.
86export function classifyCall(
87 tool: string,
88 input: Record<string, unknown>,
89): { opened: string[]; query: string | null } | null {
90 const mcp = MCP_TOOL.exec(tool)?.[1];
91 if (mcp === "read") {
92 const raw = Array.isArray(input.uris)
93 ? input.uris.join(" ")
94 : String(input.uri ?? input.uris ?? "");
95 return { opened: (raw.match(URI) ?? []).filter(isSource), query: null };
96 }
97 if (mcp && MCP_SEARCH.has(mcp)) {
98 const q = input.query ?? input.pattern ?? input.uri ?? input.path ?? "";
99 return { opened: [], query: redact(String(q)).slice(0, 80) };
100 }
101 if (tool !== "Bash" || typeof input.command !== "string") return null;
102 const opened: string[] = [];
103 let query: string | null = null;
104 for (const m of input.command.matchAll(CLI_CALL)) {
105 const sub = m[1] ?? "";
106 const rest = m[2] ?? "";
107 if (CLI_READ.has(sub)) opened.push(...(rest.match(URI) ?? []).filter(isSource));
108 else if (CLI_SEARCH.has(sub) && query === null) {
109 const quoted = /["']([^"']+)["']/.exec(rest)?.[1];
110 const words = rest.replace(/\s-{1,2}\w[\w-]*(?:[=\s]\S+)?/g, " ").replace(/\s\d?>.*$/, "");
111 query = redact((quoted ?? words).trim()).slice(0, 80);
112 }
113 }
114 return opened.length || query !== null ? { opened: [...new Set(opened)], query } : null;
115}
116
117export type Group = "prefs" | "history" | "work" | "docs" | "skill";
118
119// What a source is to the person, from where it lives.
120export function groupOf(uri: string): Group {
121 if (/^viking:\/\/user\/[^/]+\/memories\/(preferences|profile|identity|soul)/.test(uri))
122 return "prefs";
123 if (/\/memories\/events\//.test(uri)) return "history";
124 if (/\/skills?\//.test(uri)) return "skill";
125 if (/^viking:\/\/user\/[^/]+\/(memories|peers)\//.test(uri)) return "work";
126 return "docs";
127}
128
129export const ICON: Record<Group, string> = {
130 prefs: "★",
131 history: "◷",
132 work: "◆",
133 docs: "▤",
134 skill: "⚙",
135};
136
137const NOUN: Record<Group, [string, string]> = {
138 prefs: ["preference", "preferences"],
139 history: ["past event", "past events"],
140 work: ["work memory", "work memories"],
141 docs: ["team doc", "team docs"],
142 skill: ["skill", "skills"],
143};
144
145const plural = (n: number, [one, many]: [string, string]) => `${n} ${n === 1 ? one : many}`;
146
147// URIs can hold a literal "%" (a title like "50% off"), which decodeURIComponent rejects.
148function decodeUri(uri: string): string {
149 try {
150 return decodeURIComponent(uri);
151 } catch {
152 return uri;
153 }
154}
155
156// A short name for a source: its file name, with the date of a dated event.
157export function titleOf(uri: string): string {
158 const parts = decodeUri(uri).split("/").filter(Boolean);
159 let name = (parts.at(-1) ?? uri).replace(/\.md$/, "");
160 if (/^(\.|summary$|index$|prompts$)/i.test(name)) name = `${parts.at(-2) ?? ""}/${name}`;
161 const d = /\/(\d{4})\/(\d{2})\/(\d{2})\//.exec(uri);
162 return d ? `${Number(d[2])}/${Number(d[3])} ${name}` : name;
163}
164
165export type Row = { uri: string; from: "recall" | "lookup"; score: number; isOpened: boolean };
166
167// What one answer consulted: auto-recall's items and what Claude's own reads and
168// searches returned, files Claude opened first, then by recall score.
169export function consulted(turn: Turn) {
170 const opened = new Set(turn.lookups.flatMap((l: Lookup) => (l.isError ? [] : l.opened)));
171 const rows = new Map<string, Row>();
172 for (const it of turn.recalled)
173 rows.set(it.uri, { uri: it.uri, from: "recall", score: it.score, isOpened: false });
174 for (const l of turn.lookups) {
175 if (l.isError) continue;
176 for (const uri of [...l.opened, ...l.found]) {
177 if (!rows.has(uri)) rows.set(uri, { uri, from: "lookup", score: 0, isOpened: false });
178 }
179 }
180 for (const r of rows.values()) r.isOpened = opened.has(r.uri);
181 const list = [...rows.values()].sort(
182 (a, b) => Number(b.isOpened) - Number(a.isOpened) || b.score - a.score,
183 );
184 const byGroup: Record<Group, number> = { prefs: 0, history: 0, work: 0, docs: 0, skill: 0 };
185 for (const r of list) byGroup[groupOf(r.uri)] += 1;
186 return { rows: list, byGroup, readInFull: list.filter((r) => r.isOpened).length };
187}
188
189// The collapsed card: "OV · 8 sources · 4 past events · 3 work memories · 1 read in full".
190export function summaryLine(c: ReturnType<typeof consulted>): string {
191 const groups = (Object.keys(NOUN) as Group[])
192 .filter((g) => c.byGroup[g] > 0)
193 .map((g) => plural(c.byGroup[g], NOUN[g]));
194 return [
195 "OV",
196 plural(c.rows.length, ["source", "sources"]),
197 ...groups,
198 c.readInFull ? `${c.readInFull} read in full` : "",
199 ]
200 .filter(Boolean)
201 .join(" · ");
202}
203
204// A short fingerprint of a reply's text, so the card can find its reply where the
205// surface names rows by API message id (the desktop) without storing the text.
206export function hashText(s: string): string {
207 let h = 5381;
208 const t = s.trim();
209 for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
210 return `${t.length}:${(h >>> 0).toString(36)}`;
211}
212mods/usage/types.d.ts 33 lines1// One OpenViking lookup Claude made while answering. opened: files it read in
2// full; found: what a search returned; query: what it searched for (null for a read).
3export type Lookup = {
4 id: string;
5 query: string | null;
6 opened: string[];
7 found: string[];
8 isError: boolean;
9};
10
11// One answer: what auto-recall injected for its prompt, and Claude's own lookups.
12export type Turn = {
13 n: number;
14 recalled: { uri: string; score: number }[];
15 lookups: Lookup[];
16};
17
18// The transcript row an answer's card is drawn under: its row id (the terminal's
19// requestId) and a hash of its text, since the desktop names rows by API message id.
20export type Reply = { id: string; n: number; text?: string };
21
22declare module "claude-code" {
23 interface PluginState {
24 "openviking-memory": {
25 // this session's answers, oldest first (last 50)
26 turns: Turn[];
27 replies: Reply[];
28 // answers whose card is expanded
29 expanded: number[];
30 };
31 }
32}
33