@dzhi/ocpg 0.4.1 → 0.6.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.
Files changed (2) hide show
  1. package/ocpg.ts +94 -64
  2. package/package.json +3 -5
package/ocpg.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  // ocpg - DB access layer for OpenCode persistent memory plugin.
2
2
  import { SQL } from "bun";
3
- import type { Plugin } from "@opencode-ai/plugin";
4
- import { tool } from "@opencode-ai/plugin";
5
- import { z } from "zod";
3
+ import { Plugin } from "@opencode/plugin";
6
4
 
7
5
  // --- DB config: plugin options > env > defaults. Password is deliberately env-only (never in config).
8
6
  type DbOptions = {
@@ -12,6 +10,8 @@ type DbOptions = {
12
10
  database?: string;
13
11
  };
14
12
  type DbConfig = Required<DbOptions>;
13
+ type RecallArgs = { query?: string; global?: boolean; limit?: number };
14
+ type RememberArgs = { content: string; tags?: string[] };
15
15
 
16
16
  // Defaults resolved ONCE at module init — the pass lookup here is the only permitted spawn in this file.
17
17
  const defaultConfig: DbConfig = {
@@ -41,7 +41,7 @@ function makeSql(cfg: DbConfig): SQL {
41
41
 
42
42
  let sql = makeSql(defaultConfig);
43
43
 
44
- // Swap the pool when the plugin loads with config options (["pentago/ocpg", {...}] in opencode.json).
44
+ // Swap the pool when the plugin loads with config options ({ "package": "@dzhi/ocpg", "options": {...} } in opencode.jsonc).
45
45
  // No-op without options so the module-level env/default config stands. Pools are lazy — a never-connected pool closes cleanly.
46
46
  function reconfigure(options?: DbOptions): void {
47
47
  if (!options) return;
@@ -69,20 +69,15 @@ function rateLimitOk(kind: string): boolean {
69
69
  return true;
70
70
  }
71
71
 
72
- let pluginClient: { app: { log: (entry: unknown) => void } } | null = null;
73
-
74
- function setClient(client: { app: { log: (entry: unknown) => void } }): void {
75
- pluginClient = client;
72
+ // Test hook: the 60s rate-limit window is module state; tests clear it for determinism.
73
+ function resetRateLimit(): void {
74
+ lastLogTime.clear();
76
75
  }
77
76
 
78
- function logError(
79
- client: { app: { log: (entry: unknown) => void } } | null,
80
- message: string,
81
- ): void {
82
- const c = client ?? pluginClient;
83
- if (!c?.app?.log) return;
77
+ // V2 plugins have no client.app.log; console.error from plugin code lands in the server log.
78
+ function logError(message: string): void {
84
79
  if (!rateLimitOk("db-error")) return;
85
- c.app.log({ body: { service: "ocpg", level: "error", message } });
80
+ console.error(`[ocpg] ${message}`);
86
81
  }
87
82
 
88
83
  // --- Injection pipeline ---
@@ -114,7 +109,7 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
114
109
  const injectionCache = new Map<string, string>();
115
110
 
116
111
  async function handleTransform(
117
- input: { sessionID?: string; model: Record<string, unknown> },
112
+ input: { sessionID?: string; model?: unknown },
118
113
  output: { system: string[] },
119
114
  directory: string,
120
115
  ): Promise<void> {
@@ -143,7 +138,7 @@ async function handleTransform(
143
138
  injectionCache.set(sid, block);
144
139
  output.system.push(block);
145
140
  } catch (e: unknown) {
146
- logError(null, `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
141
+ logError(`ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
147
142
  }
148
143
  }
149
144
 
@@ -162,7 +157,7 @@ function normalizeTags(tags: string[] | undefined, projectDir: string): string[]
162
157
  }
163
158
 
164
159
  async function recall(
165
- args: { query?: string; global?: boolean; limit?: number },
160
+ args: RecallArgs,
166
161
  ctx: { directory: string },
167
162
  ): Promise<string> {
168
163
  try {
@@ -171,8 +166,12 @@ async function recall(
171
166
  ? sql``
172
167
  : sql`AND project = ${ctx.directory}`;
173
168
  const queryCond = args.query
174
- ? sql`AND search_vector @@ plainto_tsquery('english', ${args.query})`
169
+ ? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
175
170
  : sql``;
171
+ // Relevance-ranked when searching; recency-ordered for a plain project browse.
172
+ const orderBy = args.query
173
+ ? sql`ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', ${args.query})) DESC`
174
+ : sql`ORDER BY created_at DESC`;
176
175
 
177
176
  const rows = await sql`
178
177
  SELECT id, content, coalesce(tags, '{}') AS tags,
@@ -180,7 +179,7 @@ async function recall(
180
179
  project
181
180
  FROM memories
182
181
  WHERE 1=1 ${projectCond} ${queryCond}
183
- ORDER BY created_at DESC
182
+ ${orderBy}
184
183
  LIMIT ${limit}
185
184
  ` as MemoryRow[];
186
185
 
@@ -194,13 +193,13 @@ async function recall(
194
193
  })
195
194
  .join('\n---\n');
196
195
  } catch (e: unknown) {
197
- logError(null, `ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
196
+ logError(`ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
198
197
  return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
199
198
  }
200
199
  }
201
200
 
202
201
  async function remember(
203
- args: { content: string; tags?: string[] },
202
+ args: RememberArgs,
204
203
  ctx: { directory: string; sessionID: string },
205
204
  ): Promise<string> {
206
205
  try {
@@ -232,14 +231,14 @@ async function remember(
232
231
 
233
232
  const inserted = await sql`
234
233
  INSERT INTO memories (content, tags, session_id, project)
235
- VALUES (${args.content}, ${normalizedTags}, ${ctx.sessionID}, ${ctx.directory})
234
+ VALUES (${args.content}, ${sql.array(normalizedTags)}, ${ctx.sessionID}, ${ctx.directory})
236
235
  RETURNING id
237
236
  ` as { id: number }[];
238
237
 
239
238
  invalidateInjection(ctx.sessionID);
240
239
  return `Stored memory #${inserted[0].id} for project ${basename}.`;
241
240
  } catch (e: unknown) {
242
- logError(null, `ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
241
+ logError(`ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
243
242
  return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
244
243
  }
245
244
  }
@@ -248,6 +247,73 @@ function invalidateInjection(sessionID: string): void {
248
247
  injectionCache.delete(sessionID);
249
248
  }
250
249
 
250
+ // V2 entrypoint: registers the system-context injection hook and the agent tools
251
+ // through the plugin context. Directory comes from the plugin's load location
252
+ // (per-project instance, same semantics as V1's client.directory); sessionID
253
+ // comes from the tool execution context.
254
+ const ocpg = Plugin.define({
255
+ id: "ocpg",
256
+ async setup(ctx) {
257
+ reconfigure(ctx.options as DbOptions | undefined);
258
+ const directory = ctx.location.directory;
259
+
260
+ // Inject project memories into every model request's system context.
261
+ // handleTransform owns the per-session cache (32-slot, invalidated on remember).
262
+ await ctx.session.hook("context", async (event) => {
263
+ const output: { system: string[] } = { system: [] };
264
+ await handleTransform({ sessionID: event.sessionID, model: event.model }, output, directory);
265
+ for (const text of output.system) event.system.push({ type: "text", text });
266
+ });
267
+
268
+ // Agent tools: recall + remember with dedup-on-write. Input schemas are raw
269
+ // JSON Schema (V2 contract); content length is enforced in remember().
270
+ await ctx.tool.transform((editor) => {
271
+ editor.add({
272
+ name: "memory_recall",
273
+ description:
274
+ "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
275
+ input: {
276
+ type: "object",
277
+ properties: {
278
+ query: { type: "string", description: "Full-text search string; omit for the latest memories" },
279
+ global: { type: "boolean", description: "Search across all projects (default: current project only)" },
280
+ limit: { type: "number", description: "1-20, default 5" },
281
+ },
282
+ additionalProperties: false,
283
+ },
284
+ execute: async (input) => {
285
+ return { content: await recall(input as RecallArgs, { directory }) };
286
+ },
287
+ });
288
+ editor.add({
289
+ name: "memory_remember",
290
+ description:
291
+ "Store a memory for this project. Use after user corrections, architecture decisions, or non-trivial fixes.",
292
+ input: {
293
+ type: "object",
294
+ properties: {
295
+ content: { type: "string", description: "1-3 self-contained sentences capturing the why" },
296
+ tags: {
297
+ type: "array",
298
+ items: { type: "string" },
299
+ description:
300
+ "Category prefixes: preference, decision, debug, env, architecture, workaround, language:<x>, framework:<x>, tool:<x>",
301
+ },
302
+ },
303
+ required: ["content"],
304
+ additionalProperties: false,
305
+ },
306
+ execute: async (input, tool) => {
307
+ return { content: await remember(input as RememberArgs, { directory, sessionID: tool.sessionID }) };
308
+ },
309
+ });
310
+ });
311
+
312
+ // Close the SQL pool when the plugin unloads.
313
+ return dispose;
314
+ },
315
+ });
316
+
251
317
  const __internals = {
252
318
  get sql() {
253
319
  return sql;
@@ -262,48 +328,12 @@ const __internals = {
262
328
  invalidateInjection,
263
329
  logError,
264
330
  rateLimitOk,
265
- setClient,
331
+ resetRateLimit,
266
332
  dispose,
267
333
  };
268
334
 
269
- const plugin = (async (client, options) => {
270
- setClient(client);
271
- reconfigure(options as DbOptions | undefined);
272
- return {
273
- "experimental.chat.system.transform": async (input, output) => {
274
- await handleTransform(input, output, client.directory);
275
- },
276
- tool: {
277
- memory_recall: tool({
278
- description:
279
- "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
280
- args: {
281
- query: z.string().optional(),
282
- global: z.boolean().optional(),
283
- limit: z.number().optional(),
284
- },
285
- execute: async (args, ctx) => {
286
- return await recall(args, ctx);
287
- },
288
- }),
289
- memory_remember: tool({
290
- description:
291
- "Store a memory for this project. Use after user corrections, architecture decisions, or non-trivial fixes.",
292
- args: {
293
- content: z.string().min(10),
294
- tags: z.array(z.string()).optional(),
295
- },
296
- execute: async (args, ctx) => {
297
- return await remember(args, ctx);
298
- },
299
- }),
300
- },
301
- dispose,
302
- };
303
- }) satisfies Plugin;
304
-
305
- // Attach internals to the default export instead of as a named export: opencode's
306
- // plugin loader rejects modules whose exports aren't all plugin entry functions.
307
- export default Object.assign(plugin, { __internals }) as typeof plugin & {
335
+ // Attach test internals to the default export instead of as a named export
336
+ // (established contract; module exports stay limited to default).
337
+ export default Object.assign(ocpg, { __internals }) as typeof ocpg & {
308
338
  __internals: typeof __internals;
309
339
  };
package/package.json CHANGED
@@ -1,19 +1,17 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Postgres-backed persistent memory plugin for OpenCode",
6
6
  "main": "ocpg.ts",
7
7
  "exports": {
8
- ".": "./ocpg.ts",
9
- "./server": "./ocpg.ts"
8
+ ".": "./ocpg.ts"
10
9
  },
11
10
  "files": [
12
11
  "ocpg.ts"
13
12
  ],
14
13
  "dependencies": {
15
- "@opencode-ai/plugin": "^1.18.29",
16
- "zod": "^4.1.8"
14
+ "@opencode/plugin": "^2.0.4"
17
15
  },
18
16
  "keywords": [
19
17
  "opencode-plugin",