@dzhi/ocpg 0.14.0 → 0.16.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 +20 -17
  2. package/ocpg.ts +227 -144
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,40 +39,43 @@ 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
+ Visibility is **type-based**:
49
+
50
+ | Type | Visibility | What it's for |
51
+ | -------------- | ----------------------------- | ---------------------------------------------- |
52
+ | `preference` | Global | Personal to the operator, not about any codebase |
53
+ | `stack_fact` | Global | True about the tooling/stack itself - portable to any project using the same stack |
54
+ | `project_fact` | Origin project only (default) | True about this specific project/customer; pass `global: true` on recall to reach across |
55
+ | `episodic` | Reserved, unused | - |
56
+
57
+ `memory_forget` / `memory_update` follow the same rule: a foreign project's `project_fact` is off-limits; global types are maintainable from any project.
47
58
 
48
59
  ## How injection picks memories
49
60
 
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.
61
+ 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 every **visible** memory (global types from anywhere, `project_fact` from its origin project), 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 visible memories (preferences first). Set `OCPG_INJECTION=recency` for the blind-last-5 behavior.
51
62
 
52
63
  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
64
 
54
65
  ## Tools
55
66
 
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:
67
+ 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
68
 
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.
69
+ - Visibility follows the type (see the table above); `memory_recall` takes `global: true` to search other projects' `project_fact` memories.
70
+ - 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
71
 
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.
72
+ 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
73
 
63
74
  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
75
 
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
- ```
76
+ ### Duplicates
74
77
 
75
- Without it, `memory_remember` returns an error naming this exact fix.
78
+ 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
79
 
77
80
  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
81
 
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[]; global?: boolean };
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(
@@ -222,39 +242,50 @@ function extractPromptQuery(
222
242
  return "";
223
243
  }
224
244
 
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.
245
+ // Visibility rule shared by recall and both injection builders: global types
246
+ // (preference, stack_fact) are visible everywhere; project_fact is visible
247
+ // only from its origin project unless explicitly opted out with
248
+ // global: true (recall) - at injection time there is no caller to opt in, so
249
+ // other projects' project_fact rows never surface as ambient context.
250
+ function visibleRows(client: SQL, directory: string) {
251
+ return client`(memory_type != 'project_fact' OR project = ${directory})`;
252
+ }
253
+
254
+ // Recency query shared by the recency mode and the no-match fallback: latest
255
+ // visible rows, preferences first. Global by design - the project column
256
+ // records origin, not visibility... for global types; project_fact is
257
+ // origin-scoped (see visibleRows).
228
258
  // Query builders are pure functions of the client so the benchmark
229
259
  // (bench/run.ts) can execute the EXACT production SQL against bench
230
260
  // databases - no drift between what is measured and what runs.
231
261
  function buildRecencyQuery(client: SQL, directory: string) {
232
- const projectCond = userScope
233
- ? client`WHERE (project = ${directory} OR project = ${userScope})`
234
- : client`WHERE project = ${directory}`;
262
+ // LIMIT 20: a candidate slice for collapseDupes, not the final block.
235
263
  return client`
236
264
  SELECT content, coalesce(tags, '{}') AS tags,
237
- to_char(created_at, 'YYYY-MM-DD') AS date
265
+ to_char(created_at, 'YYYY-MM-DD') AS date,
266
+ project
238
267
  FROM memories
239
- ${projectCond}
268
+ WHERE ${visibleRows(client, directory)}
240
269
  ORDER BY (memory_type = 'preference') DESC, created_at DESC
241
- LIMIT 5
270
+ LIMIT 20
242
271
  `;
243
272
  }
244
273
 
245
274
  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.
275
+ // Relevance: full-text search over every visible memory - global types
276
+ // from anywhere, project_fact from the origin project - with a small
277
+ // same-project boost to break rank ties toward locally stored memories.
249
278
  return client`
250
279
  SELECT content, coalesce(tags, '{}') AS tags,
251
- to_char(created_at, 'YYYY-MM-DD') AS date
280
+ to_char(created_at, 'YYYY-MM-DD') AS date,
281
+ project
252
282
  FROM memories
253
283
  WHERE search_vector @@ to_tsquery('english', ${tsQuery})
284
+ AND ${visibleRows(client, directory)}
254
285
  ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
