@openviking/opencode-plugin 2026.9.10 → 2026.9.21-2

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/lib/config.mjs CHANGED
@@ -1,84 +1,20 @@
1
- import fs from "fs"
2
1
  import path from "path"
3
2
  import { homedir } from "os"
4
- import { buildUserAgent, readManifestVersion, resolveOpenVikingCredentials } from "./shared/credentials.mjs"
5
- import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs"
6
-
7
- const USER_AGENT = buildUserAgent(
8
- "opencode",
9
- readManifestVersion(new URL("../package.json", import.meta.url)),
10
- )
11
-
12
- const DEFAULT_CONFIG = {
13
- endpoint: "http://127.0.0.1:1933",
14
- apiKey: "",
15
- account: "",
16
- user: "",
17
- peerId: "",
18
- workspacePeer: true,
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",
23
- enabled: true,
24
- mcp: {
25
- enabled: true,
26
- },
27
- timeoutMs: 30000,
28
- runtime: {
29
- dataDir: "",
30
- },
31
- repoContext: {
32
- enabled: true,
33
- cacheTtlMs: 60000,
34
- },
35
- autoRecall: {
36
- enabled: true,
37
- limit: 10,
38
- scoreThreshold: 0.35,
39
- maxContentChars: 500,
40
- preferAbstract: true,
41
- tokenBudget: 2000,
42
- minQueryLength: 3,
43
- },
44
- autoCapture: true,
45
- captureMode: "semantic",
46
- captureMaxLength: 24000,
47
- captureAssistantTurns: true,
48
- captureToolMaxChars: 1000000,
49
- commitTokenThreshold: 20000,
50
- commitKeepRecentCount: 10,
51
- profileTokenBudget: 10000,
52
- resumeContextBudget: 32000,
53
- noAutoInject: false,
54
- bypassSession: false,
55
- bypassSessionPatterns: [],
56
- debug: false,
57
- debugLogPath: path.join(homedir(), ".openviking", "logs", "opencode-plugin.log"),
58
- }
59
-
60
- function cloneDefaultConfig() {
61
- return JSON.parse(JSON.stringify(DEFAULT_CONFIG))
62
- }
63
-
64
- function normalizeNumber(value, fallback, min, max) {
65
- const next = Number(value)
66
- if (!Number.isFinite(next)) return fallback
67
- return Math.max(min, Math.min(max, next))
68
- }
69
-
70
- function envBool(name) {
71
- const value = process.env[name]
72
- if (value == null || value === "") return undefined
73
- const lower = String(value).trim().toLowerCase()
74
- if (lower === "0" || lower === "false" || lower === "no" || lower === "off") return false
75
- if (lower === "1" || lower === "true" || lower === "yes" || lower === "on") return true
76
- return undefined
77
- }
78
-
79
- function str(value, fallback = "") {
80
- return typeof value === "string" && value.trim() ? value.trim() : fallback
81
- }
3
+ import { buildPluginConfig } from "./shared/plugin-config.mjs"
4
+
5
+ /**
6
+ * Configuration for the opencode plugin.
7
+ *
8
+ * This plugin used to keep its own `openviking-config.json`, looked up along
9
+ * four candidate paths. Every knob now comes from the same place as every other
10
+ * harness — `ovcli.conf`'s `plugin` section, overridden by `plugin.opencode`,
11
+ * by the workspace file, and by the environment — so `ov config switch` moves
12
+ * behaviour along with credentials instead of only half of it.
13
+ *
14
+ * What stays here is the three knobs this plugin's own code reads as sections
15
+ * rather than as flat keys.
16
+ */
17
+ const MANIFEST_URL = new URL("../package.json", import.meta.url)
82
18
 
