@openviking/opencode-plugin 0.2.2 → 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 +45 -38
- package/lib/runtime.mjs +1 -0
- package/lib/shared/async-writer.mjs +78 -0
- package/lib/shared/batch-send.mjs +121 -0
- 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 +6 -5
- 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
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
enqueue,
|
|
13
13
|
replayPending,
|
|
14
14
|
} from "./shared/pending-queue.mjs"
|
|
15
|
+
import {
|
|
16
|
+
sendSessionMessages,
|
|
17
|
+
} from "./shared/batch-send.mjs"
|
|
18
|
+
import {
|
|
19
|
+
isRetryableFailure,
|
|
20
|
+
} from "./shared/retryable.mjs"
|
|
15
21
|
import {
|
|
16
22
|
log,
|
|
17
23
|
effectivePeerId,
|
|
@@ -26,7 +32,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
26
32
|
let saveTimer = null
|
|
27
33
|
|
|
28
34
|
async function init() {
|
|
29
|
-
await migrateLegacySessionMap()
|
|
35
|
+
if (config.autoCapture) await migrateLegacySessionMap()
|
|
30
36
|
await loadState()
|
|
31
37
|
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
|
|
32
38
|
if (health.ok) {
|
|
@@ -137,9 +143,9 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
137
143
|
await handleSessionCompacted(event)
|
|
138
144
|
} else if (event.type === "session.idle") {
|
|
139
145
|
await handleSessionIdle(event)
|
|
140
|
-
} else if (event.type === "message.updated") {
|
|
146
|
+
} else if (event.type === "message.updated" && config.autoCapture) {
|
|
141
147
|
await handleMessageUpdated(event)
|
|
142
|
-
} else if (event.type === "message.part.updated") {
|
|
148
|
+
} else if (event.type === "message.part.updated" && config.autoCapture) {
|
|
143
149
|
await handleMessagePartUpdated(event)
|
|
144
150
|
}
|
|
145
151
|
}
|
|
@@ -256,7 +262,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
256
262
|
if (!state) return false
|
|
257
263
|
|
|
258
264
|
const added = await flushPendingMessages(opencodeSessionId, state)
|
|
259
|
-
if (commit) {
|
|
265
|
+
if (commit && config.autoCapture) {
|
|
260
266
|
await commitOvSession(state.ovSessionId, { force: true, reason })
|
|
261
267
|
} else if (added > 0) {
|
|
262
268
|
await maybeCommitByThreshold(state)
|
|
@@ -358,7 +364,8 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
async function flushPendingMessages(opencodeSessionId, state) {
|
|
361
|
-
|
|
367
|
+
if (!config.autoCapture) return 0
|
|
368
|
+
const toSend = []
|
|
362
369
|
for (const [messageId, message] of state.messages.entries()) {
|
|
363
370
|
if (message.captured) continue
|
|
364
371
|
const body = buildCapturePayload(message)
|
|
@@ -366,40 +373,45 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
366
373
|
message.captured = true
|
|
367
374
|
continue
|
|
368
375
|
}
|
|
369
|
-
|
|
370
|
-
if (!ok) break
|
|
371
|
-
message.captured = true
|
|
372
|
-
added += 1
|
|
373
|
-
}
|
|
374
|
-
if (added > 0) {
|
|
375
|
-
state.lastActivityAt = Date.now()
|
|
376
|
-
debouncedSaveState()
|
|
376
|
+
toSend.push({ messageId, message, body })
|
|
377
377
|
}
|
|
378
|
-
return
|
|
379
|
-
}
|
|
378
|
+
if (toSend.length === 0) return 0
|
|
380
379
|
|
|
381
|
-
|
|
380
|
+
let added = 0
|
|
382
381
|
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
|
|
383
382
|
if (!health.ok) {
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
for (const item of toSend) {
|
|
384
|
+
const queued = await enqueue("addMessage", state.ovSessionId, item.body)
|
|
385
|
+
if (!queued.ok) break
|
|
386
|
+
item.message.captured = true
|
|
387
|
+
added += 1
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
const res = await sendSessionMessages(
|
|
391
|
+
(endpoint, init = {}, options = {}) => fetchJSON(config, endpoint, init, { timeoutMs: 10000, ...options }),
|
|
392
|
+
state.ovSessionId,
|
|
393
|
+
toSend.map((item) => item.body),
|
|
394
|
+
{ enqueueOnRetryable: true },
|
|
395
|
+
)
|
|
396
|
+
added = res.sent + res.queued
|
|
397
|
+
for (const item of toSend.slice(0, added)) {
|
|
398
|
+
item.message.captured = true
|
|
399
|
+
}
|
|
400
|
+
if (res.failed > 0 || res.enqueueFailed > 0) {
|
|
401
|
+
log("ERROR", "message", "Failed to add message to OpenViking session", {
|
|
402
|
+
openviking_session: state.ovSessionId,
|
|
403
|
+
status: res.lastError?.status,
|
|
404
|
+
error: res.lastError,
|
|
405
|
+
failed: res.failed,
|
|
406
|
+
enqueueFailed: res.enqueueFailed,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
386
409
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}, { timeoutMs: 10000 })
|
|
391
|
-
if (res.ok) return true
|
|
392
|
-
if (isRetryableFailure(res)) {
|
|
393
|
-
const queued = await enqueue("addMessage", ovSessionId, body)
|
|
394
|
-
return Boolean(queued.ok)
|
|
410
|
+
if (added > 0) {
|
|
411
|
+
state.lastActivityAt = Date.now()
|
|
412
|
+
debouncedSaveState()
|
|
395
413
|
}
|
|
396
|
-
|
|
397
|
-
openviking_session: ovSessionId,
|
|
398
|
-
role: body.role,
|
|
399
|
-
status: res.status,
|
|
400
|
-
error: res.error,
|
|
401
|
-
})
|
|
402
|
-
return false
|
|
414
|
+
return added
|
|
403
415
|
}
|
|
404
416
|
|
|
405
417
|
async function maybeCommitByThreshold(state) {
|
|
@@ -466,9 +478,4 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
466
478
|
}
|
|
467
479
|
}
|
|
468
480
|
|
|
469
|
-
function isRetryableFailure(res) {
|
|
470
|
-
if (!res || res.ok) return false
|
|
471
|
-
const status = Number(res.status || 0)
|
|
472
|
-
return !status || status >= 500 || status === 408 || status === 429
|
|
473
|
-
}
|
|
474
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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Async write-path helper for hook-based memory plugins.
|
|
4
|
+
*
|
|
5
|
+
* When `cfg.writePathAsync` is true and we're not already the worker, we:
|
|
6
|
+
* 1. Drain the parent's stdin (the hook payload)
|
|
7
|
+
* 2. Emit the harness-specific approve/no-op response on stdout immediately
|
|
8
|
+
* 3. Spawn a detached clone of ourselves with env OV_HOOK_WORKER=1
|
|
9
|
+
* 4. Feed the drained payload to the worker's stdin, unref, and exit
|
|
10
|
+
*
|
|
11
|
+
* The worker re-enters the same script, maybeDetach returns false (because
|
|
12
|
+
* OV_HOOK_WORKER=1), and the hook's normal synchronous code path reads its
|
|
13
|
+
* own stdin and runs the OV HTTP work with nobody waiting.
|
|
14
|
+
*
|
|
15
|
+
* Call this BEFORE the hook reads stdin itself. When it returns true, the
|
|
16
|
+
* caller must `return` out of main() immediately.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const WORKER_ENV = "OV_HOOK_WORKER";
|
|
22
|
+
|
|
23
|
+
export async function maybeDetach(cfg, { approve }) {
|
|
24
|
+
if (!cfg.writePathAsync) return false;
|
|
25
|
+
if (process.env[WORKER_ENV] === "1") return false;
|
|
26
|
+
|
|
27
|
+
// Drain parent stdin so we can forward to the worker.
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
32
|
+
raw = Buffer.concat(chunks);
|
|
33
|
+
} catch {
|
|
34
|
+
// stdin read failed - let the synchronous path handle it.
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn(process.execPath, [process.argv[1]], {
|
|
41
|
+
detached: true,
|
|
42
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
43
|
+
env: { ...process.env, [WORKER_ENV]: "1" },
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
// spawn failed - fall through to sync mode, but we've already drained
|
|
47
|
+
// stdin so hand the payload back via env var path below.
|
|
48
|
+
process.env.OPENVIKING_HOOK_STDIN_CACHE = raw.toString();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Approve first, then write to the detached child. Ordering matters for
|
|
53
|
+
// harnesses that read stdout before waiting for the hook process to exit.
|
|
54
|
+
approve();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
child.stdin.write(raw);
|
|
58
|
+
child.stdin.end();
|
|
59
|
+
} catch { /* worker may have already exited; nothing useful to recover */ }
|
|
60
|
+
child.unref();
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fallback stdin reader used when the async path drained stdin but spawn
|
|
66
|
+
* failed; hooks should call this instead of reading stdin directly when
|
|
67
|
+
* possible so the sync fallback still works after a failed detach.
|
|
68
|
+
*/
|
|
69
|
+
export async function readHookStdin() {
|
|
70
|
+
if (process.env.OPENVIKING_HOOK_STDIN_CACHE) {
|
|
71
|
+
const cached = process.env.OPENVIKING_HOOK_STDIN_CACHE;
|
|
72
|
+
delete process.env.OPENVIKING_HOOK_STDIN_CACHE;
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
const chunks = [];
|
|
76
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
77
|
+
return Buffer.concat(chunks).toString();
|
|
78
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { enqueue } from "./pending-queue.mjs";
|
|
3
|
+
import { isRetryableFailure } from "./retryable.mjs";
|
|
4
|
+
|
|
5
|
+
export const BATCH_LIMIT = 100;
|
|
6
|
+
|
|
7
|
+
export function isRetryableSendFailure(res) {
|
|
8
|
+
return isRetryableFailure(res);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeResult() {
|
|
12
|
+
return {
|
|
13
|
+
sent: 0,
|
|
14
|
+
queued: 0,
|
|
15
|
+
enqueueFailed: 0,
|
|
16
|
+
failed: 0,
|
|
17
|
+
retryable: false,
|
|
18
|
+
usedBatch: true,
|
|
19
|
+
lastError: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function enqueueRemainder(sessionId, payloads, startIndex, result, retryable, lastError) {
|
|
24
|
+
result.retryable = retryable;
|
|
25
|
+
result.lastError = lastError ?? null;
|
|
26
|
+
|
|
27
|
+
if (!retryable) {
|
|
28
|
+
result.failed += Math.max(0, payloads.length - startIndex);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const baseCreatedAt = Date.now();
|
|
33
|
+
for (let i = startIndex; i < payloads.length; i++) {
|
|
34
|
+
const queued = await enqueue("addMessage", sessionId, payloads[i], {
|
|
35
|
+
createdAt: baseCreatedAt + (i - startIndex),
|
|
36
|
+
});
|
|
37
|
+
if (!queued.ok) {
|
|
38
|
+
// Stop at the first enqueue failure so queued entries stay a contiguous
|
|
39
|
+
// prefix: consumers mark the first sent+queued payloads as captured, so
|
|
40
|
+
// skipping a payload here and queueing a later one would silently drop it.
|
|
41
|
+
result.enqueueFailed += payloads.length - i;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
result.queued++;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function sendSerial(fetchJSON, sessionId, payloads, startIndex, opts, result) {
|
|
50
|
+
result.usedBatch = false;
|
|
51
|
+
const encodedSid = encodeURIComponent(sessionId);
|
|
52
|
+
for (let i = startIndex; i < payloads.length; i++) {
|
|
53
|
+
const res = await fetchJSON(`/api/v1/sessions/${encodedSid}/messages`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: JSON.stringify(payloads[i]),
|
|
56
|
+
});
|
|
57
|
+
if (res?.ok) {
|
|
58
|
+
result.sent++;
|
|
59
|
+
await opts.onSent?.(1);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const retryable = isRetryableSendFailure(res);
|
|
64
|
+
if (opts.enqueueOnRetryable) {
|
|
65
|
+
return enqueueRemainder(sessionId, payloads, i, result, retryable, res?.error ?? res);
|
|
66
|
+
}
|
|
67
|
+
result.retryable = retryable;
|
|
68
|
+
result.lastError = res?.error ?? res ?? null;
|
|
69
|
+
result.failed += payloads.length - i;
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Send add-message payloads in server-sized batches with serial fallback.
|
|
77
|
+
*
|
|
78
|
+
* @param {Function} fetchJSON - (path, init) => { ok, status, result?, error? }
|
|
79
|
+
* @param {string} sessionId - OpenViking session id
|
|
80
|
+
* @param {Array<object>} payloads - sanitized add-message request bodies
|
|
81
|
+
* @param {object} opts
|
|
82
|
+
* @param {boolean} opts.enqueueOnRetryable - enqueue unsent payloads after a retryable failure
|
|
83
|
+
* @param {Function} opts.onSent - called with the number of messages durably sent after each success
|
|
84
|
+
* @returns {Promise<{sent:number,queued:number,enqueueFailed:number,failed:number,retryable:boolean,usedBatch:boolean,lastError:any}>}
|
|
85
|
+
*/
|
|
86
|
+
export async function sendSessionMessages(fetchJSON, sessionId, payloads, opts = {}) {
|
|
87
|
+
const result = makeResult();
|
|
88
|
+
const messages = Array.isArray(payloads) ? payloads : [];
|
|
89
|
+
if (messages.length === 0) return result;
|
|
90
|
+
|
|
91
|
+
const encodedSid = encodeURIComponent(sessionId);
|
|
92
|
+
for (let start = 0; start < messages.length; start += BATCH_LIMIT) {
|
|
93
|
+
const chunk = messages.slice(start, start + BATCH_LIMIT);
|
|
94
|
+
const res = await fetchJSON(`/api/v1/sessions/${encodedSid}/messages/batch`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
body: JSON.stringify({ messages: chunk }),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (res?.ok) {
|
|
100
|
+
result.sent += chunk.length;
|
|
101
|
+
await opts.onSent?.(chunk.length);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const status = Number(res?.status || 0);
|
|
106
|
+
if (status === 404 || status === 405) {
|
|
107
|
+
return sendSerial(fetchJSON, sessionId, messages, start, opts, result);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const retryable = isRetryableSendFailure(res);
|
|
111
|
+
if (opts.enqueueOnRetryable) {
|
|
112
|
+
return enqueueRemainder(sessionId, messages, start, result, retryable, res?.error ?? res);
|
|
113
|
+
}
|
|
114
|
+
result.retryable = retryable;
|
|
115
|
+
result.lastError = res?.error ?? res ?? null;
|
|
116
|
+
result.failed += messages.length - start;
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
@@ -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;
|