255
286
  + (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
256
287
  created_at DESC
257
- LIMIT 5
288
+ LIMIT 20
258
289
  `;
259
290
  }
260
291
 
@@ -298,7 +329,7 @@ async function handleTransform(
298
329
  } else {
299
330
  rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
300
331
  }
301
- const block = formatBlock(rows, directory);
332
+ const block = formatBlock(collapseDupes(rows), directory);
302
333
  // Evict oldest entry when cache exceeds 32
303
334
  if (injectionCache.size >= 32) {
304
335
  const firstKey = injectionCache.keys().next().value;
@@ -344,9 +375,12 @@ const MAX_TAG_LENGTH = 64;
344
375
 
345
376
  // The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
346
377
  // 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
378
+ // type is always defaulted. Visibility follows the type: preference and
379
+ // stack_fact are global (tooling knowledge ports across every project using
380
+ // the stack), project_fact is origin-project-only (customer-specific facts
381
+ // must not surface elsewhere), episodic is reserved for the (cut, opt-in) 1.3
348
382
  // feature; remember accepts it so the vocabulary stays in one place.
349
- const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
383
+ const MEMORY_TYPES = ["preference", "stack_fact", "project_fact", "episodic"] as const;
350
384
  type MemoryType = (typeof MEMORY_TYPES)[number];
351
385
 
352
386
  function resolveMemoryType(raw: unknown): MemoryType {
@@ -381,16 +415,10 @@ async function recall(
381
415
  ): Promise<string> {
382
416
  try {
383
417
  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}`;
418
+ // Visibility: global types (preference, stack_fact) everywhere;
419
+ // project_fact only from the origin project unless the caller opts in
420
+ // with global: true.
421
+ const visibleCond = args.global ? sql`` : sql`AND ${visibleRows(sql, ctx.directory)}`;
394
422
  const queryCond = args.query
395
423
  ? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
396
424
  : sql``;
@@ -401,21 +429,21 @@ async function recall(
401
429
  const tagCond = tagList.length
402
430
  ? sql`AND tags @> ${sql.array(tagList, "text")}`
403
431
  : 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.
432
+ // Relevance-ranked when searching; undirected browse is plain recency
433
+ // (preferences first, matching the injection fallback). A frequency blend
434
+ // was tried and removed: bumping exactly the returned top-5 is a
435
+ // rich-get-richer loop - live corpus rows pinned the top slot after a few
436
+ // runs. access_count stays as data collection; no ranking consumes it.
409
437
  const orderBy = args.query
410
438
  ? 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`;
439
+ : sql`ORDER BY (memory_type = 'preference') DESC, created_at DESC`;
412
440
 
413
441
  const rows = await sql`
414
442
  SELECT id, content, coalesce(tags, '{}') AS tags,
415
443
  to_char(created_at, 'YYYY-MM-DD') AS date,
416
444
  project, memory_type
417
445
  FROM memories
418
- WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
446
+ WHERE 1=1 ${visibleCond} ${queryCond} ${tagCond}
419
447
  ${orderBy}
420
448
  LIMIT ${limit}
421
449
  ` as (MemoryRow & { memory_type: string })[];
@@ -472,11 +500,11 @@ function validateWrite(args: RememberArgs): string | null {
472
500
  return null;
473
501
  }
474
502
 
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.
503
+ // Trigram similarity threshold for near-duplicate handling (consolidation and
504
+ // the injection collapse pass). Measured on a real 485-memory corpus: 0.8 is
505
+ // strict enough that only restatements collide. Writes never reject on it -
506
+ // duplicates are cleaned up by memory_consolidate and collapsed out of the
507
+ // injected block.
480
508
  const DEDUP_SIMILARITY = 0.8;
481
509
 
482
510
  async function remember(
@@ -487,51 +515,22 @@ async function remember(
487
515
  const invalid = validateWrite(args);
488
516
  if (invalid) return invalid;
489
517
 
490
- // Tags are stored verbatim - project scoping lives in the project column, not tags.
518
+ // Tags are stored verbatim - the project column records origin, not visibility.
491
519
  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
520
  const basename = ctx.directory.split('/').pop() ?? ctx.directory;
501
521
 
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
522
  // sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
522
523
  // the element type hint is required for clean array storage.
523
524
  const inserted = await sql`
524
525
  INSERT INTO memories (content, tags, session_id, project, memory_type)
525
- VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${target}, ${resolveMemoryType(args.type)})
526
+ VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory}, ${resolveMemoryType(args.type)})
526
527
  RETURNING id
527
528
  ` as { id: number }[];
528
529
 
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.
530
+ // The injection block is global, but its cache is keyed by the calling
531
+ // directory + prompt; clear the directory's keys.
531
532
  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}.`;
