@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.
- package/README.md +56 -0
- package/ocpg.ts +309 -0
- 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
|
+
}
|