@dzhi/ocpg 0.14.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 +11 -17
  2. package/ocpg.ts +181 -143
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,40 +39,34 @@ export OCPG_SSL="disable"
39
39
  | `OCPG_USER` | `ocpguser` |
40
40
  | `OCPG_DB` | `ocpg` |
41
41
  | `OCPG_SSL` | `disable` |
42
- | `OCPG_USER_ID` | _(unset)_ |
42
+ | `OCPG_INJECTION`| `relevance` |
43
43
 
44
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.
45
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.
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.
47
49
 
48
50
  ## How injection picks memories
49
51
 
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.
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.
51
53
 
52
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.
53
55
 
54
56
  ## Tools
55
57
 
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:
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:
57
59
 
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.
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.
60
62
 
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.
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.
62
64
 
63
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.
64
66
 
65
- ### Duplicate detection
66
-
67
- `memory_remember` rejects near-duplicates of existing memories in the same project instead of storing them.
68
-
69
- This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
70
-
71
- ```sql
72
- CREATE EXTENSION pg_trgm;
73
- ```
67
+ ### Duplicates
74
68
 
75
- 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).
76
70
 
77
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.
78
72
 
package/ocpg.ts CHANGED
@@ -13,12 +13,10 @@ type DbConfig = {
13
13
  database: string;
14
14
  ssl: SslMode;
15
15
  };
16
- type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[]; scope?: "project" | "user" };
16
+ type RecallArgs = { query?: string; limit?: number; tags?: string[] };
17
17
  type RememberArgs = {
18
18
  content: string;
19
19
  tags?: string[];
20
- force?: boolean;
21
- scope?: "project" | "user";
22
20
  type?: MemoryType;
23
21
  };
24
22
  type ForgetArgs = { id: number };
@@ -43,22 +41,6 @@ const defaultConfig: DbConfig = {
43
41
  };
44
42
  const password = process.env.OCPG_PASSWORD || "";
45
43
 
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
-
62
44
  // Options-object constructor, not a URL string: Bun's SQL parses string URLs via
63
45
  // url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
64
46
  function makeSql(cfg: DbConfig): SQL {
@@ -87,8 +69,9 @@ export interface MemoryRow {
87
69
  date: string;
88
70
  }
89
71
 
90
- // The injection query selects neither id nor project - they are never rendered.
91
- 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">;
92
75
 
93
76
  // --- Rate-limited error logging ---
94
77
 
@@ -168,6 +151,40 @@ function sanitizeMemory(content: string): string {
168
151
  return content.replaceAll("</persistent-project-memory>", "");
169
152
  }
170
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
+
171
188
  function formatBlock(rows: InjectionRow[], projectDir: string): string {
172
189
  if (rows.length === 0) return "";
173
190
  const lines: string[] = [
@@ -178,7 +195,10 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
178
195
  for (const row of rows) {
179
196
  const tags = row.tags ?? [];
180
197
  const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
181
- 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))}`);
182
202
  }
183
203
  lines.push("");
184
204
  lines.push(
@@ -223,38 +243,37 @@ function extractPromptQuery(
223
243
  }
224
244
 
225
245
  // 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.
246
+ // rows across ALL memories, preferences first. Global by design - the project
247
+ // column records origin, not visibility.
228
248
  // Query builders are pure functions of the client so the benchmark
229
249
  // (bench/run.ts) can execute the EXACT production SQL against bench
230
250
  // 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}`;
251
+ function buildRecencyQuery(client: SQL) {
252
+ // LIMIT 20: a candidate slice for collapseDupes, not the final block.
235
253
  return client`
236
254
  SELECT content, coalesce(tags, '{}') AS tags,
237
- to_char(created_at, 'YYYY-MM-DD') AS date
255
+ to_char(created_at, 'YYYY-MM-DD') AS date,
256
+ project
238
257
  FROM memories
239
- ${projectCond}
240
258
  ORDER BY (memory_type = 'preference') DESC, created_at DESC
241
- LIMIT 5
259
+ LIMIT 20
242
260
  `;
243
261
  }
244
262
 
245
263
  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.
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.
249
267
  return client`
250
268
  SELECT content, coalesce(tags, '{}') AS tags,
251
- to_char(created_at, 'YYYY-MM-DD') AS date
269
+ to_char(created_at, 'YYYY-MM-DD') AS date,
270
+ project
252
271
  FROM memories
253
272
  WHERE search_vector @@ to_tsquery('english', ${tsQuery})
254
273
  ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
255
274
  + (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
256
275
  created_at DESC
257
- LIMIT 5
276
+ LIMIT 20
258
277
  `;
259
278
  }
260
279
 
@@ -293,12 +312,12 @@ async function handleTransform(
293
312
  );
294
313
  if (rows.length === 0) {
295
314
  // No keyword match for this prompt - recency beats an empty block.
296
- rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
315
+ rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
297
316
  }
298
317
  } else {
299
- rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
318
+ rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
300
319
  }
301
- const block = formatBlock(rows, directory);
320
+ const block = formatBlock(collapseDupes(rows), directory);
302
321
  // Evict oldest entry when cache exceeds 32