533
+ return `Stored memory #${inserted[0].id} (project ${basename}).`;
535
534
  } catch (e: unknown) {
536
535
  return toolError("remember", "remember", e);
537
536
  }
@@ -581,11 +580,9 @@ async function captureFromPrompt(
581
580
  }
582
581
  }
583
582
 
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.
583
+ // project_fact rows are origin-scoped (a customer-B agent must not delete
584
+ // customer-A's customer facts); global types (preference, stack_fact) are
585
+ // maintainable from any project - that's what global means for them.
589
586
  async function forget(
590
587
  args: ForgetArgs,
591
588
  ctx: { directory: string },
@@ -595,17 +592,18 @@ async function forget(
595
592
  if (!Number.isInteger(id) || id <= 0) {
596
593
  return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
597
594
  }
598
- const projectCond = userScope
599
- ? sql`project IN (${ctx.directory}, ${userScope})`
600
- : sql`project = ${ctx.directory}`;
601
595
  const deleted = await sql`
602
596
  DELETE FROM memories
603
- WHERE id = ${id} AND ${projectCond}
597
+ WHERE id = ${id} AND ${visibleRows(sql, ctx.directory)}
604
598
  RETURNING id
605
599
  ` as { id: number }[];
606
600
 
607
601
  if (deleted.length === 0) {
608
- return `No memory #${id} in this project; nothing deleted.`;
602
+ const exists = await sql`SELECT project, memory_type FROM memories WHERE id = ${id}` as { project: string; memory_type: string }[];
603
+ if (exists.length > 0) {
604
+ return `Memory #${id} is a ${exists[0].memory_type} belonging to ${exists[0].project}; not deleted. Only that project's agent can delete it.`;
605
+ }
606
+ return `No memory #${id}; nothing deleted.`;
609
607
  }
610
608
  invalidateInjection(ctx.directory);
611
609
  return `Deleted memory #${id}.`;
@@ -631,9 +629,6 @@ async function updateMemory(
631
629
  const invalid = validateWrite(args);
632
630
  if (invalid) return invalid;
633
631
 
634
- const projectCond = userScope
635
- ? sql`project IN (${ctx.directory}, ${userScope})`
636
- : sql`project = ${ctx.directory}`;
637
632
  const tags = args.tags ?? [];
638
633
  const tagCond = Array.isArray(args.tags)
639
634
  ? sql`tags = ${sql.array(tags, "text")},`
@@ -647,12 +642,16 @@ async function updateMemory(
647
642
  ${tagCond}
648
643
  ${typeCond}
649
644
  updated_at = now()
650
- WHERE id = ${id} AND ${projectCond}
645
+ WHERE id = ${id} AND ${visibleRows(sql, ctx.directory)}
651
646
  RETURNING id
652
647
  ` as { id: number }[];
653
648
 
654
649
  if (updated.length === 0) {
655
- return `No memory #${id} in this project; nothing updated.`;
650
+ const exists = await sql`SELECT project, memory_type FROM memories WHERE id = ${id}` as { project: string; memory_type: string }[];
651
+ if (exists.length > 0) {
652
+ return `Memory #${id} is a ${exists[0].memory_type} belonging to ${exists[0].project}; not updated. Only that project's agent can edit it.`;
653
+ }
654
+ return `No memory #${id}; nothing updated.`;
656
655
  }
657
656
  invalidateInjection(ctx.directory);
658
657
  return `Updated memory #${id}.`;
@@ -661,6 +660,74 @@ async function updateMemory(
661
660
  }
662
661
  }
663
662
 
663
+ // Deterministic consolidation, no model calls inside the plugin: find
664
+ // near-duplicate clusters (trigram similarity >= DEDUP_SIMILARITY over the
665
+ // whole content, global), keep the newest of each, delete the rest. The
666
+ // deleted texts are returned verbatim so the CALLING agent - itself a model -
667
+ // can merge any unique fact back into the survivor via memory_update. Merging
668
+ // is language synthesis, which is the caller's job, not the plugin's.
669
+ // Runs on demand (user-invoked), never on a schedule; capped at 25 clusters
670
+ // per run so a wildly-duplicated corpus cannot turn into one huge report.
671
+ async function consolidate(): Promise<string> {
672
+ try {
673
+ const rows = await sql`
674
+ SELECT id, content, coalesce(tags, '{}') AS tags, created_at
675
+ FROM memories
676
+ ORDER BY created_at DESC
677
+ ` as { id: number; content: string; tags: string[]; created_at: Date }[];
678
+
679
+ // Greedy clustering newest-first: each row joins the first cluster whose
680
+ // representative (the newest member) it near-dupes. Trigram sets are built
681
+ // once (rebuilding per comparison made consolidate O(n^2) set-constructions,
682
+ // seconds at 5k rows), and a size-ratio prefilter skips pairs whose Jaccard
683
+ // can never reach the threshold.
684
+ type Entry = { row: (typeof rows)[number]; set: Set<string> };
685
+ const entries: Entry[] = rows.map((row) => ({ row, set: trigrams(row.content) }));
686
+ const clusters: Array<Array<Entry>> = [];
687
+ for (const entry of entries) {
688
+ const host = clusters.find((c) => {
689
+ const ra = c[0].set.size;
690
+ const rb = entry.set.size;
691
+ // Jaccard >= 0.8 is impossible when one set is much smaller; the
692
+ // comparison itself is the expensive part, so prefilter on sizes.
693
+ if (ra === 0 || rb === 0 || ra > rb * 4 || rb > ra * 4) return false;
694
+ return nearDupeSets(c[0].set, entry.set);
695
+ });
696
+ if (host) host.push(entry);
697
+ else clusters.push([entry]);
698
+ }
699
+
700
+ const multi = clusters.filter((c) => c.length > 1).slice(0, 25);
701
+ if (multi.length === 0) return "No duplicates found; nothing to consolidate.";
702
+
703
+ let removed = 0;
704
+ const report: string[] = [];
705
+ for (const cluster of multi) {
706
+ const survivor = cluster[0].row;
707
+ const removedRows = cluster.slice(1).map((e) => e.row);
708
+ for (const r of removedRows) {
709
+ await sql`DELETE FROM memories WHERE id = ${r.id}`;
710
+ }
711
+ removed += removedRows.length;
712
+ // Show what died so the calling agent can merge unique facts back into
713
+ // the survivor.
714
+ report.push(
715
+ `Kept #${survivor.id}: ${truncateMemory(survivor.content)}\n` +
716
+ removedRows.map((r) => ` removed #${r.id}: ${truncateMemory(r.content)}`).join("\n"),
717
+ );
718
+ }
719
+
720
+ if (removed > 0) injectionCache.clear();
721
+ return (
722
+ `Removed ${removed} duplicate ${removed === 1 ? "memory" : "memories"} across ${multi.length} groups (kept the newest of each).\n` +
723
+ `Check the removed texts - if any carries a fact the kept memory lacks, merge it in with memory_update:\n\n` +
724
+ report.join("\n")
725
+ );
726
+ } catch (e: unknown) {
727
+ return toolError("consolidate", "consolidate", e);
728
+ }
729
+ }
730
+
664
731
  // Clears every cache entry for the directory - relevance mode keys by
665
732
  // directory + prompt hash, so a write invalidates them all.
666
733
  function invalidateInjection(directory: string): void {
@@ -712,7 +779,7 @@ const ocpg = Plugin.define({
712
779
  // breaks direct invocation without adding value.
713
780
  options: { codemode: false },
714
781
  description:
715
- "Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
782
+ "Search past memories. Global types (preference, stack_fact) are always searched; this project's project_fact memories are searched by default. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
716
783
  input: {
717
784
  type: "object",
718
785
  properties: {
@@ -724,11 +791,12 @@ const ocpg = Plugin.define({
724
791
  "Only return memories carrying all of these tags. Tags are not full-text " +
725
792
  "searchable (search covers content only), so this filter is the only way to reach them.",
726
793
  },
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)',
794
+ global: {
795
+ type: "boolean",
796
+ description:
797
+ "Also search other projects' project_fact memories (default: only this " +
798
+ "project's project_fact memories, plus all preference/stack_fact memories, " +
799
+ "which are always global).",
732
800
  },
733
801
  limit: { type: "number", description: "1-20, default 5" },
734
802
  },
@@ -746,8 +814,10 @@ const ocpg = Plugin.define({
746
814
  // injected block is skipped entirely for projects with no memories and
747
815
  // the user may have no project instructions at all.
748
816
  description:
749
- "Store a durable memory for this project. Use after user corrections (immediately), " +
750
- "architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
817
+ "Store a durable memory. preference and stack_fact are shared across all projects; " +
818
+ "project_fact (the default) is visible only in this project unless recalled with " +
819
+ "global: true. Use after user corrections (immediately), architecture decisions, " +
820
+ "non-trivial fixes, environment facts, and stated preferences. " +
751
821
  "Do not store session progress, secrets, or anything the code itself already states.",
752
822
  input: {
753
823
  type: "object",
@@ -760,8 +830,15 @@ const ocpg = Plugin.define({
760
830
  type: "string",
761
831
  enum: [...MEMORY_TYPES],
762
832
  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.",
833
+ "preference = a standing user preference (global, injected first). " +
834
+ "stack_fact = true about the tooling/stack itself, portable to any project " +
835
+ "using the same stack (e.g. a Terraform module quirk, an ArgoCD gotcha, a " +
836
+ "Helm chart convention) - global, like preference. " +
837
+ "project_fact (default) = true about THIS specific project/customer only " +
838
+ "(an environment quirk, a customer's specific request, a one-off workaround) " +
839
+ "- visible only in this project unless the caller asks for global search. " +
840
+ "Test: would this fact help in a different customer's repo using the same " +
841
+ "tools? If yes, stack_fact. If no, project_fact.",
765
842
  },
766
843
  tags: {
767
844
  type: "array",
@@ -769,19 +846,8 @@ const ocpg = Plugin.define({
769
846
  maxItems: MAX_TAGS,
770
847
  description:
771
848
  "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',
849
+ "language:<x>, framework:<x>, tool:<x>. The origin project is recorded " +
850
+ "automatically (a project column, not a tag) - never add project:<name>.",
785
851
  },
786
852
  },
787
853
  required: ["content"],
@@ -796,7 +862,8 @@ const ocpg = Plugin.define({
796
862
  options: { codemode: false },
797
863
  description:
798
864
  "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.",
865
+ "preference and stack_fact can be deleted from any project; project_fact can only be " +
866
+ "deleted by its origin project (the delete will fail with the owning project's name).",
800
867
  input: {
801
868
  type: "object",
802
869
  properties: {
@@ -814,7 +881,9 @@ const ocpg = Plugin.define({
814
881
  options: { codemode: false },
815
882
  description:
816
883
  "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.",
884
+ "Omitted tags/type are kept as-is. preference and stack_fact can be edited from any " +
885
+ "project; project_fact can only be edited by its origin project. " +
886
+ "For obsolete memories use memory_forget; for genuinely new memories use memory_remember.",
818
887
  input: {
819
888
  type: "object",
820
889
  properties: {
@@ -842,6 +911,27 @@ const ocpg = Plugin.define({
842
911
  return { content: await updateMemory(input as UpdateArgs, { directory }) };
843
912
  },
844
913
  });
914
+ editor.add({
915
+ name: "memory_consolidate",
916
+ options: { codemode: false },
917
+ // User-invoked cleanup, not a write-path gate: writes never reject on
918
+ // duplicates, so call this when the corpus has accumulated near-dupes.
919
+ // User-invoked cleanup, not a write-path gate: writes never reject on
920
+ // duplicates, so call this when the corpus has accumulated near-dupes.
921
+ // The deleted texts come back in the result so the calling agent can
922
+ // merge unique facts into the survivors via memory_update.
923
+ description:
924
+ "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. " +
925
+ "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.",
926
+ input: {
927
+ type: "object",
928
+ properties: {},
929
+ additionalProperties: false,
930
+ },
931
+ execute: async () => {
932
+ return { content: await consolidate() };
933
+ },
934
+ });
845
935
  });
846
936
 
847
937
  // Close the SQL pool when the last plugin instance unloads.
@@ -862,15 +952,14 @@ const __internals = {
862
952
  remember,
863
953
  forget,
864
954
  updateMemory,
865
- extractMemoryRequest,
866
- captureFromPrompt,
955
+ consolidate,
956
+ extractMemoryRequest, captureFromPrompt,
867
957
  invalidateInjection,
868
958
  resolveSslMode,
869
959
  resolveLimit,
870
960
  resolveMemoryType,
871
961
  validateWrite,
872
962
  logError,
873
- resolveUserScope,
874
963
  toolError,
875
964
  rateLimitOk,
876
965
  resetRateLimit,
@@ -885,12 +974,6 @@ const __internals = {
885
974
  orTsQuery,
886
975
  buildRecencyQuery,
887
976
  buildRelevanceQuery,
888
- get userScope() {
889
- return userScope;
890
- },
891
- setUserScope(raw: string | undefined | null) {
892
- userScope = resolveUserScope(raw ?? undefined);
893
- },
894
977
  retain,
895
978
  dispose,
896
979
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Postgres-backed persistent memory plugin for OpenCode",
6
6
  "main": "ocpg.ts",