@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 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.1.0"]
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, 256 completion tokens, and no retries.
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.1.0",
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",
@@ -3,16 +3,55 @@ import * as path from "node:path";
3
3
 
4
4
  import { loadConfig } from "./journal";
5
5
 
6
- export type RerankConfig = { enabled: true; baseURL: string; model: string } | { enabled: false };
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 keys = new Set<string>();
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
- if (objectDepth === 1 && arrayDepth === 0) {
29
- let next = i;
30
- while (/\s/.test(raw[next] ?? "")) next++;
31
- if (raw[next] === ":") {
32
- const key = JSON.parse(raw.slice(start, i)) as string;
33
- if (keys.has(key)) return key;
34
- keys.add(key);
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 === "{") objectDepth++;
40
- else if (char === "}") objectDepth--;
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
  }
@@ -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
- max_completion_tokens: 256;
39
+ max_tokens: 512;
38
40
  stream: false;
39
41
  } {
40
42
  const count = Math.min(10, args.candidates.length);
41
- return {
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
- max_completion_tokens: 256,
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 };