@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/INSTALL-ZH.md +225 -0
- package/INSTALL.md +220 -0
- package/README.md +261 -0
- package/index.mjs +84 -0
- package/lib/code-tools.mjs +134 -0
- package/lib/memadd-local.mjs +118 -0
- package/lib/memory-recall.mjs +214 -0
- package/lib/memory-session.mjs +651 -0
- package/lib/memory-tools.mjs +410 -0
- package/lib/repo-context.mjs +68 -0
- package/lib/runtime.mjs +33 -0
- package/lib/utils.mjs +341 -0
- package/lib/viking-uri-guard.mjs +52 -0
- package/package.json +42 -0
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
|
+
}
|