@openviking/opencode-plugin 0.1.5 → 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/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 { initLogger, loadConfig, log, resolveDataDir } from "./lib/utils.mjs"
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
- stop: async () => {
85
+ dispose: async () => {
78
86
  await sessionManager.flushAll({ commit: true })
79
- log("INFO", "plugin", "OpenViking plugin stopped")
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
+ }
@@ -1,59 +1,34 @@
1
- import { effectivePeerId, log, makeRequest, unwrapResponse } from "./utils.mjs"
2
-
3
- const AUTO_RECALL_TIMEOUT_MS = 5000
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
- const rawResults = await performRecallSearch(query)
20
- if (rawResults.length === 0) return
21
-
22
- const ranked = pickMemoriesForInjection(
23
- rawResults,
24
- config.autoRecall.limit,
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
- config.autoRecall.scoreThreshold,
27
- )
28
- if (ranked.length === 0) return
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", `Injected ${processed.length} memories`)
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("<relevant-memories>")) return null
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