@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 +3 -2
- package/INSTALL.md +4 -2
- package/README.md +6 -2
- package/index.mjs +1 -1
- package/lib/config.mjs +33 -5
- package/lib/memory-recall.mjs +14 -5
- package/lib/memory-session.mjs +8 -9
- package/lib/runtime.mjs +1 -0
- package/lib/shared/batch-send.mjs +2 -3
- package/lib/shared/capture-utils.mjs +1 -2
- package/lib/shared/credentials.mjs +21 -0
- package/lib/shared/mcp-proxy-core.mjs +47 -1
- package/lib/shared/pending-queue.mjs +2 -3
- package/lib/shared/plugin-config.mjs +90 -0
- package/lib/shared/recall-compress-core.mjs +195 -0
- package/lib/shared/recall-core.mjs +323 -16
- package/lib/shared/retryable.mjs +9 -0
- package/lib/utils.mjs +1 -0
- package/lib/viking-uri-guard.mjs +1 -1
- package/package.json +1 -1
- package/servers/mcp-proxy.mjs +1 -0
- package/servers/mcp-proxy.test.mjs +2 -0
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
|
-
|
|
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:
|
|
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)
|
|
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) ||
|
|
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
|
-
|
|
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 = []
|
package/lib/memory-recall.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
texts.push(part.text)
|
|
44
53
|
}
|
|
45
54
|
const joined = texts.join(" ").trim()
|
|
46
55
|
return joined || null
|
package/lib/memory-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
281
|
-
|
|
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 ||
|
|
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
|
|
package/lib/viking-uri-guard.mjs
CHANGED
|
@@ -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(
|
|
7
|
+
example: (uri) => `openviking_read(uris=["${uri}"])`,
|
|
8
8
|
},
|
|
9
9
|
glob: {
|
|
10
10
|
tool: "openviking_glob",
|
package/package.json
CHANGED
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -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",
|