@dzhi/ocpg 0.12.0 → 0.15.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 +18 -18
  2. package/ocpg.ts +481 -85
  3. package/package.json +4 -2
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`) and the `pg_trgm` extension. Injects recent project memories into the system prompt and exposes `memory_recall` / `memory_remember` / `memory_forget` tools.
6
+ Uses the `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`, `memory_type`, `access_count`, `last_accessed_at`, `updated_at`) and the `pg_trgm` extension. Injects the memories most relevant to what you're currently asking into the system prompt, captures deliberate "remember that..." prompts verbatim, and exposes `memory_recall` / `memory_remember` / `memory_forget` / `memory_update` tools.
7
7
 
8
8
  ## Install
9
9
 
@@ -21,7 +21,7 @@ In `opencode.json`:
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
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 shell environment variables:
25
25
 
26
26
  ```bash
27
27
  export OCPG_HOST="localhost"
@@ -39,34 +39,34 @@ export OCPG_SSL="disable"
39
39
  | `OCPG_USER` | `ocpguser` |
40
40
  | `OCPG_DB` | `ocpg` |
41
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).
42
+ | `OCPG_INJECTION`| `relevance` |
44
43
 
45
44
  `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.
46
45
 
47
- ## Tools
46
+ ## How memory works
47
+
48
+ Memories are **global**: recall, injection, dedup, deletion and updates see and touch every row. The `project` column records which project a memory came from and is shown in output - it is metadata, not a visibility boundary.
48
49
 
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
50
+ ## How injection picks memories
52
51
 
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.
52
+ By default the block is **relevance-ranked, not recency-ranked**: the user's latest prompt is turned into a full-text query (OR of stemmed words) over **all memories**, ranked by relevance with a small same-project tiebreak, top 5 injected - each line labeled with its origin project. When nothing matches the prompt, it falls back to the latest memories (preferences first). Set `OCPG_INJECTION=recency` for the old blind-last-5 behavior.
54
53
 
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.
54
+ This is keyword relevance, not embedding-based semantic search - close phrasing wins, paraphrases may not. Note the OR ranking: common words in a prompt surface more rows; the ranking favors rows matching more distinctive terms.
56
55
 
57
- `memory_forget` only ever deletes within the calling project, so an id from another project matches nothing.
56
+ ## Tools
58
57
 
59
- ### Duplicate detection
58
+ Four agent tools are registered: `memory_remember` (store), `memory_recall` (search), `memory_forget` (delete by id), `memory_update` (rewrite an existing memory, keeping its original learned date), and `memory_consolidate` (remove near-duplicates on demand). The agent reads their usage rules from the tool schemas - as the user, the things worth knowing are:
60
59
 
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.
60
+ - Memories are shared across all projects: `memory_forget` / `memory_update` work on any row by id, from any project.
61
+ - Duplicate writes are **never rejected** - they land, the injection block collapses them, and `memory_consolidate` cleans them up when you ask: it keeps the newest of each >=80%-similar group and reports the removed texts so the agent can merge any unique fact back.
62
62
 
63
- This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
63
+ 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. Memories carry a `type` (`preference`, `project_fact` default, or `episodic`); `preference` memories come first in recency mode.
64
64
 
65
- ```sql
66
- CREATE EXTENSION pg_trgm;
67
- ```
65
+ Saying "remember that ..." (or "don't forget ...", "keep in mind ...") in a prompt stores the text after the phrase verbatim, tagged `user-requested` - no model judgment involved.
66
+
67
+ ### Duplicates
68
68
 
69
- Without it, `memory_remember` returns an error naming this exact fix.
69
+ Writes are never rejected for duplicates. Near-duplicates (>=80% content similarity, measured on the real corpus - the old FTS-on-first-60-chars rule missed 28 pairs) are collapsed out of the injected block automatically, and `memory_consolidate` removes them on demand (keeps the newest of each group, reports removed texts for the agent to merge back). Needs the trgm index; fresh installs from [`deploy/`](./deploy) get it automatically, existing databases run the upgrade block in [`deploy/README.md`](./deploy/README.md).
70
70
 
71
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
72
 
package/ocpg.ts CHANGED
@@ -13,9 +13,14 @@ type DbConfig = {
13
13
  database: string;
14
14
  ssl: SslMode;
15
15
  };
16
- type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[] };
17
- type RememberArgs = { content: string; tags?: string[]; force?: boolean };
16
+ type RecallArgs = { query?: string; limit?: number; tags?: string[] };
17
+ type RememberArgs = {
18
+ content: string;
19
+ tags?: string[];
20
+ type?: MemoryType;
21
+ };
18
22
  type ForgetArgs = { id: number };
23
+ type UpdateArgs = { id: number; content: string; tags?: string[]; type?: MemoryType };
19
24
 
20
25
  // Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
21
26
  // when the database is remote, otherwise the SCRAM handshake crosses the network
@@ -64,8 +69,9 @@ export interface MemoryRow {
64
69
  date: string;
65
70
  }
66
71
 
67
- // The injection query selects neither id nor project - they are never rendered.
68
- type InjectionRow = Pick<MemoryRow, "content" | "tags" | "date">;
72
+ // Injected lines render each memory's origin project (memories are global),
73
+ // so the injection queries must select project; id is never rendered.
74
+ type InjectionRow = Pick<MemoryRow, "content" | "tags" | "date" | "project">;
69
75
 
70
76
  // --- Rate-limited error logging ---
71
77
 
@@ -125,6 +131,14 @@ async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
125
131
 
126
132
  // --- Injection pipeline ---
127
133
 
134
+ // Injection ranking mode (plan follow-up: relevance over blind recency).
135
+ // "relevance" (default) scores all memories - every project - against the
136
+ // user's latest prompt via full-text search, falling back to recency when the
137
+ // prompt matches nothing; "recency" restores the old last-5 behavior via
138
+ // OCPG_INJECTION=recency. Resolved once at init, env-only.
139
+ let injectionMode: "relevance" | "recency" =
140
+ process.env.OCPG_INJECTION === "recency" ? "recency" : "relevance";
141
+
128
142
  function truncateMemory(content: string): string {
129
143
  if (content.length <= 600) return content;
130
144
  return `${content.slice(0, 600)}…[truncated]`;
@@ -137,6 +151,40 @@ function sanitizeMemory(content: string): string {
137
151
  return content.replaceAll("</persistent-project-memory>", "");
138
152
  }
139
153
 
154
+ // Approximates pg_trgm similarity in TS: character-trigram Jaccard. Used only
155
+ // to collapse near-duplicate rows out of the injection candidates (the
156
+ // candidate set is tiny), never for storage decisions.
157
+ function trigrams(text: string): Set<string> {
158
+ const s = text.toLowerCase().replace(/\s+/g, " ");
159
+ const out = new Set<string>();
160
+ for (let i = 0; i < s.length - 2; i++) out.add(s.slice(i, i + 3));
161
+ return out;
162
+ }
163
+
164
+ function nearDupe(a: string, b: string): boolean {
165
+ return nearDupeSets(trigrams(a), trigrams(b));
166
+ }
167
+
168
+ function nearDupeSets(A: Set<string>, B: Set<string>): boolean {
169
+ if (A.size === 0 || B.size === 0) return false;
170
+ let inter = 0;
171
+ for (const t of A) if (B.has(t)) inter++;
172
+ return inter / (A.size + B.size - inter) >= DEDUP_SIMILARITY;
173
+ }
174
+
175
+ // Injection fetches a deeper candidate slice (rank-ordered) and greedily drops
176
+ // rows that near-dupe an already-kept row, emitting the top 5. Without this,
177
+ // duplicate writes (there is no write-time rejection) would fill the 5 slots
178
+ // with restatements of one fact.
179
+ function collapseDupes(rows: InjectionRow[]): InjectionRow[] {
180
+ const kept: InjectionRow[] = [];
181
+ for (const row of rows) {
182
+ if (kept.length >= 5) break;
183
+ if (!kept.some((k) => nearDupe(k.content, row.content))) kept.push(row);
184
+ }
185
+ return kept;
186
+ }
187
+
140
188
  function formatBlock(rows: InjectionRow[], projectDir: string): string {
141
189
  if (rows.length === 0) return "";
142
190
  const lines: string[] = [
@@ -147,7 +195,10 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
147
195
  for (const row of rows) {
148
196
  const tags = row.tags ?? [];
149
197
  const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
150
- lines.push(`- [${row.date}]${tagStr} ${sanitizeMemory(truncateMemory(row.content))}`);
198
+ // Origin project matters now that memories are shared: basename keeps the
199
+ // injected line short.
200
+ const origin = row.project.split('/').pop() ?? row.project;
201
+ lines.push(`- [${row.date}] (${origin})${tagStr} ${sanitizeMemory(truncateMemory(row.content))}`);
151
202
  }
152
203
  lines.push("");
153
204
  lines.push(
@@ -157,43 +208,122 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
157
208
  return lines.join("\n");
158
209
  }
159
210
 
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.
211
+ // Keyed by directory + prompt hash now that the block depends on the prompt
212
+ // (relevance mode): an identical prompt (model retries, re-requests) hits the
213
+ // cache; a new prompt queries afresh. An empty string is cached for
214
+ // no-match/empty prompts so they stop re-querying.
164
215
  const injectionCache = new Map<string, string>();
165
216
 
166
- // Session-independent by design: the block depends only on the project
167
- // directory, so the hook passes nothing else.
217
+ // djb2 - just a stable key shortener; a same-hash different-prompt collision
218
+ // would serve a stale block, which remember/forget invalidation clears.
219
+ function hashQuery(text: string): string {
220
+ let h = 5381;
221
+ for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0;
222
+ return (h >>> 0).toString(36);
223
+ }
224
+
225
+ // The latest user message is the retrieval signal: what the user is asking
226
+ // about right now is the best proxy for which memories matter. Text parts
227
+ // only; capped because a query is a query, not a transcript - FTS is not
228
+ // helped by thousands of characters.
229
+ function extractPromptQuery(
230
+ messages: ReadonlyArray<{ role: unknown; content: ReadonlyArray<{ type?: unknown; text?: unknown }> }>,
231
+ ): string {
232
+ for (let i = messages.length - 1; i >= 0; i--) {
233
+ const message = messages[i];
234
+ if (message?.role !== "user") continue;
235
+ const text = message.content
236
+ .filter((p) => p.type === "text" && typeof p.text === "string")
237
+ .map((p) => p.text as string)
238
+ .join(" ")
239
+ .trim();
240
+ return text.length > 512 ? text.slice(0, 512) : text;
241
+ }
242
+ return "";
243
+ }
244
+
245
+ // Recency query shared by the recency mode and the no-match fallback: latest 5
246
+ // rows across ALL memories, preferences first. Global by design - the project
247
+ // column records origin, not visibility.
248
+ // Query builders are pure functions of the client so the benchmark
249
+ // (bench/run.ts) can execute the EXACT production SQL against bench
250
+ // databases - no drift between what is measured and what runs.
251
+ function buildRecencyQuery(client: SQL) {
252
+ // LIMIT 20: a candidate slice for collapseDupes, not the final block.
253
+ return client`
254
+ SELECT content, coalesce(tags, '{}') AS tags,
255
+ to_char(created_at, 'YYYY-MM-DD') AS date,
256
+ project
257
+ FROM memories
258
+ ORDER BY (memory_type = 'preference') DESC, created_at DESC
259
+ LIMIT 20
260
+ `;
261
+ }
262
+
263
+ function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
264
+ // Relevance: full-text search across ALL memories - global by design - with
265
+ // a small same-project boost to break rank ties toward locally stored
266
+ // memories.
267
+ return client`
268
+ SELECT content, coalesce(tags, '{}') AS tags,
269
+ to_char(created_at, 'YYYY-MM-DD') AS date,
270
+ project
271
+ FROM memories
272
+ WHERE search_vector @@ to_tsquery('english', ${tsQuery})
273
+ ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
274
+ + (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
275
+ created_at DESC
276
+ LIMIT 20
277
+ `;
278
+ }
279
+
280
+ // The prompt as an OR of stemmed words: websearch_to_tsquery ANDs the terms,
281
+ // so one word the memory never uses would zero out the whole query (bench:
282
+ // recall 0.00-0.02 on multi-word queries). OR ranks by how many (and how
283
+ // rare) the matched terms are, and sanitizing to [a-z0-9]+ tokens keeps
284
+ // to_tsquery syntax-safe. Capped at 24 words to bound the query.
285
+ function orTsQuery(text: string): string {
286
+ const words = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
287
+ return words.slice(0, 24).join(" | ");
288
+ }
289
+
290
+ // The block depends on the directory plus (in relevance mode) the prompt,
291
+ // passed explicitly by the caller.
168
292
  async function handleTransform(
169
293
  output: { system: string[] },
170
294
  directory: string,
295
+ prompt = "",
171
296
  ): Promise<void> {
172
297
  if (!directory) return;
173
- const cached = injectionCache.get(directory);
298
+ const query = injectionMode === "relevance" ? prompt.trim() : "";
299
+ const cacheKey = `${directory}\u0001${hashQuery(query)}`;
300
+ const cached = injectionCache.get(cacheKey);
174
301
  if (cached !== undefined) {
175
302
  if (cached) output.system.push(cached);
176
303
  return;
177
304
  }
178
305
  try {
179
- const rows = await withDeadline(
180
- sql`
181
- SELECT content, coalesce(tags, '{}') AS tags,
182
- to_char(created_at, 'YYYY-MM-DD') AS date
183
- FROM memories
184
- WHERE project = ${directory}
185
- ORDER BY created_at DESC
186
- LIMIT 5
187
- ` as unknown as PromiseLike<InjectionRow[]>,
188
- 1000,
189
- );
190
- const block = formatBlock(rows, directory);
306
+ let rows: InjectionRow[];
307
+ const tsQuery = orTsQuery(query);
308
+ if (tsQuery) {
309
+ rows = await withDeadline(
310
+ buildRelevanceQuery(sql, tsQuery, directory) as unknown as PromiseLike<InjectionRow[]>,
311
+ 1000,
312
+ );
313
+ if (rows.length === 0) {
314
+ // No keyword match for this prompt - recency beats an empty block.
315
+ rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
316
+ }
317
+ } else {
318
+ rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
319
+ }
320
+ const block = formatBlock(collapseDupes(rows), directory);
191
321
  // Evict oldest entry when cache exceeds 32
192
322
  if (injectionCache.size >= 32) {
193
323
  const firstKey = injectionCache.keys().next().value;
194
324
  if (firstKey !== undefined) injectionCache.delete(firstKey);
195
325
  }
196
- injectionCache.set(directory, block);
326
+ injectionCache.set(cacheKey, block);
197
327
  if (block) output.system.push(block);
198
328
  } catch (e: unknown) {
199
329
  logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -229,6 +359,19 @@ const MIN_CONTENT = 10;
229
359
  const MAX_TAGS = 10;
230
360
  const MAX_TAG_LENGTH = 64;
231
361
 
362
+ // --- Memory types (plan 2.1: defaulted, never required) ---
363
+
364
+ // The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
365
+ // rows predate the column, so "required" would break every existing caller -
366
+ // type is always defaulted. episodic is reserved for the (cut, opt-in) 1.3
367
+ // feature; remember accepts it so the vocabulary stays in one place.
368
+ const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
369
+ type MemoryType = (typeof MEMORY_TYPES)[number];
370
+
371
+ function resolveMemoryType(raw: unknown): MemoryType {
372
+ return MEMORY_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project_fact";
373
+ }
374
+
232
375
  // Raw JSON Schema input is not coerced for us: a model sending "3" or null for
233
376
  // limit would otherwise reach Postgres as LIMIT NaN.
234
377
  function resolveLimit(raw: unknown): number {
@@ -253,15 +396,13 @@ function toolError(kind: string, action: string, e: unknown): string {
253
396
 
254
397
  async function recall(
255
398
  args: RecallArgs,
256
- ctx: { directory: string },
257
399
  ): Promise<string> {
258
400
  try {
259
401
  const limit = resolveLimit(args.limit);
260
- const projectCond = args.global
261
- ? sql``
262
- : sql`AND project = ${ctx.directory}`;
402
+ // All memories are global: no project filter anywhere - the project column
403
+ // records origin (shown in output), not visibility.
263
404
  const queryCond = args.query
264
- ? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
405
+ ? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
265
406
  : sql``;
266
407
  // Tags are not part of search_vector (it covers content only), so they are
267
408
  // unreachable by query alone. Matches rows carrying ALL the given tags,
@@ -270,20 +411,34 @@ async function recall(
270
411
  const tagCond = tagList.length
271
412
  ? sql`AND tags @> ${sql.array(tagList, "text")}`
272
413
  : sql``;
273
- // Relevance-ranked when searching; recency-ordered for a plain project browse.
414
+ // Relevance-ranked when searching; undirected browse is plain recency
415
+ // (preferences first, matching the injection fallback). A frequency blend
416
+ // was tried and removed: bumping exactly the returned top-5 is a
417
+ // rich-get-richer loop - live corpus rows pinned the top slot after a few
418
+ // runs. access_count stays as data collection; no ranking consumes it.
274
419
  const orderBy = args.query
275
- ? sql`ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', ${args.query})) DESC`
276
- : sql`ORDER BY created_at DESC`;
420
+ ? sql`ORDER BY ts_rank(search_vector, to_tsquery('english', ${orTsQuery(args.query)})) DESC`
421
+ : sql`ORDER BY (memory_type = 'preference') DESC, created_at DESC`;
277
422
 
278
423
  const rows = await sql`
279
424
  SELECT id, content, coalesce(tags, '{}') AS tags,
280
425
  to_char(created_at, 'YYYY-MM-DD') AS date,
281
- project
426
+ project, memory_type
282
427
  FROM memories
283
- WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
428
+ WHERE 1=1 ${queryCond} ${tagCond}
284
429
  ${orderBy}
285
430
  LIMIT ${limit}
286
- ` as MemoryRow[];
431
+ ` as (MemoryRow & { memory_type: string })[];
432
+
433
+ // Access ranking is recall-only (plan 2.2): the injection path stays
434
+ // read-only because its per-directory cache would make increments biased.
435
+ // Fire-and-forget so the UPDATE never sits on the read path's latency.
436
+ const ids = rows.map((r) => r.id);
437
+ if (ids.length > 0) {
438
+ void sql`UPDATE memories SET access_count = access_count + 1, last_accessed_at = now() WHERE id = ANY(${sql.array(ids, "int8")})`.catch(
439
+ (e: unknown) => logError("access", `ocpg access bump failed: ${e instanceof Error ? e.message : String(e)}`),
440
+ );
441
+ }
287
442
 
288
443
  if (rows.length === 0) return "No memories found.";
289
444
 
@@ -291,7 +446,10 @@ async function recall(
291
446
  .map((r) => {
292
447
  const tags = r.tags ?? [];
293
448
  const tagStr = tags.length ? ` (${tags.join(', ')})` : '';
294
- return `[${r.date}] [${r.project}]${tagStr}\n#${r.id}\n${r.content}`;
449
+ // preference/episodic are worth surfacing; project_fact is the default
450
+ // every pre-column row carries, so printing it is pure noise.
451
+ const typeStr = r.memory_type === "project_fact" ? "" : ` [${r.memory_type}]`;
452
+ return `[${r.date}] [${r.project}]${typeStr}${tagStr}\n#${r.id}\n${r.content}`;
295
453
  })
