@dzhi/ocpg 0.12.0 → 0.14.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 +17 -11
  2. package/ocpg.ts +400 -42
  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,26 +39,32 @@ 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_USER_ID` | _(unset)_ |
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
 
46
+ Set `OCPG_USER_ID` to a short identifier to enable **user scope**: memories stored with `scope: "user"` live under a `user:<id>` sentinel and are injected into every project's context (and deletable from any project, by design). Unset, user scope is disabled entirely.
47
+
48
+ ## How injection picks memories
49
+
50
+ 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 projects**, ranked by relevance with a small same-project tiebreak, top 5 injected. Working on similar projects for different clients means a memory written anywhere can surface in any 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.
51
+
52
+ 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.
53
+
47
54
  ## Tools
48
55
 
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
56
+ Four agent tools are registered: `memory_remember` (store), `memory_recall` (search), `memory_forget` (delete by id), and `memory_update` (rewrite an existing memory, keeping its original learned date). The agent reads their usage rules from the tool schemas - as the user, the things worth knowing are:
52
57
 
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.
58
+ - Memories are scoped to the project directory they were stored from; recall only sees them cross-project when the agent explicitly asks for `global` search (or stores them in user scope).
59
+ - `memory_forget` / `memory_update` only touch memories of the calling project - plus, when user scope is enabled, the shared user memories.
54
60
 
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.
61
+ 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 are injected ahead of newer facts.
56
62
 
57
- `memory_forget` only ever deletes within the calling project, so an id from another project matches nothing.
63
+ 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.
58
64
 
59
65
  ### Duplicate detection
60
66
 
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.
67
+ `memory_remember` rejects near-duplicates of existing memories in the same project instead of storing them.
62
68
 
63
69
  This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
64
70
 
package/ocpg.ts CHANGED
@@ -13,9 +13,16 @@ 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; global?: boolean; limit?: number; tags?: string[]; scope?: "project" | "user" };
17
+ type RememberArgs = {
18
+ content: string;
19
+ tags?: string[];
20
+ force?: boolean;
21
+ scope?: "project" | "user";
22
+ type?: MemoryType;
23
+ };
18
24
  type ForgetArgs = { id: number };
25
+ type UpdateArgs = { id: number; content: string; tags?: string[]; type?: MemoryType };
19
26
 
20
27
  // Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
21
28
  // when the database is remote, otherwise the SCRAM handshake crosses the network
@@ -36,6 +43,22 @@ const defaultConfig: DbConfig = {
36
43
  };
37
44
  const password = process.env.OCPG_PASSWORD || "";
38
45
 
46
+ // --- User scope (plan 1.2, revised: sentinel value, no migration) ---
47
+
48
+ // User memories are ordinary rows with the sentinel project value `user:<id>`,
49
+ // written into the existing project column - identical semantics to a scope
50
+ // column with zero migration. OCPG_USER_ID is read once at init (env-only, no
51
+ // process spawning); unset means user scope is disabled entirely and every
52
+ // query degrades to exactly the pre-feature behavior.
53
+ //
54
+ // Mutable module state + a reset hook so tests can toggle it deterministically.
55
+ let userScope = resolveUserScope(process.env.OCPG_USER_ID);
56
+
57
+ function resolveUserScope(raw: string | undefined): string | null {
58
+ const id = raw?.trim();
59
+ return id ? `user:${id}` : null;
60
+ }
61
+
39
62
  // Options-object constructor, not a URL string: Bun's SQL parses string URLs via
40
63
  // url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
41
64
  function makeSql(cfg: DbConfig): SQL {
@@ -125,6 +148,14 @@ async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
125
148
 
126
149
  // --- Injection pipeline ---
127
150
 
151
+ // Injection ranking mode (plan follow-up: relevance over blind recency).
152
+ // "relevance" (default) scores all memories - every project - against the
153
+ // user's latest prompt via full-text search, falling back to recency when the
154
+ // prompt matches nothing; "recency" restores the old last-5 behavior via
155
+ // OCPG_INJECTION=recency. Resolved once at init, env-only.
156
+ let injectionMode: "relevance" | "recency" =
157
+ process.env.OCPG_INJECTION === "recency" ? "recency" : "relevance";
158
+
128
159
  function truncateMemory(content: string): string {
129
160
  if (content.length <= 600) return content;
130
161
  return `${content.slice(0, 600)}…[truncated]`;
@@ -157,43 +188,123 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
157
188
  return lines.join("\n");
158
189
  }
159
190
 
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.
191
+ // Keyed by directory + prompt hash now that the block depends on the prompt
192
+ // (relevance mode): an identical prompt (model retries, re-requests) hits the
193
+ // cache; a new prompt queries afresh. An empty string is cached for
194
+ // no-match/empty prompts so they stop re-querying.
164
195
  const injectionCache = new Map<string, string>();
165
196
 
166
- // Session-independent by design: the block depends only on the project
167
- // directory, so the hook passes nothing else.
197
+ // djb2 - just a stable key shortener; a same-hash different-prompt collision
198
+ // would serve a stale block, which remember/forget invalidation clears.
199
+ function hashQuery(text: string): string {
200
+ let h = 5381;
201
+ for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0;
202
+ return (h >>> 0).toString(36);
203
+ }
204
+
205
+ // The latest user message is the retrieval signal: what the user is asking
206
+ // about right now is the best proxy for which memories matter. Text parts
207
+ // only; capped because a query is a query, not a transcript - FTS is not
208
+ // helped by thousands of characters.
209
+ function extractPromptQuery(
210
+ messages: ReadonlyArray<{ role: unknown; content: ReadonlyArray<{ type?: unknown; text?: unknown }> }>,
211
+ ): string {
212
+ for (let i = messages.length - 1; i >= 0; i--) {
213
+ const message = messages[i];
214
+ if (message?.role !== "user") continue;
215
+ const text = message.content
216
+ .filter((p) => p.type === "text" && typeof p.text === "string")
217
+ .map((p) => p.text as string)
218
+ .join(" ")
219
+ .trim();
220
+ return text.length > 512 ? text.slice(0, 512) : text;
221
+ }
222
+ return "";
223
+ }
224
+
225
+ // Recency query shared by the recency mode and the no-match fallback: latest 5
226
+ // rows of the project (plus the user-scope sentinel when enabled), preferences
227
+ // first.
228
+ // Query builders are pure functions of the client so the benchmark
229
+ // (bench/run.ts) can execute the EXACT production SQL against bench
230
+ // databases - no drift between what is measured and what runs.
231
+ function buildRecencyQuery(client: SQL, directory: string) {
232
+ const projectCond = userScope
233
+ ? client`WHERE (project = ${directory} OR project = ${userScope})`
234
+ : client`WHERE project = ${directory}`;
235
+ return client`
236
+ SELECT content, coalesce(tags, '{}') AS tags,
237
+ to_char(created_at, 'YYYY-MM-DD') AS date
238
+ FROM memories
239
+ ${projectCond}
240
+ ORDER BY (memory_type = 'preference') DESC, created_at DESC
241
+ LIMIT 5
242
+ `;
243
+ }
244
+
245
+ function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
246
+ // Relevance: full-text search across ALL projects - shared memory by
247
+ // design - with a small same-project boost to break rank ties toward
248
+ // locally stored memories.
249
+ return client`
250
+ SELECT content, coalesce(tags, '{}') AS tags,
251
+ to_char(created_at, 'YYYY-MM-DD') AS date
252
+ FROM memories
253
+ WHERE search_vector @@ to_tsquery('english', ${tsQuery})
254
+ ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
255
+ + (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
256
+ created_at DESC
257
+ LIMIT 5
258
+ `;
259
+ }
260
+
261
+ // The prompt as an OR of stemmed words: websearch_to_tsquery ANDs the terms,
262
+ // so one word the memory never uses would zero out the whole query (bench:
263
+ // recall 0.00-0.02 on multi-word queries). OR ranks by how many (and how
264
+ // rare) the matched terms are, and sanitizing to [a-z0-9]+ tokens keeps
265
+ // to_tsquery syntax-safe. Capped at 24 words to bound the query.
266
+ function orTsQuery(text: string): string {
267
+ const words = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
268
+ return words.slice(0, 24).join(" | ");
269
+ }
270
+
271
+ // The block depends on the directory plus (in relevance mode) the prompt,
272
+ // passed explicitly by the caller.
168
273
  async function handleTransform(
169
274
  output: { system: string[] },
170
275
  directory: string,
276
+ prompt = "",
171
277
  ): Promise<void> {
172
278
  if (!directory) return;
173
- const cached = injectionCache.get(directory);
279
+ const query = injectionMode === "relevance" ? prompt.trim() : "";
280
+ const cacheKey = `${directory}\u0001${hashQuery(query)}`;
281
+ const cached = injectionCache.get(cacheKey);
174
282
  if (cached !== undefined) {
175
283
  if (cached) output.system.push(cached);
176
284
  return;
177
285
  }
178
286
  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
- );
287
+ let rows: InjectionRow[];
288
+ const tsQuery = orTsQuery(query);
289
+ if (tsQuery) {
290
+ rows = await withDeadline(
291
+ buildRelevanceQuery(sql, tsQuery, directory) as unknown as PromiseLike<InjectionRow[]>,
292
+ 1000,
293
+ );
294
+ if (rows.length === 0) {
295
+ // No keyword match for this prompt - recency beats an empty block.
296
+ rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
297
+ }
298
+ } else {
299
+ rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
300
+ }
190
301
  const block = formatBlock(rows, directory);
191
302
  // Evict oldest entry when cache exceeds 32
192
303
  if (injectionCache.size >= 32) {
193
304
  const firstKey = injectionCache.keys().next().value;
194
305
  if (firstKey !== undefined) injectionCache.delete(firstKey);
195
306
  }
196
- injectionCache.set(directory, block);
307
+ injectionCache.set(cacheKey, block);
197
308
  if (block) output.system.push(block);
198
309
  } catch (e: unknown) {
199
310
  logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -229,6 +340,19 @@ const MIN_CONTENT = 10;
229
340
  const MAX_TAGS = 10;
230
341
  const MAX_TAG_LENGTH = 64;
231
342
 
343
+ // --- Memory types (plan 2.1: defaulted, never required) ---
344
+
345
+ // The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
346
+ // rows predate the column, so "required" would break every existing caller -
347
+ // type is always defaulted. episodic is reserved for the (cut, opt-in) 1.3
348
+ // feature; remember accepts it so the vocabulary stays in one place.
349
+ const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
350
+ type MemoryType = (typeof MEMORY_TYPES)[number];
351
+
352
+ function resolveMemoryType(raw: unknown): MemoryType {
353
+ return MEMORY_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project_fact";
354
+ }
355
+
232
356
  // Raw JSON Schema input is not coerced for us: a model sending "3" or null for
233
357
  // limit would otherwise reach Postgres as LIMIT NaN.
234
358
  function resolveLimit(raw: unknown): number {
@@ -257,11 +381,18 @@ async function recall(
257
381
  ): Promise<string> {
258
382
  try {
259
383
  const limit = resolveLimit(args.limit);
384
+ // scope: "user" addresses the shared user scope; global: true already
385
+ // covers user rows since it drops the project filter entirely.
386
+ if (args.scope === "user" && !userScope) {
387
+ return "ERROR: user scope is disabled (set OCPG_USER_ID to enable it).";
388
+ }
260
389
  const projectCond = args.global
261
390
  ? sql``
262
- : sql`AND project = ${ctx.directory}`;
391
+ : args.scope === "user"
392
+ ? sql`AND project = ${userScope}`
393
+ : sql`AND project = ${ctx.directory}`;
263
394
  const queryCond = args.query
264
- ? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
395
+ ? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
265
396
  : sql``;
266
397
  // Tags are not part of search_vector (it covers content only), so they are
267
398
  // unreachable by query alone. Matches rows carrying ALL the given tags,
@@ -270,20 +401,34 @@ async function recall(
270
401
  const tagCond = tagList.length
271
402
  ? sql`AND tags @> ${sql.array(tagList, "text")}`
272
403
  : sql``;
273
- // Relevance-ranked when searching; recency-ordered for a plain project browse.
404
+ // Relevance-ranked when searching; undirected browse uses a recency×
405
+ // frequency blend. The gravity form keeps zero-access rows ordered by pure
406
+ // recency (fresh installs have access_count = 0 everywhere) and lets access
407
+ // bumps resurface used memories without letting a single old favorite pin
408
+ // the top slot forever.
274
409
  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`;
410
+ ? sql`ORDER BY ts_rank(search_vector, to_tsquery('english', ${orTsQuery(args.query)})) DESC`
411
+ : sql`ORDER BY (1 + access_count) / (GREATEST(EXTRACT(EPOCH FROM (now() - created_at)) / 86400, 0) + 2) DESC, created_at DESC`;
277
412
 
278
413
  const rows = await sql`
279
414
  SELECT id, content, coalesce(tags, '{}') AS tags,
280
415
  to_char(created_at, 'YYYY-MM-DD') AS date,
281
- project
416
+ project, memory_type
282
417
  FROM memories
283
418
  WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
284
419
  ${orderBy}
285
420
  LIMIT ${limit}
286
- ` as MemoryRow[];
421
+ ` as (MemoryRow & { memory_type: string })[];
422
+
423
+ // Access ranking is recall-only (plan 2.2): the injection path stays
424
+ // read-only because its per-directory cache would make increments biased.
425
+ // Fire-and-forget so the UPDATE never sits on the read path's latency.
426
+ const ids = rows.map((r) => r.id);
427
+ if (ids.length > 0) {
428
+ void sql`UPDATE memories SET access_count = access_count + 1, last_accessed_at = now() WHERE id = ANY(${sql.array(ids, "int8")})`.catch(
429
+ (e: unknown) => logError("access", `ocpg access bump failed: ${e instanceof Error ? e.message : String(e)}`),
430
+ );
431
+ }
287
432
 
288
433
  if (rows.length === 0) return "No memories found.";
289
434
 
@@ -291,7 +436,10 @@ async function recall(
291
436
  .map((r) => {
292
437
  const tags = r.tags ?? [];
293
438
  const tagStr = tags.length ? ` (${tags.join(', ')})` : '';
294
- return `[${r.date}] [${r.project}]${tagStr}\n#${r.id}\n${r.content}`;
439
+ // preference/episodic are worth surfacing; project_fact is the default
440
+ // every pre-column row carries, so printing it is pure noise.
441
+ const typeStr = r.memory_type === "project_fact" ? "" : ` [${r.memory_type}]`;
442
+ return `[${r.date}] [${r.project}]${typeStr}${tagStr}\n#${r.id}\n${r.content}`;
295
443
  })
