@dzhi/ocpg 0.15.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 +12 -3
  2. package/ocpg.ts +73 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,11 +45,20 @@ export OCPG_SSL="disable"
45
45
 
46
46
  ## How memory works
47
47
 
48
- Memories are **global**: recall, injection, dedup, deletion and updates see and touch every row. The `project` column records which project a memory came from and is shown in output - it is metadata, not a visibility boundary.
48
+ 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.
49
58
 
50
59
  ## How injection picks memories
51
60
 
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.
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.
53
62
 
54
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.
55
64
 
@@ -57,7 +66,7 @@ This is keyword relevance, not embedding-based semantic search - close phrasing
57
66
 
58
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:
59
68
 
60
- - Memories are shared across all projects: `memory_forget` / `memory_update` work on any row by id, from any project.
69
+ - Visibility follows the type (see the table above); `memory_recall` takes `global: true` to search other projects' `project_fact` memories.
61
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.
62
71
 
63
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.
package/ocpg.ts CHANGED
@@ -13,7 +13,7 @@ type DbConfig = {
13
13
  database: string;
14
14
  ssl: SslMode;
15
15
  };
16
- type RecallArgs = { query?: string; limit?: number; tags?: string[] };
16
+ type RecallArgs = { query?: string; limit?: number; tags?: string[]; global?: boolean };
17
17
  type RememberArgs = {
18
18
  content: string;
19
19
  tags?: string[];
@@ -242,34 +242,46 @@ function extractPromptQuery(
242
242
  return "";
243
243
  }
244
244
 
245
- // Recency query shared by the recency mode and the no-match fallback: latest 5
246
- // rows across ALL memories, preferences first. Global by design - the project
247
- // column records origin, not visibility.
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).
248
258
  // Query builders are pure functions of the client so the benchmark
249
259
  // (bench/run.ts) can execute the EXACT production SQL against bench
250
260
  // databases - no drift between what is measured and what runs.