296
454
  .join('\n---\n');
297
455
  } catch (e: unknown) {
@@ -318,14 +476,17 @@ function validateWrite(args: RememberArgs): string | null {
318
476
  if (oversized !== undefined) {
319
477
  return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
320
478
  }
479
+ if (args.type !== undefined && !MEMORY_TYPES.includes(args.type)) {
480
+ return `ERROR: type must be one of ${MEMORY_TYPES.join(", ")}.`;
481
+ }
321
482
  return null;
322
483
  }
323
484
 
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.
485
+ // Trigram similarity threshold for near-duplicate handling (consolidation and
486
+ // the injection collapse pass). Measured on a real 485-memory corpus: 0.8 is
487
+ // strict enough that only restatements collide. Writes never reject on it -
488
+ // duplicates are cleaned up by memory_consolidate and collapsed out of the
489
+ // injected block.
329
490
  const DEDUP_SIMILARITY = 0.8;
330
491
 
331
492
  async function remember(
@@ -336,45 +497,74 @@ async function remember(
336
497
  const invalid = validateWrite(args);
337
498
  if (invalid) return invalid;
338
499
 
339
- // Tags are stored verbatim - project scoping lives in the project column, not tags.
500
+ // Tags are stored verbatim - the project column records origin, not visibility.
340
501
  const tags = args.tags ?? [];
341
502
  const basename = ctx.directory.split('/').pop() ?? ctx.directory;
342
503
 
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
- }
359
- }
360
-
361
504
  // sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
362
505
  // the element type hint is required for clean array storage.
363
506
  const inserted = await sql`
364
- INSERT INTO memories (content, tags, session_id, project)
365
- VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory})
507
+ INSERT INTO memories (content, tags, session_id, project, memory_type)
508
+ VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory}, ${resolveMemoryType(args.type)})
366
509
  RETURNING id
367
510
  ` as { id: number }[];
368
511
 
512
+ // The injection block is global, but its cache is keyed by the calling
513
+ // directory + prompt; clear the directory's keys.
369
514
  invalidateInjection(ctx.directory);
370
- return `Stored memory #${inserted[0].id} for project ${basename}.`;
515
+ return `Stored memory #${inserted[0].id} (project ${basename}).`;
371
516
  } catch (e: unknown) {
372
517
  return toolError("remember", "remember", e);
373
518
  }