303
322
  if (injectionCache.size >= 32) {
304
323
  const firstKey = injectionCache.keys().next().value;
@@ -377,20 +396,11 @@ function toolError(kind: string, action: string, e: unknown): string {
377
396
 
378
397
  async function recall(
379
398
  args: RecallArgs,
380
- ctx: { directory: string },
381
399
  ): Promise<string> {
382
400
  try {
383
401
  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
- }
389
- const projectCond = args.global
390
- ? sql``
391
- : args.scope === "user"
392
- ? sql`AND project = ${userScope}`
393
- : 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.
394
404
  const queryCond = args.query
395
405
  ? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
396
406
  : sql``;
@@ -401,21 +411,21 @@ async function recall(
401
411
  const tagCond = tagList.length
402
412
  ? sql`AND tags @> ${sql.array(tagList, "text")}`
403
413
  : sql``;
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.
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.
409
419
  const orderBy = args.query
410
420
  ? 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`;
421
+ : sql`ORDER BY (memory_type = 'preference') DESC, created_at DESC`;
412
422
 
413
423
  const rows = await sql`
414
424
  SELECT id, content, coalesce(tags, '{}') AS tags,
415
425
  to_char(created_at, 'YYYY-MM-DD') AS date,
416
426
  project, memory_type
417
427
  FROM memories
418
- WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
428
+ WHERE 1=1 ${queryCond} ${tagCond}
419
429
  ${orderBy}
420
430
  LIMIT ${limit}
421
431
  ` as (MemoryRow & { memory_type: string })[];
@@ -472,11 +482,11 @@ function validateWrite(args: RememberArgs): string | null {
472
482
  return null;
473
483
  }
474
484
 
475
- // Trigram similarity threshold for dedup-on-write. Measured on a real
476
- // 485-memory corpus: the previous rule (FTS on the first 60 characters) let 28
477
- // pairs at >=0.8 similarity through because they differed in their opening
478
- // words, while wrongly rejecting ~1% of genuinely distinct memories. 0.8 is
479
- // 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.
480
490
  const DEDUP_SIMILARITY = 0.8;
481
491
 
482
492
  async function remember(
@@ -487,51 +497,22 @@ async function remember(
487
497
  const invalid = validateWrite(args);
488
498
  if (invalid) return invalid;
489
499
 
490
- // 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.
491
501
  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;
500
502
  const basename = ctx.directory.split('/').pop() ?? ctx.directory;
501
503
 
502
- if (!args.force) {
503
- // Dedup: target-scoped trigram similarity over the whole content. No
504
- // trigram index - the project filter narrows to a few hundred rows, which
505
- // similarity() scans in single-digit milliseconds.
506
- const dedup = await sql`
507
- SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
508
- FROM memories
509
- WHERE project = ${target}
510
- AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
511
- ORDER BY similarity(content, ${args.content}) DESC
512
- LIMIT 1
513
- ` as { id: number; score: string }[];
514
-
515
- if (dedup.length > 0) {
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.`;
518
- }
519
- }
520
-
521
504
  // sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
522
505
  // the element type hint is required for clean array storage.
523
506
  const inserted = await sql`
524
507
  INSERT INTO memories (content, tags, session_id, project, memory_type)
525
- VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${target}, ${resolveMemoryType(args.type)})
508
+ VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory}, ${resolveMemoryType(args.type)})
526
509
  RETURNING id
527
510
  ` as { id: number }[];
528
511
 
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.
512
+ // The injection block is global, but its cache is keyed by the calling
513
+ // directory + prompt; clear the directory's keys.
531
514
  invalidateInjection(ctx.directory);
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}.`;
515
+ return `Stored memory #${inserted[0].id} (project ${basename}).`;
535
516
  } catch (e: unknown) {
536
517
  return toolError("remember", "remember", e);
537
518
  }
@@ -581,11 +562,9 @@ async function captureFromPrompt(
581
562
  }
582
563
  }
583
564
 
