@openviking/opencode-plugin 0.1.5

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 ADDED
@@ -0,0 +1,84 @@
1
+ import { dirname } from "path"
2
+ import { fileURLToPath } from "url"
3
+ import { initializeRuntime } from "./lib/runtime.mjs"
4
+ import { createRepoContext } from "./lib/repo-context.mjs"
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
+ import { createMemoryRecall } from "./lib/memory-recall.mjs"
9
+ import { createVikingUriGuard } from "./lib/viking-uri-guard.mjs"
10
+ import { initLogger, loadConfig, log, resolveDataDir } from "./lib/utils.mjs"
11
+
12
+ const pluginRoot = dirname(fileURLToPath(import.meta.url))
13
+
14
+ /**
15
+ * @type {import('@opencode-ai/plugin').Plugin}
16
+ */
17
+ export async function OpenVikingPlugin({ client, directory }) {
18
+ const config = loadConfig(pluginRoot, directory)
19
+ const dataDir = resolveDataDir(pluginRoot, config)
20
+ initLogger(dataDir)
21
+
22
+ if (!config.enabled) {
23
+ log("INFO", "plugin", "OpenViking plugin is disabled in configuration")
24
+ return {}
25
+ }
26
+
27
+ const repoContext = createRepoContext({ config })
28
+ const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir })
29
+ const recall = createMemoryRecall({ config })
30
+ const vikingUriGuard = createVikingUriGuard()
31
+ const tools = createMemoryTools({ config, sessionManager, projectDirectory: directory })
32
+ const codeTools = createCodeTools({ config })
33
+
34
+ await sessionManager.init()
35
+
36
+ Promise.resolve().then(async () => {
37
+ const ready = await initializeRuntime(config, client)
38
+ if (ready) await repoContext.refreshRepos({ force: true })
39
+ })
40
+
41
+ return {
42
+ event: async ({ event }) => {
43
+ await sessionManager.handleEvent(event)
44
+ if (event?.type === "session.created") {
45
+ await repoContext.refreshRepos({ force: true })
46
+ }
47
+ },
48
+
49
+ "tool.execute.before": vikingUriGuard,
50
+
51
+ tool: { ...tools, ...codeTools },
52
+
53
+ "experimental.chat.system.transform": (_input, output) => {
54
+ const prompt = repoContext.getRepoSystemPrompt()
55
+ if (prompt) output.system.push(prompt)
56
+ },
57
+
58
+ "chat.message": async (input, output) => {
59
+ try {
60
+ if (!config.autoRecall?.enabled) return
61
+ await recall.injectRelevantMemories(input, output)
62
+ } catch (error) {
63
+ log("WARN", "recall", "Auto recall failed", { error: error?.message ?? String(error) })
64
+ }
65
+ },
66
+
67
+ "experimental.session.compacting": async (input) => {
68
+ log("INFO", "compaction", "OpenCode session compacting", {
69
+ opencode_session: input.sessionID,
70
+ })
71
+ await sessionManager.flushSession(input.sessionID, {
72
+ commit: true,
73
+ reason: "experimental.session.compacting",
74
+ })
75
+ },
76
+
77
+ stop: async () => {
78
+ await sessionManager.flushAll({ commit: true })
79
+ log("INFO", "plugin", "OpenViking plugin stopped")
80
+ },
81
+ }
82
+ }
83
+
84
+ export default OpenVikingPlugin
@@ -0,0 +1,134 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { effectivePeerId, log, makeRequest, unwrapResponse, validateVikingUri } from "./utils.mjs"
3
+
4
+ const z = tool.schema
5
+
6
+ const CODE_TOOL_ENDPOINTS = {
7
+ search: "/api/v1/code/search",
8
+ outline: "/api/v1/code/outline",
9
+ expand: "/api/v1/code/expand",
10
+ }
11
+
12
+ function codeToolRequestOptions(kind, { uri, query, symbol, actorPeerId, abortSignal }) {
13
+ const body = { uri }
14
+ if (kind === "search") body.query = query
15
+ if (kind === "expand") body.symbol = symbol
16
+
17
+ return {
18
+ method: "POST",
19
+ endpoint: CODE_TOOL_ENDPOINTS[kind],
20
+ body,
21
+ abortSignal,
22
+ actorPeerId,
23
+ }
24
+ }
25
+
26
+ export function createCodeTools({ config }) {
27
+ const actorPeerId = effectivePeerId(config)
28
+ return {
29
+ codesearch: tool({
30
+ description:
31
+ "Search AST-supported symbol names (classes, functions, methods) by substring across a confirmed viking:// code repository or source subtree. " +
32
+ "Use only after you have evidence that the uri contains supported source files. " +
33
+ "Use when you do not know which file contains a symbol. " +
34
+ "Do not use for general memory search, documentation-only resources, plain text notes, chat/session history, or local filesystem paths. " +
35
+ "Returns structured results: symbol name, class context, file URI, line range. " +
36
+ "Typical workflow: verify code repo with ls/glob/add_resource output, then codesearch, codeoutline, codeexpand.",
37
+ args: {
38
+ query: z.string().describe("Symbol name substring to search for (case-insensitive)."),
39
+ uri: z
40
+ .string()
41
+ .describe(
42
+ "Viking URI for a confirmed ingested code repository or source subtree. Do not pass a local path or an unverified viking:// directory.",
43
+ ),
44
+ },
45
+ async execute(args, context) {
46
+ const validationError = validateVikingUri(args.uri, "codesearch")
47
+ if (validationError) return validationError
48
+ try {
49
+ const response = await makeRequest(config, {
50
+ ...codeToolRequestOptions("search", {
51
+ uri: args.uri,
52
+ query: args.query,
53
+ actorPeerId,
54
+ abortSignal: context.abort,
55
+ }),
56
+ })
57
+ return unwrapResponse(response)
58
+ } catch (error) {
59
+ log("ERROR", "codesearch", "Search failed", { error: error?.message, args })
60
+ return `Error: ${error.message}`
61
+ }
62
+ },
63
+ }),
64
+
65
+ codeoutline: tool({
66
+ description:
67
+ "Show the symbol structure of a confirmed viking:// source file: classes, functions, methods, and line spans. " +
68
+ "Use only for source files inside an ingested code repository, after you know the exact viking:// file URI. " +
69
+ "Do not use on directories, documentation-only files, plain text notes, or files that are not supported source code. " +
70
+ "Use read instead when you need the full file content.",
71
+ args: {
72
+ uri: z
73
+ .string()
74
+ .describe(
75
+ "Viking URI of a confirmed supported source file, e.g. viking://resources/myproject/src/main.py.",
76
+ ),
77
+ },
78
+ async execute(args, context) {
79
+ const validationError = validateVikingUri(args.uri, "codeoutline")
80
+ if (validationError) return validationError
81
+ try {
82
+ const response = await makeRequest(config, {
83
+ ...codeToolRequestOptions("outline", {
84
+ uri: args.uri,
85
+ actorPeerId,
86
+ abortSignal: context.abort,
87
+ }),
88
+ })
89
+ return unwrapResponse(response)
90
+ } catch (error) {
91
+ log("ERROR", "codeoutline", "Outline failed", { error: error?.message, uri: args.uri })
92
+ return `Error: ${error.message}`
93
+ }
94
+ },
95
+ }),
96
+
97
+ codeexpand: tool({
98
+ description:
99
+ "Return the full source of one named symbol from a confirmed viking:// source file. " +
100
+ "Use only after codeoutline or other evidence shows the symbol exists in that file. " +
101
+ "Do not use for broad exploration, non-code files, documentation, chat/session history, or unverified viking:// resources. " +
102
+ "Accepts 'bar' (top-level function or class) or 'Foo.bar' (method inside class Foo). " +
103
+ "For multiple symbols from the same file, read may be more efficient.",
104
+ args: {
105
+ uri: z
106
+ .string()
107
+ .describe("Viking URI of the confirmed supported source file containing the symbol."),
108
+ symbol: z
109
+ .string()
110
+ .describe(
111
+ "Symbol name: 'foo' for top-level, 'Foo.bar' for a method inside class Foo.",
112
+ ),
113
+ },
114
+ async execute(args, context) {
115
+ const validationError = validateVikingUri(args.uri, "codeexpand")
116
+ if (validationError) return validationError
117
+ try {
118
+ const response = await makeRequest(config, {
119
+ ...codeToolRequestOptions("expand", {
120
+ uri: args.uri,
121
+ symbol: args.symbol,
122
+ actorPeerId,
123
+ abortSignal: context.abort,
124
+ }),
125
+ })
126
+ return unwrapResponse(response)
127
+ } catch (error) {
128
+ log("ERROR", "codeexpand", "Expand failed", { error: error?.message, args })
129
+ return `Error: ${error.message}`
130
+ }
131
+ },
132
+ }),
133
+ }
134
+ }
@@ -0,0 +1,118 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import { fileURLToPath } from "url"
4
+ import {
5
+ ensureRemoteUrl,
6
+ makeMultipartRequest,
7
+ makeRequest,
8
+ unwrapResponse,
9
+ } from "./utils.mjs"
10
+
11
+ export const MEMADD_LOCAL_FILE_ONLY_ERROR = "Error: memadd local upload currently supports files only."
12
+
13
+ const ADD_RESOURCE_KEYS = [
14
+ "to",
15
+ "parent",
16
+ "reason",
17
+ "instruction",
18
+ "wait",
19
+ "timeout",
20
+ "watch_interval",
21
+ ]
22
+
23
+ export function resolveMemaddSource(inputPath, projectDirectory = process.cwd()) {
24
+ if (ensureRemoteUrl(inputPath)) {
25
+ return { kind: "remote", path: inputPath }
26
+ }
27
+
28
+ let filePath
29
+ try {
30
+ filePath = resolveLocalPath(inputPath, projectDirectory)
31
+ } catch (error) {
32
+ return { kind: "error", error: `Error: ${error.message}` }
33
+ }
34
+
35
+ let stat
36
+ try {
37
+ stat = fs.statSync(filePath)
38
+ } catch (error) {
39
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
40
+ return { kind: "error", error: `Error: Local file not found: ${filePath}` }
41
+ }
42
+ return { kind: "error", error: `Error: Unable to access local file: ${filePath}: ${error.message}` }
43
+ }
44
+
45
+ if (!stat.isFile()) {
46
+ return { kind: "error", error: MEMADD_LOCAL_FILE_ONLY_ERROR }
47
+ }
48
+
49
+ return { kind: "local", path: filePath, filename: path.basename(filePath) }
50
+ }
51
+
52
+ export function resolveLocalPath(inputPath, projectDirectory = process.cwd()) {
53
+ if (typeof inputPath !== "string" || inputPath.trim() === "") {
54
+ throw new Error("memadd path is required.")
55
+ }
56
+
57
+ let localPath = inputPath
58
+ if (isFileUrl(inputPath)) {
59
+ localPath = fileURLToPath(inputPath)
60
+ }
61
+
62
+ if (path.isAbsolute(localPath)) return path.normalize(localPath)
63
+ return path.resolve(projectDirectory || process.cwd(), localPath)
64
+ }
65
+
66
+ export function buildAddResourceBody(args, source, tempFileId) {
67
+ const body = source.kind === "remote" ? { path: source.path } : { temp_file_id: tempFileId }
68
+ for (const key of ADD_RESOURCE_KEYS) {
69
+ if (args[key] !== undefined) body[key] = args[key]
70
+ }
71
+ return body
72
+ }
73
+
74
+ export async function uploadLocalResource(config, source, abortSignal, actorPeerId) {
75
+ const bytes = await fs.promises.readFile(source.path)
76
+ const form = new FormData()
77
+ form.append("file", new Blob([bytes], { type: "application/octet-stream" }), source.filename)
78
+
79
+ const uploadResponse = await makeMultipartRequest(config, {
80
+ method: "POST",
81
+ endpoint: "/api/v1/resources/temp_upload",
82
+ body: form,
83
+ abortSignal,
84
+ actorPeerId,
85
+ })
86
+ const tempFileId = unwrapResponse(uploadResponse)?.temp_file_id
87
+ if (!tempFileId) {
88
+ throw new Error("OpenViking temp upload did not return temp_file_id")
89
+ }
90
+ return tempFileId
91
+ }
92
+
93
+ export async function addMemaddResource(config, args, projectDirectory, abortSignal, actorPeerId) {
94
+ const source = resolveMemaddSource(args.path, projectDirectory)
95
+ if (source.kind === "error") return { error: source.error }
96
+
97
+ const tempFileId = source.kind === "local"
98
+ ? await uploadLocalResource(config, source, abortSignal, actorPeerId)
99
+ : undefined
100
+ const body = buildAddResourceBody(args, source, tempFileId)
101
+ const addResponse = await makeRequest(config, {
102
+ method: "POST",
103
+ endpoint: "/api/v1/resources",
104
+ body,
105
+ abortSignal,
106
+ timeoutMs: args.wait ? Math.max(config.timeoutMs, (args.timeout ?? 300) * 1000) : config.timeoutMs,
107
+ actorPeerId,
108
+ })
109
+ return { addResponse, source }
110
+ }
111
+
112
+ function isFileUrl(value) {
113
+ try {
114
+ return new URL(value).protocol === "file:"
115
+ } catch {
116
+ return false
117
+ }
118
+ }
@@ -0,0 +1,214 @@
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
12
+
13
+ export function createMemoryRecall({ config }) {
14
+ async function injectRelevantMemories(input, output) {
15
+ if (!config.autoRecall?.enabled) return
16
+ const query = extractCurrentUserText(output.parts ?? [])
17
+ 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,
25
+ 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,
34
+ )
35
+ const block = formatMemoryBlock(processed, config.autoRecall.tokenBudget)
36
+ if (!block) return
37
+
38
+ 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 []
57
+ }
58
+ }
59
+
60
+ return { injectRelevantMemories }
61
+ }
62
+
63
+ function extractCurrentUserText(parts) {
64
+ const texts = []
65
+ for (const part of parts) {
66
+ if (part.type !== "text" || typeof part.text !== "string") continue
67
+ if (part.text.includes("<relevant-memories>")) return null
68
+ if (!part.synthetic && !part.ignored) texts.push(part.text)
69
+ }
70
+ const joined = texts.join(" ").trim()
71
+ return joined || null
72
+ }
73
+
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
+ function prependSyntheticRecallPart(input, output, injection) {
201
+ const sessionID = input.sessionID ?? output.message?.sessionID
202
+ const messageID = input.messageID ?? output.message?.id
203
+ if (!sessionID || !messageID) return false
204
+
205
+ output.parts.unshift({
206
+ id: `prt-ov-recall-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
207
+ type: "text",
208
+ text: injection,
209
+ synthetic: true,
210
+ sessionID,
211
+ messageID,
212
+ })
213
+ return true
214
+ }