@openviking/opencode-plugin 0.1.6 → 0.2.0
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 +50 -49
- package/INSTALL.md +50 -49
- package/README.md +40 -123
- package/index.mjs +20 -12
- package/lib/config.mjs +272 -0
- package/lib/mcp-config.mjs +21 -0
- package/lib/memory-recall.mjs +21 -172
- package/lib/memory-session.mjs +301 -478
- package/lib/repo-context.mjs +5 -5
- package/lib/session-inject.mjs +71 -0
- package/lib/shared/capture-utils.mjs +500 -0
- package/lib/shared/credentials.mjs +207 -0
- package/lib/shared/debug-log.mjs +58 -0
- package/lib/shared/mcp-proxy-core.mjs +451 -0
- package/lib/shared/pending-queue.mjs +439 -0
- package/lib/shared/profile-inject.mjs +294 -0
- package/lib/shared/recall-core.mjs +282 -0
- package/lib/shared/session-model.mjs +57 -0
- package/lib/shared/setup-wizard.mjs +111 -0
- package/lib/shared/uri-guard.mjs +56 -0
- package/lib/utils.mjs +33 -116
- package/lib/viking-uri-guard.mjs +9 -24
- package/package.json +8 -7
- package/scripts/setup.mjs +8 -0
- package/servers/mcp-proxy.mjs +67 -0
- package/servers/mcp-proxy.test.mjs +100 -0
- package/lib/code-tools.mjs +0 -134
- package/lib/memadd-local.mjs +0 -118
- package/lib/memory-tools.mjs +0 -410
package/index.mjs
CHANGED
|
@@ -3,17 +3,15 @@ import { fileURLToPath } from "url"
|
|
|
3
3
|
import { initializeRuntime } from "./lib/runtime.mjs"
|
|
4
4
|
import { createRepoContext } from "./lib/repo-context.mjs"
|
|
5
5
|
import { createMemorySessionManager } from "./lib/memory-session.mjs"
|
|
6
|
-
import { createCodeTools } from "./lib/code-tools.mjs"
|
|
7
|
-
import { createMemoryTools } from "./lib/memory-tools.mjs"
|
|
8
6
|
import { createMemoryRecall } from "./lib/memory-recall.mjs"
|
|
7
|
+
import { createSessionInject } from "./lib/session-inject.mjs"
|
|
9
8
|
import { createVikingUriGuard } from "./lib/viking-uri-guard.mjs"
|
|
10
|
-
import {
|
|
9
|
+
import { injectOpenVikingMcpConfig } from "./lib/mcp-config.mjs"
|
|
10
|
+
import { loadConfig, resolveDataDir } from "./lib/config.mjs"
|
|
11
|
+
import { initLogger, log, makeToast } from "./lib/utils.mjs"
|
|
11
12
|
|
|
12
13
|
const pluginRoot = dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
* @type {import('@opencode-ai/plugin').Plugin}
|
|
16
|
-
*/
|
|
17
15
|
export async function OpenVikingPlugin({ client, directory }) {
|
|
18
16
|
const config = loadConfig(pluginRoot, directory)
|
|
19
17
|
const dataDir = resolveDataDir(pluginRoot, config)
|
|
@@ -27,11 +25,17 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
27
25
|
const repoContext = createRepoContext({ config })
|
|
28
26
|
const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir })
|
|
29
27
|
const recall = createMemoryRecall({ config })
|
|
28
|
+
const sessionInject = createSessionInject({ config, sessionManager })
|
|
30
29
|
const vikingUriGuard = createVikingUriGuard()
|
|
31
|
-
const tools = createMemoryTools({ config, sessionManager, projectDirectory: directory })
|
|
32
|
-
const codeTools = createCodeTools({ config })
|
|
33
30
|
|
|
34
31
|
await sessionManager.init()
|
|
32
|
+
const toast = makeToast(client)
|
|
33
|
+
if (config.legacyCredentialsUsed) {
|
|
34
|
+
log("WARN", "config", "Legacy OpenCode credential fields are still in use; run node scripts/setup.mjs to migrate to ovcli.conf", {
|
|
35
|
+
configPath: config.configPath,
|
|
36
|
+
})
|
|
37
|
+
await toast("OpenViking credentials in openviking-config.json are deprecated. Run node scripts/setup.mjs to migrate.", "warning")
|
|
38
|
+
}
|
|
35
39
|
|
|
36
40
|
Promise.resolve().then(async () => {
|
|
37
41
|
const ready = await initializeRuntime(config, client)
|
|
@@ -39,6 +43,11 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
39
43
|
})
|
|
40
44
|
|
|
41
45
|
return {
|
|
46
|
+
config: async (opencodeConfig) => {
|
|
47
|
+
const injected = injectOpenVikingMcpConfig(opencodeConfig, pluginRoot)
|
|
48
|
+
log(injected ? "INFO" : "WARN", "mcp", injected ? "Registered OpenViking MCP server" : "OpenViking MCP server was not registered")
|
|
49
|
+
},
|
|
50
|
+
|
|
42
51
|
event: async ({ event }) => {
|
|
43
52
|
await sessionManager.handleEvent(event)
|
|
44
53
|
if (event?.type === "session.created") {
|
|
@@ -48,8 +57,6 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
48
57
|
|
|
49
58
|
"tool.execute.before": vikingUriGuard,
|
|
50
59
|
|
|
51
|
-
tool: { ...tools, ...codeTools },
|
|
52
|
-
|
|
53
60
|
"experimental.chat.system.transform": (_input, output) => {
|
|
54
61
|
const prompt = repoContext.getRepoSystemPrompt()
|
|
55
62
|
if (prompt) output.system.push(prompt)
|
|
@@ -57,6 +64,7 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
57
64
|
|
|
58
65
|
"chat.message": async (input, output) => {
|
|
59
66
|
try {
|
|
67
|
+
await sessionInject.injectSessionContext(input, output)
|
|
60
68
|
if (!config.autoRecall?.enabled) return
|
|
61
69
|
await recall.injectRelevantMemories(input, output)
|
|
62
70
|
} catch (error) {
|
|
@@ -74,9 +82,9 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
74
82
|
})
|
|
75
83
|
},
|
|
76
84
|
|
|
77
|
-
|
|
85
|
+
dispose: async () => {
|
|
78
86
|
await sessionManager.flushAll({ commit: true })
|
|
79
|
-
log("INFO", "plugin", "OpenViking plugin
|
|
87
|
+
log("INFO", "plugin", "OpenViking plugin disposed")
|
|
80
88
|
},
|
|
81
89
|
}
|
|
82
90
|
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { homedir } from "os"
|
|
4
|
+
import { resolveOpenVikingCredentials } from "./shared/credentials.mjs"
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG = {
|
|
7
|
+
endpoint: "http://127.0.0.1:1933",
|
|
8
|
+
apiKey: "",
|
|
9
|
+
account: "",
|
|
10
|
+
user: "",
|
|
11
|
+
peerId: "",
|
|
12
|
+
enabled: true,
|
|
13
|
+
timeoutMs: 30000,
|
|
14
|
+
runtime: {
|
|
15
|
+
dataDir: "",
|
|
16
|
+
},
|
|
17
|
+
repoContext: {
|
|
18
|
+
enabled: true,
|
|
19
|
+
cacheTtlMs: 60000,
|
|
20
|
+
},
|
|
21
|
+
autoRecall: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
limit: 6,
|
|
24
|
+
scoreThreshold: 0.35,
|
|
25
|
+
maxContentChars: 500,
|
|
26
|
+
preferAbstract: true,
|
|
27
|
+
tokenBudget: 2000,
|
|
28
|
+
minQueryLength: 3,
|
|
29
|
+
},
|
|
30
|
+
autoCapture: true,
|
|
31
|
+
captureMode: "semantic",
|
|
32
|
+
captureMaxLength: 24000,
|
|
33
|
+
captureAssistantTurns: true,
|
|
34
|
+
captureToolMaxChars: 2000,
|
|
35
|
+
commitTokenThreshold: 20000,
|
|
36
|
+
commitKeepRecentCount: 10,
|
|
37
|
+
profileTokenBudget: 10000,
|
|
38
|
+
resumeContextBudget: 32000,
|
|
39
|
+
noAutoInject: false,
|
|
40
|
+
bypassSession: false,
|
|
41
|
+
bypassSessionPatterns: [],
|
|
42
|
+
debug: false,
|
|
43
|
+
debugLogPath: path.join(homedir(), ".openviking", "logs", "opencode-plugin.log"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cloneDefaultConfig() {
|
|
47
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeNumber(value, fallback, min, max) {
|
|
51
|
+
const next = Number(value)
|
|
52
|
+
if (!Number.isFinite(next)) return fallback
|
|
53
|
+
return Math.max(min, Math.min(max, next))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function envBool(name) {
|
|
57
|
+
const value = process.env[name]
|
|
58
|
+
if (value == null || value === "") return undefined
|
|
59
|
+
const lower = String(value).trim().toLowerCase()
|
|
60
|
+
if (lower === "0" || lower === "false" || lower === "no" || lower === "off") return false
|
|
61
|
+
if (lower === "1" || lower === "true" || lower === "yes" || lower === "on") return true
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function str(value, fallback = "") {
|
|
66
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function expandHome(value) {
|
|
70
|
+
if (!value || typeof value !== "string") return value
|
|
71
|
+
if (value === "~") return homedir()
|
|
72
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2))
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readConfigFile(pluginRoot, projectDirectory) {
|
|
77
|
+
for (const configPath of getConfigPaths(pluginRoot, projectDirectory)) {
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.existsSync(configPath)) continue
|
|
80
|
+
return { path: configPath, data: JSON.parse(fs.readFileSync(configPath, "utf8")) }
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(`Failed to load OpenViking config from ${configPath}:`, error)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { path: "", data: {} }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getConfigPaths(pluginRoot, projectDirectory) {
|
|
89
|
+
const paths = []
|
|
90
|
+
if (process.env.OPENVIKING_PLUGIN_CONFIG) paths.push(expandHome(process.env.OPENVIKING_PLUGIN_CONFIG))
|
|
91
|
+
if (projectDirectory) paths.push(path.join(projectDirectory, ".opencode", "openviking-config.json"))
|
|
92
|
+
paths.push(path.join(homedir(), ".config", "opencode", "openviking-config.json"))
|
|
93
|
+
paths.push(path.join(pluginRoot, "openviking-config.json"))
|
|
94
|
+
return paths
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyLegacyConnection(config, fileConfig) {
|
|
98
|
+
const hasLegacyCredentials = ["endpoint", "apiKey", "account", "user", "peerId"]
|
|
99
|
+
.some((key) => fileConfig[key] !== undefined && fileConfig[key] !== "")
|
|
100
|
+
if (!hasLegacyCredentials) return false
|
|
101
|
+
|
|
102
|
+
if (fileConfig.endpoint !== undefined) config.endpoint = fileConfig.endpoint
|
|
103
|
+
if (fileConfig.apiKey !== undefined) config.apiKey = fileConfig.apiKey
|
|
104
|
+
if (fileConfig.account !== undefined) config.account = fileConfig.account
|
|
105
|
+
if (fileConfig.user !== undefined) config.user = fileConfig.user
|
|
106
|
+
if (fileConfig.peerId !== undefined) config.peerId = fileConfig.peerId
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyBehaviorConfig(config, fileConfig = {}) {
|
|
111
|
+
if (fileConfig.enabled !== undefined) config.enabled = fileConfig.enabled !== false
|
|
112
|
+
if (fileConfig.timeoutMs !== undefined) config.timeoutMs = fileConfig.timeoutMs
|
|
113
|
+
config.runtime = {
|
|
114
|
+
...DEFAULT_CONFIG.runtime,
|
|
115
|
+
...(fileConfig.runtime ?? {}),
|
|
116
|
+
}
|
|
117
|
+
config.repoContext = {
|
|
118
|
+
...DEFAULT_CONFIG.repoContext,
|
|
119
|
+
...(fileConfig.repoContext ?? {}),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const autoRecall = fileConfig.autoRecall ?? {}
|
|
123
|
+
config.autoRecall = {
|
|
124
|
+
...DEFAULT_CONFIG.autoRecall,
|
|
125
|
+
...autoRecall,
|
|
126
|
+
enabled: autoRecall.enabled !== false,
|
|
127
|
+
limit: autoRecall.limit ?? fileConfig.recallLimit ?? DEFAULT_CONFIG.autoRecall.limit,
|
|
128
|
+
scoreThreshold: autoRecall.scoreThreshold ?? fileConfig.scoreThreshold ?? DEFAULT_CONFIG.autoRecall.scoreThreshold,
|
|
129
|
+
maxContentChars: autoRecall.maxContentChars ?? fileConfig.recallMaxContentChars ?? DEFAULT_CONFIG.autoRecall.maxContentChars,
|
|
130
|
+
preferAbstract: autoRecall.preferAbstract ?? fileConfig.recallPreferAbstract ?? DEFAULT_CONFIG.autoRecall.preferAbstract,
|
|
131
|
+
tokenBudget: autoRecall.tokenBudget ?? fileConfig.recallTokenBudget ?? DEFAULT_CONFIG.autoRecall.tokenBudget,
|
|
132
|
+
minQueryLength: autoRecall.minQueryLength ?? fileConfig.minQueryLength ?? DEFAULT_CONFIG.autoRecall.minQueryLength,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const key of [
|
|
136
|
+
"autoCapture",
|
|
137
|
+
"captureMode",
|
|
138
|
+
"captureMaxLength",
|
|
139
|
+
"captureAssistantTurns",
|
|
140
|
+
"captureToolMaxChars",
|
|
141
|
+
"commitTokenThreshold",
|
|
142
|
+
"commitKeepRecentCount",
|
|
143
|
+
"profileTokenBudget",
|
|
144
|
+
"resumeContextBudget",
|
|
145
|
+
"noAutoInject",
|
|
146
|
+
"bypassSession",
|
|
147
|
+
"bypassSessionPatterns",
|
|
148
|
+
"debug",
|
|
149
|
+
"debugLogPath",
|
|
150
|
+
]) {
|
|
151
|
+
if (fileConfig[key] !== undefined) config[key] = fileConfig[key]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyEnv(config) {
|
|
156
|
+
if (process.env.OPENVIKING_TIMEOUT_MS) config.timeoutMs = process.env.OPENVIKING_TIMEOUT_MS
|
|
157
|
+
if (process.env.OPENVIKING_AUTO_RECALL !== undefined) {
|
|
158
|
+
config.autoRecall.enabled = envBool("OPENVIKING_AUTO_RECALL") ?? config.autoRecall.enabled
|
|
159
|
+
}
|
|
160
|
+
if (process.env.OPENVIKING_RECALL_LIMIT) config.autoRecall.limit = process.env.OPENVIKING_RECALL_LIMIT
|
|
161
|
+
if (process.env.OPENVIKING_SCORE_THRESHOLD) config.autoRecall.scoreThreshold = process.env.OPENVIKING_SCORE_THRESHOLD
|
|
162
|
+
if (process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS) {
|
|
163
|
+
config.autoRecall.maxContentChars = process.env.OPENVIKING_RECALL_MAX_CONTENT_CHARS
|
|
164
|
+
}
|
|
165
|
+
if (process.env.OPENVIKING_RECALL_TOKEN_BUDGET) config.autoRecall.tokenBudget = process.env.OPENVIKING_RECALL_TOKEN_BUDGET
|
|
166
|
+
if (process.env.OPENVIKING_RECALL_PREFER_ABSTRACT !== undefined) {
|
|
167
|
+
config.autoRecall.preferAbstract = envBool("OPENVIKING_RECALL_PREFER_ABSTRACT") ?? config.autoRecall.preferAbstract
|
|
168
|
+
}
|
|
169
|
+
if (process.env.OPENVIKING_MIN_QUERY_LENGTH) config.autoRecall.minQueryLength = process.env.OPENVIKING_MIN_QUERY_LENGTH
|
|
170
|
+
if (process.env.OPENVIKING_AUTO_CAPTURE !== undefined) {
|
|
171
|
+
config.autoCapture = envBool("OPENVIKING_AUTO_CAPTURE") ?? config.autoCapture
|
|
172
|
+
}
|
|
173
|
+
if (process.env.OPENVIKING_CAPTURE_MODE) config.captureMode = process.env.OPENVIKING_CAPTURE_MODE
|
|
174
|
+
if (process.env.OPENVIKING_CAPTURE_MAX_LENGTH) config.captureMaxLength = process.env.OPENVIKING_CAPTURE_MAX_LENGTH
|
|
175
|
+
if (process.env.OPENVIKING_CAPTURE_ASSISTANT_TURNS !== undefined) {
|
|
176
|
+
config.captureAssistantTurns = envBool("OPENVIKING_CAPTURE_ASSISTANT_TURNS") ?? config.captureAssistantTurns
|
|
177
|
+
}
|
|
178
|
+
if (process.env.OPENVIKING_CAPTURE_TOOL_MAX_CHARS) {
|
|
179
|
+
config.captureToolMaxChars = process.env.OPENVIKING_CAPTURE_TOOL_MAX_CHARS
|
|
180
|
+
}
|
|
181
|
+
if (process.env.OPENVIKING_COMMIT_TOKEN_THRESHOLD) {
|
|
182
|
+
config.commitTokenThreshold = process.env.OPENVIKING_COMMIT_TOKEN_THRESHOLD
|
|
183
|
+
}
|
|
184
|
+
if (process.env.OPENVIKING_COMMIT_KEEP_RECENT_COUNT) {
|
|
185
|
+
config.commitKeepRecentCount = process.env.OPENVIKING_COMMIT_KEEP_RECENT_COUNT
|
|
186
|
+
}
|
|
187
|
+
if (process.env.OPENVIKING_PROFILE_TOKEN_BUDGET) config.profileTokenBudget = process.env.OPENVIKING_PROFILE_TOKEN_BUDGET
|
|
188
|
+
if (process.env.OPENVIKING_RESUME_CONTEXT_BUDGET) config.resumeContextBudget = process.env.OPENVIKING_RESUME_CONTEXT_BUDGET
|
|
189
|
+
if (process.env.OPENVIKING_NO_AUTO_INJECT !== undefined) {
|
|
190
|
+
config.noAutoInject = envBool("OPENVIKING_NO_AUTO_INJECT") ?? config.noAutoInject
|
|
191
|
+
}
|
|
192
|
+
if (process.env.OPENVIKING_BYPASS_SESSION !== undefined) {
|
|
193
|
+
config.bypassSession = envBool("OPENVIKING_BYPASS_SESSION") ?? config.bypassSession
|
|
194
|
+
}
|
|
195
|
+
if (process.env.OPENVIKING_BYPASS_SESSION_PATTERNS) {
|
|
196
|
+
config.bypassSessionPatterns = process.env.OPENVIKING_BYPASS_SESSION_PATTERNS
|
|
197
|
+
.split(",")
|
|
198
|
+
.map((item) => item.trim())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
}
|
|
201
|
+
if (process.env.OPENVIKING_DEBUG !== undefined) config.debug = envBool("OPENVIKING_DEBUG") ?? config.debug
|
|
202
|
+
if (process.env.OPENVIKING_DEBUG_LOG) config.debugLogPath = process.env.OPENVIKING_DEBUG_LOG
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeConfig(config) {
|
|
206
|
+
config.endpoint = str(config.endpoint, DEFAULT_CONFIG.endpoint).replace(/\/+$/, "")
|
|
207
|
+
config.baseUrl = config.endpoint
|
|
208
|
+
config.accountId = config.account
|
|
209
|
+
config.userId = config.user
|
|
210
|
+
config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000)
|
|
211
|
+
config.repoContext.cacheTtlMs = normalizeNumber(
|
|
212
|
+
config.repoContext.cacheTtlMs,
|
|
213
|
+
DEFAULT_CONFIG.repoContext.cacheTtlMs,
|
|
214
|
+
1000,
|
|
215
|
+
60 * 60 * 1000,
|
|
216
|
+
)
|
|
217
|
+
config.autoRecall.limit = Math.max(1, Math.min(50, Math.round(Number(config.autoRecall.limit) || 6)))
|
|
218
|
+
config.autoRecall.scoreThreshold = Math.max(0, Math.min(1, Number(config.autoRecall.scoreThreshold) || 0))
|
|
219
|
+
config.autoRecall.maxContentChars = Math.max(100, Math.min(5000, Math.round(Number(config.autoRecall.maxContentChars) || 500)))
|
|
220
|
+
config.autoRecall.tokenBudget = Math.max(200, Math.min(50000, Math.round(Number(config.autoRecall.tokenBudget) || 2000)))
|
|
221
|
+
config.autoRecall.minQueryLength = Math.max(1, Math.min(64, Math.round(Number(config.autoRecall.minQueryLength) || 3)))
|
|
222
|
+
config.captureMode = config.captureMode === "keyword" ? "keyword" : "semantic"
|
|
223
|
+
config.captureMaxLength = Math.max(200, Math.min(100000, Math.round(Number(config.captureMaxLength) || 24000)))
|
|
224
|
+
config.captureToolMaxChars = Math.max(200, Math.min(20000, Math.round(Number(config.captureToolMaxChars) || 2000)))
|
|
225
|
+
config.commitTokenThreshold = Math.max(1000, Math.round(Number(config.commitTokenThreshold) || 20000))
|
|
226
|
+
config.commitKeepRecentCount = Math.max(0, Math.round(Number(config.commitKeepRecentCount) || 10))
|
|
227
|
+
config.profileTokenBudget = Math.max(500, Math.round(Number(config.profileTokenBudget) || 10000))
|
|
228
|
+
config.resumeContextBudget = Math.max(1024, Math.round(Number(config.resumeContextBudget) || 32000))
|
|
229
|
+
if (!Array.isArray(config.bypassSessionPatterns)) config.bypassSessionPatterns = []
|
|
230
|
+
|
|
231
|
+
config.recallLimit = config.autoRecall.limit
|
|
232
|
+
config.scoreThreshold = config.autoRecall.scoreThreshold
|
|
233
|
+
config.recallMaxContentChars = config.autoRecall.maxContentChars
|
|
234
|
+
config.recallPreferAbstract = config.autoRecall.preferAbstract !== false
|
|
235
|
+
config.recallTokenBudget = config.autoRecall.tokenBudget
|
|
236
|
+
config.minQueryLength = config.autoRecall.minQueryLength
|
|
237
|
+
return config
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function loadConfig(pluginRoot, projectDirectory) {
|
|
241
|
+
const config = cloneDefaultConfig()
|
|
242
|
+
const { path: configPath, data: fileConfig } = readConfigFile(pluginRoot, projectDirectory)
|
|
243
|
+
applyBehaviorConfig(config, fileConfig)
|
|
244
|
+
|
|
245
|
+
const creds = resolveOpenVikingCredentials()
|
|
246
|
+
config.endpoint = creds.baseUrl
|
|
247
|
+
config.apiKey = creds.apiKey
|
|
248
|
+
config.account = creds.account
|
|
249
|
+
config.user = creds.user
|
|
250
|
+
config.peerId = creds.peerId
|
|
251
|
+
config.mcpUrl = creds.mcpUrl
|
|
252
|
+
config.credentialSource = creds.credentialSource
|
|
253
|
+
config.credentialPath = creds.cliPath || creds.ovPath || ""
|
|
254
|
+
|
|
255
|
+
const mayUseLegacyCredentials = creds.credentialSource !== "env" &&
|
|
256
|
+
creds.credentialSource !== "ovcli" &&
|
|
257
|
+
!creds.apiKey &&
|
|
258
|
+
!creds.account &&
|
|
259
|
+
!creds.user &&
|
|
260
|
+
!creds.peerId
|
|
261
|
+
const legacyUsed = mayUseLegacyCredentials ? applyLegacyConnection(config, fileConfig) : false
|
|
262
|
+
applyEnv(config)
|
|
263
|
+
config.configPath = configPath
|
|
264
|
+
config.legacyCredentialsUsed = legacyUsed && creds.credentialSource !== "env"
|
|
265
|
+
return normalizeConfig(config)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function resolveDataDir(pluginRoot, config) {
|
|
269
|
+
const configured = config.runtime?.dataDir
|
|
270
|
+
if (configured) return expandHome(configured)
|
|
271
|
+
return path.join(homedir(), ".config", "opencode", "openviking")
|
|
272
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { resolve as resolvePath } from "node:path"
|
|
2
|
+
|
|
3
|
+
export const OPENCODE_MCP_NAME = "openviking"
|
|
4
|
+
|
|
5
|
+
export function createOpenVikingMcpConfig(pluginRoot) {
|
|
6
|
+
return {
|
|
7
|
+
type: "local",
|
|
8
|
+
command: ["node", resolvePath(pluginRoot, "servers", "mcp-proxy.mjs")],
|
|
9
|
+
enabled: true,
|
|
10
|
+
timeout: 15000,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function injectOpenVikingMcpConfig(config, pluginRoot) {
|
|
15
|
+
if (!config || typeof config !== "object") return false
|
|
16
|
+
config.mcp = config.mcp && typeof config.mcp === "object" ? config.mcp : {}
|
|
17
|
+
const current = config.mcp[OPENCODE_MCP_NAME]
|
|
18
|
+
if (current?.enabled === false) return false
|
|
19
|
+
config.mcp[OPENCODE_MCP_NAME] = createOpenVikingMcpConfig(pluginRoot)
|
|
20
|
+
return true
|
|
21
|
+
}
|
package/lib/memory-recall.mjs
CHANGED
|
@@ -1,59 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const RECALL_STOPWORDS = new Set([
|
|
5
|
-
"what", "when", "where", "which", "who", "whom", "whose", "why", "how",
|
|
6
|
-
"did", "does", "is", "are", "was", "were", "the", "and", "for", "with",
|
|
7
|
-
"from", "that", "this", "your", "you",
|
|
8
|
-
])
|
|
9
|
-
const RECALL_TOKEN_RE = /[a-z0-9]{2,}/gi
|
|
10
|
-
const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i
|
|
11
|
-
const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i
|
|
1
|
+
import { buildRecallBlock } from "./shared/recall-core.mjs"
|
|
2
|
+
import { isBypassed } from "./shared/session-model.mjs"
|
|
3
|
+
import { effectivePeerId, fetchJSON, log } from "./utils.mjs"
|
|
12
4
|
|
|
13
5
|
export function createMemoryRecall({ config }) {
|
|
14
6
|
async function injectRelevantMemories(input, output) {
|
|
15
7
|
if (!config.autoRecall?.enabled) return
|
|
16
8
|
const query = extractCurrentUserText(output.parts ?? [])
|
|
17
9
|
if (!query) return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
if (query.length < config.minQueryLength) return
|
|
11
|
+
if (isBypassed(config, {
|
|
12
|
+
sessionId: input.sessionID ?? output.message?.sessionID,
|
|
13
|
+
cwd: input.directory ?? input.cwd,
|
|
14
|
+
})) return
|
|
15
|
+
|
|
16
|
+
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
|
|
17
|
+
if (!health.ok) return
|
|
18
|
+
|
|
19
|
+
const block = await buildRecallBlock(
|
|
20
|
+
(path, init = {}, options = {}) => fetchJSON(config, path, init, { ...options, timeoutMs: 5000 }),
|
|
21
|
+
config,
|
|
25
22
|
query,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const processed = postProcessMemories(
|
|
31
|
-
ranked,
|
|
32
|
-
config.autoRecall.maxContentChars,
|
|
33
|
-
config.autoRecall.preferAbstract,
|
|
23
|
+
{
|
|
24
|
+
actorPeerId: effectivePeerId(config),
|
|
25
|
+
log: (stage, data) => log("DEBUG", "recall", stage, data),
|
|
26
|
+
},
|
|
34
27
|
)
|
|
35
|
-
const block = formatMemoryBlock(processed, config.autoRecall.tokenBudget)
|
|
36
28
|
if (!block) return
|
|
37
29
|
|
|
38
30
|
if (prependSyntheticRecallPart(input, output, block)) {
|
|
39
|
-
log("INFO", "recall",
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function performRecallSearch(query) {
|
|
44
|
-
try {
|
|
45
|
-
const body = { query: query.slice(0, 4000), limit: 20 }
|
|
46
|
-
const response = await makeRequest(config, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
endpoint: "/api/v1/search/find",
|
|
49
|
-
body,
|
|
50
|
-
timeoutMs: AUTO_RECALL_TIMEOUT_MS,
|
|
51
|
-
actorPeerId: effectivePeerId(config),
|
|
52
|
-
})
|
|
53
|
-
const result = unwrapResponse(response)
|
|
54
|
-
return result?.memories ?? result?.results ?? []
|
|
55
|
-
} catch {
|
|
56
|
-
return []
|
|
31
|
+
log("INFO", "recall", "Injected OpenViking context")
|
|
57
32
|
}
|
|
58
33
|
}
|
|
59
34
|
|
|
@@ -64,139 +39,13 @@ function extractCurrentUserText(parts) {
|
|
|
64
39
|
const texts = []
|
|
65
40
|
for (const part of parts) {
|
|
66
41
|
if (part.type !== "text" || typeof part.text !== "string") continue
|
|
67
|
-
if (part.text.includes("<
|
|
42
|
+
if (part.text.includes("<openviking-context")) return null
|
|
68
43
|
if (!part.synthetic && !part.ignored) texts.push(part.text)
|
|
69
44
|
}
|
|
70
45
|
const joined = texts.join(" ").trim()
|
|
71
46
|
return joined || null
|
|
72
47
|
}
|
|
73
48
|
|
|
74
|
-
function buildRecallQueryProfile(query) {
|
|
75
|
-
const text = query.trim()
|
|
76
|
-
const allTokens = text.toLowerCase().match(RECALL_TOKEN_RE) ?? []
|
|
77
|
-
return {
|
|
78
|
-
tokens: allTokens.filter((token) => !RECALL_STOPWORDS.has(token)),
|
|
79
|
-
wantsPreference: PREFERENCE_QUERY_RE.test(text),
|
|
80
|
-
wantsTemporal: TEMPORAL_QUERY_RE.test(text),
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function recallClampScore(value) {
|
|
85
|
-
if (typeof value !== "number" || Number.isNaN(value)) return 0
|
|
86
|
-
return Math.max(0, Math.min(1, value))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function lexicalOverlapBoost(tokens, text) {
|
|
90
|
-
if (tokens.length === 0 || !text) return 0
|
|
91
|
-
const haystack = ` ${text.toLowerCase()} `
|
|
92
|
-
let matched = 0
|
|
93
|
-
for (const token of tokens.slice(0, 8)) {
|
|
94
|
-
if (haystack.includes(` ${token} `) || haystack.includes(token)) matched += 1
|
|
95
|
-
}
|
|
96
|
-
return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function isEventMemory(item) {
|
|
100
|
-
const category = (item.category ?? "").toLowerCase()
|
|
101
|
-
return category === "events" || item.uri?.includes("/events/")
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function isPreferencesMemory(item) {
|
|
105
|
-
return item.category === "preferences" || item.uri?.includes("/preferences/") || item.uri?.endsWith("/preferences")
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function isLeafLikeMemory(item) {
|
|
109
|
-
return item.level === 2 || item.is_leaf === true
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function rankForInjection(item, query) {
|
|
113
|
-
const baseScore = recallClampScore(item.score)
|
|
114
|
-
const abstract = (item.abstract ?? item.overview ?? "").trim()
|
|
115
|
-
const leafBoost = isLeafLikeMemory(item) ? 0.12 : 0
|
|
116
|
-
const eventBoost = query.wantsTemporal && isEventMemory(item) ? 0.1 : 0
|
|
117
|
-
const preferenceBoost = query.wantsPreference && isPreferencesMemory(item) ? 0.08 : 0
|
|
118
|
-
const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`)
|
|
119
|
-
return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function normalizeDedupeText(text) {
|
|
123
|
-
return text.toLowerCase().replace(/\s+/g, " ").trim()
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isEventOrCaseMemory(item) {
|
|
127
|
-
const category = (item.category ?? "").toLowerCase()
|
|
128
|
-
const uri = (item.uri ?? "").toLowerCase()
|
|
129
|
-
return category === "events" || category === "cases" || uri.includes("/events/") || uri.includes("/cases/")
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function getMemoryDedupeKey(item) {
|
|
133
|
-
const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "")
|
|
134
|
-
const category = (item.category ?? "").toLowerCase() || "unknown"
|
|
135
|
-
if (abstract && !isEventOrCaseMemory(item)) return `abstract:${category}:${abstract}`
|
|
136
|
-
return `uri:${item.uri}`
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function pickMemoriesForInjection(items, limit, queryText, scoreThreshold = 0) {
|
|
140
|
-
const query = buildRecallQueryProfile(queryText)
|
|
141
|
-
const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query))
|
|
142
|
-
const deduped = []
|
|
143
|
-
const seen = new Set()
|
|
144
|
-
|
|
145
|
-
for (const item of sorted) {
|
|
146
|
-
const key = getMemoryDedupeKey(item)
|
|
147
|
-
if (seen.has(key)) continue
|
|
148
|
-
seen.add(key)
|
|
149
|
-
deduped.push(item)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const leaves = deduped.filter((item) => isLeafLikeMemory(item))
|
|
153
|
-
if (leaves.length >= limit) return leaves.slice(0, limit)
|
|
154
|
-
|
|
155
|
-
const picked = [...leaves]
|
|
156
|
-
const used = new Set(leaves.map((item) => item.uri))
|
|
157
|
-
for (const item of deduped) {
|
|
158
|
-
if (picked.length >= limit) break
|
|
159
|
-
if (used.has(item.uri)) continue
|
|
160
|
-
if (recallClampScore(item.score) < scoreThreshold) continue
|
|
161
|
-
picked.push(item)
|
|
162
|
-
}
|
|
163
|
-
return picked
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function postProcessMemories(items, maxContentChars, preferAbstract) {
|
|
167
|
-
return items.map((item) => {
|
|
168
|
-
const abstract = (item.abstract ?? "").trim()
|
|
169
|
-
const content = (item.content ?? "").trim()
|
|
170
|
-
let displayContent = ""
|
|
171
|
-
if (preferAbstract && abstract) displayContent = abstract
|
|
172
|
-
else if (content) displayContent = content
|
|
173
|
-
else if (abstract) displayContent = abstract
|
|
174
|
-
if (displayContent.length > maxContentChars) displayContent = `${displayContent.slice(0, maxContentChars)}...`
|
|
175
|
-
return { ...item, content: displayContent, abstract: abstract || undefined }
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function formatMemoryBlock(items, tokenBudget) {
|
|
180
|
-
if (items.length === 0) return ""
|
|
181
|
-
const maxBlockChars = tokenBudget * 4
|
|
182
|
-
let usedChars = 0
|
|
183
|
-
const lines = ["<relevant-memories>"]
|
|
184
|
-
|
|
185
|
-
for (const item of items) {
|
|
186
|
-
const title = item.title ? `${item.title}\n` : ""
|
|
187
|
-
const content = item.content ?? ""
|
|
188
|
-
const entry = `<memory uri="${item.uri}">\n${title}${content}\n</memory>`
|
|
189
|
-
if (usedChars + entry.length + 1 > maxBlockChars) break
|
|
190
|
-
lines.push(entry)
|
|
191
|
-
usedChars += entry.length + 1
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (usedChars === 0) return ""
|
|
195
|
-
lines.push("</relevant-memories>")
|
|
196
|
-
lines.push('Use `memread` with a memory URI and level="overview" or level="read" for more details.')
|
|
197
|
-
return lines.join("\n")
|
|
198
|
-
}
|
|
199
|
-
|
|
200
49
|
function prependSyntheticRecallPart(input, output, injection) {
|
|
201
50
|
const sessionID = input.sessionID ?? output.message?.sessionID
|
|
202
51
|
const messageID = input.messageID ?? output.message?.id
|