@openviking/opencode-plugin 0.2.3 → 0.2.4

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/INSTALL-ZH.md CHANGED
@@ -110,6 +110,9 @@ export { OpenVikingPlugin, default } from "./openviking/index.mjs"
110
110
  }
111
111
  ```
112
112
 
113
+ `autoRecall.limit` 是遗留的配额缩放输入,不是最终结果上限。显式设置为
114
+ 1 到 5 时,有效总配额仍为 6,因为六个 coding 分类会各保留一个检索槽位。
115
+
113
116
  推荐通过环境变量提供 API Key,而不是写入配置文件:
114
117
 
115
118
  ```bash
@@ -137,7 +140,6 @@ user/admin API key 的 API_KEY mode 时应留空。
137
140
  - `openviking_read`、`openviking_list`、`openviking_grep`、`openviking_glob`
138
141
  - `openviking_remember`、`openviking_add_resource`、`openviking_forget`、`openviking_health`
139
142
  - `openviking_list_watches`、`openviking_cancel_watch`
140
- - `openviking_code_search`、`openviking_code_outline`、`openviking_code_expand`
141
143
 
142
144
  如果行为异常,先查看运行时文件:
143
145
 
@@ -167,7 +169,6 @@ curl http://localhost:1933/health
167
169
  - `openviking_add_resource`:添加 URL、本地文件、sitemap 或 feed
168
170
  - `openviking_forget`:在用户明确确认后删除 `viking://` URI
169
171
  - `openviking_list_watches` / `openviking_cancel_watch`:查看或取消资源 watch
170
- - `openviking_code_search`、`openviking_code_outline`、`openviking_code_expand`:查看已索引代码符号
171
172
  - `openviking_health`:检查 OpenViking server 健康状态
172
173
 
173
174
  使用建议:
package/INSTALL.md CHANGED
@@ -110,6 +110,10 @@ Example configuration:
110
110
  }
111
111
  ```
112
112
 
113
+ `autoRecall.limit` is a legacy quota-scaling input, not a final result cap.
114
+ Explicit values from 1 through 5 produce an effective total quota of 6 because
115
+ each coding category keeps one retrieval slot.
116
+
113
117
  It is recommended to provide the API key through an environment variable instead of writing it into the configuration file:
114
118
 
115
119
  ```bash
@@ -132,7 +136,6 @@ In a new OpenCode session, ask the agent to browse OpenViking memory or search f
132
136
  - `openviking_read`, `openviking_list`, `openviking_grep`, `openviking_glob`
133
137
  - `openviking_remember`, `openviking_add_resource`, `openviking_forget`, `openviking_health`
134
138
  - `openviking_list_watches`, `openviking_cancel_watch`
135
- - `openviking_code_search`, `openviking_code_outline`, `openviking_code_expand`
136
139
 
137
140
  If anything looks wrong, check the runtime files:
138
141
 
@@ -162,7 +165,6 @@ The plugin registers OpenViking's stdio MCP proxy through OpenCode config. The s
162
165
  - `openviking_add_resource`: add a URL, local file, sitemap, or feed.
163
166
  - `openviking_forget`: delete a `viking://` URI after explicit user confirmation.
164
167
  - `openviking_list_watches` / `openviking_cancel_watch`: inspect or cancel resource watches.
165
- - `openviking_code_search`, `openviking_code_outline`, `openviking_code_expand`: inspect indexed code symbols.
166
168
  - `openviking_health`: check OpenViking server health.
167
169
 
168
170
  Usage guidance:
package/README.md CHANGED
@@ -132,7 +132,12 @@ Create `~/.config/opencode/openviking-config.json`:
132
132
  }
133
133
  ```
134
134
 
135
- API keys are resolved from environment variables or `~/.openviking/ovcli.conf` and sent as `Authorization: Bearer ...` by both hooks and the MCP proxy. `account` and `user` are trusted-mode identity
135
+ `autoRecall.limit` is a legacy quota-scaling input, not a final result cap.
136
+ Explicit values from 1 through 5 produce an effective total quota of 6 because
137
+ each coding category keeps one retrieval slot. Use Context `quotas` directly
138
+ when exact category ceilings are required.
139
+
140
+ API keys are resolved from environment variables or `~/.openviking/ovcli.conf` and sent as `Authorization: Bearer ...` by both hooks and the MCP proxy. Recall goes through the server-side context face (`POST /api/v1/search/search` with `mode="context"`), falling back to the deprecated `/api/v1/search/recall` on older deployments. `account` and `user` are trusted-mode identity
136
141
  headers sent as `X-OpenViking-Account` and `X-OpenViking-User`; leave them empty
137
142
  when using API-key mode with user/admin API keys.
138
143
  By default the plugin derives a peer from the project directory using Claude's
@@ -177,7 +182,6 @@ OpenCode sees the OpenViking MCP server as `openviking`, so tool names are names
177
182
  - `openviking_add_resource`: add a URL, local file, sitemap, or feed.
178
183
  - `openviking_forget`: delete a `viking://` URI after explicit user confirmation.
179
184
  - `openviking_list_watches` / `openviking_cancel_watch`: inspect or cancel resource watches.
180
- - `openviking_code_search`, `openviking_code_outline`, `openviking_code_expand`: inspect indexed code symbols.
181
185
  - `openviking_health`: check OpenViking server health.
182
186
 
183
187
  The proxy forwards the server's real `tools/list` response; the plugin does not maintain a separate native tool list.
package/index.mjs CHANGED
@@ -24,7 +24,7 @@ export async function OpenVikingPlugin({ client, directory }) {
24
24
 
25
25
  const repoContext = createRepoContext({ config })
26
26
  const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir })
27
- const recall = createMemoryRecall({ config })
27
+ const recall = createMemoryRecall({ config, sessionManager })
28
28
  const sessionInject = createSessionInject({ config, sessionManager })
29
29
  const vikingUriGuard = createVikingUriGuard()
30
30
 
package/lib/config.mjs CHANGED
@@ -1,9 +1,14 @@
1
1
  import fs from "fs"
2
2
  import path from "path"
3
3
  import { homedir } from "os"
4
- import { resolveOpenVikingCredentials } from "./shared/credentials.mjs"
4
+ import { buildUserAgent, readManifestVersion, resolveOpenVikingCredentials } from "./shared/credentials.mjs"
5
5
  import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs"
6
6
 
