@ghilteras/opencode-agent-memory 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/package.json +1 -1
- package/src/rerank-config.ts +63 -15
- package/src/rerank-transport.ts +2 -1
- package/src/rerank.ts +14 -3
- package/src/tools.ts +2 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"plugins": ["@ghilteras/opencode-agent-memory@2.
|
|
35
|
+
"plugins": ["@ghilteras/opencode-agent-memory@2.2.0"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -93,6 +93,24 @@ Text search runs one MiniSearch BM25+ pass over title and body. Query terms are
|
|
|
93
93
|
|
|
94
94
|
Optional LLM reranking fuses the BM25 order with a listwise ranking of the BM25 top-50 candidates using reciprocal rank fusion (RRF, k=10). In a 30-task pilot, Hit@5 increased from 0.500 to 0.700, Hit@10 from 0.667 to 0.767, and MRR@10 from 0.321 to 0.528. A shuffled-candidate control also achieved Hit@5 of 0.700, and the direction of improvement survived pessimistic relevance judgments. The result was not statistically significant (p=0.070, n=30); this is preliminary evidence, not a guaranteed improvement. The pilot used one cloud call per query, costing approximately US$0.001 and taking about 2–3 seconds. Results, cost, and latency depend on the model, provider, and journal; these pilot measurements are not a benchmark of every supported configuration or a production service guarantee. This pilot was not an independent evaluation and does not establish a generalizable quality improvement.
|
|
95
95
|
|
|
96
|
+
#### Provider quirks
|
|
97
|
+
|
|
98
|
+
Some gateways require extra request headers. Reasoning models must be told not to reason so they return the requested labels within the completion budget. For the OpenCode inference gateway, use a stable UUID generated once:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"enabled": true,
|
|
103
|
+
"baseURL": "https://opencode.ai/inference/go/openai/v1",
|
|
104
|
+
"model": "deepseek-v4.1-flash",
|
|
105
|
+
"request": {
|
|
106
|
+
"headers": { "x-opencode-session": "<a stable UUID you generate once>" },
|
|
107
|
+
"body": { "reasoning_effort": "none" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Without `reasoning_effort: "none"`, a reasoning model spends the entire completion budget and returns empty content; the plugin then falls back to BM25.
|
|
113
|
+
|
|
96
114
|
Reranking is off by default. Without explicit enablement, journal search remains local and credential-free, with no reranking network requests. When enabled, each eligible search sends your query and the titles and excerpts of up to 50 filtered journal candidates to the HTTPS endpoint you configure. Titles are limited to 200 Unicode code points and excerpts to 600; sensitive information appearing in those fields is not automatically redacted. The plugin does not send journal paths, entry IDs, metadata, session history, or unrelated entries, and does not silently reuse OpenCode credentials. Your configured endpoint receives the dedicated API credential and processes journal content under its own policies. Reranking makes at most one request per search; failures return BM25 without retry. Requests may remain billable after timeout or cancellation.
|
|
97
115
|
|
|
98
116
|
Configure reranking in `~/.config/opencode/agent-memory-rerank.json`, alongside `agent-memory.json`:
|
|
@@ -106,7 +124,8 @@ Set `AGENT_MEMORY_RERANK_API_KEY` in the environment of the process that runs Op
|
|
|
106
124
|
Constraints and pagination:
|
|
107
125
|
|
|
108
126
|
- HTTPS only; plaintext `http` is rejected, including for localhost.
|
|
109
|
-
- One request per search, with a 5-second deadline, request and response limits of 65,536 bytes each,
|
|
127
|
+
- One request per search, with a 5-second deadline, request and response limits of 65,536 bytes each, 512 completion tokens by default (overridable via `request.body`), and no retries.
|
|
128
|
+
- `request.body` may add or override any scalar field except `model`, `messages`, and `stream` (up to 10 fields; for example `reasoning_effort`, `temperature`, or `max_tokens`). The plugin always sends `max_tokens` (512 by default), so a provider that requires `max_completion_tokens` instead of `max_tokens` cannot be used.
|
|
110
129
|
- Only the first 50 candidates can move; `total` is unchanged. A page is reranked only when it fits entirely inside that top-50 window; other pages keep plain BM25 order.
|
|
111
130
|
- Searches without non-whitespace `text` are never reranked.
|
|
112
131
|
- Enabled ordering is **not reproducible** across calls, so offset pagination is a slice of each call's ranking, not a stable snapshot.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Journal-only memory plugin for OpenCode: tagged, searchable, append-only journal with semantic search.",
|
|
5
5
|
"author": "Angelo Pantano <ghilteras@protonmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/rerank-config.ts
CHANGED
|
@@ -3,16 +3,55 @@ import * as path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { loadConfig } from "./journal";
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type RerankRequestConfig = { headers?: Record<string, string>; body?: Record<string, string | number | boolean> };
|
|
7
|
+
export type RerankConfig = { enabled: true; baseURL: string; model: string; request?: RerankRequestConfig } | { enabled: false };
|
|
7
8
|
|
|
8
9
|
export type RerankConfigLoad =
|
|
9
10
|
| { status: "disabled" }
|
|
10
11
|
| { status: "invalid"; reason: string }
|
|
11
|
-
| { status: "enabled"; config: { baseURL: string; model: string } };
|
|
12
|
+
| { status: "enabled"; config: { baseURL: string; model: string; request?: RerankRequestConfig } };
|
|
13
|
+
|
|
14
|
+
const forbiddenHeaders = new Set(["authorization", "content-type", "host", "content-length", "content-encoding", "transfer-encoding"]);
|
|
15
|
+
const forbiddenBodyKeys = new Set(["model", "messages", "stream"]);
|
|
16
|
+
|
|
17
|
+
function validateRequest(value: unknown): { valid: true; request?: RerankRequestConfig } | { valid: false } {
|
|
18
|
+
if (value === undefined) return { valid: true };
|
|
19
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { valid: false };
|
|
20
|
+
const request = value as Record<string, unknown>;
|
|
21
|
+
if (Object.keys(request).some((key) => key !== "headers" && key !== "body")) return { valid: false };
|
|
22
|
+
const result: RerankRequestConfig = {};
|
|
23
|
+
if (request.headers !== undefined) {
|
|
24
|
+
if (request.headers === null || typeof request.headers !== "object" || Array.isArray(request.headers)) return { valid: false };
|
|
25
|
+
const headers = request.headers as Record<string, unknown>;
|
|
26
|
+
const entries = Object.entries(headers);
|
|
27
|
+
if (entries.length > 10) return { valid: false };
|
|
28
|
+
const output: Record<string, string> = {};
|
|
29
|
+
for (const [key, item] of entries) {
|
|
30
|
+
if (!/^[A-Za-z0-9-]+$/.test(key) || forbiddenHeaders.has(key.toLowerCase()) || typeof item !== "string" ||
|
|
31
|
+
item.length === 0 || item.length > 1024 || /[\r\n\0]/.test(item)) return { valid: false };
|
|
32
|
+
output[key] = item;
|
|
33
|
+
}
|
|
34
|
+
result.headers = output;
|
|
35
|
+
}
|
|
36
|
+
if (request.body !== undefined) {
|
|
37
|
+
if (request.body === null || typeof request.body !== "object" || Array.isArray(request.body)) return { valid: false };
|
|
38
|
+
const body = request.body as Record<string, unknown>;
|
|
39
|
+
const entries = Object.entries(body);
|
|
40
|
+
if (entries.length > 10) return { valid: false };
|
|
41
|
+
const output: Record<string, string | number | boolean> = Object.create(null) as Record<string, string | number | boolean>;
|
|
42
|
+
for (const [key, item] of entries) {
|
|
43
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(key) || forbiddenBodyKeys.has(key) ||
|
|
44
|
+
!(typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item))) ||
|
|
45
|
+
(typeof item === "string" && item.length > 1024)) return { valid: false };
|
|
46
|
+
output[key] = item;
|
|
47
|
+
}
|
|
48
|
+
result.body = output;
|
|
49
|
+
}
|
|
50
|
+
return { valid: true, ...(Object.keys(result).length ? { request: result } : {}) };
|
|
51
|
+
}
|
|
12
52
|
|
|
13
53
|
function duplicateTopLevelKey(raw: string): string | undefined {
|
|
14
|
-
const
|
|
15
|
-
let objectDepth = 0;
|
|
54
|
+
const objects: { path: string[]; keys: Set<string>; pendingKey?: string }[] = [];
|
|
16
55
|
let arrayDepth = 0;
|
|
17
56
|
for (let i = 0; i < raw.length;) {
|
|
18
57
|
const char = raw[i]!;
|
|
@@ -25,19 +64,26 @@ function duplicateTopLevelKey(raw: string): string | undefined {
|
|
|
25
64
|
else if (current === "\\") escaped = true;
|
|
26
65
|
else if (current === '"') break;
|
|
27
66
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
const current = objects.at(-1);
|
|
68
|
+
let next = i;
|
|
69
|
+
while (/\s/.test(raw[next] ?? "")) next++;
|
|
70
|
+
if (current && raw[next] === ":") {
|
|
71
|
+
const key = JSON.parse(raw.slice(start, i)) as string;
|
|
72
|
+
if (arrayDepth === 0 && (current.path.length === 0 ||
|
|
73
|
+
(current.path.length === 2 && current.path[0] === "request" &&
|
|
74
|
+
(current.path[1] === "headers" || current.path[1] === "body")))) {
|
|
75
|
+
if (current.keys.has(key)) return key;
|
|
76
|
+
current.keys.add(key);
|
|
35
77
|
}
|
|
78
|
+
current.pendingKey = key;
|
|
36
79
|
}
|
|
37
80
|
continue;
|
|
38
81
|
}
|
|
39
|
-
if (char === "{")
|
|
40
|
-
|
|
82
|
+
if (char === "{") {
|
|
83
|
+
const parent = objects.at(-1);
|
|
84
|
+
objects.push({ path: parent ? [...parent.path, parent.pendingKey ?? ""] : [], keys: new Set() });
|
|
85
|
+
if (parent) parent.pendingKey = undefined;
|
|
86
|
+
} else if (char === "}") objects.pop();
|
|
41
87
|
else if (char === "[") arrayDepth++;
|
|
42
88
|
else if (char === "]") arrayDepth--;
|
|
43
89
|
i++;
|
|
@@ -71,7 +117,7 @@ export async function loadRerankConfig(configDir?: string): Promise<RerankConfig
|
|
|
71
117
|
if (duplicate !== undefined) return { status: "invalid", reason: `duplicate config key: ${duplicate}` };
|
|
72
118
|
|
|
73
119
|
const config = value as Record<string, unknown>;
|
|
74
|
-
if (Object.keys(config).some((key) => !["enabled", "baseURL", "model"].includes(key))) {
|
|
120
|
+
if (Object.keys(config).some((key) => !["enabled", "baseURL", "model", "request"].includes(key))) {
|
|
75
121
|
return { status: "invalid", reason: "unknown config field" };
|
|
76
122
|
}
|
|
77
123
|
if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
|
|
@@ -83,6 +129,8 @@ export async function loadRerankConfig(configDir?: string): Promise<RerankConfig
|
|
|
83
129
|
if (config.model !== undefined && typeof config.model !== "string") {
|
|
84
130
|
return { status: "invalid", reason: "model must be a string" };
|
|
85
131
|
}
|
|
132
|
+
const request = validateRequest(config.request);
|
|
133
|
+
if (!request.valid) return { status: "invalid", reason: "request must contain valid headers and body" };
|
|
86
134
|
if (config.enabled !== true) return { status: "disabled" };
|
|
87
135
|
if (typeof config.baseURL !== "string" || typeof config.model !== "string" || config.model.trim().length === 0) {
|
|
88
136
|
return { status: "invalid", reason: "enabled config requires baseURL and non-empty model" };
|
|
@@ -99,5 +147,5 @@ export async function loadRerankConfig(configDir?: string): Promise<RerankConfig
|
|
|
99
147
|
}
|
|
100
148
|
const baseURL = config.baseURL.replace(/\/+$/, "");
|
|
101
149
|
if (!baseURL) return { status: "invalid", reason: "baseURL is invalid" };
|
|
102
|
-
return { status: "enabled", config: { baseURL, model: config.model } };
|
|
150
|
+
return { status: "enabled", config: { baseURL, model: config.model, ...(request.request ? { request: request.request } : {}) } };
|
|
103
151
|
}
|
package/src/rerank-transport.ts
CHANGED
|
@@ -51,6 +51,7 @@ export async function requestRerank(args: {
|
|
|
51
51
|
signal?: AbortSignal;
|
|
52
52
|
fetchImpl?: typeof fetch;
|
|
53
53
|
timeoutMs?: number;
|
|
54
|
+
headers?: Record<string, string>;
|
|
54
55
|
}): Promise<RerankOutcome> {
|
|
55
56
|
if (typeof args.apiKey !== "string" || args.apiKey.length === 0) return { ok: false, reason: "missing_credential" };
|
|
56
57
|
try {
|
|
@@ -84,7 +85,7 @@ export async function requestRerank(args: {
|
|
|
84
85
|
try {
|
|
85
86
|
response = await raceAbort(fetchImpl(`${args.baseURL}/chat/completions`, {
|
|
86
87
|
method: "POST",
|
|
87
|
-
headers: { Authorization: `Bearer ${args.apiKey}`, "Content-Type": "application/json" },
|
|
88
|
+
headers: { Authorization: `Bearer ${args.apiKey}`, "Content-Type": "application/json", ...args.headers },
|
|
88
89
|
body: serialized,
|
|
89
90
|
redirect: "error",
|
|
90
91
|
signal,
|
package/src/rerank.ts
CHANGED
|
@@ -30,24 +30,35 @@ export function buildRequestBody(args: {
|
|
|
30
30
|
query: string;
|
|
31
31
|
candidates: RerankCandidate[];
|
|
32
32
|
count: number;
|
|
33
|
+
extraBody?: Record<string, string | number | boolean>;
|
|
33
34
|
}): {
|
|
35
|
+
[key: string]: unknown;
|
|
34
36
|
model: string;
|
|
35
37
|
messages: Array<{ role: "system" | "user"; content: string }>;
|
|
36
38
|
temperature: 0;
|
|
37
|
-
|
|
39
|
+
max_tokens: 512;
|
|
38
40
|
stream: false;
|
|
39
41
|
} {
|
|
40
42
|
const count = Math.min(10, args.candidates.length);
|
|
41
|
-
|
|
43
|
+
const base: {
|
|
44
|
+
model: string;
|
|
45
|
+
messages: Array<{ role: "system" | "user"; content: string }>;
|
|
46
|
+
temperature: 0;
|
|
47
|
+
max_tokens: 512;
|
|
48
|
+
stream: false;
|
|
49
|
+
} = {
|
|
42
50
|
model: args.model,
|
|
43
51
|
messages: [
|
|
44
52
|
{ role: "system", content: RERANK_SYSTEM_PROMPT },
|
|
45
53
|
{ role: "user", content: JSON.stringify({ query: args.query, count, candidates: args.candidates }) },
|
|
46
54
|
],
|
|
47
55
|
temperature: 0,
|
|
48
|
-
|
|
56
|
+
max_tokens: 512,
|
|
49
57
|
stream: false,
|
|
50
58
|
};
|
|
59
|
+
const extraBody = Object.fromEntries(Object.entries(args.extraBody ?? {}).filter(([key]) =>
|
|
60
|
+
key !== "model" && key !== "messages" && key !== "stream"));
|
|
61
|
+
return { ...base, ...extraBody, model: base.model, messages: base.messages, stream: base.stream };
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export function parseLabels(content: unknown, candidates: RerankCandidate[], count: number): string[] | null {
|
package/src/tools.ts
CHANGED
|
@@ -147,10 +147,11 @@ export function JournalSearch(store: JournalStore, deps: RerankDependencies = {}
|
|
|
147
147
|
const outcome = await request({
|
|
148
148
|
baseURL: config.config.baseURL,
|
|
149
149
|
apiKey: getEnv("AGENT_MEMORY_RERANK_API_KEY") ?? "",
|
|
150
|
-
body: buildRequestBody({ model: config.config.model, query: query.text, candidates, count }),
|
|
150
|
+
body: buildRequestBody({ model: config.config.model, query: query.text, candidates, count, extraBody: config.config.request?.body }),
|
|
151
151
|
candidates,
|
|
152
152
|
count,
|
|
153
153
|
signal: toolCtx.signal,
|
|
154
|
+
headers: config.config.request?.headers,
|
|
154
155
|
});
|
|
155
156
|
if (!outcome.ok) {
|
|
156
157
|
ranking = { method: "bm25", status: "fallback", reason: outcome.reason };
|