296
444
  .join('\n---\n');
297
445
  } catch (e: unknown) {
@@ -318,6 +466,9 @@ function validateWrite(args: RememberArgs): string | null {
318
466
  if (oversized !== undefined) {
319
467
  return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
320
468
  }
469
+ if (args.type !== undefined && !MEMORY_TYPES.includes(args.type)) {
470
+ return `ERROR: type must be one of ${MEMORY_TYPES.join(", ")}.`;
471
+ }
321
472
  return null;
322
473
  }
323
474
 
@@ -338,43 +489,103 @@ async function remember(
338
489
 
339
490
  // Tags are stored verbatim - project scoping lives in the project column, not tags.
340
491
  const tags = args.tags ?? [];
492
+ const scope = args.scope === "user" ? "user" : "project";
493
+ if (scope === "user" && !userScope) {
494
+ return "ERROR: user scope is disabled (set OCPG_USER_ID to enable it).";
495
+ }
496
+ // The write target: the calling project's directory, or the shared user
497
+ // sentinel. Dedup is scoped to the target so a user write never collides
498
+ // with a project row (or vice versa).
499
+ const target = scope === "user" ? (userScope as string) : ctx.directory;
341
500
  const basename = ctx.directory.split('/').pop() ?? ctx.directory;
342
501
 
343
502
  if (!args.force) {
344
- // Dedup: project-scoped trigram similarity over the whole content. No
503
+ // Dedup: target-scoped trigram similarity over the whole content. No
345
504
  // trigram index - the project filter narrows to a few hundred rows, which
346
505
  // similarity() scans in single-digit milliseconds.
347
506
  const dedup = await sql`
348
507
  SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
349
508
  FROM memories
350
- WHERE project = ${ctx.directory}
509
+ WHERE project = ${target}
351
510
  AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
352
511
  ORDER BY similarity(content, ${args.content}) DESC
353
512
  LIMIT 1
354
513
  ` as { id: number; score: string }[];
355
514
 
356
515
  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.`;
516
+ const where = scope === "user" ? " in user scope" : "";
517
+ return `Similar memory already stored as #${dedup[0].id} (similarity ${dedup[0].score})${where}; skipping insert. Pass force: true to store it anyway.`;
358
518
  }
359
519
  }
360
520
 
361
521
  // sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
362
522
  // the element type hint is required for clean array storage.
363
523
  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})