7
+ const USER_AGENT = buildUserAgent(
8
+ "opencode",
9
+ readManifestVersion(new URL("../package.json", import.meta.url)),
10
+ )
11
+
7
12
  const DEFAULT_CONFIG = {
8
13
  endpoint: "http://127.0.0.1:1933",
9
14
  apiKey: "",
@@ -12,6 +17,9 @@ const DEFAULT_CONFIG = {
12
17
  peerId: "",
13
18
  workspacePeer: true,
14
19
  recallPeerScope: "all",
20
+ // Server-side query expansion costs a model call before retrieval starts, so
21
+ // it has to be switchable from the client that pays the latency.
22
+ recallQueryExpansion: "auto",
15
23
  enabled: true,
16
24
  timeoutMs: 30000,
17
25
  runtime: {
@@ -23,7 +31,7 @@ const DEFAULT_CONFIG = {
23
31
  },
24
32
  autoRecall: {
25
33
  enabled: true,
26
- limit: 6,
34
+ limit: 10,
27
35
  scoreThreshold: 0.35,
28
36
  maxContentChars: 500,
29
37
  preferAbstract: true,
@@ -123,6 +131,8 @@ function applyBehaviorConfig(config, fileConfig = {}) {
123
131
  }
124
132
 
125
133
  const autoRecall = fileConfig.autoRecall ?? {}
134
+ config.recallLimitConfigured = autoRecall.limit !== undefined ||
135
+ fileConfig.recallLimit !== undefined
126
136
  config.autoRecall = {
127
137
  ...DEFAULT_CONFIG.autoRecall,
128
138
  ...autoRecall,
@@ -152,9 +162,11 @@ function applyBehaviorConfig(config, fileConfig = {}) {
152
162
  "debugLogPath",
153
163
  "workspacePeer",
154
164
  "recallPeerScope",
165
+ "recallQueryExpansion",
155
166
  ]) {
156
167
  if (fileConfig[key] !== undefined) config[key] = fileConfig[key]
157
168
  }
169
+ config.recallQueryExpansionConfigured = fileConfig.recallQueryExpansion !== undefined
158
170
  }
159
171
 
160
172
  function applyEnv(config) {
@@ -162,7 +174,10 @@ function applyEnv(config) {
162
174
  if (process.env.OPENVIKING_AUTO_RECALL !== undefined) {
163
175
  config.autoRecall.enabled = envBool("OPENVIKING_AUTO_RECALL") ?? config.autoRecall.enabled
164
176
  }
165
- if (process.env.OPENVIKING_RECALL_LIMIT) config.autoRecall.limit = process.env.OPENVIKING_RECALL_LIMIT
177
+ if (process.env.OPENVIKING_RECALL_LIMIT) {
178
+ config.autoRecall.limit = process.env.OPENVIKING_RECALL_LIMIT
179
+ config.recallLimitConfigured = true
180
+ }
166
181
  if (process.env.OPENVIKING_SCORE_THRESHOLD) config.autoRecall.scoreThreshold = process.env.OPENVIKING_SCORE_THRESHOLD
167
182
  if (process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS) {
168
183
  config.autoRecall.maxContentChars = process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS
@@ -172,6 +187,10 @@ function applyEnv(config) {
172
187
  config.autoRecall.preferAbstract = envBool("OPENVIKING_RECALL_PREFER_ABSTRACT") ?? config.autoRecall.preferAbstract
173
188
  }
174
189
  if (process.env.OPENVIKING_RECALL_PEER_SCOPE) config.recallPeerScope = process.env.OPENVIKING_RECALL_PEER_SCOPE
190
+ if (process.env.OPENVIKING_RECALL_QUERY_EXPANSION) {
191
+ config.recallQueryExpansion = process.env.OPENVIKING_RECALL_QUERY_EXPANSION
192
+ config.recallQueryExpansionConfigured = true
193
+ }
175
194
  if (process.env.OPENVIKING_WORKSPACE_PEER !== undefined) {
176
195
  config.workspacePeer = envBool("OPENVIKING_WORKSPACE_PEER") ?? config.workspacePeer
177
196
  }
@@ -216,6 +235,7 @@ function normalizeConfig(config) {
216
235
  config.baseUrl = config.endpoint
217
236
  config.accountId = config.account
218
237
  config.userId = config.user
238
+ config.userAgent = USER_AGENT
219
239
  config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000)
220
240
  config.repoContext.cacheTtlMs = normalizeNumber(
221
241
  config.repoContext.cacheTtlMs,
@@ -223,17 +243,25 @@ function normalizeConfig(config) {
223
243
  1000,
224
244
  60 * 60 * 1000,
225
245
  )
226
- config.autoRecall.limit = Math.max(1, Math.min(50, Math.round(Number(config.autoRecall.limit) || 6)))
246
+ config.autoRecall.limit = Math.max(1, Math.min(50, Math.round(Number(config.autoRecall.limit) || 10)))
227
247
  config.autoRecall.scoreThreshold = Math.max(0, Math.min(1, Number(config.autoRecall.scoreThreshold) || 0))
228
248
  config.autoRecall.maxContentChars = Math.max(100, Math.min(5000, Math.round(Number(config.autoRecall.maxContentChars) || 500)))
229
249
  config.autoRecall.tokenBudget = Math.max(200, Math.min(50000, Math.round(Number(config.autoRecall.tokenBudget) || 2000)))
230
250
  config.autoRecall.minQueryLength = Math.max(1, Math.min(64, Math.round(Number(config.autoRecall.minQueryLength) || 3)))
231
251
  config.captureMode = config.captureMode === "keyword" ? "keyword" : "semantic"
232
252
  config.recallPeerScope = config.recallPeerScope === "actor" ? "actor" : "all"
253
+ config.recallQueryExpansion = config.recallQueryExpansion === "off" ? "off" : "auto"
233
254
  config.captureMaxLength = Math.max(200, Math.min(100000, Math.round(Number(config.captureMaxLength) || 24000)))
234
255
  config.captureToolMaxChars = Math.max(200, Math.min(20000, Math.round(Number(config.captureToolMaxChars) || 2000)))
235
256
  config.commitTokenThreshold = Math.max(1000, Math.round(Number(config.commitTokenThreshold) || 20000))
236
- config.commitKeepRecentCount = Math.max(0, Math.round(Number(config.commitKeepRecentCount) || 10))
257
+ const rawCommitKeepRecentCount = config.commitKeepRecentCount
258
+ const commitKeepRecentCount = rawCommitKeepRecentCount == null ||
259
+ (typeof rawCommitKeepRecentCount === "string" && rawCommitKeepRecentCount.trim() === "")
260
+ ? Number.NaN
261
+ : Number(rawCommitKeepRecentCount)
262
+ config.commitKeepRecentCount = Number.isFinite(commitKeepRecentCount)
263
+ ? Math.max(0, Math.round(commitKeepRecentCount))
264
+ : DEFAULT_CONFIG.commitKeepRecentCount
237
265
  config.profileTokenBudget = Math.max(500, Math.round(Number(config.profileTokenBudget) || 10000))
238
266
  config.resumeContextBudget = Math.max(1024, Math.round(Number(config.resumeContextBudget) || 32000))
239
267
  if (!Array.isArray(config.bypassSessionPatterns)) config.bypassSessionPatterns = []
@@ -2,14 +2,15 @@ import { buildRecallBlock } from "./shared/recall-core.mjs"
2
2
  import { isBypassed } from "./shared/session-model.mjs"
3
3
  import { effectivePeerId, fetchJSON, log } from "./utils.mjs"
4
4
 
5
- export function createMemoryRecall({ config }) {
5
+ export function createMemoryRecall({ config, sessionManager }) {
6
6
  async function injectRelevantMemories(input, output) {
7
7
  if (!config.autoRecall?.enabled) return
8
8
  const query = extractCurrentUserText(output.parts ?? [])
9
9
  if (!query) return
10
10
  if (query.length < config.minQueryLength) return
11
+ const sessionID = input.sessionID ?? output.message?.sessionID
11
12
  if (isBypassed(config, {
12
- sessionId: input.sessionID ?? output.message?.sessionID,
13
+ sessionId: sessionID,
13
14
  cwd: input.directory ?? input.cwd,
14
15
  })) return
15
16
 
@@ -17,11 +18,18 @@ export function createMemoryRecall({ config }) {
17
18
  if (!health.ok) return
18
19
 
19
20
  const block = await buildRecallBlock(
20
- (path, init = {}, options = {}) => fetchJSON(config, path, init, { ...options, timeoutMs: 5000 }),
21
+ // 5s is this hook's own budget for a bare retrieval; when the request
22
+ // also spends a server fuse the helper hands down a longer deadline, and
23
+ // overriding it here would abort a request the server was still inside.
24
+ (path, init = {}, options = {}) =>
25
+ fetchJSON(config, path, init, { ...options, timeoutMs: options.timeoutMs ?? 5000 }),
21
26
  config,
22
27
  query,
23
28
  {
24
29
  actorPeerId: effectivePeerId(config),
30
+ // The mapped OV session is what turns on server-side query expansion
31
+ // and the cross-turn dedup ledger.
32
+ sessionId: sessionID ? sessionManager.getMappedSessionId(sessionID) : "",
25
33
  log: (stage, data) => log("DEBUG", "recall", stage, data),
26
34
  },
27
35
  )
@@ -35,12 +43,13 @@ export function createMemoryRecall({ config }) {
35
43
  return { injectRelevantMemories }
36
44
  }
37
45
 
38
- function extractCurrentUserText(parts) {
46
+ export function extractCurrentUserText(parts) {
39
47
  const texts = []
40
48
  for (const part of parts) {
41
49
  if (part.type !== "text" || typeof part.text !== "string") continue
50
+ if (part.synthetic || part.ignored) continue
42
51
  if (part.text.includes("<openviking-context")) return null
43
- if (!part.synthetic && !part.ignored) texts.push(part.text)
52
+ texts.push(part.text)
44
53
  }
45
54
  const joined = texts.join(" ").trim()
46
55
  return joined || null
@@ -15,6 +15,9 @@ import {
15
15
  import {
16
16
  sendSessionMessages,
17
17
  } from "./shared/batch-send.mjs"
18
+ import {
19
+ isRetryableFailure,
20
+ } from "./shared/retryable.mjs"
18
21
  import {
19
22
  log,
20
23
  effectivePeerId,
@@ -29,7 +32,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
29
32
  let saveTimer = null
30
33
 
31
34
  async function init() {
32
- await migrateLegacySessionMap()
35
+ if (config.autoCapture) await migrateLegacySessionMap()
33
36
  await loadState()
34
37
  const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
35
38
  if (health.ok) {
@@ -140,9 +143,9 @@ export function createMemorySessionManager({ config, pluginRoot }) {
140
143
  await handleSessionCompacted(event)
141
144
  } else if (event.type === "session.idle") {
142
145
  await handleSessionIdle(event)
143
- } else if (event.type === "message.updated") {
146
+ } else if (event.type === "message.updated" && config.autoCapture) {
144
147
  await handleMessageUpdated(event)
145
- } else if (event.type === "message.part.updated") {
148
+ } else if (event.type === "message.part.updated" && config.autoCapture) {
146
149
  await handleMessagePartUpdated(event)
147
150
  }
148
151
  }
@@ -259,7 +262,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
259
262
  if (!state) return false
260
263
 
261
264
  const added = await flushPendingMessages(opencodeSessionId, state)
262
- if (commit) {
265
+ if (commit && config.autoCapture) {
263
266
  await commitOvSession(state.ovSessionId, { force: true, reason })
264
267
  } else if (added > 0) {
265
268
  await maybeCommitByThreshold(state)
@@ -361,6 +364,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
361
364
  }
362
365
 
363
366
  async function flushPendingMessages(opencodeSessionId, state) {
367
+ if (!config.autoCapture) return 0
364
368
  const toSend = []
365
369
  for (const [messageId, message] of state.messages.entries()) {
366
370
  if (message.captured) continue
@@ -474,9 +478,4 @@ export function createMemorySessionManager({ config, pluginRoot }) {
474
478
  }
475
479
  }
476
480
 
477
- function isRetryableFailure(res) {
478
- if (!res || res.ok) return false
479
- const status = Number(res.status || 0)
480
- return !status || status >= 500 || status === 408 || status === 429
481
- }
482
481
  }
package/lib/runtime.mjs CHANGED
@@ -6,6 +6,7 @@ export async function checkServiceHealth(config, timeoutMs = 3000) {
6
6
  try {
7
7
  const response = await fetch(`${normalizeEndpoint(config.endpoint)}/health`, {
8
8
  method: "GET",
9
+ headers: config.userAgent ? { "User-Agent": config.userAgent } : undefined,
9
10
  signal: controller.signal,
10
11
  })
11
12
  return response.ok
@@ -1,12 +1,11 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
2
  import { enqueue } from "./pending-queue.mjs";
3
+ import { isRetryableFailure } from "./retryable.mjs";
3
4
 
4
5
  export const BATCH_LIMIT = 100;
5
6
 
6
7
  export function isRetryableSendFailure(res) {
7
- if (!res || res.ok) return false;
8
- const status = Number(res.status || 0);
9
- return !status || status === 408 || status === 429 || status >= 500;
8
+ return isRetryableFailure(res);
10
9
  }
11
10
 
12
11
  function makeResult() {
@@ -99,7 +99,6 @@ function toolName(block) {
99
99
  block?.toolName ||
100
100
  block?.function?.name ||
101
101
  block?.call?.name ||
102
- block?.id ||
103
102
  "",
104
103
  );
105
104
  }
@@ -128,7 +127,6 @@ function toolPayload(block, kind) {
128
127
 
129
128
  function toolId(block) {
130
129
  return oneLine(
131
- block?.id ||
132
130
  block?.call_id ||
133
131
  block?.callId ||
134
132
  block?.tool_call_id ||
@@ -137,6 +135,7 @@ function toolId(block) {
137
135
  block?.toolUseId ||
138
136
  block?.function_call_id ||
139
137
  block?.functionCallId ||
138
+ block?.id ||
140
139
  "",
141
140
  );
142
141
  }
@@ -30,6 +30,27 @@ function tryLoadJson(path) {
30
30
  }
31
31
  }
32
32
 
33
+ /**
34
+ * Build the User-Agent every harness plugin sends on OpenViking-bound requests.
35
+ * Shape is `name/semver` so downstream stats layers can parse it as one token.
36
+ */
37
+ export function buildUserAgent(harness, version) {
38
+ return `openviking-memory-${harness}/${str(version, "") || "0.0.0"}`;
39
+ }
40
+
41
+ /**
42
+ * Read a plugin manifest's `version` field. Accepts a path or a URL (so callers
43
+ * can resolve relative to import.meta.url). Returns "" when unreadable so the
44
+ * User-Agent falls back to 0.0.0 instead of throwing inside a short-lived hook.
45
+ */
46
+ export function readManifestVersion(manifest) {
47
+ try {
48
+ return str(JSON.parse(readFileSync(manifest, "utf-8")).version, "");
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+
33
54
  function looksLikeOvcli(obj) {
34
55
  if (!obj || typeof obj !== "object") return false;
35
56
  if (obj.server && typeof obj.server === "object") return false;
@@ -142,6 +142,7 @@ export function createOpenVikingMcpProxy({
142
142
  readConfig,
143
143
  loggerFactory,
144
144
  fetchImpl = globalThis.fetch,
145
+ localToolProvider = null,
145
146
  } = {}) {
146
147
  if (typeof fetchImpl !== "function") {
147
148
  throw new Error("global fetch is required; use Node.js 18 or newer");
@@ -179,6 +180,43 @@ export function createOpenVikingMcpProxy({
179
180
  } catch { /* debug logging must never affect protocol IO */ }
180
181
  }
181
182
 
183
+ function localTools() {
184
+ if (!localToolProvider || typeof localToolProvider.listTools !== "function") return [];
185
+ const tools = localToolProvider.listTools();
186
+ return Array.isArray(tools) ? tools : [];
187
+ }
188
+
189
+ function appendLocalTools(message, outbound) {
190
+ if (message.method !== "tools/list" || !Array.isArray(outbound?.result?.tools)) {
191
+ return outbound;
192
+ }
193
+ const additions = localTools().filter((tool) => tool?.name);
194
+ if (additions.length === 0) return outbound;
195
+ const localNames = new Set(additions.map((tool) => tool.name));
196
+ const upstreamTools = outbound.result.tools.filter((tool) => !localNames.has(tool?.name));
197
+ return {
198
+ ...outbound,
199
+ result: {
200
+ ...outbound.result,
201
+ tools: [...upstreamTools, ...additions],
202
+ },
203
+ };
204
+ }
205
+
206
+ async function callLocalTool(message) {
207
+ if (
208
+ message.method !== "tools/call"
209
+ || !localToolProvider
210
+ || typeof localToolProvider.callTool !== "function"
211
+ ) {
212
+ return null;
213
+ }
214
+ const name = message.params?.name;
215
+ if (!localTools().some((tool) => tool?.name === name)) return null;
216
+ reloadIfCredentialFilesChanged("local_tool_call");
217
+ return localToolProvider.callTool(message.params, { config: proxyConfig });
218
+ }
219
+
182
220
  function reloadConfig(reason) {
183
221
  proxyConfig = readConfig();
184
222
  logger = loggerFactory("mcp-proxy", proxyConfig);
@@ -215,6 +253,7 @@ export function createOpenVikingMcpProxy({
215
253
  if (proxyConfig.account) headers["X-OpenViking-Account"] = proxyConfig.account;
216
254
  if (proxyConfig.user) headers["X-OpenViking-User"] = proxyConfig.user;
217
255
  if (proxyConfig.peerId) headers["X-OpenViking-Actor-Peer"] = proxyConfig.peerId;
256
+ if (proxyConfig.userAgent) headers["User-Agent"] = proxyConfig.userAgent;
218
257
  return headers;
219
258
  }
220
259
 
@@ -381,6 +420,13 @@ export function createOpenVikingMcpProxy({
381
420
  }
382
421
 
383
422
  try {
423
+ const localResult = await callLocalTool(message);
424
+ if (localResult !== null) {
425
+ if (expectsResponse) {
426
+ await writeMessage({ jsonrpc: "2.0", id: message.id, result: localResult });
427
+ }
428
+ return;
429
+ }
384
430
  const send = sendWithRetry(message, { expectsResponse });
385
431
  if (message.method === "initialize") {
386
432
  initializeInFlight = send
@@ -396,7 +442,7 @@ export function createOpenVikingMcpProxy({
396
442
  return;
397
443
  }
398
444
  for (const outbound of result.messages) {
399
- await writeMessage(outbound);
445
+ await writeMessage(appendLocalTools(message, outbound));
400
446
  }
401
447
  } catch (err) {
402
448
  if (expectsResponse) {
@@ -24,6 +24,7 @@ import { mkdir, readdir, readFile, rename, writeFile, unlink, stat, chmod } from
24
24
  import { createHash } from "node:crypto";
25
25
  import { join } from "node:path";
26
26
  import { homedir } from "node:os";
27
+ import { isRetryableFailure } from "./retryable.mjs";
27
28
 
28
29
  const DEFAULT_MAX_RETRIES = 3;
29
30
  const DEFAULT_TTL_DAYS = 7;
@@ -109,9 +110,7 @@ function pendingFromProcessingFilename(filename) {
109
110
  }
110
111
 
111
112
  function isRetryableReplayFailure(res) {
112
- if (!res || res.ok) return false;
113
- const status = Number(res.status || 0);
114
- return !status || status >= 500 || status === 408 || status === 429;
113
+ return isRetryableFailure(res);
115
114
  }
116
115
 
117
116
  async function readEntry(dir, filename) {
@@ -0,0 +1,90 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Per-harness plugin settings from ovcli.conf.
4
+ *
5
+ * ovcli.conf carried connection fields only, so every harness had to keep its
6
+ * tuning knobs in ov.conf's harness section — a server-side file that a
7
+ * client-side plugin has no business editing. The `plugin` section fixes that:
8
+ * shared keys apply to every harness that reads them, and a per-harness object
9
+ * overrides them.
10
+ *
11
+ * {
12
+ * "url": "...", "api_key": "...",
13
+ * "plugin": {
14
+ * "recallQueryExpansion": "off",
15
+ * "recallCompress": "auto"
16
+ * }
17
+ * }
18
+ *
19
+ * Resolution stays env → ovcli.conf plugin.<harness> → ovcli.conf plugin →
20
+ * ov.conf harness section (legacy) → defaults.
21
+ *
22
+ * Consumers: Claude Code and Codex only. The other harnesses ship this module
23
+ * through `sync.mjs` but still read their knobs from the environment, so a
24
+ * `plugin` entry named after them is inert. `HARNESS_KEYS` lists what a harness
25
+ * loader actually consumes today — add a key here as its loader starts calling
26
+ * `loadPluginSettings`, not before, so the section never promises a knob that
27
+ * silently does nothing.
28
+ */
29
+
30
+ import { readFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join, resolve as resolvePath } from "node:path";
33
+
34
+ const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf");
35
+
36
+ export const HARNESS_KEYS = {
37
+ claudeCode: "claude_code",
38
+ codex: "codex",
39
+ };
40
+
41
+ function tryLoadJson(path) {
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf-8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Merge the shared plugin section with the harness-specific override.
51
+ * Returns a flat settings object; unknown keys pass through untouched so a
52
+ * harness can add its own knobs without touching this module.
53
+ */
54
+ export function loadPluginSettings(harness, env = process.env) {
55
+ const path = resolvePath(
56
+ (env.OPENVIKING_CLI_CONFIG_FILE || DEFAULT_OVCLI_CONF_PATH).replace(/^~/, homedir()),
57
+ );
58
+ const file = tryLoadJson(path);
59
+ const plugin = file && typeof file.plugin === "object" && file.plugin ? file.plugin : {};
60
+
61
+ const shared = {};
62
+ for (const [key, value] of Object.entries(plugin)) {
63
+ if (value && typeof value === "object" && !Array.isArray(value)) continue;
64
+ shared[key] = value;
65
+ }
66
+ const scoped = harness && plugin[harness] && typeof plugin[harness] === "object"
67
+ ? plugin[harness]
68
+ : {};
69
+
70
+ return { ...shared, ...scoped };
71
+ }
72
+
73
+ const REWRITE_MODES = new Set(["off", "client", "server", "auto"]);
74
+
75
+ /**
76
+ * Normalize the tri-state rewrite knob.
77
+ *
78
+ * off — never compress
79
+ * client — always compress locally through the host CLI
80
+ * server — always ask the server for a digest
81
+ * auto — prefer local (cost lands on the user's own subscription), fall back
82
+ * to the server when no healthy host CLI is available
83
+ */
84
+ export function normalizeRewriteMode(value, fallback = "off") {
85
+ const raw = String(value ?? "").trim().toLowerCase();
86
+ if (REWRITE_MODES.has(raw)) return raw;
87
+ if (raw === "1" || raw === "true" || raw === "yes") return "auto";
88
+ if (raw === "0" || raw === "false" || raw === "no") return "off";
89
+ return fallback;
90
+ }
@@ -0,0 +1,195 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+
6
+ export const NO_RELEVANT_MEMORY = "NO_RELEVANT_MEMORY";
7
+ export const DIGEST_HEADER = "OpenViking memory digest:";
8
+ const COMPRESS_OK = "ok";
9
+ const COMPRESS_EMPTY = "empty";
10
+ const COMPRESS_FAILED = "failed";
11
+
12
+ // Medium-constraint prompt: state the goal and two structural floors, but no hard
13
+ // bullet-length contract. Hard per-bullet limits pin the digest to headline
14
+ // density; leaving it unconstrained lets small models rewrite long URIs into dead
15
+ // links, which the URI repair below cleans up.
16
+ export function buildRecallCompressionPrompt({ query, rendered, maxBullets = 6 }) {
17
+ return `You are a memory relevance compressor utility.
18
+ Do not use any tools. Do not investigate. Only transform the given text.
19
+
20
+ User query:
21
+ ${query}
22
+
23
+ Retrieved OpenViking context fragments:
24
+ ${rendered}
25
+
26
+ Write a memory digest for a coding agent about to answer that query. Keep the
27
+ concrete facts (paths, identifiers, decisions, constraints); drop pleasantries
28
+ and conversational filler.
29
+
30
+ Format rules:
31
+ - Group related facts by topic, one bullet per topic, at most ${maxBullets} bullets.
32
+ - Start every bullet with "- ".
33
+ - End every bullet with its source, copied verbatim from the fragments above:
34
+ "来源:viking://..." or "source: viking://...". Never edit, shorten, or invent a URI.
35
+ - Output the digest body only. No preamble, no closing remark.
36
+
37
+ If nothing above is relevant to the query, output exactly: ${NO_RELEVANT_MEMORY}`;
38
+ }
39
+
40
+ function editDistance(a, b) {
41
+ if (a === b) return 0;
42
+ const rows = a.length + 1;
43
+ const cols = b.length + 1;
44
+ let prev = Array.from({ length: cols }, (_, i) => i);
45
+ for (let i = 1; i < rows; i += 1) {
46
+ const cur = [i];
47
+ for (let j = 1; j < cols; j += 1) {
48
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
49
+ cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
50
+ }
51
+ prev = cur;
52
+ }
53
+ return prev[cols - 1];
54
+ }
55
+
56
+ function nearestUri(candidate, validUris) {
57
+ let best = "";
58
+ let bestDistance = Infinity;
59
+ for (const uri of validUris) {
60
+ const distance = editDistance(candidate, uri);
61
+ if (distance < bestDistance) {
62
+ best = uri;
63
+ bestDistance = distance;
64
+ }
65
+ }
66
+ // Only repair near-misses; an unrelated hallucination is dropped instead.
67
+ const tolerance = Math.max(4, Math.floor(candidate.length * 0.25));
68
+ return bestDistance <= tolerance ? best : "";
69
+ }
70
+
71
+ /**
72
+ * Small models occasionally mangle long URIs. Snap every cited URI back onto the
73
+ * set the server actually returned, and drop bullets whose citation cannot be
74
+ * recovered so the digest never carries a dead link.
75
+ */
76
+ export function repairDigestUris(digest, validUris = []) {
77
+ const text = String(digest || "");
78
+ if (!text) return "";
79
+ const valid = validUris.map((uri) => String(uri || "").trim()).filter(Boolean);
80
+ if (!valid.length) return text;
81
+ const validSet = new Set(valid);
82
+
83
+ const lines = [];
84
+ for (const line of text.split(/\r?\n/)) {
85
+ if (!line.trim().startsWith("- ")) {
86
+ lines.push(line);
87
+ continue;
88
+ }
89
+ let dropped = false;
90
+ const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91
+ if (validSet.has(uri)) return uri;
92
+ const nearest = nearestUri(uri, valid);
93
+ if (nearest) return nearest;
94
+ dropped = true;
95
+ return uri;
96
+ });
97
+ if (!dropped) lines.push(repaired);
98
+ }
99
+ return lines.join("\n").trim();
100
+ }
101
+
102
+ export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
103
+ const text = String(raw || "").trim();
104
+ if (!text) return null;
105
+ if (text.toUpperCase() === NO_RELEVANT_MEMORY) return "";
106
+ const bullets = text.split(/\r?\n/)
107
+ .map((line) => line.trim())
108
+ .filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
109
+ .slice(0, Math.max(1, maxBullets))
110
+ .map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
111
+ if (!bullets.length) return null;
112
+ return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
113
+ }
114
+
115
+ export function recallDigestCacheKey({
116
+ query = "",
117
+ rendered = "",
118
+ entries = [],
119
+ maxInputChars = 18000,
120
+ maxBullets = 6,
121
+ } = {}) {
122
+ const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
123
+ const source = JSON.stringify({
124
+ version: 2,
125
+ query: String(query),
126
+ rendered: String(rendered).slice(0, maxInputChars),
127
+ uris,
128
+ maxInputChars,
129
+ maxBullets,
130
+ });
131
+ return createHash("sha256").update(source).digest("hex");
132
+ }
133
+
134
+ async function readCache(path) {
135
+ if (!path) return null;
136
+ try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
137
+ }
138
+
139
+ async function writeCache(path, value) {
140
+ if (!path) return;
141
+ try {
142
+ await mkdir(dirname(path), { recursive: true });
143
+ const tmp = `${path}.tmp`;
144
+ await writeFile(tmp, JSON.stringify(value));
145
+ await rename(tmp, path);
146
+ } catch { /* best effort */ }
147
+ }
148
+
149
+ export async function compressRecallContext({
150
+ query,
151
+ rendered,
152
+ entries = [],
153
+ cfg = {},
154
+ runCompressor,
155
+ cachePath = "",
156
+ now = 0,
157
+ }) {
158
+ const input = String(rendered || "").trim();
159
+ if (!input) return { status: COMPRESS_EMPTY, context: "" };
160
+ const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
161
+ if (input.length < minChars) return { status: COMPRESS_OK, context: input };
162
+
163
+ const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
164
+ const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
165
+ const key = recallDigestCacheKey({
166
+ query,
167
+ rendered: input,
168
+ entries,
169
+ maxInputChars,
170
+ maxBullets,
171
+ });
172
+ const cached = await readCache(cachePath);
173
+ if (cached?.key === key && typeof cached.digest === "string") {
174
+ return { status: COMPRESS_OK, context: cached.digest };
175
+ }
176
+
177
+ const prompt = buildRecallCompressionPrompt({
178
+ query,
179
+ rendered: input.slice(0, maxInputChars),
180
+ maxBullets,
181
+ });
182
+ const raw = await runCompressor(prompt);
183
+ const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
184
+ if (normalized === null) return { status: COMPRESS_FAILED, context: "" };
185
+ if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
186
+
187
+ const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
188
+ const digest = repairDigestUris(normalized, validUris.length
189
+ ? validUris
190
+ : (input.match(/viking:\/\/[^\s<>"']+/g) || []));
191
+ if (!digest) return { status: COMPRESS_FAILED, context: "" };
192
+
193
+ await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
194
+ return { status: COMPRESS_OK, context: digest };
195
+ }
@@ -1,4 +1,10 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { compressRecallContext } from "./recall-compress-core.mjs";
7
+
2
8
  const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
3
9
  const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i;
4
10
  const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi;
@@ -11,6 +17,17 @@ const SOURCES = [
11
17
  { type: "memory", uri: "viking://user/memories", bucket: "memories" },
12
18
  { type: "skill", uri: "viking://user/skills", bucket: "skills" },
13
19
  ];
20
+ const DEFAULT_CONTEXT_LIMIT = 10;
21
+ const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
22
+ const DEFAULT_REWRITE_MAX_BULLETS = 6;
23
+ const CODING_QUOTA_WEIGHTS = {
24
+ events: 1,
25
+ entities: 2,
26
+ preferences: 1,
27
+ experiences: 1,
28
+ resources: 3,
29
+ skills: 2,
30
+ };
14
31
 
15
32
  let userSpaceCache = "";
16
33
 
@@ -18,16 +35,47 @@ export function estimateTokens(text) {
18
35
  return text ? Math.ceil(String(text).length / 4) : 0;
19
36
  }
20
37
 
38
+ function scaleQuotas(limit, weights) {
39
+ const slots = Math.max(1, Math.floor(Number(limit) || DEFAULT_CONTEXT_LIMIT));
40
+ const order = Object.keys(weights);
41
+ const quotas = Object.fromEntries(order.map((key) => [key, 0]));
42
+ if (slots < order.length) {
43
+ for (const key of order) quotas[key] = 1;
44
+ return quotas;
45
+ }
46
+
47
+ for (const key of order) quotas[key] = 1;
48
+ const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
49
+ const ideals = Object.fromEntries(
50
+ order.map((key) => [key, slots * weights[key] / totalWeight]),
51
+ );
52
+ while (order.reduce((sum, key) => sum + quotas[key], 0) < slots) {
53
+ const key = order.reduce((best, candidate) => (
54
+ ideals[candidate] - quotas[candidate] > ideals[best] - quotas[best]
55
+ ? candidate
56
+ : best
57
+ ));
58
+ quotas[key] += 1;
59
+ }
60
+ return quotas;
61
+ }
62
+
63
+ function legacyMemoryQuotas(limit) {
64
+ return {
65
+ ...scaleQuotas(limit, { events: 10, entities: 10, preferences: 3 }),
66
+ experiences: 0,
67
+ };
68
+ }
69
+
70
+ function codingQuotas(limit) {
71
+ return scaleQuotas(limit, CODING_QUOTA_WEIGHTS);
72
+ }
73
+
21
74
  export function buildRecallEndpointBody(cfg = {}) {
22
- const limit = Math.max(Number(cfg.recallLimit || 0), 1);
75
+ const limit = Math.max(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT), 1);
23
76
  const body = {
24
77
  query: "",
25
- quotas: {
26
- events: limit,
27
- entities: limit,
28
- preferences: Math.max(1, Math.min(limit, 3)),
29
- experiences: 0,
30
- },
78
+ quotas: legacyMemoryQuotas(limit),
31
79
  max_chars: Math.max(Number(cfg.recallMaxContentChars || 0) * limit, 1000),
32
80
  min_score: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
33
81
  render: true,
@@ -36,6 +84,106 @@ export function buildRecallEndpointBody(cfg = {}) {
36
84
  return body;
37
85
  }
38
86
 
87
+ /**
88
+ * Body for the server-side context face. The plugin declares intent (coding
89
+ * purpose, budget, session) and leaves the mechanics — quota ratios, tier
90
+ * degradation, cross-turn dedup — to the server's defaults.
91
+ */
92
+ export function buildContextSearchBody(cfg = {}, options = {}) {
93
+ const rewriteMode = String(cfg.recallRewrite || "off").toLowerCase();
94
+ const limit = Math.max(1, Math.floor(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT)));
95
+ const maxTokens = Math.max(
96
+ 64,
97
+ Math.floor(Number(cfg.recallMaxTokens || DEFAULT_CONTEXT_MAX_TOKENS)),
98
+ );
99
+ const body = {
100
+ query: "",
101
+ mode: "context",
102
+ purpose: "coding",
103
+ score_threshold: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
104
+ };
105
+ const limitConfigured = cfg.recallLimitConfigured === true;
106
+ const maxTokensConfigured = cfg.recallMaxTokensConfigured === true;
107
+ if (limitConfigured) body.quotas = codingQuotas(limit);
108
+ if (maxTokensConfigured) body.max_tokens = maxTokens;
109
+ if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
110
+
111
+ const sessionId = String(options.sessionId || "").trim();
112
+ if (sessionId) {
113
+ body.session_id = sessionId;
114
+ const queryExpansionConfigured = cfg.recallQueryExpansionConfigured === true;
115
+ if (queryExpansionConfigured) {
116
+ body.query_expansion = cfg.recallQueryExpansion === "off" ? "off" : "auto";
117
+ }
118
+ const dedupTurns = Number(cfg.recallDedupTurns);
119
+ const resolvedDedupTurns = Number.isFinite(dedupTurns)
120
+ ? Math.max(0, Math.floor(dedupTurns))
121
+ : 5;
122
+ if (resolvedDedupTurns > 0) body.dedup_turns = resolvedDedupTurns;
123
+ }
124
+
125
+ const excludeUris = Array.isArray(options.excludeUris) ? options.excludeUris.slice(0, 200) : [];
126
+ if (excludeUris.length) body.exclude_uris = excludeUris;
127
+
128
+ if (rewriteMode === "server") body.rewrite = true;
129
+ else if (rewriteMode === "auto" && !options.localCompressorAvailable) body.rewrite = "auto";
130
+ const rewriteMaxBullets = Math.max(
131
+ 1,
132
+ Math.floor(Number(cfg.recallCompressMaxBullets || DEFAULT_REWRITE_MAX_BULLETS)),
133
+ );
134
+ const rewriteMaxBulletsConfigured = cfg.recallCompressMaxBulletsConfigured === true;
135
+ if (body.rewrite !== undefined && rewriteMaxBulletsConfigured) {
136
+ body.rewrite_max_bullets = rewriteMaxBullets;
137
+ }
138
+ return body;
139
+ }
140
+
141
+ // The server pipeline is serial and each optional stage has its own fuse. A
142
+ // request is aborted client-side unless its deadline covers every stage it
143
+ // asked for, and aborting discards the whole response rather than just the
144
+ // stage that ran long.
145
+ //
146
+ // session_id -> query expansion (retrieval.recall_intent_timeout_s, 5s)
147
+ // always -> retrieval, body reads, budget planning
148
+ // rewrite -> digest (retrieval.recall_rewrite_timeout_s, 30s)
149
+ //
150
+ // Both budgets stay inside the 60s prompt-hook allowance, the rewrite one with
151
+ // a quarter to spare.
152
+ const EXPANSION_REQUEST_TIMEOUT_MS = 15000;
153
+ const SERVER_REWRITE_REQUEST_TIMEOUT_MS = 45000;
154
+
155
+ /**
156
+ * HTTP deadline for one context request, or undefined to keep the caller's own.
157
+ *
158
+ * Derived from the request body, because the body is what states which server
159
+ * stages will run: reading `cfg` alone cannot tell a bare retrieval from one
160
+ * that also spends the expansion or rewrite fuse.
161
+ */
162
+ export function contextRequestTimeoutMs(cfg = {}, body = {}) {
163
+ const wantsRewrite = body.rewrite !== undefined;
164
+ // `query_expansion` defaults to "auto" server-side, so only an explicit "off"
165
+ // takes the expansion fuse back out of the budget.
166
+ const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
167
+ if (!wantsRewrite && !wantsExpansion) return undefined;
168
+
169
+ const configured = Number(cfg.recallContextTimeoutMs);
170
+ if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
171
+ const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
172
+ return Math.max(Number(cfg.timeoutMs) || 0, floor);
173
+ }
174
+
175
+ /**
176
+ * Strip the context-face fields a pre-context server rejects, converting the
177
+ * token budget back to v1's character budget.
178
+ */
179
+ export function downgradeToRecallBody(contextBody = {}, cfg = {}) {
180
+ const body = buildRecallEndpointBody(cfg);
181
+ body.query = contextBody.query || "";
182
+ body.max_chars = Math.max(1000, Math.floor(Number(contextBody.max_tokens || 1600) * 4));
183
+ if (contextBody.peer_scope) body.peer_scope = contextBody.peer_scope;
184
+ return body;
185
+ }
186
+
39
187
  function clampScore(v) {
40
188
  if (typeof v !== "number" || Number.isNaN(v)) return 0;
41
189
  return Math.max(0, Math.min(1, v));
@@ -232,6 +380,164 @@ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId =
232
380
  return lines.join("\n");
233
381
  }
234
382
 
383
+ const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
384
+
385
+ function stateFile(name) {
386
+ const override = String(process.env.OPENVIKING_STATE_DIR || "").trim();
387
+ return override ? join(override, name) : join(homedir(), ".openviking", "state", name);
388
+ }
389
+
390
+ async function readJsonFile(path) {
391
+ try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
392
+ }
393
+
394
+ async function writeJsonFile(path, value) {
395
+ try {
396
+ await mkdir(dirname(path), { recursive: true });
397
+ const tmp = `${path}.tmp`;
398
+ await writeFile(tmp, JSON.stringify(value));
399
+ await rename(tmp, path);
400
+ } catch { /* best effort */ }
401
+ }
402
+
403
+ /**
404
+ * Hooks are one-shot processes, so "this server has no context face" has to be
405
+ * remembered on disk or every turn pays for a rejected request.
406
+ */
407
+ export async function isContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
408
+ const cached = await readJsonFile(path);
409
+ return Boolean(cached?.legacyUntil && Number(cached.legacyUntil) > now);
410
+ }
411
+
412
+ export async function markContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
413
+ await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
414
+ }
415
+
416
+ function looksLikeUnknownField(res) {
417
+ const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
418
+ return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
419
+ }
420
+
421
+ function wrapContext(body) {
422
+ return [
423
+ "<openviking-context>",
424
+ "Relevant memory from OpenViking. Use the recall/read MCP tools to expand URIs.",
425
+ body,
426
+ "</openviking-context>",
427
+ ].join("\n");
428
+ }
429
+
430
+ /**
431
+ * Server-assembled context: the context face when the deployment has it, else
432
+ * the deprecated /recall preset. Returns the injection block, "" when there was
433
+ * nothing relevant, or null when no server-side path was usable at all.
434
+ */
435
+ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options = {}) {
436
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
437
+ const log = options.log || (() => {});
438
+
439
+ const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
440
+ if (block !== null) return block;
441
+ return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
442
+ }
443
+
444
+ /**
445
+ * Raw server-assembled context, or null when the deployment has no context face.
446
+ * Returns `{ rendered, entries, digest, stats }` — callers that need the entries
447
+ * (their own compression, their own envelope) use this instead of the block
448
+ * builders below.
449
+ */
450
+ export async function fetchAssembledContext(fetchJSON, cfg, query, options = {}) {
451
+ const actorPeerId = options.actorPeerId || "";
452
+ const log = options.log || (() => {});
453
+ if (await isContextFaceLegacy(options.legacyCachePath)) return null;
454
+
455
+ const body = buildContextSearchBody(cfg, options);
456
+ body.query = query;
457
+ const res = await fetchJSON("/api/v1/search/search", {
458
+ method: "POST",
459
+ body: JSON.stringify(body),
460
+ }, { actorPeerId, timeoutMs: contextRequestTimeoutMs(cfg, body) });
461
+
462
+ if (!res.ok) {
463
+ const status = res.status || 0;
464
+ if ((status === 400 || status === 422) && looksLikeUnknownField(res)) {
465
+ await markContextFaceLegacy(options.legacyCachePath);
466
+ log("recall_context_face_unsupported", { status });
467
+ } else {
468
+ log("recall_context_face_error", { status });
469
+ }
470
+ return null;
471
+ }
472
+
473
+ const result = res.result || {};
474
+ const stats = result.stats || {};
475
+ log("recall_context_assembled", {
476
+ entries: Array.isArray(result.entries) ? result.entries.length : 0,
477
+ usedTokens: stats.used_tokens || 0,
478
+ tiers: stats.tier_counts || {},
479
+ rewrite: stats.rewrite || "off",
480
+ });
481
+ return {
482
+ rendered: String(result.rendered || "").trim(),
483
+ entries: Array.isArray(result.entries) ? result.entries : [],
484
+ digest: String(result.digest || "").trim(),
485
+ stats,
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Entry field compatibility: the context face returns `category`/`text`, while
491
+ * the deprecated /recall v1 shape used `type` plus `content`/`summary`.
492
+ */
493
+ export function normalizeContextEntry(entry = {}) {
494
+ return {
495
+ uri: String(entry.uri || "").trim(),
496
+ category: String(entry.category || entry.type || "memory").trim() || "memory",
497
+ detail: String(entry.detail || entry.mode || "").trim(),
498
+ score: Number(entry.score) || 0,
499
+ text: String(
500
+ entry.text || entry.content || entry.summary || entry.abstract || entry.uri || "",
501
+ ).trim(),
502
+ };
503
+ }
504
+
505
+ async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
506
+ const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
507
+ if (assembled === null) return null;
508
+
509
+ const { rendered, entries } = assembled;
510
+ let digest = assembled.digest;
511
+ const mode = String(cfg.recallRewrite || "off").toLowerCase();
512
+ if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
513
+ log("recall_server_compression", { status: "empty" });
514
+ return "";
515
+ }
516
+ const wantsLocal = mode === "client" || (mode === "auto" && !digest);
517
+ if (wantsLocal && rendered && typeof options.runCompressor === "function") {
518
+ try {
519
+ const compression = await compressRecallContext({
520
+ query,
521
+ rendered,
522
+ entries,
523
+ cfg,
524
+ runCompressor: options.runCompressor,
525
+ cachePath: options.digestCachePath || stateFile("recall-digest.json"),
526
+ now: Date.now(),
527
+ });
528
+ log("recall_local_compression", { status: compression.status });
529
+ if (compression.status === "ok") digest = compression.context;
530
+ if (compression.status === "empty") return "";
531
+ } catch (err) {
532
+ log("recall_local_compression_failed", { error: String(err?.message || err) });
533
+ }
534
+ }
535
+
536
+ const injected = digest || rendered;
537
+ if (!injected) return "";
538
+ return wrapContext(injected);
539
+ }
540
+
235
541
  async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
236
542
  const body = buildRecallEndpointBody(cfg);
237
543
  body.query = query;
@@ -242,12 +548,7 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
242
548
  }
243
549
  const rendered = String(res.result?.rendered || "").trim();
244
550
  if (!rendered) return "";
245
- return [
246
- "<openviking-context>",
247
- "Relevant memory from OpenViking. Use the recall/read MCP tools to expand URIs.",
248
- rendered,
249
- "</openviking-context>",
250
- ].join("\n");
551
+ return wrapContext(rendered);
251
552
  }
252
553
 
253
554
  export async function postRecall(fetchJSON, body, opts = {}) {
@@ -277,10 +578,16 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
277
578
  const trimmed = String(query || "").trim();
278
579
  if (!trimmed) return null;
279
580
 
280
- const endpointBlock = await recallViaEndpoint(fetchJSON, cfg, trimmed, actorPeerId, log);
281
- if (endpointBlock !== null) return endpointBlock || null;
581
+ // Assembly happens server-side when the deployment offers the context face;
582
+ // older servers fall through to /recall, then to raw find.
583
+ const serverBlock = await buildServerAssembledBlock(fetchJSON, cfg, trimmed, {
584
+ ...options,
585
+ actorPeerId,
586
+ log,
587
+ });
588
+ if (serverBlock !== null) return serverBlock || null;
282
589
 
283
- const recallLimit = Math.max(1, Number(cfg.recallLimit || 6));
590
+ const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
284
591
  const perSourceLimit = Math.max(recallLimit * 2, 8);
285
592
  const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
286
593
  if (raw.length === 0) return null;
@@ -0,0 +1,9 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ export function isRetryableFailure(result) {
3
+ if (!result || result.ok) return false;
4
+ const status = Number(result.status || 0);
5
+ if (!status || status === 408 || status === 429 || status >= 500) {
6
+ return true;
7
+ }
8
+ return status === 409 && result.error?.details?.retryable === true;
9
+ }
package/lib/utils.mjs CHANGED
@@ -213,6 +213,7 @@ function makeAuthHeaders(config, headers = {}, actorPeerId = "") {
213
213
  if (config.user) result["X-OpenViking-User"] = config.user
214
214
  const peerId = String(actorPeerId || "").trim()
215
215
  if (peerId) result["X-OpenViking-Actor-Peer"] = peerId
216
+ if (config.userAgent) result["User-Agent"] = config.userAgent
216
217
  return result
217
218
  }
218
219
 
@@ -4,7 +4,7 @@ import { buildGuardMessage, findVikingUri, normalizeToolName } from "./shared/ur
4
4
  const FILESYSTEM_TOOL_HINTS = {
5
5
  read: {
6
6
  tool: "openviking_read",
7
- example: (uri) => `openviking_read(uri="${uri}")`,
7
+ example: (uri) => `openviking_read(uris=["${uri}"])`,
8
8
  },
9
9
  glob: {
10
10
  tool: "openviking_glob",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",
@@ -46,6 +46,7 @@ function readProxyConfig() {
46
46
  account: cfg.account || "",
47
47
  user: cfg.user || "",
48
48
  peerId: effectivePeer.peerId,
49
+ userAgent: cfg.userAgent || "",
49
50
  timeoutMs: Math.max(1000, Number(cfg.timeoutMs) || DEFAULT_TIMEOUT_MS),
50
51
  debug: cfg.debug === true,
51
52
  debugLogPath: cfg.debugLogPath,
@@ -56,6 +56,7 @@ function makeProxy(url) {
56
56
  account: "acct",
57
57
  user: "user",
58
58
  peerId: "peer",
59
+ userAgent: "openviking-memory-opencode/9.9.9",
59
60
  timeoutMs: 5000,
60
61
  debug: false,
61
62
  debugLogPath: "",
@@ -81,6 +82,7 @@ test("OpenCode MCP proxy forwards initialize with auth and identity headers", as
81
82
  assert.equal(entry.headers["x-openviking-account"], "acct")
82
83
  assert.equal(entry.headers["x-openviking-user"], "user")
83
84
  assert.equal(entry.headers["x-openviking-actor-peer"], "peer")
85
+ assert.equal(entry.headers["user-agent"], "openviking-memory-opencode/9.9.9")
84
86
  res.writeHead(200, {
85
87
  "content-type": "text/event-stream",
86
88
  "mcp-session-id": "sid-oc",