@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.
Files changed (3) hide show
  1. package/README.md +48 -6
  2. package/ocpg.ts +274 -68
  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,7 +19,9 @@ In `opencode.json`:
19
19
 
20
20
  ## Connecting to the database
21
21
 
22
- 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:
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
- **Password is env-only.** If `OCPG_PASSWORD` is unset, the plugin falls back to `pass show postgres-workstation-password` at startup.
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` store a memory (dedups against similar entries per project)
44
- - `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
+ ```
45
87
 
46
- 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 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 the pass lookup here is the only permitted spawn in this file.
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
- let sql = makeSql(defaultConfig);
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
- function logError(message: string): void {
70
- 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;
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) + "…[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>", "");
79
138
  }
80
139
 
81
- function formatBlock(rows: MemoryRow[], projectDir: string): string {
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 (!input.sessionID) return;
108
- const sid = input.sessionID;
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 sql`
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 MemoryRow[];
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(sid, block);
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
- function normalizeTags(tags: string[] | undefined, projectDir: string): string[] {
145
- const input = tags ?? [];
146
- const base = projectDir.split('/').pop() ?? projectDir;
147
- return [...input, `project:${base}`];
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 = Math.min(Math.max(args.limit ?? 5, 1), 20);
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
- logError(`ocpg recall failed: ${e instanceof Error ? e.message : String(e)}`);
188
- return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
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
- if (args.content.length < 10) {
198
- return 'ERROR: content must be at least 10 characters.';
199
- }
336
+ const invalid = validateWrite(args);
337
+ if (invalid) return invalid;
200
338
 
201
- const normalizedTags = normalizeTags(args.tags, ctx.directory);
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
- // Dedup: project-scoped, common-opening-words AND-match via FTS
205
- const dedup = await sql`
206
- SELECT id FROM memories
207
- WHERE project = ${ctx.directory}
208
- AND (
209
- content = ${args.content}
210
- OR search_vector @@ plainto_tsquery('english', ${args.content.slice(0, 60)})
211
- )
212
- ORDER BY created_at DESC
213
- LIMIT 1
214
- ` as { id: number }[];
215
-
216
- if (dedup.length > 0) {
217
- // ponytail: dedup uses common-opening-words AND-match across rows via FTS
218
- // so false positives are expected; upgrade path = pg_trgm similarity or
219
- // wider dedup scope.
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(normalizedTags, "text")}, ${ctx.sessionID}, ${ctx.directory})
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.sessionID);
369
+ invalidateInjection(ctx.directory);
232
370
  return `Stored memory #${inserted[0].id} for project ${basename}.`;
233
371
  } catch (e: unknown) {
234
- logError(`ocpg remember failed: ${e instanceof Error ? e.message : String(e)}`);
235
- return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
372
+ return toolError("remember", "remember", e);
236
373
  }
237
374
  }
238
375
 
239
- function invalidateInjection(sessionID: string): void {
240
- 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);
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-session cache (32-slot, invalidated on remember).
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({ sessionID: event.sessionID, model: event.model }, output, directory);
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); content length is enforced in remember().
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: { type: "string", description: "1-3 self-contained sentences capturing the why" },
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.7.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
  }