374
519
  }
375
520
 
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.
521
+ // --- Keyword capture (plan 1.1, revised) ---
522
+
523
+ // Deterministic capture, no LLM: a trigger phrase in the prompt stores the text
524
+ // following it verbatim (minus the trigger) through the normal write path.
525
+ // Extracting "relevant content" instead would be a model judgment on the
526
+ // prompt-admission path - nondeterministic, and it violates the project rule
527
+ // that model judgment never becomes load-bearing (memory #1555).
528
+ const MEMORY_TRIGGER_RE =
529
+ /\b(?:remember(?:\s+(?:this|that|to))?|do(?:n'?| no)t forget(?:\s+(?:this|that|to))?|keep (?:this|that )?in mind(?: that)?)\b\s*[:,]?\s*/i;
530
+
531
+ // Interrogative follow-ons are questions about the past ("remember when the
532
+ // pool broke?"), not storage requests. The list is deliberately narrow:
533
+ // "remember that when X happens, do Y" is imperative and must be captured, so
534
+ // "when" alone is not enough - only skip the bare question forms.
535
+ const INTERROGATIVE_RE = /^(?:when|what|where|why|how|who|whom|whose|which|did)\b/i;
536
+
537
+ function extractMemoryRequest(text: string): string | null {
538
+ const match = MEMORY_TRIGGER_RE.exec(text);
539
+ if (!match) return null;
540
+ const rest = text.slice(match.index + match[0].length).trim();
541
+ if (!rest || INTERROGATIVE_RE.test(rest)) return null;
542
+ return rest;
543
+ }
544
+
545
+ // Fire-and-forget by design: prompt admission must not wait on a database
546
+ // write, and the prompt itself is never mutated on failure.
547
+ //
548
+ // Not exactly-once: the docs allow prompt hooks to run more than once under
549
+ // concurrent submissions. Dedup-on-write (trigram similarity) is the guard -
550
+ // no hook-side deduplication layer on top of it.
551
+ async function captureFromPrompt(
552
+ text: string,
553
+ directory: string,
554
+ sessionID: string,
555
+ ): Promise<void> {
556
+ const content = extractMemoryRequest(text);
557
+ if (!content) return;
558
+ try {
559
+ await remember({ content, tags: ["user-requested"] }, { directory, sessionID });
560
+ } catch (e: unknown) {
561
+ logError("capture", `ocpg keyword capture failed: ${e instanceof Error ? e.message : String(e)}`);
562
+ }
563
+ }
564
+
565
+ // Memories are global: any project can delete any row by id - recall shows
566
+ // ids across projects, so deletability matches visibility. The project column
567
+ // stays as origin metadata, not an authorization boundary.
378
568
  async function forget(
379
569
  args: ForgetArgs,
380
570
  ctx: { directory: string },
@@ -386,12 +576,12 @@ async function forget(
386
576
  }
387
577
  const deleted = await sql`
388
578
  DELETE FROM memories
389
- WHERE id = ${id} AND project = ${ctx.directory}
579
+ WHERE id = ${id}
390
580
  RETURNING id
391
581
  ` as { id: number }[];
392
582
 
393
583
  if (deleted.length === 0) {
394
- return `No memory #${id} in this project; nothing deleted.`;
584
+ return `No memory #${id}; nothing deleted.`;
395
585
  }
396
586
  invalidateInjection(ctx.directory);
397
587
  return `Deleted memory #${id}.`;
@@ -400,8 +590,126 @@ async function forget(
400
590
  }
401
591
  }
402
592
 
593
+ // No dedup fall-through, by design: an update that lands close to another
594
+ // memory is an intentional correction, not a dupe to reject. updated_at is
595
+ // set; created_at is deliberately NOT bumped - the displayed date must keep
596
+ // saying when the memory was learned, not when it was last edited. Omitted
597
+ // tags/type are preserved, not reset to their defaults.
598
+ async function updateMemory(
599
+ args: UpdateArgs,
600
+ ctx: { directory: string },
601
+ ): Promise<string> {
602
+ try {
603
+ const id = Number(args.id);
604
+ if (!Number.isInteger(id) || id <= 0) {
605
+ return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
606
+ }
607
+ const invalid = validateWrite(args);
608
+ if (invalid) return invalid;
609
+
610
+ const tags = args.tags ?? [];
611
+ const tagCond = Array.isArray(args.tags)
612
+ ? sql`tags = ${sql.array(tags, "text")},`
613
+ : sql``;
614
+ const typeCond = args.type !== undefined
615
+ ? sql`memory_type = ${resolveMemoryType(args.type)},`
616
+ : sql``;
617
+ const updated = await sql`
618
+ UPDATE memories
619
+ SET content = ${args.content},
620
+ ${tagCond}
621
+ ${typeCond}
622
+ updated_at = now()
623
+ WHERE id = ${id}
624
+ RETURNING id
625
+ ` as { id: number }[];
626
+
627
+ if (updated.length === 0) {
628
+ return `No memory #${id}; nothing updated.`;
629
+ }
630
+ invalidateInjection(ctx.directory);
631
+ return `Updated memory #${id}.`;
632
+ } catch (e: unknown) {
633
+ return toolError("update", "update", e);
634
+ }
635
+ }
636
+
637
+ // Deterministic consolidation, no model calls inside the plugin: find
638
+ // near-duplicate clusters (trigram similarity >= DEDUP_SIMILARITY over the
639
+ // whole content, global), keep the newest of each, delete the rest. The
640
+ // deleted texts are returned verbatim so the CALLING agent - itself a model -
641
+ // can merge any unique fact back into the survivor via memory_update. Merging
642
+ // is language synthesis, which is the caller's job, not the plugin's.
643
+ // Runs on demand (user-invoked), never on a schedule; capped at 25 clusters
644
+ // per run so a wildly-duplicated corpus cannot turn into one huge report.
645
+ async function consolidate(): Promise<string> {
646
+ try {
647
+ const rows = await sql`
648
+ SELECT id, content, coalesce(tags, '{}') AS tags, created_at
649
+ FROM memories
650
+ ORDER BY created_at DESC
651
+ ` as { id: number; content: string; tags: string[]; created_at: Date }[];
652
+
653
+ // Greedy clustering newest-first: each row joins the first cluster whose
654
+ // representative (the newest member) it near-dupes. Trigram sets are built
655
+ // once (rebuilding per comparison made consolidate O(n^2) set-constructions,
656
+ // seconds at 5k rows), and a size-ratio prefilter skips pairs whose Jaccard
657
+ // can never reach the threshold.
658
+ type Entry = { row: (typeof rows)[number]; set: Set<string> };
659
+ const entries: Entry[] = rows.map((row) => ({ row, set: trigrams(row.content) }));
660
+ const clusters: Array<Array<Entry>> = [];
661
+ for (const entry of entries) {
662
+ const host = clusters.find((c) => {
663
+ const ra = c[0].set.size;
664
+ const rb = entry.set.size;
665
+ // Jaccard >= 0.8 is impossible when one set is much smaller; the
666
+ // comparison itself is the expensive part, so prefilter on sizes.
667
+ if (ra === 0 || rb === 0 || ra > rb * 4 || rb > ra * 4) return false;
668
+ return nearDupeSets(c[0].set, entry.set);
669
+ });
670
+ if (host) host.push(entry);
671
+ else clusters.push([entry]);
672
+ }
673
+
674
+ const multi = clusters.filter((c) => c.length > 1).slice(0, 25);
675
+ if (multi.length === 0) return "No duplicates found; nothing to consolidate.";
676
+
677
+ let removed = 0;
678
+ const report: string[] = [];
679
+ for (const cluster of multi) {
680
+ const survivor = cluster[0].row;
681
+ const removedRows = cluster.slice(1).map((e) => e.row);
682
+ for (const r of removedRows) {
683
+ await sql`DELETE FROM memories WHERE id = ${r.id}`;
684
+ }
685
+ removed += removedRows.length;
686
+ // Show what died so the calling agent can merge unique facts back into
687
+ // the survivor.
688
+ report.push(
689
+ `Kept #${survivor.id}: ${truncateMemory(survivor.content)}\n` +
690
+ removedRows.map((r) => ` removed #${r.id}: ${truncateMemory(r.content)}`).join("\n"),
691
+ );
692
+ }
693
+
694
+ if (removed > 0) injectionCache.clear();
695
+ return (
696
+ `Removed ${removed} duplicate ${removed === 1 ? "memory" : "memories"} across ${multi.length} groups (kept the newest of each).\n` +
697
+ `Check the removed texts - if any carries a fact the kept memory lacks, merge it in with memory_update:\n\n` +
698
+ report.join("\n")
699
+ );
700
+ } catch (e: unknown) {
701
+ return toolError("consolidate", "consolidate", e);
702
+ }
703
+ }
704
+
705
+ // Clears every cache entry for the directory - relevance mode keys by
706
+ // directory + prompt hash, so a write invalidates them all.
403
707
  function invalidateInjection(directory: string): void {
404
- injectionCache.delete(directory);
708
+ for (const key of [...injectionCache.keys()]) {
709
+ if (key === directory || key.startsWith(`${directory}\u0001`)) {
710
+ injectionCache.delete(key);
711
+ }
712
+ }
405
713
  }
406
714
 
407
715
  // V2 entrypoint: registers the system-context injection hook and the agent tools
@@ -419,20 +727,33 @@ const ocpg = Plugin.define({
419
727
  void sql`SELECT 1`.catch(() => {});
420
728
 
421
729
  // Inject project memories into every model request's system context.
422
- // handleTransform owns the per-directory cache (32-slot, invalidated on remember).
730
+ // Relevance mode derives the retrieval query from the latest user message;
731
+ // handleTransform owns the cache (32-slot, keyed by directory + prompt).
423
732
  await ctx.session.hook("context", async (event) => {
424
733
  const output: { system: string[] } = { system: [] };
425
- await handleTransform(output, directory);
734
+ await handleTransform(output, directory, extractPromptQuery(event.messages));
426
735
  for (const text of output.system) event.system.push({ type: "text", text });
427
736
  });
428
737
 
738
+ // Keyword capture (plan 1.1 revised): a trigger phrase ("remember this,
739
+ // ...") stores the following text verbatim through the normal write path -
740
+ // same validateWrite, same trigram dedup. No LLM call, and the prompt
741
+ // itself is never mutated.
742
+ await ctx.session.hook("prompt", (event) => {
743
+ void captureFromPrompt(event.prompt.text, directory, event.sessionID);
744
+ });
745
+
429
746
  // Agent tools: recall + remember with dedup-on-write. Input schemas are raw
430
747
  // JSON Schema (V2 contract); sizes are enforced in remember().
431
748
  await ctx.tool.transform((editor) => {
432
749
  editor.add({
433
750
  name: "memory_recall",
751
+ // Direct (non-codemode) tool: memory ops are single-shot calls, not
752
+ // scriptable sequences - hiding them behind the execute sandbox only
753
+ // breaks direct invocation without adding value.
754
+ options: { codemode: false },
434
755
  description:
435
- "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
756
+ "Search past memories across all projects. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
436
757
  input: {
437
758
  type: "object",
438
759
  properties: {
@@ -440,25 +761,27 @@ const ocpg = Plugin.define({
440
761
  tags: {
441
762
  type: "array",
442
763
  items: { type: "string" },
443
- description: "Only return memories carrying all of these tags",
764
+ description:
765
+ "Only return memories carrying all of these tags. Tags are not full-text " +
766
+ "searchable (search covers content only), so this filter is the only way to reach them.",
444
767
  },
445
- global: { type: "boolean", description: "Search across all projects (default: current project only)" },
446
768
  limit: { type: "number", description: "1-20, default 5" },
447
769
  },
448
770
  additionalProperties: false,
449
771
  },
450
772
  execute: async (input) => {
451
- return { content: await recall(input as RecallArgs, { directory }) };
773
+ return { content: await recall(input as RecallArgs) };
452
774
  },
453
775
  });
454
776
  editor.add({
455
777
  name: "memory_remember",
778
+ options: { codemode: false },
456
779
  // This description is the only place the write policy is guaranteed to
457
780
  // reach the model: it is in the tool schema every session, whereas the
458
781
  // injected block is skipped entirely for projects with no memories and
459
782
  // the user may have no project instructions at all.
460
783
  description:
461
- "Store a durable memory for this project. Use after user corrections (immediately), " +
784
+ "Store a durable memory shared across all projects. Use after user corrections (immediately), " +
462
785
  "architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
463
786
  "Do not store session progress, secrets, or anything the code itself already states.",
464
787
  input: {
@@ -468,19 +791,21 @@ const ocpg = Plugin.define({
468
791
  type: "string",
469
792
  description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
470
793
  },
794
+ type: {
795
+ type: "string",
796
+ enum: [...MEMORY_TYPES],
797
+ description:
798
+ "preference = a standing user preference (these are injected first); " +
799
+ "project_fact (default) = decisions, fixes, env facts. Omit unless the memory is a preference.",
800
+ },
471
801
  tags: {
472
802
  type: "array",
473
803
  items: { type: "string", maxLength: MAX_TAG_LENGTH },
474
804
  maxItems: MAX_TAGS,
475
805
  description:
476
- "Category prefixes: preference, decision, debug, env, architecture, workaround, " +
477
- "language:<x>, framework:<x>, tool:<x>. Project scoping is automatic (a project " +
478
- "column, not a tag) - never add project:<name>.",
479
- },
480
- force: {
481
- type: "boolean",
482
- description:
483
- "Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
806
+ "Fine-grained facets: decision, debug, env, architecture, workaround, " +
807
+ "language:<x>, framework:<x>, tool:<x>. The origin project is recorded " +
808
+ "automatically (a project column, not a tag) - never add project:<name>.",
484
809
  },
485
810
  },
486
811
  required: ["content"],
@@ -492,8 +817,10 @@ const ocpg = Plugin.define({
492
817
  });
493
818
  editor.add({
494
819
  name: "memory_forget",
820
+ options: { codemode: false },
495
821
  description:
496
- "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.",
822
+ "Delete a memory 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. " +
823
+ "Memories are shared across projects, so any project can delete any of them.",
497
824
  input: {
498
825
  type: "object",
499
826
  properties: {
@@ -506,6 +833,60 @@ const ocpg = Plugin.define({
506
833
  return { content: await forget(input as ForgetArgs, { directory }) };
507
834
  },
508
835
  });
836
+ editor.add({
837
+ name: "memory_update",
838
+ options: { codemode: false },
839
+ description:
840
+ "Rewrite an existing memory by id (get ids from memory_recall). Use when a memory is outdated but still worth keeping: the corrected content replaces the old, keeping the original learned date. " +
841
+ "Omitted tags/type are kept as-is. For obsolete memories use memory_forget; for genuinely new memories use memory_remember.",
842
+ input: {
843
+ type: "object",
844
+ properties: {
845
+ id: { type: "number", description: "The #id shown by memory_recall" },
846
+ content: {
847
+ type: "string",
848
+ description: `1-3 self-contained sentences replacing the old content (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
849
+ },
850
+ tags: {
851
+ type: "array",
852
+ items: { type: "string", maxLength: MAX_TAG_LENGTH },
853
+ maxItems: MAX_TAGS,
854
+ description: "Replaces the tag list; omit to keep the current tags",
855
+ },
856
+ type: {
857
+ type: "string",
858
+ enum: [...MEMORY_TYPES],
859
+ description: "Replaces the memory type; omit to keep the current type",
860
+ },
861
+ },
862
+ required: ["id", "content"],
863
+ additionalProperties: false,
864
+ },
865
+ execute: async (input) => {
866
+ return { content: await updateMemory(input as UpdateArgs, { directory }) };
867
+ },
868
+ });
869
+ editor.add({
870
+ name: "memory_consolidate",
871
+ options: { codemode: false },
872
+ // User-invoked cleanup, not a write-path gate: writes never reject on
873
+ // duplicates, so call this when the corpus has accumulated near-dupes.
874
+ // User-invoked cleanup, not a write-path gate: writes never reject on
875
+ // duplicates, so call this when the corpus has accumulated near-dupes.
876
+ // The deleted texts come back in the result so the calling agent can
877
+ // merge unique facts into the survivors via memory_update.
878
+ description:
879
+ "Remove near-duplicate memories: keeps the newest of each >=80%-similar content group anywhere in the store and deletes the rest, returning the removed texts. " +
880
+ "After running it, merge any unique fact from the removed texts into the kept memory via memory_update. Deterministic - run it when the user asks to tidy or consolidate memories.",
881
+ input: {
882
+ type: "object",
883
+ properties: {},
884
+ additionalProperties: false,
885
+ },
886
+ execute: async () => {
887
+ return { content: await consolidate() };
888
+ },
889
+ });
509
890
  });
510
891
 
511
892
  // Close the SQL pool when the last plugin instance unloads.
@@ -525,14 +906,29 @@ const __internals = {
525
906
  recall,
526
907
  remember,
527
908
  forget,
909
+ updateMemory,
910
+ consolidate,
911
+ extractMemoryRequest, captureFromPrompt,
528
912
  invalidateInjection,
529
913
  resolveSslMode,
530
914
  resolveLimit,
915
+ resolveMemoryType,
531
916
  validateWrite,
532
917
  logError,
533
918
  toolError,
534
919
  rateLimitOk,
535
920
  resetRateLimit,
921
+ get injectionMode() {
922
+ return injectionMode;
923
+ },
924
+ setInjectionMode(mode: "relevance" | "recency") {
925
+ injectionMode = mode;
926
+ },
927
+ extractPromptQuery,
928
+ hashQuery,
929
+ orTsQuery,
930
+ buildRecencyQuery,
931
+ buildRelevanceQuery,
536
932
  retain,
537
933
  dispose,
538
934
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.12.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Postgres-backed persistent memory plugin for OpenCode",
6
6
  "main": "ocpg.ts",
@@ -8,7 +8,9 @@
8
8
  "check": "biome check .",
9
9
  "format": "biome format --write .",
10
10
  "typecheck": "tsc --noEmit",
11
- "test": "bun test"
11
+ "test": "bun test",
12
+ "bench:generate": "bun bench/generate.ts",
13
+ "bench": "bun bench/run.ts"
12
14
  },
13
15
  "exports": {
14
16
  ".": "./ocpg.ts"