524
+ INSERT INTO memories (content, tags, session_id, project, memory_type)
525
+ VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${target}, ${resolveMemoryType(args.type)})
366
526
  RETURNING id
367
527
  ` as { id: number }[];
368
528
 
529
+ // The injection cache is keyed by directory and user rows are injected into
530
+ // it, so a user-scope write invalidates the calling project's entry too.
369
531
  invalidateInjection(ctx.directory);
370
- return `Stored memory #${inserted[0].id} for project ${basename}.`;
532
+ return scope === "user"
533
+ ? `Stored memory #${inserted[0].id} for user scope (shared across projects).`
534
+ : `Stored memory #${inserted[0].id} for project ${basename}.`;
371
535
  } catch (e: unknown) {
372
536
  return toolError("remember", "remember", e);
373
537
  }
374
538
  }
375
539
 
540
+ // --- Keyword capture (plan 1.1, revised) ---
541
+
542
+ // Deterministic capture, no LLM: a trigger phrase in the prompt stores the text
543
+ // following it verbatim (minus the trigger) through the normal write path.
544
+ // Extracting "relevant content" instead would be a model judgment on the
545
+ // prompt-admission path - nondeterministic, and it violates the project rule
546
+ // that model judgment never becomes load-bearing (memory #1555).
547
+ const MEMORY_TRIGGER_RE =
548
+ /\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;
549
+
550
+ // Interrogative follow-ons are questions about the past ("remember when the
551
+ // pool broke?"), not storage requests. The list is deliberately narrow:
552
+ // "remember that when X happens, do Y" is imperative and must be captured, so
553
+ // "when" alone is not enough - only skip the bare question forms.
554
+ const INTERROGATIVE_RE = /^(?:when|what|where|why|how|who|whom|whose|which|did)\b/i;
555
+
556
+ function extractMemoryRequest(text: string): string | null {
557
+ const match = MEMORY_TRIGGER_RE.exec(text);
558
+ if (!match) return null;
559
+ const rest = text.slice(match.index + match[0].length).trim();
560
+ if (!rest || INTERROGATIVE_RE.test(rest)) return null;
561
+ return rest;
562
+ }
563
+
564
+ // Fire-and-forget by design: prompt admission must not wait on a database
565
+ // write, and the prompt itself is never mutated on failure.
566
+ //
567
+ // Not exactly-once: the docs allow prompt hooks to run more than once under
568
+ // concurrent submissions. Dedup-on-write (trigram similarity) is the guard -
569
+ // no hook-side deduplication layer on top of it.
570
+ async function captureFromPrompt(
571
+ text: string,
572
+ directory: string,
573
+ sessionID: string,
574
+ ): Promise<void> {
575
+ const content = extractMemoryRequest(text);
576
+ if (!content) return;
577
+ try {
578
+ await remember({ content, tags: ["user-requested"] }, { directory, sessionID });
579
+ } catch (e: unknown) {
580
+ logError("capture", `ocpg keyword capture failed: ${e instanceof Error ? e.message : String(e)}`);
581
+ }
582
+ }
583
+
376
584
  // Project-scoped by construction: an agent can only delete what its own project
