@openviking/opencode-plugin 2026.9.9 → 2026.9.21-1
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 +42 -28
- package/INSTALL.md +41 -27
- package/README.md +47 -33
- package/index.mjs +5 -9
- package/lib/config.mjs +30 -307
- package/lib/memory-recall.mjs +2 -2
- package/lib/memory-session.mjs +6 -5
- package/lib/repo-context.mjs +2 -3
- package/lib/runtime.mjs +12 -19
- package/lib/shared/capture-utils.mjs +145 -6
- package/lib/shared/config-schema.mjs +359 -0
- package/lib/shared/credentials.mjs +238 -102
- package/lib/shared/input-filters.mjs +219 -0
- package/lib/shared/mcp-proxy-config.mjs +154 -2
- package/lib/shared/mcp-proxy-core.mjs +23 -15
- package/lib/shared/ov-http.mjs +112 -0
- package/lib/shared/pending-queue.mjs +41 -5
- package/lib/shared/plugin-config.mjs +300 -0
- package/lib/shared/recall-core.mjs +66 -13
- package/lib/shared/uri-guard.mjs +173 -1
- package/lib/shared/workspace-config.mjs +433 -0
- package/lib/shared/workspace-peer.mjs +63 -0
- package/lib/shared/workspace-registry.mjs +87 -0
- package/lib/utils.mjs +34 -177
- package/lib/viking-uri-guard.mjs +23 -8
- package/package.json +4 -3
- package/servers/mcp-proxy.mjs +7 -27
- package/servers/mcp-proxy.test.mjs +0 -102
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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) {
|
package/lib/memory-recall.mjs
CHANGED
|
@@ -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
|
|
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
|
package/lib/memory-session.mjs
CHANGED
|
@@ -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
|
|
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
|
|
161
|
+
} else if (event.type === "message.updated" && isCaptureEnabled(config)) {
|
|
161
162
|
await handleMessageUpdated(event)
|
|
162
|
-
} else if (event.type === "message.part.updated" && config
|
|
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
|
|
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
|
|
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
|
package/lib/repo-context.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { effectivePeerId, log, makeRequest
|
|
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
|
|
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
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
|
|
3
|
+
|
|
2
4
|
const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
|
|
3
5
|
const TOOL_CALL_TYPES = new Set([
|
|
4
6
|
"tool_call",
|
|
@@ -264,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
|
|
|
264
266
|
return chunks.join("\n\n");
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
|
|
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) {
|
|
268
276
|
const map = {};
|
|
269
277
|
for (const entry of entries || []) {
|
|
270
278
|
const payload = entry?.payload && typeof entry.payload === "object" ? entry.payload : entry;
|
|
@@ -354,6 +362,53 @@ export function extractPartsFromPayload(payload, options = {}) {
|
|
|
354
362
|
return parts;
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
|
|
367
|
+
*
|
|
368
|
+
* One drop verdict is taken per turn, on the aggregate of its text parts; the
|
|
369
|
+
* individual parts are then rewritten with the substitutions only, so a rule can
|
|
370
|
+
* never both keep and drop the same turn. Tool call/result parts are carried
|
|
371
|
+
* through untouched — filters match conversation text, not tool payloads — but a
|
|
372
|
+
* turn dropped on its text takes its tool parts with it.
|
|
373
|
+
*/
|
|
374
|
+
export function filterCaptureParts(parts, role, cfg = {}) {
|
|
375
|
+
const kept = [];
|
|
376
|
+
for (const part of parts || []) {
|
|
377
|
+
if (!part) continue;
|
|
378
|
+
if (part.type !== "text") {
|
|
379
|
+
kept.push(part);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const text = typeof part.text === "string" ? part.text.trim() : "";
|
|
383
|
+
if (!text) continue;
|
|
384
|
+
kept.push(text === part.text ? part : { ...part, text });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const blank = { parts: kept, dropped: false };
|
|
388
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
389
|
+
if (!compiled.rules.length || kept.length === 0) return blank;
|
|
390
|
+
|
|
391
|
+
const textParts = kept.filter((part) => part.type === "text");
|
|
392
|
+
if (textParts.length === 0) return blank;
|
|
393
|
+
|
|
394
|
+
const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
|
|
395
|
+
role,
|
|
396
|
+
});
|
|
397
|
+
if (verdict.dropped) return { parts: [], dropped: true };
|
|
398
|
+
|
|
399
|
+
const out = [];
|
|
400
|
+
for (const part of kept) {
|
|
401
|
+
if (part.type !== "text") {
|
|
402
|
+
out.push(part);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
|
|
406
|
+
if (!shaped.text) continue;
|
|
407
|
+
out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
|
|
408
|
+
}
|
|
409
|
+
return { parts: out, dropped: out.length === 0 };
|
|
410
|
+
}
|
|
411
|
+
|
|
357
412
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
358
413
|
const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
|
|
359
414
|
const turns = [];
|
|
@@ -371,14 +426,37 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
|
371
426
|
toolMaxChars: cfg.captureToolMaxChars,
|
|
372
427
|
toolNameById,
|
|
373
428
|
});
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
429
|
+
const shaped = filterCaptureParts(parts, role, cfg);
|
|
430
|
+
if (shaped.dropped) continue;
|
|
431
|
+
// With parts on the wire the drop decision was already taken above, so the
|
|
432
|
+
// text path only runs the filters for the `content` fallback. That also
|
|
433
|
+
// keeps `turn.text` faithful for callers that scan it for trigger words.
|
|
434
|
+
const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
|
|
435
|
+
if (!decision.shouldCapture && shaped.parts.length === 0) continue;
|
|
376
436
|
const text = decision.shouldCapture ? decision.text : "";
|
|
377
|
-
turns.push({ role, text, parts });
|
|
437
|
+
turns.push({ role, text, parts: shaped.parts });
|
|
378
438
|
}
|
|
379
439
|
return turns;
|
|
380
440
|
}
|
|
381
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
|
+
|
|
382
460
|
export function normalizeCaptureRole(role) {
|
|
383
461
|
const value = normalizeType(role);
|
|
384
462
|
if (value === "user") return "user";
|
|
@@ -457,12 +535,24 @@ function stripInjectedDigestBlocks(text) {
|
|
|
457
535
|
return out.join("\n");
|
|
458
536
|
}
|
|
459
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
|
+
*/
|
|
460
546
|
export function sanitizeCapturedText(text) {
|
|
461
547
|
let value = String(text || "");
|
|
462
548
|
value = value
|
|
463
549
|
.replace(/\u0000/g, "")
|
|
464
550
|
.replace(/<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi, " ")
|
|
465
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, " ")
|
|
466
556
|
.replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
|
|
467
557
|
.replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
|
|
468
558
|
.replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
|
|
@@ -486,12 +576,38 @@ function isPunctuationOnly(text) {
|
|
|
486
576
|
return !/[a-z0-9\u3400-\u9fff]/i.test(text);
|
|
487
577
|
}
|
|
488
578
|
|
|
489
|
-
|
|
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
|
+
|
|
596
|
+
export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
|
|
490
597
|
const maxLength = cfg.captureMaxLength || 24000;
|
|
491
598
|
const sanitized = sanitizeCapturedText(text);
|
|
492
599
|
if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
|
|
493
600
|
|
|
494
|
-
|
|
601
|
+
let capped = truncateCaptureText(sanitized, maxLength);
|
|
602
|
+
if (filters) {
|
|
603
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
604
|
+
if (compiled.rules.length) {
|
|
605
|
+
const verdict = applyInputFilters(capped, compiled.rules, { role });
|
|
606
|
+
if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
|
|
607
|
+
capped = verdict.text;
|
|
608
|
+
if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
495
611
|
const compact = oneLine(capped);
|
|
496
612
|
const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
|
|
497
613
|
|
|
@@ -513,3 +629,26 @@ export function shouldCaptureText(text, role, cfg = {}) {
|
|
|
513
629
|
|
|
514
630
|
return { shouldCapture: true, reason: "ok", text: capped };
|
|
515
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
|
+
}
|