@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 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
@@ -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
- let added = 0
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
- const ok = await addMessageToSession(state.ovSessionId, body)
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 added
379
- }
378
+ if (toSend.length === 0) return 0
380
379
 
381
- async function addMessageToSession(ovSessionId, body) {
380
+ let added = 0
382
381
  const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
383
382
  if (!health.ok) {
384
- await enqueue("addMessage", ovSessionId, body)
385
- return true
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
- const res = await fetchJSON(config, `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, {
388
- method: "POST",
389
- body: JSON.stringify(body),
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
- log("ERROR", "message", "Failed to add message to OpenViking session", {
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;