251
- function buildRecencyQuery(client: SQL) {
261
+ function buildRecencyQuery(client: SQL, directory: string) {
252
262
  // LIMIT 20: a candidate slice for collapseDupes, not the final block.
253
263
  return client`
254
264
  SELECT content, coalesce(tags, '{}') AS tags,
255
265
  to_char(created_at, 'YYYY-MM-DD') AS date,
256
266
  project
257
267
  FROM memories
268
+ WHERE ${visibleRows(client, directory)}
258
269
  ORDER BY (memory_type = 'preference') DESC, created_at DESC
259
270
  LIMIT 20
260
271
  `;
261
272
  }
262
273
 
263
274
  function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
264
- // Relevance: full-text search across ALL memories - global by design - with
265
- // a small same-project boost to break rank ties toward locally stored
266
- // memories.
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.
267
278
  return client`
268
279
  SELECT content, coalesce(tags, '{}') AS tags,
269
280
  to_char(created_at, 'YYYY-MM-DD') AS date,
270
281
  project
271
282
  FROM memories
272
283
  WHERE search_vector @@ to_tsquery('english', ${tsQuery})
284
+ AND ${visibleRows(client, directory)}
273
285
  ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
274
286
  + (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
275
287
  created_at DESC
@@ -312,10 +324,10 @@ async function handleTransform(
312
324
  );
313
325
  if (rows.length === 0) {
314
326
  // No keyword match for this prompt - recency beats an empty block.
315
- rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
327
+ rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
316
328
  }
317
329
  } else {
318
- rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
330
+ rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
319
331
  }
320
332
  const block = formatBlock(collapseDupes(rows), directory);
321
333
  // Evict oldest entry when cache exceeds 32
@@ -363,9 +375,12 @@ const MAX_TAG_LENGTH = 64;
363
375
 
364
376
  // The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
365
377
  // rows predate the column, so "required" would break every existing caller -
366
- // type is always defaulted. episodic is reserved for the (cut, opt-in) 1.3
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
367
382
  // feature; remember accepts it so the vocabulary stays in one place.
368
- const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
383
+ const MEMORY_TYPES = ["preference", "stack_fact", "project_fact", "episodic"] as const;
369
384
  type MemoryType = (typeof MEMORY_TYPES)[number];
370
385
 
371
386
  function resolveMemoryType(raw: unknown): MemoryType {
@@ -396,11 +411,14 @@ function toolError(kind: string, action: string, e: unknown): string {
396
411
 
397
412
  async function recall(
398
413
  args: RecallArgs,
414
+ ctx: { directory: string },
399
415
  ): Promise<string> {
400
416
  try {
401
417
  const limit = resolveLimit(args.limit);
402
- // All memories are global: no project filter anywhere - the project column
403
- // records origin (shown in output), not visibility.
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)}`;
404
422
  const queryCond = args.query
405
423
  ? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
406
424
  : sql``;
@@ -425,7 +443,7 @@ async function recall(
425
443
  to_char(created_at, 'YYYY-MM-DD') AS date,
426
444
  project, memory_type
427
445
  FROM memories
428
- WHERE 1=1 ${queryCond} ${tagCond}
446
+ WHERE 1=1 ${visibleCond} ${queryCond} ${tagCond}
429
447
  ${orderBy}
430
448
  LIMIT ${limit}
431
449
  ` as (MemoryRow & { memory_type: string })[];
@@ -562,9 +580,9 @@ async function captureFromPrompt(
562
580
  }
563
581
  }
564
582
 
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.
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.
568
586
  async function forget(
569
587
  args: ForgetArgs,
570
588
  ctx: { directory: string },
@@ -576,11 +594,15 @@ async function forget(
576
594
  }
577
595
  const deleted = await sql`
578
596
  DELETE FROM memories
579
- WHERE id = ${id}
597
+ WHERE id = ${id} AND ${visibleRows(sql, ctx.directory)}
580
598
  RETURNING id
581
599
  ` as { id: number }[];
582
600
 
583
601
  if (deleted.length === 0) {
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
+ }
584
606
  return `No memory #${id}; nothing deleted.`;
585
607
  }
586
608
  invalidateInjection(ctx.directory);
@@ -620,11 +642,15 @@ async function updateMemory(
620
642
  ${tagCond}
621
643
  ${typeCond}
622
644
  updated_at = now()
623
- WHERE id = ${id}
645
+ WHERE id = ${id} AND ${visibleRows(sql, ctx.directory)}
624
646
  RETURNING id
625
647
  ` as { id: number }[];
626
648
 
627
649
  if (updated.length === 0) {
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
+ }
628
654
  return `No memory #${id}; nothing updated.`;
629
655
  }
630
656
  invalidateInjection(ctx.directory);
@@ -753,7 +779,7 @@ const ocpg = Plugin.define({
753
779
  // breaks direct invocation without adding value.
754
780
  options: { codemode: false },
755
781
  description:
756
- "Search past memories across all projects. 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.",
757
783
  input: {
758
784
  type: "object",
759
785
  properties: {
@@ -765,12 +791,19 @@ const ocpg = Plugin.define({
765
791
  "Only return memories carrying all of these tags. Tags are not full-text " +
766
792
  "searchable (search covers content only), so this filter is the only way to reach them.",
767
793
  },
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).",
800
+ },
768
801
  limit: { type: "number", description: "1-20, default 5" },
769
802
  },
770
803
  additionalProperties: false,
771
804
  },
772
805
  execute: async (input) => {
773
- return { content: await recall(input as RecallArgs) };
806
+ return { content: await recall(input as RecallArgs, { directory }) };
774
807
  },
775
808
  });
776
809
  editor.add({
@@ -781,8 +814,10 @@ const ocpg = Plugin.define({
781
814
  // injected block is skipped entirely for projects with no memories and
782
815
  // the user may have no project instructions at all.
783
816
  description:
784
- "Store a durable memory shared across all projects. Use after user corrections (immediately), " +
785
- "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. " +
786
821
  "Do not store session progress, secrets, or anything the code itself already states.",
787
822
  input: {
788
823
  type: "object",
@@ -795,8 +830,15 @@ const ocpg = Plugin.define({
795
830
  type: "string",
796
831
  enum: [...MEMORY_TYPES],
797
832
  description:
798
- "preference = a standing user preference (these are injected first); " +
799
- "project_fact (default) = decisions, fixes, env facts. Omit unless the memory is a preference.",
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.",
800
842
  },
801
843
  tags: {
802
844
  type: "array",
@@ -820,7 +862,8 @@ const ocpg = Plugin.define({
820
862
  options: { codemode: false },
821
863
  description:
822
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. " +
823
- "Memories are shared across projects, so any project can delete any of 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).",
824
867
  input: {
825
868
  type: "object",
826
869
  properties: {
@@ -838,7 +881,9 @@ const ocpg = Plugin.define({
838
881
  options: { codemode: false },
839
882
  description:
840
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. " +
841
- "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.",
842
887
  input: {
843
888
  type: "object",
844
889
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhi/ocpg",
3
- "version": "0.15.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",