@dzhi/ocpg 0.8.0 → 0.11.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 +47 -7
  2. package/ocpg.ts +280 -58
  3. 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,9 +19,9 @@ In `opencode.json`:
19
19
 
20
20
  ## Connecting to the database
21
21
 
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).
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
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:
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:
25
25
 
26
26
  ```bash
27
27
  export OCPG_HOST="localhost"
@@ -29,6 +29,7 @@ export OCPG_PORT="5432"
29
29
  export OCPG_USER="ocpguser"
30
30
  export OCPG_PASSWORD="your-postgres-password"
31
31
  export OCPG_DB="ocpg"
32
+ export OCPG_SSL="disable"
32
33
  ```
33
34
 
34
35
  | Env var | Default |
@@ -37,12 +38,51 @@ export OCPG_DB="ocpg"
37
38
  | `OCPG_PORT` | `5432` |
38
39
  | `OCPG_USER` | `ocpguser` |
39
40
  | `OCPG_DB` | `ocpg` |
41
+ | `OCPG_SSL` | `disable` |
40
42
 
41
- **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).
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).
44
+
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.
42
46
 
43
47
  ## Tools
44
48
 
45
- - `memory_remember` store a memory (dedups against similar entries per project)
46
- - `memory_recall` search past memories (`query`, `global`, `limit`)
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
+ ```
47
87
 
48
- Memories are project-scoped by working directory; use `global: true` on recall to search across projects.
88
+ It runs lint and typecheck on commits that touch `.ts` files. `bun test` is left out of the hook because it writes to a real database — CI runs it against a throwaway Postgres service container instead.
package/ocpg.ts CHANGED
@@ -3,21 +3,36 @@ 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 env-only, no process spawning.
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
37
  const password = process.env.OCPG_PASSWORD || "";
23
38
 
@@ -30,11 +45,16 @@ function makeSql(cfg: DbConfig): SQL {
30
45
  username: cfg.user,
31
46
  password,
32
47
  database: cfg.database,
48
+ ssl: cfg.ssl,
33
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,
34
54
  });
35
55
  }
36
56
 
37
- let sql = makeSql(defaultConfig);
57
+ const sql = makeSql(defaultConfig);
38
58
 
39
59
  export interface MemoryRow {
40
60
  id: number;
@@ -44,6 +64,9 @@ export interface MemoryRow {
44
64
  date: string;
45
65
  }
46
66
 
67
+ // The injection query selects neither id nor project - they are never rendered.
68
+ type InjectionRow = Pick<MemoryRow, "content" | "tags" | "date">;
69
+
47
70
  // --- Rate-limited error logging ---
48
71
 
49
72
  const lastLogTime = new Map<string, number>();
@@ -62,19 +85,60 @@ function resetRateLimit(): void {
62
85
  }
63
86
 
64
87
  // V2 plugins have no client.app.log; console.error from plugin code lands in the server log.
65
- function logError(message: string): void {
66
- if (!rateLimitOk("db-error")) return;
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;
67
91
  console.error(`[ocpg] ${message}`);
68
92
  }
69
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
+
70
126
  // --- Injection pipeline ---
71
127
 
72
128
  function truncateMemory(content: string): string {
73
129
  if (content.length <= 600) return content;
74
- return content.slice(0, 600) + "…[truncated]";
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>", "");
75
138
  }
76
139
 
77
- function formatBlock(rows: MemoryRow[], projectDir: string): string {
140
+ function formatBlock(rows: InjectionRow[], projectDir: string): string {
141
+ if (rows.length === 0) return "";
78
142
  const lines: string[] = [
79
143
  "<persistent-project-memory>",
80
144
  `Project: ${projectDir}`,
@@ -83,7 +147,7 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
83
147
  for (const row of rows) {
84
148
  const tags = row.tags ?? [];
85
149
  const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
86
- lines.push(`- [${row.date}]${tagStr} ${truncateMemory(row.content)}`);
150
+ lines.push(`- [${row.date}]${tagStr} ${sanitizeMemory(truncateMemory(row.content))}`);
87
151
  }
88
152
  lines.push("");
89
153
  lines.push(
@@ -93,62 +157,119 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
93
157
  return lines.join("\n");
94
158
  }
95
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.
96
164
  const injectionCache = new Map<string, string>();
97
165
 
166
+ // Session-independent by design: the block depends only on the project
167
+ // directory, so the hook passes nothing else.
98
168
  async function handleTransform(
99
- input: { sessionID?: string; model?: unknown },
100
169
  output: { system: string[] },
101
170
  directory: string,
102
171
  ): Promise<void> {
103
- if (!input.sessionID) return;
104
- const sid = input.sessionID;
105
- const cached = injectionCache.get(sid);
172
+ if (!directory) return;
173
+ const cached = injectionCache.get(directory);
106
174
  if (cached !== undefined) {
107
- output.system.push(cached);
175
+ if (cached) output.system.push(cached);
108
176
  return;
109
177
  }
110
178
  try {
111
- const rows = await sql`
179
+ const rows = await withDeadline(
180
+ sql`
112
181
  SELECT content, coalesce(tags, '{}') AS tags,
113
182
  to_char(created_at, 'YYYY-MM-DD') AS date
114
183
  FROM memories
115
184
  WHERE project = ${directory}
116
185
  ORDER BY created_at DESC
117
186
  LIMIT 5
118
- ` as MemoryRow[];
187
+ ` as unknown as PromiseLike<InjectionRow[]>,
188
+ 1000,
189
+ );
119
190
  const block = formatBlock(rows, directory);
120
191
  // Evict oldest entry when cache exceeds 32
121
192
  if (injectionCache.size >= 32) {
122
- const firstKey = injectionCache.keys().next().value!;
123
- injectionCache.delete(firstKey);
193
+ const firstKey = injectionCache.keys().next().value;
194
+ if (firstKey !== undefined) injectionCache.delete(firstKey);
124
195
  }
125
- injectionCache.set(sid, block);
126
- output.system.push(block);
196
+ injectionCache.set(directory, block);
197
+ if (block) output.system.push(block);
127
198
  } catch (e: unknown) {
128
- logError(`ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
199
+ logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
129
200
  }