377
585
  // can recall, so a poisoned id from another project silently matches nothing.
586
+ // When user scope is enabled the boundary deliberately widens to include the
587
+ // shared user sentinel - user memories are visible to every project of the
588
+ // user, so any of them can delete them. Documented in the tool description.
378
589
  async function forget(
379
590
  args: ForgetArgs,
380
591
  ctx: { directory: string },
@@ -384,9 +595,12 @@ async function forget(
384
595
  if (!Number.isInteger(id) || id <= 0) {
385
596
  return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
386
597
  }
598
+ const projectCond = userScope
599
+ ? sql`project IN (${ctx.directory}, ${userScope})`
600
+ : sql`project = ${ctx.directory}`;
387
601
  const deleted = await sql`
388
602
  DELETE FROM memories
389
- WHERE id = ${id} AND project = ${ctx.directory}
603
+ WHERE id = ${id} AND ${projectCond}
390
604
  RETURNING id
391
605
  ` as { id: number }[];
392
606
 
@@ -400,8 +614,61 @@ async function forget(
400
614
  }
401
615
  }
402
616
 
617
+ // No dedup fall-through, by design: an update that lands close to another
618
+ // memory is an intentional correction, not a dupe to reject. updated_at is
619
+ // set; created_at is deliberately NOT bumped - the displayed date must keep
620
+ // saying when the memory was learned, not when it was last edited. Omitted
621
+ // tags/type are preserved, not reset to their defaults.
622
+ async function updateMemory(
623
+ args: UpdateArgs,
624
+ ctx: { directory: string },
625
+ ): Promise<string> {
626
+ try {
627
+ const id = Number(args.id);
628
+ if (!Number.isInteger(id) || id <= 0) {
629
+ return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
630
+ }
631
+ const invalid = validateWrite(args);
632
+ if (invalid) return invalid;
633
+
634
+ const projectCond = userScope
635
+ ? sql`project IN (${ctx.directory}, ${userScope})`
636
+ : sql`project = ${ctx.directory}`;
637
+ const tags = args.tags ?? [];
638
+ const tagCond = Array.isArray(args.tags)
639
+ ? sql`tags = ${sql.array(tags, "text")},`
640
+ : sql``;
641
+ const typeCond = args.type !== undefined
642
+ ? sql`memory_type = ${resolveMemoryType(args.type)},`
643
+ : sql``;
644
+ const updated = await sql`
645
+ UPDATE memories
646
+ SET content = ${args.content},
647
+ ${tagCond}
648
+ ${typeCond}
649
+ updated_at = now()
650
+ WHERE id = ${id} AND ${projectCond}
651
+ RETURNING id
652
+ ` as { id: number }[];
653
+
654
+ if (updated.length === 0) {
655
+ return `No memory #${id} in this project; nothing updated.`;
656
+ }
657
+ invalidateInjection(ctx.directory);
658
+ return `Updated memory #${id}.`;
659
+ } catch (e: unknown) {
660
+ return toolError("update", "update", e);
661
+ }
662
+ }
663
+
664
+ // Clears every cache entry for the directory - relevance mode keys by
665
+ // directory + prompt hash, so a write invalidates them all.
403
666
  function invalidateInjection(directory: string): void {
404
- injectionCache.delete(directory);
667
+ for (const key of [...injectionCache.keys()]) {
668
+ if (key === directory || key.startsWith(`${directory}\u0001`)) {
669
+ injectionCache.delete(key);
670
+ }
671
+ }
405
672
  }
406
673
 
407
674
  // V2 entrypoint: registers the system-context injection hook and the agent tools
@@ -419,18 +686,31 @@ const ocpg = Plugin.define({
419
686
  void sql`SELECT 1`.catch(() => {});
420
687
 
421
688
  // Inject project memories into every model request's system context.
422
- // handleTransform owns the per-directory cache (32-slot, invalidated on remember).
689
+ // Relevance mode derives the retrieval query from the latest user message;
690
+ // handleTransform owns the cache (32-slot, keyed by directory + prompt).
423
691
  await ctx.session.hook("context", async (event) => {
424
692
  const output: { system: string[] } = { system: [] };
425
- await handleTransform(output, directory);
693
+ await handleTransform(output, directory, extractPromptQuery(event.messages));
426
694
  for (const text of output.system) event.system.push({ type: "text", text });
427
695
  });
428
696
 
697
+ // Keyword capture (plan 1.1 revised): a trigger phrase ("remember this,
698
+ // ...") stores the following text verbatim through the normal write path -
699
+ // same validateWrite, same trigram dedup. No LLM call, and the prompt
700
+ // itself is never mutated.
701
+ await ctx.session.hook("prompt", (event) => {
702
+ void captureFromPrompt(event.prompt.text, directory, event.sessionID);
703
+ });
704
+
429
705
  // Agent tools: recall + remember with dedup-on-write. Input schemas are raw
430
706
  // JSON Schema (V2 contract); sizes are enforced in remember().
431
707
  await ctx.tool.transform((editor) => {
432
708
  editor.add({
433
709
  name: "memory_recall",
710
+ // Direct (non-codemode) tool: memory ops are single-shot calls, not
711
+ // scriptable sequences - hiding them behind the execute sandbox only
712
+ // breaks direct invocation without adding value.
713
+ options: { codemode: false },
434
714
  description:
435
715
  "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
436
716
  input: {
@@ -440,9 +720,16 @@ const ocpg = Plugin.define({
440
720
  tags: {
441
721
  type: "array",
442
722
  items: { type: "string" },
443
- description: "Only return memories carrying all of these tags",
723
+ description:
724
+ "Only return memories carrying all of these tags. Tags are not full-text " +
725
+ "searchable (search covers content only), so this filter is the only way to reach them.",
444
726
  },
445
727
  global: { type: "boolean", description: "Search across all projects (default: current project only)" },
728
+ scope: {
729
+ type: "string",
730
+ enum: ["project", "user"],
731
+ description: '"user" searches your shared cross-project memories instead of this project\'s (requires user scope to be enabled)',
732
+ },
446
733
  limit: { type: "number", description: "1-20, default 5" },
447
734
  },
448
735
  additionalProperties: false,
@@ -453,6 +740,7 @@ const ocpg = Plugin.define({
453
740
  });
454
741
  editor.add({
455
742
  name: "memory_remember",
743
+ options: { codemode: false },
456
744
  // This description is the only place the write policy is guaranteed to
457
745
  // reach the model: it is in the tool schema every session, whereas the
458
746
  // injected block is skipped entirely for projects with no memories and
@@ -468,12 +756,19 @@ const ocpg = Plugin.define({
468
756
  type: "string",
469
757
  description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
470
758
  },
759
+ type: {
760
+ type: "string",
761
+ enum: [...MEMORY_TYPES],
762
+ description:
763
+ "preference = a standing user preference (these are injected first); " +
764
+ "project_fact (default) = decisions, fixes, env facts. Omit unless the memory is a preference.",
765
+ },
471
766
  tags: {
472
767
  type: "array",
473
768
  items: { type: "string", maxLength: MAX_TAG_LENGTH },
474
769
  maxItems: MAX_TAGS,
475
770
  description:
476
- "Category prefixes: preference, decision, debug, env, architecture, workaround, " +
771
+ "Fine-grained facets: decision, debug, env, architecture, workaround, " +
477
772
  "language:<x>, framework:<x>, tool:<x>. Project scoping is automatic (a project " +
478
773
  "column, not a tag) - never add project:<name>.",
479
774
  },
@@ -482,6 +777,12 @@ const ocpg = Plugin.define({
482
777
  description:
483
778
  "Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
484
779
  },
780
+ scope: {
781
+ type: "string",
782
+ enum: ["project", "user"],
783
+ description:
784
+ '"user" stores for you across all projects (requires user scope to be enabled); default "project" stores for this project only',
785
+ },
485
786
  },
486
787
  required: ["content"],
487
788
  additionalProperties: false,
@@ -492,8 +793,10 @@ const ocpg = Plugin.define({
492
793
  });
493
794
  editor.add({
494
795
  name: "memory_forget",
796
+ options: { codemode: false },
495
797
  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.",
798
+ "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. " +
799
+ "Deletes match this project's memories and, when user scope is enabled, your shared user memories too - those are visible to all your projects by design, so any of them can delete them.",
497
800
  input: {
498
801
  type: "object",
499
802
  properties: {
@@ -506,6 +809,39 @@ const ocpg = Plugin.define({
506
809
  return { content: await forget(input as ForgetArgs, { directory }) };
507
810
  },
508
811
  });
812
+ editor.add({
813
+ name: "memory_update",
814
+ options: { codemode: false },
815
+ description:
816
+ "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. " +
817
+ "Omitted tags/type are kept as-is. For obsolete memories use memory_forget; for genuinely new memories use memory_remember.",
818
+ input: {
819
+ type: "object",
820
+ properties: {
821
+ id: { type: "number", description: "The #id shown by memory_recall" },
822
+ content: {
823
+ type: "string",
824
+ description: `1-3 self-contained sentences replacing the old content (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
825
+ },
826
+ tags: {
827
+ type: "array",
828
+ items: { type: "string", maxLength: MAX_TAG_LENGTH },
829
+ maxItems: MAX_TAGS,
830
+ description: "Replaces the tag list; omit to keep the current tags",
831
+ },
832
+ type: {
833
+ type: "string",
834
+ enum: [...MEMORY_TYPES],
835
+ description: "Replaces the memory type; omit to keep the current type",
836
+ },
837
+ },
838
+ required: ["id", "content"],
839
+ additionalProperties: false,
840
+ },
841
+ execute: async (input) => {
842
+ return { content: await updateMemory(input as UpdateArgs, { directory }) };
843
+ },
844
+ });
509
845
  });
510
846
 
511
847
  // Close the SQL pool when the last plugin instance unloads.
@@ -525,14 +861,36 @@ const __internals = {
525
861
  recall,
526
862
  remember,
527
863
  forget,
864
+ updateMemory,
865
+ extractMemoryRequest,
866
+ captureFromPrompt,
528
867
  invalidateInjection,
529
868
  resolveSslMode,
530
869
  resolveLimit,
870
+ resolveMemoryType,
531
871
  validateWrite,
532
872
  logError,
873
+ resolveUserScope,
533
874
  toolError,
534
875
  rateLimitOk,
535
876
  resetRateLimit,
877
+ get injectionMode() {
878
+ return injectionMode;
879
+ },
880
+ setInjectionMode(mode: "relevance" | "recency") {
881
+ injectionMode = mode;
882
+ },
883
+ extractPromptQuery,
884
+ hashQuery,
885
+ orTsQuery,
886
+ buildRecencyQuery,
887
+ buildRelevanceQuery,
888
+ get userScope() {
889
+ return userScope;
890
+ },
891
+ setUserScope(raw: string | undefined | null) {
892
+ userScope = resolveUserScope(raw ?? undefined);
893
+ },
536
894
  retain,
537
895
  dispose,
538
896
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.12.0",
3
+ "version": "0.14.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"