SLOPSHOPPER

openviking-memory

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

newrowsguardcommand
★ 39,511v0.7.6Apache-2.0updated 2026-10-09volcengine/OpenViking/examples/claude-code-memory-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openviking-memory
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /openviking-usage ⎿ openviking-memory: Usage: /openviking-usage expand | collapse ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

OpenViking Memory Plugin for Claude Code

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 through viking://~/memories and viking://~/skills; the uid-less viking://user/memories shorthand 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.

Quick Start

One-line installer (recommended)

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.

Manual setup

1. Have an OpenViking server reachable

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
2. Tell the plugin where the server is

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.

3. Install the plugin

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 user explicitly 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 with claude 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 named openviking, so the plugin id is always openviking-memory@openviking; switch modes by removing the marketplace and re-adding the other source (the installer does this automatically).

Legacy mode (Claude Code < 2.0)

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.

4. Start Claude Code
claude

If it doesn't seem to fire, set OPENVIKING_DEBUG=1 and check ~/.openviking/logs/cc-hooks.log.

Configuring MCP

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.

Configuration

Resolution priority

Every plugin field follows this chain (highest → lowest):

  1. Environment variables (OPENVIKING_* — see tables below)
  2. Workspace registry — this machine's entry for the current repository, ~/.openviking/workspaces/<slot>.json
  3. <repo-root>/.openviking/config.local.json — private, gitignored workspace settings
  4. <repo-root>/.openviking/config.json — workspace settings the team commits
  5. ovcli.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 plugin
  6. ov.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)
  7. Built-in defaults (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.

Environment variables

All plugin behavior can be set via env vars. Connection / identity vars affect both hooks and the MCP proxy; tuning vars only affect hooks.

Connection / identity
Env VarDescription
OPENVIKING_URL / OPENVIKING_BASE_URLFull server URL (e.g. https://remote.example.com)
OPENVIKING_API_KEY / OPENVIKING_BEARER_TOKENAPI key; sent as Authorization: Bearer <key>
OPENVIKING_ACCOUNTMulti-tenant account (X-OpenViking-Account header)
OPENVIKING_USERMulti-tenant user (X-OpenViking-User header)
OPENVIKING_PEER_IDOptional stable peer for recall and captured session messages
OPENVIKING_PEER_SOURCEHow the workspace peer is derived: git (default), cwd, none, or a template
OPENVIKING_WORKSPACE_PEERDerive 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:

ValueMeaning
gitDefault. Same as ["{git_remote}", "{git_root}"]: normalized origin, else repository root. Outside a repository nothing is sent. No prefix is added
cwdThe previous behaviour, byte for byte — every non-letter-or-digit character becomes -, so /Users/x/Dev/OpenViking becomes -Users-x-Dev-OpenViking
noneSend 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.

Recall tuning
Env VarDefaultDescription
OPENVIKING_AUTO_RECALLtrueEnable auto-recall on every user prompt
OPENVIKING_RECALL_LIMIT10Legacy quota-scaling input; converted to six coding quotas, not a final cap
OPENVIKING_RECALL_TOKEN_BUDGET2000Inline token budget for the final raw-find fallback only
OPENVIKING_RECALL_MAX_CONTENT_CHARS500Per-item content cap
OPENVIKING_RECALL_PREFER_ABSTRACTtruePrefer abstract over full body when available
OPENVIKING_RECALL_PEER_SCOPEallall can recall other project memories with a score penalty; actor only sees global plus the current project
OPENVIKING_RECALL_MAX_TOKENS1600Token budget for the server-assembled context block (independent of local compression limits)
OPENVIKING_RECALL_DEDUP_TURNS5Cross-turn cooldown: URIs served in the last N turns are skipped
OPENVIKING_RECALL_QUERY_EXPANSIONautoauto lets the server widen short prompts using session context; off disables it
OPENVIKING_RECALL_COMPRESSautoDigest compression: off, client (host CLI), server, or auto (local first, server fallback)
OPENVIKING_RECALL_COMPRESS_MAX_BULLETS6Digest bullet ceiling
OPENVIKING_SCORE_THRESHOLD0.35Min relevance score (0–1)
OPENVIKING_MIN_QUERY_LENGTH3Skip 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_DETAILSfalsePer-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.

Capture tuning
Env VarDefaultDescription
OPENVIKING_AUTO_CAPTUREtrueEnable auto-capture; also gates write hooks (PreCompact / SessionEnd / SubagentStop)
OPENVIKING_CAPTURE_MODEsemanticsemantic (always capture) or keyword (trigger-based)
OPENVIKING_CAPTURE_MAX_LENGTH24000Max sanitized text length for the capture decision
OPENVIKING_CAPTURE_ASSISTANT_TURNStrueInclude assistant turns (text + tool I/O). Set to 0 for user-only.
OPENVIKING_CAPTURE_TOOL_MAX_CHARS1000000Guard cap on one tool part's tool_output; oversized output is externalized server-side
OPENVIKING_COMMIT_TOKEN_THRESHOLD20000Pending-token threshold for client-driven commit
OPENVIKING_RESUME_CONTEXT_BUDGET32000Token budget when fetching archive overview on session resume
OPENVIKING_CAPTURE_FILTERS""CSV of regex rules applied to every captured turn — see Input filters
Session-start injection
Env VarDefaultDescription
OPENVIKING_NO_AUTO_INJECTfalseSkip the profile, memory index, and skill catalog at session start; the resume/compact archive overview and per-prompt recall still run
OPENVIKING_PROFILE_TOKEN_BUDGET10000CJK-aware token budget for profile.md plus the preferences/ and entities/ indexes
OPENVIKING_SKILL_CATALOGtrueAdd the <available-skills> catalog to the session-start block
OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET1200Token budget for <available-skills> (0–20000), not taken from the profile budget; 0 drops the catalog
OPENVIKING_SESSION_START_MAX_BYTES9500Byte 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.

Lifecycle / behavior / misc
Env VarDefaultDescription
OPENVIKING_TIMEOUT_MS15000HTTP timeout for recall + general requests (ms)
OPENVIKING_CAPTURE_TIMEOUT_MS30000HTTP timeout for capture path (must stay under the Stop hook timeout)
OPENVIKING_WRITE_PATH_ASYNCtrueDetach write hooks into a background worker so CC isn't blocked on commit RTT
OPENVIKING_BYPASS_SESSIONfalseOne-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_DEBUGfalse1/true=write hook logs to ~/.openviking/logs/cc-hooks.log
OPENVIKING_DEBUG_LOG~/.openviking/logs/cc-hooks.logOverride log path
OPENVIKING_CONFIG_FILE~/.openviking/ov.confOverride ov.conf path
OPENVIKING_CLI_CONFIG_FILE~/.openviking/ovcli.confOverride 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

Enable / disable

  1. 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)
  2. claude_code.enabled in ov.conf — false disables
  3. Config file existence — enabled if ov.conf or ovcli.conf exists; otherwise silently disabled (no error, hooks pass through)

Bypass a session

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.

Input filters

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:

FormMeaning
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: prefixapply the rule to that role only

<d> is any punctuation delimiter — /, |, #, : — and \ escapes it inside the pat

Source 3 files
mods/usage/register.tsx 259 lines
1import 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};
259
mods/usage/sources.ts 212 lines
1// 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}
212
mods/usage/types.d.ts 33 lines
1// 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