@dzhi/ocpg 0.7.0 → 0.10.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 +48 -6
- package/ocpg.ts +274 -68
- package/package.json +12 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
Postgres-backed persistent memory plugin for [OpenCode](https://opencode.ai).
|
|
5
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.
|
|
6
|
+
Uses the existing `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`) and the `pg_trgm` extension. Injects recent project memories into the system prompt and exposes `memory_recall` / `memory_remember` / `memory_forget` tools.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -19,7 +19,9 @@ In `opencode.json`:
|
|
|
19
19
|
|
|
20
20
|
## Connecting to the database
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Need a Postgres instance? The [`deploy/`](./deploy) directory ships a hardened Docker Compose setup (localhost-only, `memories` schema auto-created on first boot) - see [`deploy/README.md`](./deploy/README.md).
|
|
23
|
+
|
|
24
|
+
Configure the connection via environment variables, e.g. in `~/.zshenv`. Any Postgres user and database name will do - use whatever names fit your setup and mirror them here:
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
27
|
export OCPG_HOST="localhost"
|
|
@@ -27,6 +29,7 @@ export OCPG_PORT="5432"
|
|
|
27
29
|
export OCPG_USER="ocpguser"
|
|
28
30
|
export OCPG_PASSWORD="your-postgres-password"
|
|
29
31
|
export OCPG_DB="ocpg"
|
|
32
|
+
export OCPG_SSL="disable"
|
|
30
33
|
```
|
|
31
34
|
|
|
32
35
|
| Env var | Default |
|
|
@@ -35,12 +38,51 @@ export OCPG_DB="ocpg"
|
|
|
35
38
|
| `OCPG_PORT` | `5432` |
|
|
36
39
|
| `OCPG_USER` | `ocpguser` |
|
|
37
40
|
| `OCPG_DB` | `ocpg` |
|
|
41
|
+
| `OCPG_SSL` | `disable` |
|
|
42
|
+
|
|
43
|
+
**Password is env-only.** The plugin never reads config files, options, or external secret managers - set `OCPG_PASSWORD` in your shell environment (e.g. via direnv/.envrc, however you source your secrets).
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
`OCPG_SSL` accepts `disable`, `prefer`, `require`, `verify-ca`, or `verify-full` (anything else falls back to `disable`). It defaults to `disable` for the usual localhost setup - **set it to `require` or stricter whenever `OCPG_HOST` is not local**, otherwise the password handshake crosses the network in plaintext.
|
|
40
46
|
|
|
41
47
|
## Tools
|
|
42
48
|
|
|
43
|
-
- `memory_remember`
|
|
44
|
-
- `memory_recall`
|
|
49
|
+
- `memory_remember` - store a memory (`content`, `tags`, `force`); rejects near-duplicates within the project
|
|
50
|
+
- `memory_recall` - search past memories (`query`, `tags`, `global`, `limit`)
|
|
51
|
+
- `memory_forget` - delete a memory of the current project by id
|
|
52
|
+
|
|
53
|
+
Memories are project-scoped by working directory; use `global: true` on recall to search across projects. `tags` on recall matches rows carrying *all* the given tags — tags aren't covered by full-text search, so this is the only way to reach them.
|
|
54
|
+
|
|
55
|
+
Writes are capped at 4000 characters of content, 10 tags, and 64 characters per tag; oversized writes are rejected with the actual size rather than silently truncated.
|
|
56
|
+
|
|
57
|
+
`memory_forget` only ever deletes within the calling project, so an id from another project matches nothing.
|
|
58
|
+
|
|
59
|
+
### Duplicate detection
|
|
60
|
+
|
|
61
|
+
`memory_remember` rejects a new memory when the project already holds one with trigram similarity ≥ 0.8, reporting the score and the existing id. Pass `force: true` to store it anyway.
|
|
62
|
+
|
|
63
|
+
This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
|
|
64
|
+
|
|
65
|
+
```sql
|
|
66
|
+
CREATE EXTENSION pg_trgm;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Without it, `memory_remember` returns an error naming this exact fix.
|
|
70
|
+
|
|
71
|
+
If the database is unreachable, memory injection is skipped and the tools return a generic error - a slow or dead database never blocks a model request.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bun install
|
|
77
|
+
bun run check # biome lint
|
|
78
|
+
bun run typecheck # tsc --noEmit
|
|
79
|
+
bun test # integration suite, needs a live Postgres with pg_trgm
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Enable the commit hooks once per clone ([pre-commit](https://pre-commit.com)):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pre-commit install
|
|
86
|
+
```
|
|
45
87
|
|
|
46
|
-
|
|
88
|
+
It runs lint and typecheck on commits that touch `.ts` files. `bun test` is deliberately excluded from both the hook and CI, because it writes to a real database.
|
package/ocpg.ts
CHANGED
|
@@ -3,27 +3,38 @@ import { SQL } from "bun";
|
|
|
3
3
|
import { Plugin } from "@opencode/plugin";
|
|
4
4
|
|
|
5
5
|
// --- DB config: env > defaults. Plugin options are not read; password is deliberately env-only (never in config).
|
|
6
|
+
const SSL_MODES = ["disable", "prefer", "require", "verify-ca", "verify-full"] as const;
|
|
7
|
+
type SslMode = (typeof SSL_MODES)[number];
|
|
8
|
+
|
|
6
9
|
type DbConfig = {
|
|
7
10
|
host: string;
|
|
8
11
|
port: number;
|
|
9
12
|
user: string;
|
|
10
13
|
database: string;
|
|
14
|
+
ssl: SslMode;
|
|
11
15
|
};
|
|
12
|
-
type RecallArgs = { query?: string; global?: boolean; limit?: number };
|
|
13
|
-
type RememberArgs = { content: string; tags?: string[] };
|
|
16
|
+
type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[] };
|
|
17
|
+
type RememberArgs = { content: string; tags?: string[]; force?: boolean };
|
|
18
|
+
type ForgetArgs = { id: number };
|
|
19
|
+
|
|
20
|
+
// Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
|
|
21
|
+
// when the database is remote, otherwise the SCRAM handshake crosses the network
|
|
22
|
+
// in plaintext.
|
|
23
|
+
function resolveSslMode(raw: string | undefined): SslMode {
|
|
24
|
+
if (!raw) return "disable";
|
|
25
|
+
const mode = raw.toLowerCase() as SslMode;
|
|
26
|
+
return SSL_MODES.includes(mode) ? mode : "disable";
|
|
27
|
+
}
|
|
14
28
|
|
|
15
|
-
// Defaults resolved ONCE at module init
|
|
29
|
+
// Defaults resolved ONCE at module init - env-only, no process spawning.
|
|
16
30
|
const defaultConfig: DbConfig = {
|
|
17
31
|
host: process.env.OCPG_HOST || "localhost",
|
|
18
32
|
port: Number(process.env.OCPG_PORT) || 5432,
|
|
19
33
|
user: process.env.OCPG_USER || "ocpguser",
|
|
20
34
|
database: process.env.OCPG_DB || "ocpg",
|
|
35
|
+
ssl: resolveSslMode(process.env.OCPG_SSL),
|
|
21
36
|
};
|
|
22
|
-
const password =
|
|
23
|
-
process.env.OCPG_PASSWORD ||
|
|
24
|
-
Bun.spawnSync(["pass", "show", "postgres-workstation-password"])
|
|
25
|
-
.stdout.toString()
|
|
26
|
-
.trim();
|
|
37
|
+
const password = process.env.OCPG_PASSWORD || "";
|
|
27
38
|
|
|
28
39
|
// Options-object constructor, not a URL string: Bun's SQL parses string URLs via
|
|
29
40
|
// url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
|
|
@@ -34,11 +45,16 @@ function makeSql(cfg: DbConfig): SQL {
|
|
|
34
45
|
username: cfg.user,
|
|
35
46
|
password,
|
|
36
47
|
database: cfg.database,
|
|
48
|
+
ssl: cfg.ssl,
|
|
37
49
|
max: 2,
|
|
50
|
+
// A dead database must fail fast: this pool is queried from the session
|
|
51
|
+
// context hook, which sits in front of every model request.
|
|
52
|
+
connectionTimeout: 3,
|
|
53
|
+
idleTimeout: 30,
|
|
38
54
|
});
|
|
39
55
|
}
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
const sql = makeSql(defaultConfig);
|
|
42
58
|
|
|
43
59
|
export interface MemoryRow {
|
|
44
60
|
id: number;
|
|
@@ -48,6 +64,9 @@ export interface MemoryRow {
|
|
|
48
64
|
date: string;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
// The injection query selects neither id nor project - they are never rendered.
|
|
68
|
+
type InjectionRow = Pick<MemoryRow, "content" | "tags" | "date">;
|
|
69
|
+
|
|
51
70
|
// --- Rate-limited error logging ---
|
|
52
71
|
|
|
53
72
|
const lastLogTime = new Map<string, number>();
|
|
@@ -66,19 +85,60 @@ function resetRateLimit(): void {
|
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
// V2 plugins have no client.app.log; console.error from plugin code lands in the server log.
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
// Rate limiting is per kind so a failing recall does not mute injection errors.
|
|
89
|
+
function logError(kind: string, message: string): void {
|
|
90
|
+
if (!rateLimitOk(kind)) return;
|
|
71
91
|
console.error(`[ocpg] ${message}`);
|
|
72
92
|
}
|
|
73
93
|
|
|
94
|
+
// --- Query deadline ---
|
|
95
|
+
|
|
96
|
+
class DeadlineError extends Error {
|
|
97
|
+
constructor(ms: number) {
|
|
98
|
+
super(`query exceeded ${ms}ms deadline`);
|
|
99
|
+
this.name = "DeadlineError";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Guards the injection query, which runs in front of every model request: a
|
|
104
|
+
// hung database must degrade to "no memories" rather than stall the turn.
|
|
105
|
+
//
|
|
106
|
+
// This races instead of cancelling. Bun documents query.cancel(), but on bun
|
|
107
|
+
// 1.4.2 it is a no-op for an in-flight Postgres query - verified against
|
|
108
|
+
// SELECT pg_sleep(5), which ran the full 5s under both .execute()+.cancel()
|
|
109
|
+
// and bare .cancel(). So the query is abandoned, not aborted: the caller is
|
|
110
|
+
// freed on time while the connection stays busy until the server finishes.
|
|
111
|
+
async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
114
|
+
timer = setTimeout(() => reject(new DeadlineError(ms)), ms);
|
|
115
|
+
});
|
|
116
|
+
// An abandoned query that later rejects would otherwise surface as an
|
|
117
|
+
// unhandled rejection and take the process down.
|
|
118
|
+
Promise.resolve(query).catch(() => {});
|
|
119
|
+
try {
|
|
120
|
+
return await Promise.race([query, deadline]);
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
74
126
|
// --- Injection pipeline ---
|
|
75
127
|
|
|
76
128
|
function truncateMemory(content: string): string {
|
|
77
129
|
if (content.length <= 600) return content;
|
|
78
|
-
return content.slice(0, 600)
|
|
130
|
+
return `${content.slice(0, 600)}…[truncated]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Memory content is interpolated verbatim into the system prompt; a stored
|
|
134
|
+
// memory containing the closing tag would otherwise end the block early and
|
|
135
|
+
// have its remainder read as top-level instructions.
|
|
136
|
+
function sanitizeMemory(content: string): string {
|
|
137
|
+
return content.replaceAll("</persistent-project-memory>", "");
|
|
79
138
|
}
|
|
80
139
|
|
|
81
|
-
function formatBlock(rows:
|
|
140
|
+
function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
141
|
+
if (rows.length === 0) return "";
|
|
82
142
|
const lines: string[] = [
|
|
83
143
|
"<persistent-project-memory>",
|
|
84
144
|
`Project: ${projectDir}`,
|
|
@@ -87,7 +147,7 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
|
|
|
87
147
|
for (const row of rows) {
|
|
88
148
|
const tags = row.tags ?? [];
|
|
89
149
|
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
90
|
-
lines.push(`- [${row.date}]${tagStr} ${truncateMemory(row.content)}`);
|
|
150
|
+
lines.push(`- [${row.date}]${tagStr} ${sanitizeMemory(truncateMemory(row.content))}`);
|
|
91
151
|
}
|
|
92
152
|
lines.push("");
|
|
93
153
|
lines.push(
|
|
@@ -97,54 +157,98 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
|
|
|
97
157
|
return lines.join("\n");
|
|
98
158
|
}
|
|
99
159
|
|
|
160
|
+
// Keyed by project directory, not session: the query depends only on the
|
|
161
|
+
// directory, so every session in a project shares one entry and a remember in
|
|
162
|
+
// any session invalidates it for all of them. An empty string is cached for
|
|
163
|
+
// projects with no memories so they stop re-querying, and nothing is injected.
|
|
100
164
|
const injectionCache = new Map<string, string>();
|
|
101
165
|
|
|
166
|
+
// Session-independent by design: the block depends only on the project
|
|
167
|
+
// directory, so the hook passes nothing else.
|
|
102
168
|
async function handleTransform(
|
|
103
|
-
input: { sessionID?: string; model?: unknown },
|
|
104
169
|
output: { system: string[] },
|
|
105
170
|
directory: string,
|
|
106
171
|
): Promise<void> {
|
|
107
|
-
if (!
|
|
108
|
-
const
|
|
109
|
-
const cached = injectionCache.get(sid);
|
|
172
|
+
if (!directory) return;
|
|
173
|
+
const cached = injectionCache.get(directory);
|
|
110
174
|
if (cached !== undefined) {
|
|
111
|
-
output.system.push(cached);
|
|
175
|
+
if (cached) output.system.push(cached);
|
|
112
176
|
return;
|
|
113
177
|
}
|
|
114
178
|
try {
|
|
115
|
-
const rows = await
|
|
179
|
+
const rows = await withDeadline(
|
|
180
|
+
sql`
|
|
116
181
|
SELECT content, coalesce(tags, '{}') AS tags,
|
|
117
182
|
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
118
183
|
FROM memories
|
|
119
184
|
WHERE project = ${directory}
|
|
120
185
|
ORDER BY created_at DESC
|
|
121
186
|
LIMIT 5
|
|
122
|
-
` as
|
|
187
|
+
` as unknown as PromiseLike<InjectionRow[]>,
|
|
188
|
+
1000,
|
|
189
|
+
);
|
|
123
190
|
const block = formatBlock(rows, directory);
|
|
124
191
|
// Evict oldest entry when cache exceeds 32
|
|
125
192
|
if (injectionCache.size >= 32) {
|
|
126
|
-
const firstKey = injectionCache.keys().next().value
|
|
127
|
-
injectionCache.delete(firstKey);
|
|
193
|
+
const firstKey = injectionCache.keys().next().value;
|
|
194
|
+
if (firstKey !== undefined) injectionCache.delete(firstKey);
|
|
128
195
|
}
|
|
129
|
-
injectionCache.set(
|
|
130
|
-
output.system.push(block);
|
|
196
|
+
injectionCache.set(directory, block);
|
|
197
|
+
if (block) output.system.push(block);
|
|
131
198
|
} catch (e: unknown) {
|
|
132
|
-
logError(`ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
199
|
+
logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
133
200
|
}
|
|
134
201
|
}
|
|
135
202
|
|
|
136
203
|
// --- Dispose ---
|
|
137
204
|
|
|
205
|
+
// One opencode process evaluates this module once but runs setup() once per
|
|
206
|
+
// project location, so instances share the pool. Verified against opencode
|
|
207
|
+
// v2.0.3: four locations were live in a single pid with one module instance.
|
|
208
|
+
// Without the refcount, a config change in one project disposes that instance
|
|
209
|
+
// and closes the pool out from under every other live project.
|
|
210
|
+
let instances = 0;
|
|
211
|
+
|
|
212
|
+
function retain(): void {
|
|
213
|
+
instances++;
|
|
214
|
+
}
|
|
215
|
+
|
|
138
216
|
async function dispose(): Promise<void> {
|
|
217
|
+
if (instances > 0) instances--;
|
|
218
|
+
if (instances > 0) return;
|
|
139
219
|
await sql.close().catch(() => {});
|
|
140
220
|
}
|
|
141
221
|
|
|
142
222
|
// --- Agent tools: recall + remember with dedup-on-write ---
|
|
143
223
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
224
|
+
// Write caps: these are abuse guards, not style rules. Measured against a real
|
|
225
|
+
// 485-memory corpus (p95 content 517 chars, p95 5 tags, longest tag 26 chars),
|
|
226
|
+
// so ordinary memories never come close.
|
|
227
|
+
const MAX_CONTENT = 4000;
|
|
228
|
+
const MIN_CONTENT = 10;
|
|
229
|
+
const MAX_TAGS = 10;
|
|
230
|
+
const MAX_TAG_LENGTH = 64;
|
|
231
|
+
|
|
232
|
+
// Raw JSON Schema input is not coerced for us: a model sending "3" or null for
|
|
233
|
+
// limit would otherwise reach Postgres as LIMIT NaN.
|
|
234
|
+
function resolveLimit(raw: unknown): number {
|
|
235
|
+
const n = Number(raw);
|
|
236
|
+
if (!Number.isFinite(n)) return 5;
|
|
237
|
+
return Math.min(Math.max(Math.trunc(n), 1), 20);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The model sees a generic failure; the operator sees the real message in the
|
|
241
|
+
// server log. Raw driver errors carry host, user and schema details that should
|
|
242
|
+
// not end up in a transcript sent to the provider.
|
|
243
|
+
function toolError(kind: string, action: string, e: unknown): string {
|
|
244
|
+
logError(kind, `ocpg ${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
245
|
+
// 42883 = undefined_function. The only way to hit it here is a database
|
|
246
|
+
// without pg_trgm, which is worth naming: the fix is one statement and the
|
|
247
|
+
// generic message would send the operator hunting.
|
|
248
|
+
if (String((e as { errno?: unknown })?.errno) === "42883") {
|
|
249
|
+
return "ERROR: this database is missing the pg_trgm extension, which memory_remember needs for duplicate detection. Run: CREATE EXTENSION pg_trgm;";
|
|
250
|
+
}
|
|
251
|
+
return `ERROR: memory store unavailable (${action} failed; see opencode server log).`;
|
|
148
252
|
}
|
|
149
253
|
|
|
150
254
|
async function recall(
|
|
@@ -152,13 +256,20 @@ async function recall(
|
|
|
152
256
|
ctx: { directory: string },
|
|
153
257
|
): Promise<string> {
|
|
154
258
|
try {
|
|
155
|
-
const limit =
|
|
259
|
+
const limit = resolveLimit(args.limit);
|
|
156
260
|
const projectCond = args.global
|
|
157
261
|
? sql``
|
|
158
262
|
: sql`AND project = ${ctx.directory}`;
|
|
159
263
|
const queryCond = args.query
|
|
160
264
|
? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
|
|
161
265
|
: sql``;
|
|
266
|
+
// Tags are not part of search_vector (it covers content only), so they are
|
|
267
|
+
// unreachable by query alone. Matches rows carrying ALL the given tags,
|
|
268
|
+
// served by idx_memories_tags.
|
|
269
|
+
const tagList = Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === "string" && t) : [];
|
|
270
|
+
const tagCond = tagList.length
|
|
271
|
+
? sql`AND tags @> ${sql.array(tagList, "text")}`
|
|
272
|
+
: sql``;
|
|
162
273
|
// Relevance-ranked when searching; recency-ordered for a plain project browse.
|
|
163
274
|
const orderBy = args.query
|
|
164
275
|
? sql`ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', ${args.query})) DESC`
|
|
@@ -169,7 +280,7 @@ async function recall(
|
|
|
169
280
|
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
170
281
|
project
|
|
171
282
|
FROM memories
|
|
172
|
-
WHERE 1=1 ${projectCond} ${queryCond}
|
|
283
|
+
WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
|
|
173
284
|
${orderBy}
|
|
174
285
|
LIMIT ${limit}
|
|
175
286
|
` as MemoryRow[];
|
|
@@ -184,60 +295,113 @@ async function recall(
|
|
|
184
295
|
})
|
|
185
296
|
.join('\n---\n');
|
|
186
297
|
} catch (e: unknown) {
|
|
187
|
-
|
|
188
|
-
|
|
298
|
+
return toolError("recall", "recall", e);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Rejects rather than truncates: a clipped memory loses its tail silently,
|
|
303
|
+
// while an error reports the actual size and lets the agent retry shorter.
|
|
304
|
+
function validateWrite(args: RememberArgs): string | null {
|
|
305
|
+
const content = typeof args.content === "string" ? args.content : "";
|
|
306
|
+
if (content.length < MIN_CONTENT) {
|
|
307
|
+
return `ERROR: content must be at least ${MIN_CONTENT} characters.`;
|
|
308
|
+
}
|
|
309
|
+
if (content.length > MAX_CONTENT) {
|
|
310
|
+
return `ERROR: content is ${content.length} characters, max ${MAX_CONTENT}; store the essentials in 1-3 sentences and retry.`;
|
|
311
|
+
}
|
|
312
|
+
const tags = args.tags ?? [];
|
|
313
|
+
if (!Array.isArray(tags)) return "ERROR: tags must be an array of strings.";
|
|
314
|
+
if (tags.length > MAX_TAGS) {
|
|
315
|
+
return `ERROR: ${tags.length} tags given, max ${MAX_TAGS}.`;
|
|
316
|
+
}
|
|
317
|
+
const oversized = tags.find((t) => typeof t !== "string" || t.length > MAX_TAG_LENGTH);
|
|
318
|
+
if (oversized !== undefined) {
|
|
319
|
+
return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
|
|
189
320
|
}
|
|
321
|
+
return null;
|
|
190
322
|
}
|
|
191
323
|
|
|
324
|
+
// Trigram similarity threshold for dedup-on-write. Measured on a real
|
|
325
|
+
// 485-memory corpus: the previous rule (FTS on the first 60 characters) let 28
|
|
326
|
+
// pairs at >=0.8 similarity through because they differed in their opening
|
|
327
|
+
// words, while wrongly rejecting ~1% of genuinely distinct memories. 0.8 is
|
|
328
|
+
// strict enough that only restatements collide.
|
|
329
|
+
const DEDUP_SIMILARITY = 0.8;
|
|
330
|
+
|
|
192
331
|
async function remember(
|
|
193
332
|
args: RememberArgs,
|
|
194
333
|
ctx: { directory: string; sessionID: string },
|
|
195
334
|
): Promise<string> {
|
|
196
335
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
336
|
+
const invalid = validateWrite(args);
|
|
337
|
+
if (invalid) return invalid;
|
|
200
338
|
|
|
201
|
-
|
|
339
|
+
// Tags are stored verbatim - project scoping lives in the project column, not tags.
|
|
340
|
+
const tags = args.tags ?? [];
|
|
202
341
|
const basename = ctx.directory.split('/').pop() ?? ctx.directory;
|
|
203
342
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return `Similar memory already stored as #${dedup[0].id} for this project; skipping insert.`;
|
|
343
|
+
if (!args.force) {
|
|
344
|
+
// Dedup: project-scoped trigram similarity over the whole content. No
|
|
345
|
+
// trigram index - the project filter narrows to a few hundred rows, which
|
|
346
|
+
// similarity() scans in single-digit milliseconds.
|
|
347
|
+
const dedup = await sql`
|
|
348
|
+
SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
|
|
349
|
+
FROM memories
|
|
350
|
+
WHERE project = ${ctx.directory}
|
|
351
|
+
AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
|
|
352
|
+
ORDER BY similarity(content, ${args.content}) DESC
|
|
353
|
+
LIMIT 1
|
|
354
|
+
` as { id: number; score: string }[];
|
|
355
|
+
|
|
356
|
+
if (dedup.length > 0) {
|
|
357
|
+
return `Similar memory already stored as #${dedup[0].id} (similarity ${dedup[0].score}) for this project; skipping insert. Pass force: true to store it anyway.`;
|
|
358
|
+
}
|
|
221
359
|
}
|
|
222
360
|
|
|
223
361
|
// sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
|
|
224
362
|
// the element type hint is required for clean array storage.
|
|
225
363
|
const inserted = await sql`
|
|
226
364
|
INSERT INTO memories (content, tags, session_id, project)
|
|
227
|
-
VALUES (${args.content}, ${sql.array(
|
|
365
|
+
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory})
|
|
228
366
|
RETURNING id
|
|
229
367
|
` as { id: number }[];
|
|
230
368
|
|
|
231
|
-
invalidateInjection(ctx.
|
|
369
|
+
invalidateInjection(ctx.directory);
|
|
232
370
|
return `Stored memory #${inserted[0].id} for project ${basename}.`;
|
|
233
371
|
} catch (e: unknown) {
|
|
234
|
-
|
|
235
|
-
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
372
|
+
return toolError("remember", "remember", e);
|
|
236
373
|
}
|
|
237
374
|
}
|
|
238
375
|
|
|
239
|
-
|
|
240
|
-
|
|
376
|
+
// Project-scoped by construction: an agent can only delete what its own project
|
|
377
|
+
// can recall, so a poisoned id from another project silently matches nothing.
|
|
378
|
+
async function forget(
|
|
379
|
+
args: ForgetArgs,
|
|
380
|
+
ctx: { directory: string },
|
|
381
|
+
): Promise<string> {
|
|
382
|
+
try {
|
|
383
|
+
const id = Number(args.id);
|
|
384
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
385
|
+
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
386
|
+
}
|
|
387
|
+
const deleted = await sql`
|
|
388
|
+
DELETE FROM memories
|
|
389
|
+
WHERE id = ${id} AND project = ${ctx.directory}
|
|
390
|
+
RETURNING id
|
|
391
|
+
` as { id: number }[];
|
|
392
|
+
|
|
393
|
+
if (deleted.length === 0) {
|
|
394
|
+
return `No memory #${id} in this project; nothing deleted.`;
|
|
395
|
+
}
|
|
396
|
+
invalidateInjection(ctx.directory);
|
|
397
|
+
return `Deleted memory #${id}.`;
|
|
398
|
+
} catch (e: unknown) {
|
|
399
|
+
return toolError("forget", "forget", e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function invalidateInjection(directory: string): void {
|
|
404
|
+
injectionCache.delete(directory);
|
|
241
405
|
}
|
|
242
406
|
|
|
243
407
|
// V2 entrypoint: registers the system-context injection hook and the agent tools
|
|
@@ -248,17 +412,22 @@ const ocpg = Plugin.define({
|
|
|
248
412
|
id: "ocpg",
|
|
249
413
|
async setup(ctx) {
|
|
250
414
|
const directory = ctx.location.directory;
|
|
415
|
+
retain();
|
|
416
|
+
|
|
417
|
+
// Open the pool before the first turn needs it: Bun connects lazily, so
|
|
418
|
+
// otherwise the TCP + SCRAM handshake is paid inside the first context hook.
|
|
419
|
+
void sql`SELECT 1`.catch(() => {});
|
|
251
420
|
|
|
252
421
|
// Inject project memories into every model request's system context.
|
|
253
|
-
// handleTransform owns the per-
|
|
422
|
+
// handleTransform owns the per-directory cache (32-slot, invalidated on remember).
|
|
254
423
|
await ctx.session.hook("context", async (event) => {
|
|
255
424
|
const output: { system: string[] } = { system: [] };
|
|
256
|
-
await handleTransform(
|
|
425
|
+
await handleTransform(output, directory);
|
|
257
426
|
for (const text of output.system) event.system.push({ type: "text", text });
|
|
258
427
|
});
|
|
259
428
|
|
|
260
429
|
// Agent tools: recall + remember with dedup-on-write. Input schemas are raw
|
|
261
|
-
// JSON Schema (V2 contract);
|
|
430
|
+
// JSON Schema (V2 contract); sizes are enforced in remember().
|
|
262
431
|
await ctx.tool.transform((editor) => {
|
|
263
432
|
editor.add({
|
|
264
433
|
name: "memory_recall",
|
|
@@ -268,6 +437,11 @@ const ocpg = Plugin.define({
|
|
|
268
437
|
type: "object",
|
|
269
438
|
properties: {
|
|
270
439
|
query: { type: "string", description: "Full-text search string; omit for the latest memories" },
|
|
440
|
+
tags: {
|
|
441
|
+
type: "array",
|
|
442
|
+
items: { type: "string" },
|
|
443
|
+
description: "Only return memories carrying all of these tags",
|
|
444
|
+
},
|
|
271
445
|
global: { type: "boolean", description: "Search across all projects (default: current project only)" },
|
|
272
446
|
limit: { type: "number", description: "1-20, default 5" },
|
|
273
447
|
},
|
|
@@ -284,13 +458,22 @@ const ocpg = Plugin.define({
|
|
|
284
458
|
input: {
|
|
285
459
|
type: "object",
|
|
286
460
|
properties: {
|
|
287
|
-
content: {
|
|
461
|
+
content: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
464
|
+
},
|
|
288
465
|
tags: {
|
|
289
466
|
type: "array",
|
|
290
|
-
items: { type: "string" },
|
|
467
|
+
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
468
|
+
maxItems: MAX_TAGS,
|
|
291
469
|
description:
|
|
292
470
|
"Category prefixes: preference, decision, debug, env, architecture, workaround, language:<x>, framework:<x>, tool:<x>",
|
|
293
471
|
},
|
|
472
|
+
force: {
|
|
473
|
+
type: "boolean",
|
|
474
|
+
description:
|
|
475
|
+
"Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
|
|
476
|
+
},
|
|
294
477
|
},
|
|
295
478
|
required: ["content"],
|
|
296
479
|
additionalProperties: false,
|
|
@@ -299,9 +482,25 @@ const ocpg = Plugin.define({
|
|
|
299
482
|
return { content: await remember(input as RememberArgs, { directory, sessionID: tool.sessionID }) };
|
|
300
483
|
},
|
|
301
484
|
});
|
|
485
|
+
editor.add({
|
|
486
|
+
name: "memory_forget",
|
|
487
|
+
description:
|
|
488
|
+
"Delete a memory of this project by id (get ids from memory_recall). Use for memories that are wrong or obsolete; prefer storing a corrected memory when the old one is still useful history.",
|
|
489
|
+
input: {
|
|
490
|
+
type: "object",
|
|
491
|
+
properties: {
|
|
492
|
+
id: { type: "number", description: "The #id shown by memory_recall" },
|
|
493
|
+
},
|
|
494
|
+
required: ["id"],
|
|
495
|
+
additionalProperties: false,
|
|
496
|
+
},
|
|
497
|
+
execute: async (input) => {
|
|
498
|
+
return { content: await forget(input as ForgetArgs, { directory }) };
|
|
499
|
+
},
|
|
500
|
+
});
|
|
302
501
|
});
|
|
303
502
|
|
|
304
|
-
// Close the SQL pool when the plugin unloads.
|
|
503
|
+
// Close the SQL pool when the last plugin instance unloads.
|
|
305
504
|
return dispose;
|
|
306
505
|
},
|
|
307
506
|
});
|
|
@@ -311,15 +510,22 @@ const __internals = {
|
|
|
311
510
|
return sql;
|
|
312
511
|
},
|
|
313
512
|
truncateMemory,
|
|
513
|
+
sanitizeMemory,
|
|
514
|
+
withDeadline,
|
|
314
515
|
formatBlock,
|
|
315
516
|
handleTransform,
|
|
316
|
-
normalizeTags,
|
|
317
517
|
recall,
|
|
318
518
|
remember,
|
|
519
|
+
forget,
|
|
319
520
|
invalidateInjection,
|
|
521
|
+
resolveSslMode,
|
|
522
|
+
resolveLimit,
|
|
523
|
+
validateWrite,
|
|
320
524
|
logError,
|
|
525
|
+
toolError,
|
|
321
526
|
rateLimitOk,
|
|
322
527
|
resetRateLimit,
|
|
528
|
+
retain,
|
|
323
529
|
dispose,
|
|
324
530
|
};
|
|
325
531
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhi/ocpg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Postgres-backed persistent memory plugin for OpenCode",
|
|
6
6
|
"main": "ocpg.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"check": "biome check .",
|
|
9
|
+
"format": "biome format --write .",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
7
13
|
"exports": {
|
|
8
14
|
".": "./ocpg.ts"
|
|
9
15
|
},
|
|
@@ -23,5 +29,10 @@
|
|
|
23
29
|
"repository": {
|
|
24
30
|
"type": "git",
|
|
25
31
|
"url": "https://github.com/pentago/ocpg.git"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "^2.5.14",
|
|
35
|
+
"@types/bun": "^1.4.2",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
26
37
|
}
|
|
27
38
|
}
|