@openviking/opencode-plugin 2026.9.24 → 2026.9.25-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL-ZH.md +3 -1
- package/INSTALL.md +3 -1
- package/README.md +6 -1
- package/index.mjs +45 -34
- package/lib/mcp-config.mjs +13 -0
- package/lib/memory-recall.mjs +12 -6
- package/lib/memory-session.mjs +30 -20
- package/lib/plugin-runtime.mjs +51 -0
- package/lib/session-inject.mjs +23 -13
- package/lib/shared/capture-utils.mjs +65 -19
- package/lib/utils.mjs +5 -3
- package/lib/v2-events.mjs +102 -0
- package/lib/v2-plugin.mjs +352 -0
- package/lib/viking-uri-guard.mjs +4 -0
- package/package.json +3 -3
package/INSTALL-ZH.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
需要先准备:
|
|
13
13
|
|
|
14
|
-
- OpenCode
|
|
14
|
+
- OpenCode 1.15.7+ 或 OpenCode 2.0.15+
|
|
15
15
|
- OpenViking HTTP Server
|
|
16
16
|
- Node.js 18+
|
|
17
17
|
- 如果服务端启用了认证,需要可用的 OpenViking API Key
|
|
@@ -38,6 +38,8 @@ curl http://localhost:1933/health
|
|
|
38
38
|
}
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
这份 `plugin` 配置同时兼容 v1 和 v2;v2 会在加载时规范化为原生的 `plugins`,无需手工迁移配置。
|
|
42
|
+
|
|
41
43
|
## 安装方式二:源码安装
|
|
42
44
|
|
|
43
45
|
用于开发调试或 PR 测试。OpenCode 推荐插件目录:
|
package/INSTALL.md
CHANGED
|
@@ -11,7 +11,7 @@ This is the only OpenCode plugin example maintained in this repository. It does
|
|
|
11
11
|
|
|
12
12
|
Prepare the following first:
|
|
13
13
|
|
|
14
|
-
- OpenCode
|
|
14
|
+
- OpenCode 1.15.7+ or OpenCode 2.0.15+
|
|
15
15
|
- OpenViking HTTP Server
|
|
16
16
|
- Node.js 18+
|
|
17
17
|
- A valid OpenViking API key if authentication is enabled on the server
|
|
@@ -38,6 +38,8 @@ Normal users are recommended to enable it through OpenCode's package plugin mech
|
|
|
38
38
|
}
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
This `plugin` entry works on both v1 and v2. OpenCode v2 normalizes it to the native `plugins` form at load time, so no manual config migration is required.
|
|
42
|
+
|
|
41
43
|
## Installation Method 2: Source Install
|
|
42
44
|
|
|
43
45
|
Use this method for development, debugging, or PR testing. OpenCode's recommended plugin directory is:
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The plugin uses OpenCode hooks for lifecycle behavior and registers OpenViking's
|
|
|
17
17
|
- Exposes the same OpenViking MCP tools used by the Claude Code and Codex memory plugins.
|
|
18
18
|
- Maps each OpenCode session to an OpenViking session.
|
|
19
19
|
- Captures user and assistant text messages into OpenViking.
|
|
20
|
+
- On OpenCode v2, reads the structured session context at execution boundaries so tool calls and results are captured too.
|
|
20
21
|
- Commits sessions at lifecycle boundaries for memory extraction.
|
|
21
22
|
- Automatically recalls relevant memories and injects them as hidden synthetic context for the current user message.
|
|
22
23
|
- Blocks accidental local filesystem reads of `viking://` URIs and points the agent back to `openviking_read`, `openviking_glob`, or `openviking_search`. Shell commands that carry a `viking://` URI still run, with a notice appended to their output.
|
|
@@ -50,11 +51,13 @@ There is intentionally no `skills/openviking/SKILL.md`. The tool surface comes f
|
|
|
50
51
|
|
|
51
52
|
## Requirements
|
|
52
53
|
|
|
53
|
-
- OpenCode
|
|
54
|
+
- OpenCode 1.15.7+ or OpenCode 2.0.15+
|
|
54
55
|
- OpenViking HTTP server
|
|
55
56
|
- Node.js 18+
|
|
56
57
|
- An OpenViking API key if your server requires authentication
|
|
57
58
|
|
|
59
|
+
The same package serves both plugin APIs. OpenCode 1 calls `server()` (or the named `OpenVikingPlugin` export), while OpenCode 2 calls `setup()`. The v2 entrypoint does not import `@opencode/plugin`, so a source install on OpenCode 1 still loads. OpenCode 2 normalizes the installer's `"plugin"` key to `"plugins"`; keep `"plugin"` in shared v1/v2 configuration. On v2, the bundled MCP server uses `codemode: false`, preserving the documented `openviking_*` tool names.
|
|
60
|
+
|
|
58
61
|
Start OpenViking first:
|
|
59
62
|
|
|
60
63
|
```bash
|
|
@@ -202,6 +205,8 @@ call and points it to the OpenViking MCP tools. A `bash` command that contains a
|
|
|
202
205
|
an HTTP payload); the command runs and the plugin appends a notice naming the
|
|
203
206
|
MCP tools to its output.
|
|
204
207
|
|
|
208
|
+
OpenCode v2 has no plugin toast API. Service availability and other non-fatal notices are written to `openviking-memory.log`. The plugin captures the transcript before compaction discards it, checks the commit threshold after every execution (succeeded, failed, or interrupted), and forces a commit after completed compaction, session deletion, and cleanup. Each plugin instance handles only the sessions of its own location, and keeps its capture cursor in OpenCode plugin storage so a reloaded instance does not resend earlier turns.
|
|
209
|
+
|
|
205
210
|
## MCP Tools
|
|
206
211
|
|
|
207
212
|
OpenCode sees the OpenViking MCP server as `openviking`, so tool names are namespaced with `openviking_`.
|
package/index.mjs
CHANGED
|
@@ -1,41 +1,30 @@
|
|
|
1
1
|
import { dirname } from "path"
|
|
2
2
|
import { fileURLToPath } from "url"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { createMemorySessionManager } from "./lib/memory-session.mjs"
|
|
6
|
-
import { createMemoryRecall } from "./lib/memory-recall.mjs"
|
|
7
|
-
import { createSessionInject } from "./lib/session-inject.mjs"
|
|
8
|
-
import { createVikingUriGuard, createVikingUriNotice } from "./lib/viking-uri-guard.mjs"
|
|
3
|
+
import { createOpenVikingRuntime } from "./lib/plugin-runtime.mjs"
|
|
4
|
+
import { startV2Plugin } from "./lib/v2-plugin.mjs"
|
|
9
5
|
import { injectOpenVikingMcpConfig } from "./lib/mcp-config.mjs"
|
|
10
|
-
import { loadConfig, resolveDataDir } from "./lib/config.mjs"
|
|
11
6
|
import { isRecallEnabled } from "./lib/shared/recall-core.mjs"
|
|
12
|
-
import {
|
|
7
|
+
import { log } from "./lib/utils.mjs"
|
|
13
8
|
|
|
14
9
|
const pluginRoot = dirname(fileURLToPath(import.meta.url))
|
|
15
10
|
|
|
16
|
-
export async function OpenVikingPlugin({ client, directory }) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
log("INFO", "plugin", "OpenViking plugin is disabled in configuration")
|
|
23
|
-
return {}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const repoContext = createRepoContext({ config })
|
|
27
|
-
const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir })
|
|
28
|
-
const recall = createMemoryRecall({ config, sessionManager })
|
|
29
|
-
const sessionInject = createSessionInject({ config, sessionManager })
|
|
30
|
-
const vikingUriGuard = createVikingUriGuard()
|
|
31
|
-
const vikingUriNotice = createVikingUriNotice()
|
|
11
|
+
export async function OpenVikingPlugin({ client, directory } = {}) {
|
|
12
|
+
const runtime = createOpenVikingRuntime({ client, directory, pluginRoot })
|
|
13
|
+
if (!runtime) return {}
|
|
14
|
+
await runtime.ready
|
|
15
|
+
return v1Hooks(runtime)
|
|
16
|
+
}
|
|
32
17
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
18
|
+
function v1Hooks(runtime) {
|
|
19
|
+
const {
|
|
20
|
+
config,
|
|
21
|
+
sessionManager,
|
|
22
|
+
repoContext,
|
|
23
|
+
recall,
|
|
24
|
+
sessionInject,
|
|
25
|
+
vikingUriGuard,
|
|
26
|
+
vikingUriNotice,
|
|
27
|
+
} = runtime
|
|
39
28
|
|
|
40
29
|
return {
|
|
41
30
|
config: async (opencodeConfig) => {
|
|
@@ -67,9 +56,18 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
67
56
|
|
|
68
57
|
"chat.message": async (input, output) => {
|
|
69
58
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
59
|
+
// opencode awaits this hook before persisting/broadcasting the user
|
|
60
|
+
// message, so serial awaits here stack remote latency onto message
|
|
61
|
+
// display (#5148). Session inject and recall are independent after
|
|
62
|
+
// entry; overlap them the way the dsh plugin does (#4643).
|
|
63
|
+
if (!isRecallEnabled(config)) {
|
|
64
|
+
await sessionInject.injectSessionContext(input, output)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
await Promise.all([
|
|
68
|
+
sessionInject.injectSessionContext(input, output),
|
|
69
|
+
recall.injectRelevantMemories(input, output),
|
|
70
|
+
])
|
|
73
71
|
} catch (error) {
|
|
74
72
|
log("WARN", "recall", "Auto recall failed", { error: error?.message ?? String(error) })
|
|
75
73
|
}
|
|
@@ -92,4 +90,17 @@ export async function OpenVikingPlugin({ client, directory }) {
|
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
const OpenVikingV2Plugin = {
|
|
94
|
+
id: "openviking",
|
|
95
|
+
async setup(ctx) {
|
|
96
|
+
const directory = ctx?.location?.project?.directory || ctx?.location?.directory
|
|
97
|
+
const runtime = createOpenVikingRuntime({ directory, pluginRoot })
|
|
98
|
+
if (!runtime) return
|
|
99
|
+
return startV2Plugin(ctx, runtime, { pluginRoot })
|
|
100
|
+
},
|
|
101
|
+
async server(input) {
|
|
102
|
+
return OpenVikingPlugin(input)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default OpenVikingV2Plugin
|
package/lib/mcp-config.mjs
CHANGED
|
@@ -11,6 +11,19 @@ export function createOpenVikingMcpConfig(pluginRoot) {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function createOpenVikingV2McpConfig(pluginRoot) {
|
|
15
|
+
return {
|
|
16
|
+
type: "local",
|
|
17
|
+
command: ["node", resolvePath(pluginRoot, "servers", "mcp-proxy.mjs")],
|
|
18
|
+
codemode: false,
|
|
19
|
+
timeout: {
|
|
20
|
+
startup: 15000,
|
|
21
|
+
catalog: 15000,
|
|
22
|
+
execution: 15000,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export function injectOpenVikingMcpConfig(config, pluginRoot, enabled = true) {
|
|
15
28
|
if (!config || typeof config !== "object") return false
|
|
16
29
|
if (!enabled) return false
|
package/lib/memory-recall.mjs
CHANGED
|
@@ -3,12 +3,12 @@ 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
|
-
async function
|
|
6
|
+
async function buildRelevantMemories(input, parts) {
|
|
7
7
|
if (!isRecallEnabled(config)) return
|
|
8
|
-
const query = extractCurrentUserText(
|
|
8
|
+
const query = extractCurrentUserText(parts ?? [])
|
|
9
9
|
if (!query) return
|
|
10
10
|
if (query.length < config.minQueryLength) return
|
|
11
|
-
const sessionID = input.sessionID
|
|
11
|
+
const sessionID = input.sessionID
|
|
12
12
|
if (isBypassed(config, {
|
|
13
13
|
sessionId: sessionID,
|
|
14
14
|
cwd: input.directory ?? input.cwd,
|
|
@@ -34,14 +34,20 @@ export function createMemoryRecall({ config, sessionManager }) {
|
|
|
34
34
|
log: (stage, data) => log("DEBUG", "recall", stage, data),
|
|
35
35
|
},
|
|
36
36
|
)
|
|
37
|
-
|
|
37
|
+
return block || undefined
|
|
38
|
+
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
async function injectRelevantMemories(input, output) {
|
|
41
|
+
const block = await buildRelevantMemories({
|
|
42
|
+
...input,
|
|
43
|
+
sessionID: input.sessionID ?? output.message?.sessionID,
|
|
44
|
+
}, output.parts)
|
|
45
|
+
if (block && prependSyntheticRecallPart(input, output, block)) {
|
|
40
46
|
log("INFO", "recall", "Injected OpenViking context")
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
return { injectRelevantMemories }
|
|
50
|
+
return { buildRelevantMemories, injectRelevantMemories }
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export function extractCurrentUserText(parts) {
|
package/lib/memory-session.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import fs from "fs"
|
|
2
2
|
import path from "path"
|
|
3
3
|
import {
|
|
4
|
-
extractPartsFromPayload,
|
|
5
4
|
extractTextFromPayload,
|
|
6
5
|
isCaptureEnabled,
|
|
7
|
-
|
|
6
|
+
shapeCapturePayload,
|
|
8
7
|
} from "./shared/capture-utils.mjs"
|
|
9
8
|
import {
|
|
10
9
|
deriveHarnessSessionId,
|
|
@@ -31,6 +30,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
31
30
|
const statePath = path.join(pluginRoot, "openviking-session-state.json")
|
|
32
31
|
const oldSessionMapPath = path.join(pluginRoot, "openviking-session-map.json")
|
|
33
32
|
let saveTimer = null
|
|
33
|
+
let initBackground = Promise.resolve()
|
|
34
34
|
// Serialize saves: concurrent saveState() calls (a debounced save racing
|
|
35
35
|
// with flushAll / flushSession / session deletion) all share the same
|
|
36
36
|
// `${statePath}.tmp` temp file, so one rename can fail with ENOENT after
|
|
@@ -46,16 +46,24 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
46
46
|
return run
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async function init() {
|
|
50
|
-
if (isCaptureEnabled(config)) await migrateLegacySessionMap()
|
|
49
|
+
async function init({ deferNetwork = false } = {}) {
|
|
51
50
|
await loadState()
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
initBackground = Promise.resolve().then(async () => {
|
|
52
|
+
if (isCaptureEnabled(config)) await migrateLegacySessionMap()
|
|
53
|
+
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
|
|
54
|
+
if (!health.ok) return
|
|
54
55
|
await replayPending(
|
|
55
56
|
(endpoint, init = {}, options = {}) => fetchJSON(config, endpoint, init, options),
|
|
56
57
|
(stage, data) => log("DEBUG", "pending", stage, data),
|
|
57
58
|
)
|
|
58
|
-
}
|
|
59
|
+
}).catch((error) => {
|
|
60
|
+
log("WARN", "pending", "Pending replay failed during initialization", { error: error?.message })
|
|
61
|
+
})
|
|
62
|
+
if (!deferNetwork) await initBackground
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function waitForBackground() {
|
|
66
|
+
await initBackground
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
async function loadState() {
|
|
@@ -117,7 +125,9 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
117
125
|
{
|
|
118
126
|
role: message.role,
|
|
119
127
|
captured: message.captured,
|
|
120
|
-
|
|
128
|
+
// Captured messages are never read by flushPendingMessages again; retain
|
|
129
|
+
// only their metadata so completed payloads cannot grow the state file.
|
|
130
|
+
parts: message.captured ? [] : Array.from(message.parts.entries()),
|
|
121
131
|
},
|
|
122
132
|
])),
|
|
123
133
|
}
|
|
@@ -306,6 +316,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
306
316
|
|
|
307
317
|
return {
|
|
308
318
|
init,
|
|
319
|
+
waitForBackground,
|
|
309
320
|
handleEvent,
|
|
310
321
|
getMappedSessionId,
|
|
311
322
|
commitSession,
|
|
@@ -371,18 +382,17 @@ export function createMemorySessionManager({ config, pluginRoot }) {
|
|
|
371
382
|
if (!role) return null
|
|
372
383
|
if (role === "assistant" && !config.captureAssistantTurns) return null
|
|
373
384
|
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
: { role, content: decision.text }
|
|
385
|
+
const shaped = shapeCapturePayload({ role, content: partsRaw }, role, config)
|
|
386
|
+
const toolParts = shaped.parts.filter((part) => part.type !== "text")
|
|
387
|
+
if (shaped.dropped || (!shaped.text && toolParts.length === 0)) return null
|
|
388
|
+
const body = toolParts.length > 0
|
|
389
|
+
? { role, parts: [
|
|
390
|
+
...(shaped.text && shaped.parts.some((part) => part.type === "text")
|
|
391
|
+
? [{ type: "text", text: shaped.text }]
|
|
392
|
+
: []),
|
|
393
|
+
...toolParts,
|
|
394
|
+
] }
|
|
395
|
+
: { role, content: shaped.text }
|
|
386
396
|
const peerId = effectivePeerId(config)
|
|
387
397
|
if (peerId) body.peer_id = peerId
|
|
388
398
|
return body
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRepoContext } from "./repo-context.mjs"
|
|
2
|
+
import { createMemorySessionManager } from "./memory-session.mjs"
|
|
3
|
+
import { createMemoryRecall } from "./memory-recall.mjs"
|
|
4
|
+
import { createSessionInject } from "./session-inject.mjs"
|
|
5
|
+
import { createVikingUriGuard, createVikingUriNotice } from "./viking-uri-guard.mjs"
|
|
6
|
+
import { loadConfig, resolveDataDir } from "./config.mjs"
|
|
7
|
+
import { initializeRuntime } from "./runtime.mjs"
|
|
8
|
+
import { initLogger, log } from "./utils.mjs"
|
|
9
|
+
|
|
10
|
+
export function createOpenVikingRuntime({ client, directory, pluginRoot }) {
|
|
11
|
+
const config = loadConfig(pluginRoot, directory)
|
|
12
|
+
const dataDir = resolveDataDir(pluginRoot, config)
|
|
13
|
+
initLogger(dataDir)
|
|
14
|
+
|
|
15
|
+
if (!config.enabled) {
|
|
16
|
+
log("INFO", "plugin", "OpenViking plugin is disabled in configuration")
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const repoContext = createRepoContext({ config })
|
|
21
|
+
const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir })
|
|
22
|
+
const recall = createMemoryRecall({ config, sessionManager })
|
|
23
|
+
const sessionInject = createSessionInject({ config, sessionManager })
|
|
24
|
+
const vikingUriGuard = createVikingUriGuard()
|
|
25
|
+
const vikingUriNotice = createVikingUriNotice()
|
|
26
|
+
|
|
27
|
+
// OpenCode v2 has no v1 client object and waits for setup before accepting
|
|
28
|
+
// the first prompt. Load local state before hooks run, but keep health and
|
|
29
|
+
// pending replay off that activation path.
|
|
30
|
+
const ready = sessionManager.init({ deferNetwork: !client })
|
|
31
|
+
const background = ready.then(async () => {
|
|
32
|
+
const healthy = await initializeRuntime(config, client)
|
|
33
|
+
if (healthy) await repoContext.refreshRepos({ force: true })
|
|
34
|
+
}).catch((error) => {
|
|
35
|
+
log("WARN", "plugin", "OpenViking runtime initialization failed", {
|
|
36
|
+
error: error?.message ?? String(error),
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
config,
|
|
42
|
+
repoContext,
|
|
43
|
+
sessionManager,
|
|
44
|
+
recall,
|
|
45
|
+
sessionInject,
|
|
46
|
+
vikingUriGuard,
|
|
47
|
+
vikingUriNotice,
|
|
48
|
+
ready,
|
|
49
|
+
background,
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/session-inject.mjs
CHANGED
|
@@ -4,10 +4,10 @@ import { effectivePeerId, fetchJSON, log } from "./utils.mjs"
|
|
|
4
4
|
export function createSessionInject({ config, sessionManager }) {
|
|
5
5
|
const injectedSessions = new Set()
|
|
6
6
|
|
|
7
|
-
async function
|
|
7
|
+
async function buildSessionContext(input) {
|
|
8
8
|
if (config.noAutoInject) return false
|
|
9
|
-
const sessionID = input.sessionID
|
|
10
|
-
const messageID = input.messageID
|
|
9
|
+
const sessionID = input.sessionID
|
|
10
|
+
const messageID = input.messageID
|
|
11
11
|
if (!sessionID || !messageID || injectedSessions.has(sessionID)) return false
|
|
12
12
|
|
|
13
13
|
const ovSessionId = sessionManager.getMappedSessionId(sessionID)
|
|
@@ -26,7 +26,7 @@ export function createSessionInject({ config, sessionManager }) {
|
|
|
26
26
|
|
|
27
27
|
if (parts.length === 0) {
|
|
28
28
|
injectedSessions.add(sessionID)
|
|
29
|
-
return
|
|
29
|
+
return undefined
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const block = [
|
|
@@ -35,14 +35,6 @@ export function createSessionInject({ config, sessionManager }) {
|
|
|
35
35
|
"</openviking-context>",
|
|
36
36
|
].join("\n")
|
|
37
37
|
|
|
38
|
-
output.parts.unshift({
|
|
39
|
-
id: `prt-ov-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
40
|
-
type: "text",
|
|
41
|
-
text: block,
|
|
42
|
-
synthetic: true,
|
|
43
|
-
sessionID,
|
|
44
|
-
messageID,
|
|
45
|
-
})
|
|
46
38
|
injectedSessions.add(sessionID)
|
|
47
39
|
log("INFO", "session-inject", "Injected OpenViking session context", {
|
|
48
40
|
opencode_session: sessionID,
|
|
@@ -50,10 +42,28 @@ export function createSessionInject({ config, sessionManager }) {
|
|
|
50
42
|
hasProfile: Boolean(profile?.block),
|
|
51
43
|
hasArchive: Boolean(archive),
|
|
52
44
|
})
|
|
45
|
+
return block
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function injectSessionContext(input, output) {
|
|
49
|
+
const block = await buildSessionContext({
|
|
50
|
+
...input,
|
|
51
|
+
sessionID: input.sessionID ?? output.message?.sessionID,
|
|
52
|
+
messageID: input.messageID ?? output.message?.id,
|
|
53
|
+
})
|
|
54
|
+
if (!block) return false
|
|
55
|
+
output.parts.unshift({
|
|
56
|
+
id: `prt-ov-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
57
|
+
type: "text",
|
|
58
|
+
text: block,
|
|
59
|
+
synthetic: true,
|
|
60
|
+
sessionID: input.sessionID ?? output.message?.sessionID,
|
|
61
|
+
messageID: input.messageID ?? output.message?.id,
|
|
62
|
+
})
|
|
53
63
|
return true
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
return { injectSessionContext }
|
|
66
|
+
return { buildSessionContext, injectSessionContext }
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
async function fetchArchiveBlock(fetcher, ovSessionId, tokenBudget) {
|
|
@@ -409,6 +409,61 @@ export function filterCaptureParts(parts, role, cfg = {}) {
|
|
|
409
409
|
return { parts: out, dropped: out.length === 0 };
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
/** Sanitize already-extracted message parts before applying capture rules. */
|
|
413
|
+
export function shapeCaptureParts(parts, role, cfg = {}) {
|
|
414
|
+
const sanitized = (parts || []).map((part) => part?.type === "text"
|
|
415
|
+
? { ...part, text: sanitizeCapturedText(part.text) }
|
|
416
|
+
: part);
|
|
417
|
+
return filterCaptureParts(sanitized, role, cfg);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function faithfulCaptureDecision(text, cfg) {
|
|
421
|
+
const sanitized = sanitizeCapturedText(text);
|
|
422
|
+
if (!sanitized) return { shouldCapture: false, text: "" };
|
|
423
|
+
const capped = truncateCaptureText(sanitized, cfg.captureMaxLength || 24000);
|
|
424
|
+
const compact = oneLine(capped);
|
|
425
|
+
if (/^\[openviking-memory\]/i.test(compact) || SLASH_COMMAND_RE.test(compact)) {
|
|
426
|
+
return { shouldCapture: false, text: "" };
|
|
427
|
+
}
|
|
428
|
+
return { shouldCapture: true, text: capped };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Shape one host message before an adapter builds its session payload.
|
|
433
|
+
* Sanitize injected context before evaluating keep/drop rules, so text that
|
|
434
|
+
* will not be sent cannot satisfy a keep rule. Tool parts are never filtered.
|
|
435
|
+
*/
|
|
436
|
+
export function shapeCapturePayload(payload, role, cfg = {}, { toolNameById = {}, faithful = false } = {}) {
|
|
437
|
+
const options = { toolMaxChars: cfg.captureToolMaxChars, toolNameById };
|
|
438
|
+
const rawText = extractTextFromPayload(payload, options);
|
|
439
|
+
const sourceParts = extractPartsFromPayload(payload, options);
|
|
440
|
+
const hasTextPart = sourceParts.some((part) => part?.type === "text");
|
|
441
|
+
const sanitizedText = sanitizeCapturedText(rawText);
|
|
442
|
+
// Some hosts supply an array of plain strings. It has text but no structured
|
|
443
|
+
// parts, so use a temporary part for the same filter verdict.
|
|
444
|
+
const fallback = sourceParts.length === 0 && sanitizedText
|
|
445
|
+
? [{ type: "text", text: rawText }]
|
|
446
|
+
: [];
|
|
447
|
+
const shaped = shapeCaptureParts(sourceParts.length ? sourceParts : fallback, role, cfg);
|
|
448
|
+
if (shaped.dropped) return { parts: [], text: "", signalText: "", dropped: true };
|
|
449
|
+
|
|
450
|
+
const parts = sourceParts.length ? shaped.parts : [];
|
|
451
|
+
const decisionText = hasTextPart || fallback.length
|
|
452
|
+
? shaped.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n")
|
|
453
|
+
: sanitizedText;
|
|
454
|
+
const decision = faithful
|
|
455
|
+
? faithfulCaptureDecision(decisionText, cfg)
|
|
456
|
+
: shouldCaptureText(decisionText, role, cfg, { filters: false });
|
|
457
|
+
// Codex scans turn.text for explicit-memory keywords; keep that scan based
|
|
458
|
+
// on the user's original words while the filtered parts go on the wire.
|
|
459
|
+
const signalText = decision.shouldCapture && parts.length
|
|
460
|
+
? (faithful
|
|
461
|
+
? faithfulCaptureDecision(sanitizedText, cfg)
|
|
462
|
+
: shouldCaptureText(sanitizedText, role, cfg, { filters: false })).text
|
|
463
|
+
: decision.text;
|
|
464
|
+
return { parts, text: decision.shouldCapture ? decision.text : "", signalText: signalText || "", dropped: false };
|
|
465
|
+
}
|
|
466
|
+
|
|
412
467
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
413
468
|
const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
|
|
414
469
|
const turns = [];
|
|
@@ -421,20 +476,9 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
|
421
476
|
if (!role) continue;
|
|
422
477
|
if (isAssistantSideCaptureRole(rawRole) && !cfg.captureAssistantTurns) continue;
|
|
423
478
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
toolNameById,
|
|
428
|
-
});
|
|
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;
|
|
436
|
-
const text = decision.shouldCapture ? decision.text : "";
|
|
437
|
-
turns.push({ role, text, parts: shaped.parts });
|
|
479
|
+
const shaped = shapeCapturePayload(payload, role, cfg, { toolNameById });
|
|
480
|
+
if (shaped.dropped || (!shaped.text && shaped.parts.length === 0)) continue;
|
|
481
|
+
turns.push({ role, text: shaped.parts.length ? shaped.signalText : shaped.text, parts: shaped.parts });
|
|
438
482
|
}
|
|
439
483
|
return turns;
|
|
440
484
|
}
|
|
@@ -555,7 +599,7 @@ export function sanitizeCapturedText(text) {
|
|
|
555
599
|
.replace(/^[ \t]*\[Subagent Context\][^\n]*$/gim, " ")
|
|
556
600
|
.replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
|
|
557
601
|
.replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
|
|
558
|
-
.replace(
|
|
602
|
+
.replace(/^[ \t]*\[\d{4}-\d{2}-\d{2}[T ][-+0-9:.Z]{5,35}\][ \t]*/gm, "")
|
|
559
603
|
.replace(/^\s*\d{10,13}\s+/gm, "");
|
|
560
604
|
value = stripMetadataFences(value);
|
|
561
605
|
value = stripInjectedDigestBlocks(value);
|
|
@@ -598,16 +642,18 @@ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {})
|
|
|
598
642
|
const sanitized = sanitizeCapturedText(text);
|
|
599
643
|
if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
|
|
600
644
|
|
|
601
|
-
|
|
645
|
+
// Rules judge the full sanitized turn; the wire cap applies afterward.
|
|
646
|
+
let filtered = sanitized;
|
|
602
647
|
if (filters) {
|
|
603
648
|
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
604
649
|
if (compiled.rules.length) {
|
|
605
|
-
const verdict = applyInputFilters(
|
|
650
|
+
const verdict = applyInputFilters(filtered, compiled.rules, { role });
|
|
606
651
|
if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
|
|
607
|
-
|
|
608
|
-
if (!
|
|
652
|
+
filtered = verdict.text;
|
|
653
|
+
if (!filtered) return { shouldCapture: false, reason: "empty", text: "" };
|
|
609
654
|
}
|
|
610
655
|
}
|
|
656
|
+
const capped = truncateCaptureText(filtered, maxLength);
|
|
611
657
|
const compact = oneLine(capped);
|
|
612
658
|
const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
|
|
613
659
|
|
package/lib/utils.mjs
CHANGED
|
@@ -56,10 +56,12 @@ export function log(level, toolName, message, data) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export function makeToast(client) {
|
|
59
|
-
return (message, variant = "warning") =>
|
|
60
|
-
client?.tui?.showToast?.({
|
|
59
|
+
return (message, variant = "warning") => {
|
|
60
|
+
const request = client?.tui?.showToast?.({
|
|
61
61
|
body: { title: "OpenViking", message, variant, duration: 8000 },
|
|
62
|
-
})
|
|
62
|
+
})
|
|
63
|
+
return request?.catch?.(() => {})
|
|
64
|
+
}
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export function normalizeEndpoint(endpoint) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function normalizeV2LifecycleEvent(event) {
|
|
2
|
+
const type = event?.type
|
|
3
|
+
const data = event?.data && typeof event.data === "object" ? event.data : {}
|
|
4
|
+
const sessionID = data.sessionID ?? data.sessionId
|
|
5
|
+
if (!type || !sessionID) return []
|
|
6
|
+
|
|
7
|
+
if (type === "session.created") {
|
|
8
|
+
return [sessionEvent("session.created", sessionID, { parentID: data.parentID })]
|
|
9
|
+
}
|
|
10
|
+
if (type === "session.deleted") {
|
|
11
|
+
return [sessionEvent("session.deleted", sessionID)]
|
|
12
|
+
}
|
|
13
|
+
if (type === "session.compaction.ended") {
|
|
14
|
+
return [sessionEvent("session.compacted", sessionID)]
|
|
15
|
+
}
|
|
16
|
+
// A failed execution ends one turn, not the session, so it keeps state like
|
|
17
|
+
// the other terminal outcomes instead of taking v1's session.error path.
|
|
18
|
+
if (
|
|
19
|
+
type === "session.execution.succeeded" ||
|
|
20
|
+
type === "session.execution.interrupted" ||
|
|
21
|
+
type === "session.execution.failed"
|
|
22
|
+
) {
|
|
23
|
+
return [sessionEvent("session.idle", sessionID)]
|
|
24
|
+
}
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function contextMessageEvents(sessionID, message) {
|
|
29
|
+
if (!sessionID || !message?.id) return []
|
|
30
|
+
if (message.type === "user") {
|
|
31
|
+
if (typeof message.text !== "string" || !message.text.trim()) return []
|
|
32
|
+
return [
|
|
33
|
+
messageUpdated(sessionID, message.id, "user"),
|
|
34
|
+
partUpdated(sessionID, message.id, `${message.id}:text`, { type: "text", text: message.text }),
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
if (message.type !== "assistant") return []
|
|
38
|
+
|
|
39
|
+
const events = [messageUpdated(sessionID, message.id, "assistant")]
|
|
40
|
+
for (const [index, block] of (message.content ?? []).entries()) {
|
|
41
|
+
const part = contentPart(block, index)
|
|
42
|
+
if (part) events.push(partUpdated(sessionID, message.id, part.id, part))
|
|
43
|
+
}
|
|
44
|
+
return events.length > 1 ? events : []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sessionEvent(type, sessionID, info = {}) {
|
|
48
|
+
return {
|
|
49
|
+
type,
|
|
50
|
+
properties: { info: { id: sessionID, sessionID, ...info } },
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function contentPart(block, index) {
|
|
55
|
+
if (!block || typeof block !== "object") return null
|
|
56
|
+
// Reasoning is dropped, matching v1 parts and the shared capture filter.
|
|
57
|
+
if (block.type === "text") {
|
|
58
|
+
return {
|
|
59
|
+
id: `text:${index}`,
|
|
60
|
+
type: "text",
|
|
61
|
+
text: typeof block.text === "string" ? block.text : "",
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (block.type !== "tool") return null
|
|
65
|
+
const output = toolContentText(block.state?.content) || errorText(block.state?.error)
|
|
66
|
+
return {
|
|
67
|
+
id: block.id || `tool:${index}`,
|
|
68
|
+
type: "tool",
|
|
69
|
+
callID: block.id,
|
|
70
|
+
tool: block.name,
|
|
71
|
+
state: block.state,
|
|
72
|
+
output,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toolContentText(content) {
|
|
77
|
+
if (!Array.isArray(content)) return ""
|
|
78
|
+
return content
|
|
79
|
+
.filter((item) => item?.type === "text" && typeof item.text === "string")
|
|
80
|
+
.map((item) => item.text)
|
|
81
|
+
.join("\n\n")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorText(error) {
|
|
85
|
+
if (typeof error === "string") return error
|
|
86
|
+
if (typeof error?.message === "string") return error.message
|
|
87
|
+
return ""
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function messageUpdated(sessionID, messageID, role) {
|
|
91
|
+
return {
|
|
92
|
+
type: "message.updated",
|
|
93
|
+
properties: { info: { sessionID, id: messageID, role } },
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function partUpdated(sessionID, messageID, partId, part) {
|
|
98
|
+
return {
|
|
99
|
+
type: "message.part.updated",
|
|
100
|
+
properties: { part: { ...part, id: partId, sessionID, messageID } },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { isRecallEnabled } from "./shared/recall-core.mjs"
|
|
2
|
+
import { createOpenVikingV2McpConfig } from "./mcp-config.mjs"
|
|
3
|
+
import { contextMessageEvents, normalizeV2LifecycleEvent } from "./v2-events.mjs"
|
|
4
|
+
import { log } from "./utils.mjs"
|
|
5
|
+
|
|
6
|
+
const METADATA_KEY = "openviking"
|
|
7
|
+
const CURSOR_KEY_PREFIX = "capture-cursor/"
|
|
8
|
+
const LIFECYCLE_EVENTS = new Set([
|
|
9
|
+
"session.created",
|
|
10
|
+
"session.deleted",
|
|
11
|
+
"session.execution.succeeded",
|
|
12
|
+
"session.execution.failed",
|
|
13
|
+
"session.execution.interrupted",
|
|
14
|
+
"session.compaction.ended",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export async function startV2Plugin(ctx, runtime, { pluginRoot }) {
|
|
18
|
+
const {
|
|
19
|
+
config,
|
|
20
|
+
sessionManager,
|
|
21
|
+
repoContext,
|
|
22
|
+
recall,
|
|
23
|
+
sessionInject,
|
|
24
|
+
vikingUriGuard,
|
|
25
|
+
vikingUriNotice,
|
|
26
|
+
} = runtime
|
|
27
|
+
const captureCursors = new Map()
|
|
28
|
+
const sessionOwnership = new Map()
|
|
29
|
+
// Compaction hooks can run while lifecycle events are still flushing. Keep
|
|
30
|
+
// capture and flush ordered so the same pending message is never sent twice.
|
|
31
|
+
let sessionTask = Promise.resolve()
|
|
32
|
+
const serializeSession = (operation) => {
|
|
33
|
+
const task = sessionTask.then(operation)
|
|
34
|
+
sessionTask = task.catch(() => {})
|
|
35
|
+
return task
|
|
36
|
+
}
|
|
37
|
+
const directory = ctx?.location?.project?.directory || ctx?.location?.directory
|
|
38
|
+
|
|
39
|
+
if (config.mcp.enabled && ctx?.mcp?.transform) {
|
|
40
|
+
await ctx.mcp.transform((editor) => {
|
|
41
|
+
const current = editor.get?.("openviking")
|
|
42
|
+
if (current?.disabled === true) return
|
|
43
|
+
editor.set("openviking", createOpenVikingV2McpConfig(pluginRoot))
|
|
44
|
+
})
|
|
45
|
+
log("INFO", "mcp", "Registered OpenViking MCP server for OpenCode v2")
|
|
46
|
+
} else if (!config.mcp.enabled) {
|
|
47
|
+
log("INFO", "mcp", "Skipped bundled MCP registration in hook-only mode")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (ctx?.tool?.hook) {
|
|
51
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
52
|
+
await runtime.ready
|
|
53
|
+
await vikingUriGuard(
|
|
54
|
+
{ tool: event.tool, args: event.input },
|
|
55
|
+
{ args: event.input },
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
59
|
+
try {
|
|
60
|
+
await runtime.ready
|
|
61
|
+
await noticeV2Tool(event, vikingUriNotice)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logHookError("tool.execute.after", error)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (ctx?.session?.hook) {
|
|
69
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
70
|
+
try {
|
|
71
|
+
await runtime.ready
|
|
72
|
+
await prepareV2Prompt(event, {
|
|
73
|
+
directory,
|
|
74
|
+
recall,
|
|
75
|
+
sessionInject,
|
|
76
|
+
recallEnabled: isRecallEnabled(config),
|
|
77
|
+
})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logHookError("session.prompt", error)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await ctx.session.hook("context", async (event) => {
|
|
84
|
+
try {
|
|
85
|
+
await runtime.ready
|
|
86
|
+
injectV2Context(event, repoContext)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logHookError("session.context", error)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
93
|
+
try {
|
|
94
|
+
await runtime.ready
|
|
95
|
+
// context() drops the old transcript as soon as compaction completes.
|
|
96
|
+
// Capture it now; the ended event below owns the commit.
|
|
97
|
+
await serializeSession(() => captureV2Context(ctx, sessionManager, captureCursors, event.sessionID))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
logHookError("session.compaction.capture", error)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const controller = new AbortController()
|
|
105
|
+
let eventTask = Promise.resolve()
|
|
106
|
+
if (ctx?.event?.subscribe) {
|
|
107
|
+
eventTask = consumeEvents(ctx, controller.signal, async (event) => {
|
|
108
|
+
const sessionID = event?.data?.sessionID ?? event?.data?.sessionId
|
|
109
|
+
if (!LIFECYCLE_EVENTS.has(event?.type) || !sessionID) return
|
|
110
|
+
if (!(await ownsSession(ctx, sessionOwnership, event, sessionID))) return
|
|
111
|
+
await serializeSession(async () => {
|
|
112
|
+
await runtime.ready
|
|
113
|
+
if (event.type === "session.execution.failed") {
|
|
114
|
+
log("WARN", "v2", "OpenCode v2 execution failed", {
|
|
115
|
+
opencode_session: sessionID,
|
|
116
|
+
error: event.data?.error?.message ?? event.data?.error,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
if (isExecutionBoundary(event.type)) {
|
|
120
|
+
try {
|
|
121
|
+
await captureV2Context(ctx, sessionManager, captureCursors, sessionID)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logHookError("session.context.capture", error)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const normalized of normalizeV2LifecycleEvent(event)) {
|
|
127
|
+
await sessionManager.handleEvent(normalized)
|
|
128
|
+
if (normalized.type === "session.created") {
|
|
129
|
+
await repoContext.refreshRepos({ force: true })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (event.type === "session.deleted") {
|
|
133
|
+
sessionOwnership.delete(sessionID)
|
|
134
|
+
await forgetCursor(ctx, captureCursors, sessionID)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return async () => {
|
|
141
|
+
controller.abort()
|
|
142
|
+
try {
|
|
143
|
+
await eventTask
|
|
144
|
+
await sessionTask
|
|
145
|
+
await runtime.ready
|
|
146
|
+
await runtime.background
|
|
147
|
+
await sessionManager.waitForBackground?.()
|
|
148
|
+
await sessionManager.flushAll({ commit: true })
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logHookError("plugin.cleanup", error)
|
|
151
|
+
}
|
|
152
|
+
log("INFO", "plugin", "OpenViking plugin disposed")
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function consumeEvents(ctx, signal, handle) {
|
|
157
|
+
try {
|
|
158
|
+
for await (const event of ctx.event.subscribe({ signal })) {
|
|
159
|
+
try {
|
|
160
|
+
await handle(event)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
logHookError(`event.${event?.type || "unknown"}`, error)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (signal.aborted) return
|
|
167
|
+
logHookError("event.subscribe", error)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function prepareV2Prompt(event, {
|
|
172
|
+
directory,
|
|
173
|
+
recall,
|
|
174
|
+
sessionInject,
|
|
175
|
+
recallEnabled,
|
|
176
|
+
}) {
|
|
177
|
+
const sessionID = event?.sessionID
|
|
178
|
+
const messageID = event?.messageID
|
|
179
|
+
if (!sessionID || !messageID) return
|
|
180
|
+
const text = event?.prompt?.text
|
|
181
|
+
const parts = typeof text === "string" && text.trim() ? [{ type: "text", text }] : []
|
|
182
|
+
const input = { sessionID, messageID, directory }
|
|
183
|
+
const blocks = []
|
|
184
|
+
try {
|
|
185
|
+
const sessionBlock = await sessionInject.buildSessionContext(input)
|
|
186
|
+
if (sessionBlock) blocks.push(sessionBlock)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logHookError("session.prompt.profile", error)
|
|
189
|
+
}
|
|
190
|
+
if (recallEnabled) {
|
|
191
|
+
try {
|
|
192
|
+
const recallBlock = await recall.buildRelevantMemories(input, parts)
|
|
193
|
+
if (recallBlock) blocks.push(recallBlock)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logHookError("session.prompt.recall", error)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (blocks.length === 0) return
|
|
199
|
+
|
|
200
|
+
event.metadata = event.metadata && typeof event.metadata === "object" ? event.metadata : {}
|
|
201
|
+
event.metadata[METADATA_KEY] = { context: blocks }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function injectV2Context(event, repoContext) {
|
|
205
|
+
const repoPrompt = repoContext.getRepoSystemPrompt()
|
|
206
|
+
if (repoPrompt) pushSystem(event, repoPrompt)
|
|
207
|
+
if (!Array.isArray(event?.messages)) return
|
|
208
|
+
|
|
209
|
+
for (const message of event.messages) {
|
|
210
|
+
const blocks = message?.metadata?.[METADATA_KEY]?.context
|
|
211
|
+
if (message?.role !== "user" || !Array.isArray(blocks) || blocks.length === 0) continue
|
|
212
|
+
if (!Array.isArray(message.content)) message.content = []
|
|
213
|
+
if (message.content.some((part) => part?.metadata?.[METADATA_KEY] === true)) continue
|
|
214
|
+
message.content.unshift({
|
|
215
|
+
type: "text",
|
|
216
|
+
text: blocks.filter((block) => typeof block === "string" && block).join("\n\n"),
|
|
217
|
+
metadata: { [METADATA_KEY]: true },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The plugin event stream carries every location served by the process, and
|
|
223
|
+
// execution events have no envelope location, so ownership comes from the
|
|
224
|
+
// session itself. An unresolvable session is handled rather than dropped.
|
|
225
|
+
async function ownsSession(ctx, ownership, event, sessionID) {
|
|
226
|
+
if (event.type === "session.created") {
|
|
227
|
+
const owned = sameLocation(event.location ?? event.data?.location, ctx.location)
|
|
228
|
+
ownership.set(sessionID, owned)
|
|
229
|
+
return owned
|
|
230
|
+
}
|
|
231
|
+
if (event.location) return sameLocation(event.location, ctx.location)
|
|
232
|
+
if (ownership.has(sessionID)) return ownership.get(sessionID)
|
|
233
|
+
if (event.type === "session.deleted" || !ctx.session?.get) return true
|
|
234
|
+
try {
|
|
235
|
+
const result = await ctx.session.get({ sessionID })
|
|
236
|
+
const location = (result?.data ?? result)?.location
|
|
237
|
+
if (location?.directory) {
|
|
238
|
+
const owned = sameLocation(location, ctx.location)
|
|
239
|
+
ownership.set(sessionID, owned)
|
|
240
|
+
return owned
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
logHookError("session.get", error)
|
|
244
|
+
}
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sameLocation(ref, location) {
|
|
249
|
+
if (!ref?.directory || !location?.directory) return true
|
|
250
|
+
if (ref.directory !== location.directory) return false
|
|
251
|
+
return !ref.workspaceID || !location.workspaceID || ref.workspaceID === location.workspaceID
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function captureV2Context(ctx, sessionManager, cursors, sessionID) {
|
|
255
|
+
const messages = await ctx.session.context({ sessionID })
|
|
256
|
+
if (!Array.isArray(messages)) return
|
|
257
|
+
// The cursor lives in host storage because the session state file is shared
|
|
258
|
+
// by every location instance and cannot prove what was already captured.
|
|
259
|
+
const cursor = cursors.get(sessionID) ?? await readCursor(ctx, sessionID)
|
|
260
|
+
const cursorIndex = cursor ? messages.findIndex((message) => message?.id === cursor) : -1
|
|
261
|
+
const pending = cursorIndex >= 0 ? messages.slice(cursorIndex + 1) : messages
|
|
262
|
+
for (const message of pending) {
|
|
263
|
+
for (const normalized of contextMessageEvents(sessionID, message)) {
|
|
264
|
+
await sessionManager.handleEvent(normalized)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const last = messages.at(-1)?.id
|
|
268
|
+
if (!last) return
|
|
269
|
+
cursors.set(sessionID, last)
|
|
270
|
+
if (last !== cursor) await writeCursor(ctx, sessionID, last)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function readCursor(ctx, sessionID) {
|
|
274
|
+
if (!ctx?.storage?.get) return undefined
|
|
275
|
+
try {
|
|
276
|
+
const value = await ctx.storage.get(CURSOR_KEY_PREFIX + sessionID)
|
|
277
|
+
return typeof value === "string" ? value : undefined
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logHookError("storage.get", error)
|
|
280
|
+
return undefined
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function writeCursor(ctx, sessionID, cursor) {
|
|
285
|
+
if (!ctx?.storage?.set) return
|
|
286
|
+
try {
|
|
287
|
+
await ctx.storage.set(CURSOR_KEY_PREFIX + sessionID, cursor)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logHookError("storage.set", error)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function forgetCursor(ctx, cursors, sessionID) {
|
|
294
|
+
cursors.delete(sessionID)
|
|
295
|
+
if (!ctx?.storage?.remove) return
|
|
296
|
+
try {
|
|
297
|
+
await ctx.storage.remove(CURSOR_KEY_PREFIX + sessionID)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
logHookError("storage.remove", error)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isExecutionBoundary(type) {
|
|
304
|
+
return type === "session.execution.succeeded" ||
|
|
305
|
+
type === "session.execution.failed" ||
|
|
306
|
+
type === "session.execution.interrupted"
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function pushSystem(event, text) {
|
|
310
|
+
if (!text) return
|
|
311
|
+
if (!Array.isArray(event.system)) event.system = []
|
|
312
|
+
if (event.system.some((part) => part?.type === "text" && part.text === text)) return
|
|
313
|
+
event.system.push({ type: "text", text })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function logHookError(hook, error) {
|
|
317
|
+
log("WARN", "v2", `OpenCode v2 ${hook} failed`, {
|
|
318
|
+
error: error?.message ?? String(error),
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function noticeV2Tool(event, vikingUriNotice) {
|
|
323
|
+
if (event?.status !== "completed") return
|
|
324
|
+
const before = toolResultText(event.result)
|
|
325
|
+
const output = { output: before }
|
|
326
|
+
await vikingUriNotice({ tool: event.tool, args: event.input }, output)
|
|
327
|
+
if (!output.output || output.output === before) return
|
|
328
|
+
writeToolResultText(event.result, output.output)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function toolResultText(result) {
|
|
332
|
+
if (!result || typeof result !== "object") return ""
|
|
333
|
+
if (typeof result.content === "string") return result.content
|
|
334
|
+
if (!Array.isArray(result.content)) return ""
|
|
335
|
+
return result.content
|
|
336
|
+
.filter((item) => item?.type === "text" && typeof item.text === "string")
|
|
337
|
+
.map((item) => item.text)
|
|
338
|
+
.join("\n\n")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function writeToolResultText(result, text) {
|
|
342
|
+
if (!result || typeof result !== "object") return
|
|
343
|
+
if (typeof result.content === "string" || result.content == null) {
|
|
344
|
+
result.content = text
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
if (!Array.isArray(result.content)) return
|
|
348
|
+
// Preserve the original text/media blocks and append only the new notice.
|
|
349
|
+
const before = toolResultText(result)
|
|
350
|
+
const notice = text.startsWith(before) ? text.slice(before.length).trimStart() : text
|
|
351
|
+
if (notice) result.content.push({ type: "text", text: notice })
|
|
352
|
+
}
|
package/lib/viking-uri-guard.mjs
CHANGED
|
@@ -18,6 +18,10 @@ const FILESYSTEM_TOOL_HINTS = {
|
|
|
18
18
|
tool: "openviking_read or openviking_search",
|
|
19
19
|
example: (uri) => `openviking_read(uris=["${uri}"])`,
|
|
20
20
|
},
|
|
21
|
+
shell: {
|
|
22
|
+
tool: "openviking_read or openviking_search",
|
|
23
|
+
example: (uri) => `openviking_read(uris=["${uri}"])`,
|
|
24
|
+
},
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function createVikingUriGuard() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/opencode-plugin",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.25-2",
|
|
4
4
|
"author": "@openviking",
|
|
5
5
|
"description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"prepack": "node ../memory-plugin-shared/sync.mjs",
|
|
27
|
-
"check": "node --check index.mjs && node --check lib/config.mjs && node --check lib/mcp-config.mjs && node --check lib/runtime.mjs && node --check lib/repo-context.mjs && node --check lib/memory-session.mjs && node --check lib/memory-recall.mjs && node --check lib/session-inject.mjs && node --check lib/viking-uri-guard.mjs && node --check lib/utils.mjs && node --check lib/shared/*.mjs && node --check servers/mcp-proxy.mjs && node --check scripts/setup.mjs && node --check tests/*.test.mjs",
|
|
27
|
+
"check": "node --check index.mjs && node --check lib/config.mjs && node --check lib/mcp-config.mjs && node --check lib/runtime.mjs && node --check lib/plugin-runtime.mjs && node --check lib/v2-events.mjs && node --check lib/v2-plugin.mjs && node --check lib/repo-context.mjs && node --check lib/memory-session.mjs && node --check lib/memory-recall.mjs && node --check lib/session-inject.mjs && node --check lib/viking-uri-guard.mjs && node --check lib/utils.mjs && node --check lib/shared/*.mjs && node --check servers/mcp-proxy.mjs && node --check scripts/setup.mjs && node --check tests/*.test.mjs",
|
|
28
28
|
"test": "node --test tests/*.test.mjs"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {},
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"code-search"
|
|
46
46
|
],
|
|
47
47
|
"license": "Apache-2.0",
|
|
48
|
-
"openvikingSourceCommit": "
|
|
48
|
+
"openvikingSourceCommit": "3e02c6ac29842be214fa1560dfaf6ea39a69322c"
|
|
49
49
|
}
|