@dzhi/ocpg 0.5.0 → 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 +88 -62
  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 {
@@ -198,13 +193,13 @@ async function recall(
198
193
  })
199
194
  .join('\n---\n');
200
195
  } catch (e: unknown) {
201
- logError(null, `ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
196
+ logError(`ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
202
197
  return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
203
198
  }
204
199
  }
205
200
 
206
201
  async function remember(
207
- args: { content: string; tags?: string[] },
202
+ args: RememberArgs,
208
203
  ctx: { directory: string; sessionID: string },
209
204
  ): Promise<string> {
210
205
  try {
@@ -236,14 +231,14 @@ async function remember(
236
231
 
237
232
  const inserted = await sql`
238
233
  INSERT INTO memories (content, tags, session_id, project)
239
- VALUES (${args.content}, ${normalizedTags}, ${ctx.sessionID}, ${ctx.directory})
234
+ VALUES (${args.content}, ${sql.array(normalizedTags)}, ${ctx.sessionID}, ${ctx.directory})
240
235
  RETURNING id
241
236
  ` as { id: number }[];
242
237
 
243
238
  invalidateInjection(ctx.sessionID);
244
239
  return `Stored memory #${inserted[0].id} for project ${basename}.`;
245
240
  } catch (e: unknown) {
246
- logError(null, `ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
241
+ logError(`ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
247
242
  return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
248
243
  }
249
244
  }
@@ -252,6 +247,73 @@ function invalidateInjection(sessionID: string): void {
252
247
  injectionCache.delete(sessionID);
253
248
  }
254
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
+
255
317
  const __internals = {
256
318
  get sql() {
257
319
  return sql;
@@ -266,48 +328,12 @@ const __internals = {
266
328
  invalidateInjection,
267
329
  logError,
268
330
  rateLimitOk,
269
- setClient,
331
+ resetRateLimit,
270
332
  dispose,
271
333
  };
272
334
 
273
- const plugin = (async (client, options) => {
274
- setClient(client);
275
- reconfigure(options as DbOptions | undefined);
276
- return {
277
- "experimental.chat.system.transform": async (input, output) => {
278
- await handleTransform(input, output, client.directory);
279
- },
280
- tool: {
281
- memory_recall: tool({
282
- description:
283
- "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
284
- args: {
285
- query: z.string().optional(),
286
- global: z.boolean().optional(),
287
- limit: z.number().optional(),
288
- },
289
- execute: async (args, ctx) => {
290
- return await recall(args, ctx);
291
- },
292
- }),
293
- memory_remember: tool({
294
- description:
295
- "Store a memory for this project. Use after user corrections, architecture decisions, or non-trivial fixes.",
296
- args: {
297
- content: z.string().min(10),
298
- tags: z.array(z.string()).optional(),
299
- },
300
- execute: async (args, ctx) => {
301
- return await remember(args, ctx);
302
- },
303
- }),
304
- },
305
- dispose,
306
- };
307
- }) satisfies Plugin;
308
-
309
- // Attach internals to the default export instead of as a named export: opencode's
310
- // plugin loader rejects modules whose exports aren't all plugin entry functions.
311
- 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 & {
312
338
  __internals: typeof __internals;
313
339
  };
package/package.json CHANGED
@@ -1,19 +1,17 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.5.0",
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",