584
- // Project-scoped by construction: an agent can only delete what its own project
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.
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.
589
568
  async function forget(
590
569
  args: ForgetArgs,
591
570
  ctx: { directory: string },
@@ -595,17 +574,14 @@ async function forget(
595
574
  if (!Number.isInteger(id) || id <= 0) {
596
575
  return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
597
576
  }
598
- const projectCond = userScope
599
- ? sql`project IN (${ctx.directory}, ${userScope})`
600
- : sql`project = ${ctx.directory}`;
601
577
  const deleted = await sql`
602
578
  DELETE FROM memories
603
- WHERE id = ${id} AND ${projectCond}
579
+ WHERE id = ${id}
604
580
  RETURNING id
605
581
  ` as { id: number }[];
606
582
 
607
583
  if (deleted.length === 0) {
608
- return `No memory #${id} in this project; nothing deleted.`;
584
+ return `No memory #${id}; nothing deleted.`;
609
585
  }
610
586
  invalidateInjection(ctx.directory);
611
587
  return `Deleted memory #${id}.`;
@@ -631,9 +607,6 @@ async function updateMemory(
631
607
  const invalid = validateWrite(args);
632
608
  if (invalid) return invalid;
633
609
 
634
- const projectCond = userScope
635
- ? sql`project IN (${ctx.directory}, ${userScope})`
636
- : sql`project = ${ctx.directory}`;
637
610
  const tags = args.tags ?? [];
638
611
  const tagCond = Array.isArray(args.tags)
639
612
  ? sql`tags = ${sql.array(tags, "text")},`
@@ -647,12 +620,12 @@ async function updateMemory(
647
620
  ${tagCond}
648
621
  ${typeCond}
649
622
  updated_at = now()
650
- WHERE id = ${id} AND ${projectCond}
623
+ WHERE id = ${id}
651
624
  RETURNING id
652
625
  ` as { id: number }[];
653
626
 
654
627
  if (updated.length === 0) {
655
- return `No memory #${id} in this project; nothing updated.`;
628
+ return `No memory #${id}; nothing updated.`;
656
629
  }
657
630
  invalidateInjection(ctx.directory);
658
631
  return `Updated memory #${id}.`;
@@ -661,6 +634,74 @@ async function updateMemory(
661
634
  }
662
635
  }
663
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
+
664
705
  // Clears every cache entry for the directory - relevance mode keys by
665
706
  // directory + prompt hash, so a write invalidates them all.
666
707
  function invalidateInjection(directory: string): void {
@@ -712,7 +753,7 @@ const ocpg = Plugin.define({
712
753
  // breaks direct invocation without adding value.
713
754
  options: { codemode: false },
714
755
  description:
715
- "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.",
716
757
  input: {
717
758
  type: "object",
718
759
  properties: {
@@ -724,18 +765,12 @@ const ocpg = Plugin.define({
724
765
  "Only return memories carrying all of these tags. Tags are not full-text " +
725
766
  "searchable (search covers content only), so this filter is the only way to reach them.",
726
767
  },
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
- },
733
768
  limit: { type: "number", description: "1-20, default 5" },
734
769
  },
735
770
  additionalProperties: false,
736
771
  },
737
772
  execute: async (input) => {
738
- return { content: await recall(input as RecallArgs, { directory }) };
773
+ return { content: await recall(input as RecallArgs) };
739
774
  },
740
775
  });
741
776
  editor.add({
@@ -746,7 +781,7 @@ const ocpg = Plugin.define({
746
781
  // injected block is skipped entirely for projects with no memories and
747
782
  // the user may have no project instructions at all.
748
783
  description:
749
- "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), " +
750
785
  "architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
751
786
  "Do not store session progress, secrets, or anything the code itself already states.",
752
787
  input: {
@@ -769,19 +804,8 @@ const ocpg = Plugin.define({
769
804
  maxItems: MAX_TAGS,
770
805
  description:
771
806
  "Fine-grained facets: decision, debug, env, architecture, workaround, " +
772
- "language:<x>, framework:<x>, tool:<x>. Project scoping is automatic (a project " +
773
- "column, not a tag) - never add project:<name>.",
774
- },
775
- force: {
776
- type: "boolean",
777
- description:
778
- "Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
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',
807
+ "language:<x>, framework:<x>, tool:<x>. The origin project is recorded " +
808
+ "automatically (a project column, not a tag) - never add project:<name>.",
785
809
  },
786
810
  },
787
811
  required: ["content"],
@@ -796,7 +820,7 @@ const ocpg = Plugin.define({
796
820
  options: { codemode: false },
797
821
  description:
798
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. " +
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.",
823
+ "Memories are shared across projects, so any project can delete any of them.",
800
824
  input: {
801
825
  type: "object",
802
826
  properties: {
@@ -842,6 +866,27 @@ const ocpg = Plugin.define({
842
866
  return { content: await updateMemory(input as UpdateArgs, { directory }) };
843
867
  },
844
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
+ });
845
890
  });
846
891
 
847
892
  // Close the SQL pool when the last plugin instance unloads.
@@ -862,15 +907,14 @@ const __internals = {
862
907
  remember,
863
908
  forget,
864
909
  updateMemory,
865
- extractMemoryRequest,
866
- captureFromPrompt,
910
+ consolidate,
911
+ extractMemoryRequest, captureFromPrompt,
867
912
  invalidateInjection,
868
913
  resolveSslMode,
869
914
  resolveLimit,
870
915
  resolveMemoryType,
871
916
  validateWrite,
872
917
  logError,
873
- resolveUserScope,
874
918
  toolError,
875
919
  rateLimitOk,
876
920
  resetRateLimit,
@@ -885,12 +929,6 @@ const __internals = {
885
929
  orTsQuery,
886
930
  buildRecencyQuery,
887
931
  buildRelevanceQuery,
888
- get userScope() {
889
- return userScope;
890
- },
891
- setUserScope(raw: string | undefined | null) {
892
- userScope = resolveUserScope(raw ?? undefined);
893
- },
894
932
  retain,
895
933
  dispose,
896
934
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.14.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",