130
201
  }
131
202
 
132
203
  // --- Dispose ---
133
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
+
134
216
  async function dispose(): Promise<void> {
217
+ if (instances > 0) instances--;
218
+ if (instances > 0) return;
135
219
  await sql.close().catch(() => {});
136
220
  }
137
221
 
138
222
  // --- Agent tools: recall + remember with dedup-on-write ---
139
223
 
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).`;
252
+ }
253
+
140
254
  async function recall(
141
255
  args: RecallArgs,
142
256
  ctx: { directory: string },
143
257
  ): Promise<string> {
144
258
  try {
145
- const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
259
+ const limit = resolveLimit(args.limit);
146
260
  const projectCond = args.global
147
261
  ? sql``
148
262
  : sql`AND project = ${ctx.directory}`;
149
263
  const queryCond = args.query
150
264
  ? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
151
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``;
152
273
  // Relevance-ranked when searching; recency-ordered for a plain project browse.
153
274
  const orderBy = args.query
154
275
  ? sql`ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', ${args.query})) DESC`
@@ -159,7 +280,7 @@ async function recall(
159
280
  to_char(created_at, 'YYYY-MM-DD') AS date,
160
281
  project
161
282
  FROM memories
162
- WHERE 1=1 ${projectCond} ${queryCond}
283
+ WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
163
284
  ${orderBy}
164
285
  LIMIT ${limit}
165
286
  ` as MemoryRow[];
@@ -174,41 +295,67 @@ async function recall(
174
295
  })
175
296
  .join('\n---\n');
176
297
  } catch (e: unknown) {
177
- logError(`ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
178
- return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
298
+ return toolError("recall", "recall", e);
179
299
  }
180
300
  }
181
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.`;
320
+ }
321
+ return null;
322
+ }
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
+
182
331
  async function remember(
183
332
  args: RememberArgs,
184
333
  ctx: { directory: string; sessionID: string },
185
334
  ): Promise<string> {
186
335
  try {
187
- if (args.content.length < 10) {
188
- return 'ERROR: content must be at least 10 characters.';
189
- }
336
+ const invalid = validateWrite(args);
337
+ if (invalid) return invalid;
190
338
 
191
- // Tags are stored verbatim project scoping lives in the project column, not tags.
339
+ // Tags are stored verbatim - project scoping lives in the project column, not tags.
192
340
  const tags = args.tags ?? [];
193
341
  const basename = ctx.directory.split('/').pop() ?? ctx.directory;
194
342
 
195
- // Dedup: project-scoped, common-opening-words AND-match via FTS
196
- const dedup = await sql`
197
- SELECT id FROM memories
198
- WHERE project = ${ctx.directory}
199
- AND (
200
- content = ${args.content}
201
- OR search_vector @@ plainto_tsquery('english', ${args.content.slice(0, 60)})
202
- )
203
- ORDER BY created_at DESC
204
- LIMIT 1
205
- ` as { id: number }[];
206
-
207
- if (dedup.length > 0) {
208
- // ponytail: dedup uses common-opening-words AND-match across rows via FTS
209
- // so false positives are expected; upgrade path = pg_trgm similarity or
210
- // wider dedup scope.
211
- 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
+ }
212
359
  }
213
360
 
214
361
  // sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
@@ -219,16 +366,42 @@ async function remember(
219
366
  RETURNING id
220
367
  ` as { id: number }[];
221
368
 
222
- invalidateInjection(ctx.sessionID);
369
+ invalidateInjection(ctx.directory);
223
370
  return `Stored memory #${inserted[0].id} for project ${basename}.`;
