@dzhi/ocpg 0.1.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 (3) hide show
  1. package/README.md +56 -0
  2. package/ocpg.ts +309 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # ocpg
2
+
3
+
4
+ Postgres-backed persistent memory plugin for [OpenCode](https://opencode.ai). Replaces the previous Postgres MCP memory server — same database, same schema, no MCP process to run.
5
+
6
+ Uses the existing `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`). Injects recent project memories into the system prompt and exposes `memory_recall` / `memory_remember` tools.
7
+
8
+ ## Install
9
+
10
+ In `opencode.json`:
11
+
12
+ ```json
13
+ {
14
+ "plugin": [
15
+ ["@dzhi/ocpg", {}]
16
+ ]
17
+ }
18
+ ```
19
+
20
+ ## Connecting to the database
21
+
22
+ Pass connection params as the plugin options tuple (all optional):
23
+
24
+ ```json
25
+ {
26
+ "plugin": [
27
+ [
28
+ "@dzhi/ocpg",
29
+ {
30
+ "host": "localhost",
31
+ "port": 5432,
32
+ "user": "pguser",
33
+ "database": "agent-memory"
34
+ }
35
+ ]
36
+ ]
37
+ }
38
+ ```
39
+
40
+ Precedence: plugin options > env vars > defaults.
41
+
42
+ | Param | Env var (fallback) | Default |
43
+ | ---------- | ------------------ | -------------- |
44
+ | `host` | `OCPG_HOST` | `localhost` |
45
+ | `port` | `OCPG_PORT` | `5432` |
46
+ | `user` | `OCPG_USER` | `pguser` |
47
+ | `database` | `OCPG_DB` | `agent-memory` |
48
+
49
+ **Password is never set via params** — it resolves from `OCPG_PASSWORD`, falling back to `pass show postgres-workstation-password`.
50
+
51
+ ## Tools
52
+
53
+ - `memory_remember` — store a memory (dedups against similar entries per project)
54
+ - `memory_recall` — search past memories (`query`, `global`, `limit`)
55
+
56
+ Memories are project-scoped by working directory; use `global: true` on recall to search across projects.
package/ocpg.ts ADDED
@@ -0,0 +1,309 @@
1
+ // ocpg - DB access layer for OpenCode persistent memory plugin.
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";
6
+
7
+ // --- DB config: plugin options > env > defaults. Password is deliberately env-only (never in config).
8
+ type DbOptions = {
9
+ host?: string;
10
+ port?: number;
11
+ user?: string;
12
+ database?: string;
13
+ };
14
+ type DbConfig = Required<DbOptions>;
15
+
16
+ // Defaults resolved ONCE at module init — the pass lookup here is the only permitted spawn in this file.
17
+ const defaultConfig: DbConfig = {
18
+ host: process.env.OCPG_HOST || "localhost",
19
+ port: Number(process.env.OCPG_PORT) || 5432,
20
+ user: process.env.OCPG_USER || "pguser",
21
+ database: process.env.OCPG_DB || "agent-memory",
22
+ };
23
+ const password =
24
+ process.env.OCPG_PASSWORD ||
25
+ Bun.spawnSync(["pass", "show", "postgres-workstation-password"])
26
+ .stdout.toString()
27
+ .trim();
28
+
29
+ // Options-object constructor, not a URL string: Bun's SQL parses string URLs via
30
+ // url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
31
+ function makeSql(cfg: DbConfig): SQL {
32
+ return new SQL({
33
+ hostname: cfg.host,
34
+ port: cfg.port,
35
+ username: cfg.user,
36
+ password,
37
+ database: cfg.database,
38
+ max: 2,
39
+ });
40
+ }
41
+
42
+ let sql = makeSql(defaultConfig);
43
+
44
+ // Swap the pool when the plugin loads with config options (["pentago/ocpg", {...}] in opencode.json).
45
+ // No-op without options so the module-level env/default config stands. Pools are lazy — a never-connected pool closes cleanly.
46
+ function reconfigure(options?: DbOptions): void {
47
+ if (!options) return;
48
+ void sql.close().catch(() => {});
49
+ sql = makeSql({ ...defaultConfig, ...options });
50
+ }
51
+
52
+ export interface MemoryRow {
53
+ id: number;
54
+ content: string;
55
+ tags: string[] | null;
56
+ project: string;
57
+ date: string;
58
+ }
59
+
60
+ // --- Rate-limited error logging ---
61
+
62
+ const lastLogTime = new Map<string, number>();
63
+
64
+ function rateLimitOk(kind: string): boolean {
65
+ const now = Date.now();
66
+ const last = lastLogTime.get(kind) ?? 0;
67
+ if (now - last < 60_000) return false;
68
+ lastLogTime.set(kind, now);
69
+ return true;
70
+ }
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;
76
+ }
77
+
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;
84
+ if (!rateLimitOk("db-error")) return;
85
+ c.app.log({ body: { service: "ocpg", level: "error", message } });
86
+ }
87
+
88
+ // --- Injection pipeline ---
89
+
90
+ function truncateMemory(content: string): string {
91
+ if (content.length <= 600) return content;
92
+ return content.slice(0, 600) + "…[truncated]";
93
+ }
94
+
95
+ function formatBlock(rows: MemoryRow[], projectDir: string): string {
96
+ const lines: string[] = [
97
+ "<persistent-project-memory>",
98
+ `Project: ${projectDir}`,
99
+ "Memories:",
100
+ ];
101
+ for (const row of rows) {
102
+ const tags = row.tags ?? [];
103
+ const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
104
+ lines.push(`- [${row.date}]${tagStr} ${truncateMemory(row.content)}`);
105
+ }
106
+ lines.push("");
107
+ lines.push(
108
+ "Before non-trivial work, check these. After user corrections, architecture decisions, or non-trivial fixes, call memory_remember. Use memory_recall to search past lessons.",
109
+ );
110
+ lines.push("</persistent-project-memory>");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ const injectionCache = new Map<string, string>();
115
+
116
+ async function handleTransform(
117
+ input: { sessionID?: string; model: Record<string, unknown> },
118
+ output: { system: string[] },
119
+ directory: string,
120
+ ): Promise<void> {
121
+ if (!input.sessionID) return;
122
+ const sid = input.sessionID;
123
+ const cached = injectionCache.get(sid);
124
+ if (cached !== undefined) {
125
+ output.system.push(cached);
126
+ return;
127
+ }
128
+ try {
129
+ const rows = await sql`
130
+ SELECT content, coalesce(tags, '{}') AS tags,
131
+ to_char(created_at, 'YYYY-MM-DD') AS date
132
+ FROM memories
133
+ WHERE project = ${directory}
134
+ ORDER BY created_at DESC
135
+ LIMIT 5
136
+ ` as MemoryRow[];
137
+ const block = formatBlock(rows, directory);
138
+ // Evict oldest entry when cache exceeds 32
139
+ if (injectionCache.size >= 32) {
140
+ const firstKey = injectionCache.keys().next().value!;
141
+ injectionCache.delete(firstKey);
142
+ }
143
+ injectionCache.set(sid, block);
144
+ output.system.push(block);
145
+ } catch (e: unknown) {
146
+ logError(null, `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
147
+ }
148
+ }
149
+
150
+ // --- Dispose ---
151
+
152
+ async function dispose(): Promise<void> {
153
+ await sql.close().catch(() => {});
154
+ }
155
+
156
+ // --- Agent tools: recall + remember with dedup-on-write ---
157
+
158
+ function normalizeTags(tags: string[] | undefined, projectDir: string): string[] {
159
+ const input = tags ?? [];
160
+ const base = projectDir.split('/').pop() ?? projectDir;
161
+ return [...input, `project:${base}`];
162
+ }
163
+
164
+ async function recall(
165
+ args: { query?: string; global?: boolean; limit?: number },
166
+ ctx: { directory: string },
167
+ ): Promise<string> {
168
+ try {
169
+ const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
170
+ const projectCond = args.global
171
+ ? sql``
172
+ : sql`AND project = ${ctx.directory}`;
173
+ const queryCond = args.query
174
+ ? sql`AND search_vector @@ plainto_tsquery('english', ${args.query})`
175
+ : sql``;
176
+
177
+ const rows = await sql`
178
+ SELECT id, content, coalesce(tags, '{}') AS tags,
179
+ to_char(created_at, 'YYYY-MM-DD') AS date,
180
+ project
181
+ FROM memories
182
+ WHERE 1=1 ${projectCond} ${queryCond}
183
+ ORDER BY created_at DESC
184
+ LIMIT ${limit}
185
+ ` as MemoryRow[];
186
+
187
+ if (rows.length === 0) return "No memories found.";
188
+
189
+ return rows
190
+ .map((r) => {
191
+ const tags = r.tags ?? [];
192
+ const tagStr = tags.length ? ` (${tags.join(', ')})` : '';
193
+ return `[${r.date}] [${r.project}]${tagStr}\n#${r.id}\n${r.content}`;
194
+ })
195
+ .join('\n---\n');
196
+ } catch (e: unknown) {
197
+ logError(null, `ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
198
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
199
+ }
200
+ }
201
+
202
+ async function remember(
203
+ args: { content: string; tags?: string[] },
204
+ ctx: { directory: string; sessionID: string },
205
+ ): Promise<string> {
206
+ try {
207
+ if (args.content.length < 10) {
208
+ return 'ERROR: content must be at least 10 characters.';
209
+ }
210
+
211
+ const normalizedTags = normalizeTags(args.tags, ctx.directory);
212
+ const basename = ctx.directory.split('/').pop() ?? ctx.directory;
213
+
214
+ // Dedup: project-scoped, common-opening-words AND-match via FTS
215
+ const dedup = await sql`
216
+ SELECT id FROM memories
217
+ WHERE project = ${ctx.directory}
218
+ AND (
219
+ content = ${args.content}
220
+ OR search_vector @@ plainto_tsquery('english', ${args.content.slice(0, 60)})
221
+ )
222
+ ORDER BY created_at DESC
223
+ LIMIT 1
224
+ ` as { id: number }[];
225
+
226
+ if (dedup.length > 0) {
227
+ // ponytail: dedup uses common-opening-words AND-match across rows via FTS
228
+ // so false positives are expected; upgrade path = pg_trgm similarity or
229
+ // wider dedup scope.
230
+ return `Similar memory already stored as #${dedup[0].id} for this project; skipping insert.`;
231
+ }
232
+
233
+ const inserted = await sql`
234
+ INSERT INTO memories (content, tags, session_id, project)
235
+ VALUES (${args.content}, ${normalizedTags}, ${ctx.sessionID}, ${ctx.directory})
236
+ RETURNING id
237
+ ` as { id: number }[];
238
+
239
+ invalidateInjection(ctx.sessionID);
240
+ return `Stored memory #${inserted[0].id} for project ${basename}.`;
241
+ } catch (e: unknown) {
242
+ logError(null, `ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
243
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
244
+ }
245
+ }
246
+
247
+ function invalidateInjection(sessionID: string): void {
248
+ injectionCache.delete(sessionID);
249
+ }
250
+
251
+ const __internals = {
252
+ get sql() {
253
+ return sql;
254
+ },
255
+ reconfigure,
256
+ truncateMemory,
257
+ formatBlock,
258
+ handleTransform,
259
+ normalizeTags,
260
+ recall,
261
+ remember,
262
+ invalidateInjection,
263
+ logError,
264
+ rateLimitOk,
265
+ setClient,
266
+ dispose,
267
+ };
268
+
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 & {
308
+ __internals: typeof __internals;
309
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@dzhi/ocpg",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Postgres-backed persistent memory plugin for OpenCode",
6
+ "main": "ocpg.ts",
7
+ "exports": {
8
+ ".": "./ocpg.ts",
9
+ "./server": "./ocpg.ts"
10
+ },
11
+ "files": [
12
+ "ocpg.ts"
13
+ ],
14
+ "dependencies": {
15
+ "zod": "^4.1.8"
16
+ },
17
+ "keywords": [
18
+ "opencode-plugin",
19
+ "opencode",
20
+ "postgres",
21
+ "memory"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/pentago/ocpg.git"
27
+ }
28
+ }