83
19
  function expandHome(value) {
84
20
  if (!value || typeof value !== "string") return value
@@ -87,236 +23,23 @@ function expandHome(value) {
87
23
  return value
88
24
  }
89
25
 
90
- function readConfigFile(pluginRoot, projectDirectory) {
91
- for (const configPath of getConfigPaths(pluginRoot, projectDirectory)) {
92
- try {
93
- if (!fs.existsSync(configPath)) continue
94
- return { path: configPath, data: JSON.parse(fs.readFileSync(configPath, "utf8")) }
95
- } catch (error) {
96
- console.warn(`Failed to load OpenViking config from ${configPath}:`, error)
97
- }
98
- }
99
- return { path: "", data: {} }
100
- }
101
-
102
- function getConfigPaths(pluginRoot, projectDirectory) {
103
- const paths = []
104
- if (process.env.OPENVIKING_PLUGIN_CONFIG) paths.push(expandHome(process.env.OPENVIKING_PLUGIN_CONFIG))
105
- if (projectDirectory) paths.push(path.join(projectDirectory, ".opencode", "openviking-config.json"))
106
- paths.push(path.join(homedir(), ".config", "opencode", "openviking-config.json"))
107
- paths.push(path.join(pluginRoot, "openviking-config.json"))
108
- return paths
109
- }
110
-
111
- function applyLegacyConnection(config, fileConfig) {
112
- const hasLegacyCredentials = ["endpoint", "apiKey", "account", "user", "peerId"]
113
- .some((key) => fileConfig[key] !== undefined && fileConfig[key] !== "")
114
- if (!hasLegacyCredentials) return false
115
-
116
- if (fileConfig.endpoint !== undefined) config.endpoint = fileConfig.endpoint
117
- if (fileConfig.apiKey !== undefined) config.apiKey = fileConfig.apiKey
118
- if (fileConfig.account !== undefined) config.account = fileConfig.account
119
- if (fileConfig.user !== undefined) config.user = fileConfig.user
120
- if (fileConfig.peerId !== undefined) config.peerId = fileConfig.peerId
121
- return true
122
- }
123
-
124
- function applyBehaviorConfig(config, fileConfig = {}) {
125
- if (fileConfig.enabled !== undefined) config.enabled = fileConfig.enabled !== false
126
- const mcp = fileConfig.mcp && typeof fileConfig.mcp === "object" ? fileConfig.mcp : {}
127
- config.mcp = {
128
- ...DEFAULT_CONFIG.mcp,
129
- ...mcp,
130
- enabled: mcp.enabled !== false,
131
- }
132
- if (fileConfig.timeoutMs !== undefined) config.timeoutMs = fileConfig.timeoutMs
133
- config.runtime = {
134
- ...DEFAULT_CONFIG.runtime,
135
- ...(fileConfig.runtime ?? {}),
136
- }
137
- config.repoContext = {
138
- ...DEFAULT_CONFIG.repoContext,
139
- ...(fileConfig.repoContext ?? {}),
140
- }
141
-
142
- const autoRecall = fileConfig.autoRecall ?? {}
143
- config.recallLimitConfigured = autoRecall.limit !== undefined ||
144
- fileConfig.recallLimit !== undefined
145
- config.autoRecall = {
146
- ...DEFAULT_CONFIG.autoRecall,
147
- ...autoRecall,
148
- enabled: autoRecall.enabled !== false,
149
- limit: autoRecall.limit ?? fileConfig.recallLimit ?? DEFAULT_CONFIG.autoRecall.limit,
150
- scoreThreshold: autoRecall.scoreThreshold ?? fileConfig.scoreThreshold ?? DEFAULT_CONFIG.autoRecall.scoreThreshold,
151
- maxContentChars: autoRecall.maxContentChars ?? fileConfig.recallMaxContentChars ?? DEFAULT_CONFIG.autoRecall.maxContentChars,
152
- preferAbstract: autoRecall.preferAbstract ?? fileConfig.recallPreferAbstract ?? DEFAULT_CONFIG.autoRecall.preferAbstract,
153
- tokenBudget: autoRecall.tokenBudget ?? fileConfig.recallTokenBudget ?? DEFAULT_CONFIG.autoRecall.tokenBudget,
154
- minQueryLength: autoRecall.minQueryLength ?? fileConfig.minQueryLength ?? DEFAULT_CONFIG.autoRecall.minQueryLength,
155
- }
26
+ export function loadConfig(pluginRoot, projectDirectory, { env = process.env } = {}) {
27
+ const config = buildPluginConfig("opencode", {
28
+ cwd: projectDirectory,
29
+ env,
30
+ manifestUrl: MANIFEST_URL,
31
+ logFile: "opencode-plugin.log",
32
+ deriveEffectivePeer: true,
33
+ })
156
34
 
157
- for (const key of [
158
- "autoCapture",
159
- "captureMode",
160
- "captureMaxLength",
161
- "captureAssistantTurns",
162
- "captureToolMaxChars",
163
- "commitTokenThreshold",
164
- "commitKeepRecentCount",
165
- "profileTokenBudget",
166
- "resumeContextBudget",
167
- "noAutoInject",
168
- "bypassSession",
169
- "bypassSessionPatterns",
170
- "debug",
171
- "debugLogPath",
172
- "workspacePeer",
173
- "recallPeerScope",
174
- "recallQueryExpansion",
175
- ]) {
176
- if (fileConfig[key] !== undefined) config[key] = fileConfig[key]
35
+ return {
36
+ ...config,
37
+ // The MCP registration, the runtime data directory, and the repo context
38
+ // cache.
39
+ mcp: { enabled: config.mcpEnabled },
40
+ runtime: { dataDir: config.dataDir },
41
+ repoContext: { enabled: config.repoContext, cacheTtlMs: config.repoContextCacheTtlMs },
177
42
  }
178
- config.recallQueryExpansionConfigured = fileConfig.recallQueryExpansion !== undefined
179
- }
180
-
181
- function applyEnv(config) {
182
- if (process.env.OPENVIKING_TIMEOUT_MS) config.timeoutMs = process.env.OPENVIKING_TIMEOUT_MS
183
- if (process.env.OPENVIKING_AUTO_RECALL !== undefined) {
184
- config.autoRecall.enabled = envBool("OPENVIKING_AUTO_RECALL") ?? config.autoRecall.enabled
185
- }
186
- if (process.env.OPENVIKING_RECALL_LIMIT) {
187
- config.autoRecall.limit = process.env.OPENVIKING_RECALL_LIMIT
188
- config.recallLimitConfigured = true
189
- }
190
- if (process.env.OPENVIKING_SCORE_THRESHOLD) config.autoRecall.scoreThreshold = process.env.OPENVIKING_SCORE_THRESHOLD
191
- if (process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS) {
192
- config.autoRecall.maxContentChars = process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS
193
- }
194
- if (process.env.OPENVIKING_RECALL_TOKEN_BUDGET) config.autoRecall.tokenBudget = process.env.OPENVIKING_RECALL_TOKEN_BUDGET
195
- if (process.env.OPENVIKING_RECALL_PREFER_ABSTRACT !== undefined) {
196
- config.autoRecall.preferAbstract = envBool("OPENVIKING_RECALL_PREFER_ABSTRACT") ?? config.autoRecall.preferAbstract
197
- }
198
- if (process.env.OPENVIKING_RECALL_PEER_SCOPE) config.recallPeerScope = process.env.OPENVIKING_RECALL_PEER_SCOPE
199
- if (process.env.OPENVIKING_RECALL_QUERY_EXPANSION) {
200
- config.recallQueryExpansion = process.env.OPENVIKING_RECALL_QUERY_EXPANSION
201
- config.recallQueryExpansionConfigured = true
202
- }
203
- if (process.env.OPENVIKING_WORKSPACE_PEER !== undefined) {
204
- config.workspacePeer = envBool("OPENVIKING_WORKSPACE_PEER") ?? config.workspacePeer
205
- }
206
- if (process.env.OPENVIKING_MIN_QUERY_LENGTH) config.autoRecall.minQueryLength = process.env.OPENVIKING_MIN_QUERY_LENGTH
207
- if (process.env.OPENVIKING_AUTO_CAPTURE !== undefined) {
208
- config.autoCapture = envBool("OPENVIKING_AUTO_CAPTURE") ?? config.autoCapture
209
- }
210
- if (process.env.OPENVIKING_CAPTURE_MODE) config.captureMode = process.env.OPENVIKING_CAPTURE_MODE
211
- if (process.env.OPENVIKING_CAPTURE_MAX_LENGTH) config.captureMaxLength = process.env.OPENVIKING_CAPTURE_MAX_LENGTH
212
- if (process.env.OPENVIKING_CAPTURE_ASSISTANT_TURNS !== undefined) {
213
- config.captureAssistantTurns = envBool("OPENVIKING_CAPTURE_ASSISTANT_TURNS") ?? config.captureAssistantTurns
214
- }
215
- if (process.env.OPENVIKING_CAPTURE_TOOL_MAX_CHARS) {
216
- config.captureToolMaxChars = process.env.OPENVIKING_CAPTURE_TOOL_MAX_CHARS
217
- }
218
- if (process.env.OPENVIKING_COMMIT_TOKEN_THRESHOLD) {
219
- config.commitTokenThreshold = process.env.OPENVIKING_COMMIT_TOKEN_THRESHOLD
220
- }
221
- if (process.env.OPENVIKING_COMMIT_KEEP_RECENT_COUNT) {
222
- config.commitKeepRecentCount = process.env.OPENVIKING_COMMIT_KEEP_RECENT_COUNT
223
- }
224
- if (process.env.OPENVIKING_PROFILE_TOKEN_BUDGET) config.profileTokenBudget = process.env.OPENVIKING_PROFILE_TOKEN_BUDGET
225
- if (process.env.OPENVIKING_RESUME_CONTEXT_BUDGET) config.resumeContextBudget = process.env.OPENVIKING_RESUME_CONTEXT_BUDGET
226
- if (process.env.OPENVIKING_NO_AUTO_INJECT !== undefined) {
227
- config.noAutoInject = envBool("OPENVIKING_NO_AUTO_INJECT") ?? config.noAutoInject
228
- }
229
- if (process.env.OPENVIKING_BYPASS_SESSION !== undefined) {
230
- config.bypassSession = envBool("OPENVIKING_BYPASS_SESSION") ?? config.bypassSession
231
- }
232
- if (process.env.OPENVIKING_BYPASS_SESSION_PATTERNS) {
233
- config.bypassSessionPatterns = process.env.OPENVIKING_BYPASS_SESSION_PATTERNS
234
- .split(",")
235
- .map((item) => item.trim())
236
- .filter(Boolean)
237
- }
238
- if (process.env.OPENVIKING_DEBUG !== undefined) config.debug = envBool("OPENVIKING_DEBUG") ?? config.debug
239
- if (process.env.OPENVIKING_DEBUG_LOG) config.debugLogPath = process.env.OPENVIKING_DEBUG_LOG
240
- }
241
-
242
- function normalizeConfig(config) {
243
- config.endpoint = str(config.endpoint, DEFAULT_CONFIG.endpoint).replace(/\/+$/, "")
244
- config.baseUrl = config.endpoint
245
- config.accountId = config.account
246
- config.userId = config.user
247
- config.userAgent = USER_AGENT
248
- config.harness = "opencode"
249
- config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000)
250
- config.repoContext.cacheTtlMs = normalizeNumber(
251
- config.repoContext.cacheTtlMs,
252
- DEFAULT_CONFIG.repoContext.cacheTtlMs,
253
- 1000,
254
- 60 * 60 * 1000,
255
- )
256
- config.autoRecall.limit = Math.max(1, Math.min(50, Math.round(Number(config.autoRecall.limit) || 10)))
257
- config.autoRecall.scoreThreshold = Math.max(0, Math.min(1, Number(config.autoRecall.scoreThreshold) || 0))
258
- config.autoRecall.maxContentChars = Math.max(100, Math.min(5000, Math.round(Number(config.autoRecall.maxContentChars) || 500)))
259
- config.autoRecall.tokenBudget = Math.max(200, Math.min(50000, Math.round(Number(config.autoRecall.tokenBudget) || 2000)))
260
- config.autoRecall.minQueryLength = Math.max(1, Math.min(64, Math.round(Number(config.autoRecall.minQueryLength) || 3)))
261
- config.captureMode = config.captureMode === "keyword" ? "keyword" : "semantic"
262
- config.recallPeerScope = config.recallPeerScope === "actor" ? "actor" : "all"
263
- config.recallQueryExpansion = config.recallQueryExpansion === "off" ? "off" : "auto"
264
- config.captureMaxLength = Math.max(200, Math.min(100000, Math.round(Number(config.captureMaxLength) || 24000)))
265
- config.captureToolMaxChars = Math.max(200, Math.min(1000000, Math.round(Number(config.captureToolMaxChars) || 1000000)))
266
- config.commitTokenThreshold = Math.max(1000, Math.round(Number(config.commitTokenThreshold) || 20000))
267
- const rawCommitKeepRecentCount = config.commitKeepRecentCount
268
- const commitKeepRecentCount = rawCommitKeepRecentCount == null ||
269
- (typeof rawCommitKeepRecentCount === "string" && rawCommitKeepRecentCount.trim() === "")
270
- ? Number.NaN
271
- : Number(rawCommitKeepRecentCount)
272
- config.commitKeepRecentCount = Number.isFinite(commitKeepRecentCount)
273
- ? Math.max(0, Math.round(commitKeepRecentCount))
274
- : DEFAULT_CONFIG.commitKeepRecentCount
275
- config.profileTokenBudget = Math.max(500, Math.round(Number(config.profileTokenBudget) || 10000))
276
- config.resumeContextBudget = Math.max(1024, Math.round(Number(config.resumeContextBudget) || 32000))
277
- if (!Array.isArray(config.bypassSessionPatterns)) config.bypassSessionPatterns = []
278
-
279
- config.recallLimit = config.autoRecall.limit
280
- config.scoreThreshold = config.autoRecall.scoreThreshold
281
- config.recallMaxContentChars = config.autoRecall.maxContentChars
282
- config.recallPreferAbstract = config.autoRecall.preferAbstract !== false
283
- config.recallTokenBudget = config.autoRecall.tokenBudget
284
- config.minQueryLength = config.autoRecall.minQueryLength
285
- return config
286
- }
287
-
288
- export function loadConfig(pluginRoot, projectDirectory) {
289
- const config = cloneDefaultConfig()
290
- const { path: configPath, data: fileConfig } = readConfigFile(pluginRoot, projectDirectory)
291
- applyBehaviorConfig(config, fileConfig)
292
-
293
- const creds = resolveOpenVikingCredentials()
294
- config.endpoint = creds.baseUrl
295
- config.apiKey = creds.apiKey
296
- config.account = creds.account
297
- config.user = creds.user
298
- // Shared credentials only carry a peer when ovcli.conf sets actor_peer_id
299
- // (or OPENVIKING_PEER_ID is exported); without one the project config's
300
- // peerId still applies so authenticated setups keep writing peer-scoped
301
- // data instead of silently dropping into the shared user tree (#4487).
302
- config.peerId = creds.peerId || str(fileConfig.peerId, "")
303
- config.mcpUrl = creds.mcpUrl
304
- config.credentialSource = creds.credentialSource
305
- config.credentialPath = creds.cliPath || creds.ovPath || ""
306
-
307
- const mayUseLegacyCredentials = creds.credentialSource !== "env" &&
308
- creds.credentialSource !== "ovcli" &&
309
- !creds.apiKey &&
310
- !creds.account &&
311
- !creds.user &&
312
- !creds.peerId
313
- const legacyUsed = mayUseLegacyCredentials ? applyLegacyConnection(config, fileConfig) : false
314
- applyEnv(config)
315
- config.configPath = configPath
316
- config.legacyCredentialsUsed = legacyUsed && creds.credentialSource !== "env"
317
- const normalized = normalizeConfig(config)
318
- normalized.effectivePeer = resolveEffectivePeerId({ cfg: normalized, cwd: projectDirectory })
319
- return normalized
320
43
  }
321
44
 
322
45
  export function resolveDataDir(pluginRoot, config) {
@@ -1,10 +1,10 @@
1
- import { buildRecallBlock } from "./shared/recall-core.mjs"
1
+ import { buildRecallBlock, isRecallEnabled } 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
5
  export function createMemoryRecall({ config, sessionManager }) {
6
6
  async function injectRelevantMemories(input, output) {
7
- if (!config.autoRecall?.enabled) return
7
+ if (!isRecallEnabled(config)) return
8
8
  const query = extractCurrentUserText(output.parts ?? [])
9
9
  if (!query) return
10
10
  if (query.length < config.minQueryLength) return
@@ -3,6 +3,7 @@ import path from "path"
3
3
  import {
4
4
  extractPartsFromPayload,
5
5
  extractTextFromPayload,
6
+ isCaptureEnabled,
6
7
  shouldCaptureText,
7
8
  } from "./shared/capture-utils.mjs"
8
9
  import {
@@ -46,7 +47,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
46
47
  }
47
48
 
48
49
  async function init() {
49
- if (config.autoCapture) await migrateLegacySessionMap()
50
+ if (isCaptureEnabled(config)) await migrateLegacySessionMap()
50
51
  await loadState()
51
52
  const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
52
53
  if (health.ok) {
@@ -157,9 +158,9 @@ export function createMemorySessionManager({ config, pluginRoot }) {
157
158
  await handleSessionCompacted(event)
158
159
  } else if (event.type === "session.idle") {
159
160
  await handleSessionIdle(event)
160
- } else if (event.type === "message.updated" && config.autoCapture) {
161
+ } else if (event.type === "message.updated" && isCaptureEnabled(config)) {
161
162
  await handleMessageUpdated(event)
162
- } else if (event.type === "message.part.updated" && config.autoCapture) {
163
+ } else if (event.type === "message.part.updated" && isCaptureEnabled(config)) {
163
164
  await handleMessagePartUpdated(event)
164
165
  }
165
166
  }
@@ -286,7 +287,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
286
287
  if (!state) return false
287
288
 
288
289
  const added = await flushPendingMessages(opencodeSessionId, state)
289
- if (commit && config.autoCapture) {
290
+ if (commit && isCaptureEnabled(config)) {
290
291
  await commitOvSession(state.ovSessionId, { force: true, reason })
291
292
  } else if (added > 0) {
292
293
  await maybeCommitByThreshold(state)
@@ -388,7 +389,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
388
389
  }
389
390
 
390
391
  async function flushPendingMessages(opencodeSessionId, state) {
391
- if (!config.autoCapture) return 0
392
+ if (!isCaptureEnabled(config)) return 0
392
393
  const toSend = []
393
394
  for (const [messageId, message] of state.messages.entries()) {
394
395
  if (message.captured) continue
@@ -1,4 +1,4 @@
1
- import { effectivePeerId, log, makeRequest, unwrapResponse } from "./utils.mjs"
1
+ import { effectivePeerId, log, makeRequest } from "./utils.mjs"
2
2
 
3
3
  export function createRepoContext({ config }) {
4
4
  let cachedRepos = null
@@ -14,13 +14,12 @@ export function createRepoContext({ config }) {
14
14
  }
15
15
 
16
16
  try {
17
- const response = await makeRequest(config, {
17
+ const result = await makeRequest(config, {
18
18
  method: "GET",
19
19
  endpoint: `/api/v1/fs/ls?uri=${encodeURIComponent("viking://resources/")}&recursive=false&simple=false`,
20
20
  timeoutMs: 8000,
21
21
  actorPeerId: effectivePeerId(config),
22
22
  })
23
- const result = unwrapResponse(response)
24
23
  const items = Array.isArray(result) ? result : []
25
24
  const repos = items
26
25
  .filter((item) => item?.uri?.startsWith("viking://resources/") && item.uri !== "viking://resources/")
package/lib/runtime.mjs CHANGED
@@ -1,24 +1,17 @@
1
- import { log, makeToast, normalizeEndpoint } from "./utils.mjs"
1
+ import { fetchJSON, getResponseErrorMessage, log, makeToast } from "./utils.mjs"
2
2
 
3
+ // The probe used to send a User-Agent and nothing else, so a server that wants
4
+ // a key reported itself as down and the plugin disabled itself on startup.
3
5
  export async function checkServiceHealth(config, timeoutMs = 3000) {
4
- const controller = new AbortController()
5
- const timeout = setTimeout(() => controller.abort(), timeoutMs)
6
- try {
7
- const response = await fetch(`${normalizeEndpoint(config.endpoint)}/health`, {
8
- method: "GET",
9
- headers: config.userAgent ? { "User-Agent": config.userAgent } : undefined,
10
- signal: controller.signal,
11
- })
12
- return response.ok
13
- } catch (error) {
14
- log("WARN", "health", "OpenViking health check failed", {
15
- endpoint: config.endpoint,
16
- error: error?.message,
17
- })
18
- return false
19
- } finally {
20
- clearTimeout(timeout)
21
- }
6
+ const res = await fetchJSON(config, "/health", { method: "GET" }, { timeoutMs })
7
+ if (res.ok) return true
8
+
9
+ log("WARN", "health", "OpenViking health check failed", {
10
+ endpoint: config.endpoint,
11
+ status: res.status,
12
+ error: getResponseErrorMessage(res.error),
13
+ })
14
+ return false
22
15
  }
23
16
 
24
17
  export async function initializeRuntime(config, client) {
@@ -266,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
266
266
  return chunks.join("\n\n");
267
267
  }
268
268
 
269
- function collectToolNamesByIdFromEntries(entries) {
269
+ /**
270
+ * Map every tool call id in a transcript to the name of the tool it invoked.
271
+ *
272
+ * A result block names the call by id only, so the name has to come from the
273
+ * call that preceded it. Pass the map as `toolNameById` to the extractors.
274
+ */
275
+ export function collectToolNamesByIdFromEntries(entries) {
270
276
  const map = {};
271
277
  for (const entry of entries || []) {
272
278
  const payload = entry?.payload && typeof entry.payload === "object" ? entry.payload : entry;
@@ -433,6 +439,24 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
433
439
  return turns;
434
440
  }
435
441
 
442
+ /**
443
+ * Index of the last turn that came from a human prompt, or -1.
444
+ *
445
+ * `role === "user"` alone is not enough: normalizeCaptureRole() maps tool
446
+ * results onto the user role too, and those carry `tool` parts rather than
447
+ * `text` parts. Used by the post-compact shrink path to find where the current
448
+ * interaction starts.
449
+ */
450
+ export function findLastHumanTurnIndex(turns) {
451
+ const list = Array.isArray(turns) ? turns : [];
452
+ for (let i = list.length - 1; i >= 0; i -= 1) {
453
+ const turn = list[i];
454
+ if (turn?.role !== "user") continue;
455
+ if (turn.parts?.some((part) => part?.type === "text")) return i;
456
+ }
457
+ return -1;
458
+ }
459
+
436
460
  export function normalizeCaptureRole(role) {
437
461
  const value = normalizeType(role);
438
462
  if (value === "user") return "user";
@@ -511,12 +535,24 @@ function stripInjectedDigestBlocks(text) {
511
535
  return out.join("\n");
512
536
  }
513
537
 
538
+ /**
539
+ * Drop everything in a turn that the conversation did not put there.
540
+ *
541
+ * Recall injects a context block into the prompt, and the host adds notes of
542
+ * its own; captured back unchanged, this turn's injection becomes next turn's
543
+ * memory and the loop feeds on itself. Formatting the conversation did author
544
+ * — newlines, code fences — survives.
545
+ */
514
546
  export function sanitizeCapturedText(text) {
515
547
  let value = String(text || "");
516
548
  value = value
517
549
  .replace(/\u0000/g, "")
518
550
  .replace(/<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi, " ")
519
551
  .replace(/<relevant-memor(?:y|ies)\b[^>]*>[\s\S]*?<\/relevant-memor(?:y|ies)>/gi, " ")
552
+ // Claude Code wraps its own out-of-band notes to the model in these two
553
+ // shapes. They are the host talking to itself, not the conversation.
554
+ .replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi, " ")
555
+ .replace(/^[ \t]*\[Subagent Context\][^\n]*$/gim, " ")
520
556
  .replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
521
557
  .replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
522
558
  .replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
@@ -540,6 +576,23 @@ function isPunctuationOnly(text) {
540
576
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
541
577
  }
542
578
 
579
+ /**
580
+ * Is capture on for this config?
581
+ *
582
+ * The switch has four spellings in the wild: a boolean `autoCapture`, opencode's
583
+ * `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
584
+ * that turns the whole plugin off. Reading it here rather than in each loader
585
+ * is what keeps it from drifting a fifth time — and any spelling that says off
586
+ * wins, so a config that disables capture under an older name still disables it.
587
+ */
588
+ export function isCaptureEnabled(cfg = {}) {
589
+ for (const value of [cfg.enabled, cfg.autoCapture, cfg.capture, cfg.syncTurns]) {
590
+ if (value === false) return false;
591
+ if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
592
+ }
593
+ return true;
594
+ }
595
+
543
596
  export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
544
597
  const maxLength = cfg.captureMaxLength || 24000;
545
598
  const sanitized = sanitizeCapturedText(text);
@@ -576,3 +629,26 @@ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {})
576
629
 
577
630
  return { shouldCapture: true, reason: "ok", text: capped };
578
631
  }
632
+
633
+ /**
634
+ * Apply the capture filter to a list of `{ role, content }` turns.
635
+ *
636
+ * The harnesses that compose `agent-hook-runtime` used to send whatever their
637
+ * transcript parser produced: an acknowledgement, a slash command, a stray
638
+ * `ok`, or a turn far past `captureMaxLength` all reached the extractor
639
+ * verbatim. This is the same decision every other harness makes, in one place,
640
+ * so a thin harness gets it by calling rather than by reimplementing it.
641
+ *
642
+ * Returns the surviving turns with `content` replaced by the sanitized and
643
+ * capped text, and the dropped ones with the reason, for the debug log.
644
+ */
645
+ export function filterCaptureTurns(turns, cfg = {}) {
646
+ const kept = [];
647
+ const dropped = [];
648
+ for (const turn of Array.isArray(turns) ? turns : []) {
649
+ const decision = shouldCaptureText(turn?.content, turn?.role, cfg);
650
+ if (decision.shouldCapture) kept.push({ ...turn, content: decision.text });
651
+ else dropped.push({ role: turn?.role, reason: decision.reason });
652
+ }
653
+ return { kept, dropped };
654
+ }