224
371
  } catch (e: unknown) {
225
- logError(`ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
226
- return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
372
+ return toolError("remember", "remember", e);
227
373
  }
228
374
  }
229
375
 
230
- function invalidateInjection(sessionID: string): void {
231
- injectionCache.delete(sessionID);
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);
232
405
  }
233
406
 
234
407
  // V2 entrypoint: registers the system-context injection hook and the agent tools
@@ -239,17 +412,22 @@ const ocpg = Plugin.define({
239
412
  id: "ocpg",
240
413
  async setup(ctx) {
241
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(() => {});
242
420
 
243
421
  // Inject project memories into every model request's system context.
244
- // handleTransform owns the per-session cache (32-slot, invalidated on remember).
422
+ // handleTransform owns the per-directory cache (32-slot, invalidated on remember).
245
423
  await ctx.session.hook("context", async (event) => {
246
424
  const output: { system: string[] } = { system: [] };
247
- await handleTransform({ sessionID: event.sessionID, model: event.model }, output, directory);
425
+ await handleTransform(output, directory);
248
426
  for (const text of output.system) event.system.push({ type: "text", text });
249
427
  });
250
428
 
251
429
  // Agent tools: recall + remember with dedup-on-write. Input schemas are raw
252
- // JSON Schema (V2 contract); content length is enforced in remember().
430
+ // JSON Schema (V2 contract); sizes are enforced in remember().
253
431
  await ctx.tool.transform((editor) => {
254
432
  editor.add({
255
433
  name: "memory_recall",
@@ -259,6 +437,11 @@ const ocpg = Plugin.define({
259
437
  type: "object",
260
438
  properties: {
261
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
+ },
262
445
  global: { type: "boolean", description: "Search across all projects (default: current project only)" },
263
446
  limit: { type: "number", description: "1-20, default 5" },
264
447
  },
@@ -270,18 +453,33 @@ const ocpg = Plugin.define({
270
453
  });
271
454
  editor.add({
272
455
  name: "memory_remember",
456
+ // This description is the only place the write policy is guaranteed to
457
+ // reach the model: it is in the tool schema every session, whereas the
458
+ // injected block is skipped entirely for projects with no memories and
459
+ // the user may have no project instructions at all.
273
460
  description:
274
- "Store a memory for this project. Use after user corrections, architecture decisions, or non-trivial fixes.",
461
+ "Store a durable memory for this project. Use after user corrections (immediately), " +
462
+ "architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
463
+ "Do not store session progress, secrets, or anything the code itself already states.",
275
464
  input: {
276
465
  type: "object",
277
466
  properties: {
278
- content: { type: "string", description: "1-3 self-contained sentences capturing the why" },
467
+ content: {
468
+ type: "string",
469
+ description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
470
+ },
279
471
  tags: {
280
472
  type: "array",
281
- items: { type: "string" },
473
+ items: { type: "string", maxLength: MAX_TAG_LENGTH },
474
+ maxItems: MAX_TAGS,
282
475
  description:
283
476
  "Category prefixes: preference, decision, debug, env, architecture, workaround, language:<x>, framework:<x>, tool:<x>",
284
477
  },
478
+ force: {
479
+ type: "boolean",
480
+ description:
481
+ "Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
482
+ },
285
483
  },
286
484
  required: ["content"],
287
485
  additionalProperties: false,
@@ -290,9 +488,25 @@ const ocpg = Plugin.define({
290
488
  return { content: await remember(input as RememberArgs, { directory, sessionID: tool.sessionID }) };
291
489
  },
292
490
  });
491
+ editor.add({
492
+ name: "memory_forget",
493
+ description:
494
+ "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.",
495
+ input: {
496
+ type: "object",
497
+ properties: {
498
+ id: { type: "number", description: "The #id shown by memory_recall" },
499
+ },
500
+ required: ["id"],
501
+ additionalProperties: false,
502
+ },
503
+ execute: async (input) => {
504
+ return { content: await forget(input as ForgetArgs, { directory }) };
505
+ },
506
+ });
293
507
  });
294
508
 
295
- // Close the SQL pool when the plugin unloads.
509
+ // Close the SQL pool when the last plugin instance unloads.
296
510
  return dispose;
297
511
  },
298
512
  });
@@ -302,14 +516,22 @@ const __internals = {
302
516
  return sql;
303
517
  },
304
518
  truncateMemory,
519
+ sanitizeMemory,
520
+ withDeadline,
305
521
  formatBlock,
306
522
  handleTransform,
307
523
  recall,
308
524
  remember,
525
+ forget,
309
526
  invalidateInjection,
527
+ resolveSslMode,
528
+ resolveLimit,
529
+ validateWrite,
310
530
  logError,
531
+ toolError,
311
532
  rateLimitOk,
312
533
  resetRateLimit,
534
+ retain,
313
535
  dispose,
314
536
  };
315
537
 
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.8.0",
3
+ "version": "0.11.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
  }