@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.
- package/README.md +20 -17
- package/ocpg.ts +227 -144
- 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
|
-
| `
|
|
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
|
-
|
|
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 **
|
|
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),
|
|
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
|
-
-
|
|
59
|
-
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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;
|
|
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
|
-
//
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
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
|
-
|
|
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
|
-
${
|
|
268
|
+
WHERE ${visibleRows(client, directory)}
|
|
240
269
|
ORDER BY (memory_type = 'preference') DESC, created_at DESC
|
|
241
|
-
LIMIT
|
|
270
|
+
LIMIT 20
|
|
242
271
|
`;
|
|
243
272
|
}
|
|
244
273
|
|
|
245
274
|
function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
|
|
246
|
-
// Relevance: full-text search
|
|
247
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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 (
|
|
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 ${
|
|
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
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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 -
|
|
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}, ${
|
|
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
|
|
530
|
-
//
|
|
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
|
|
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
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
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 ${
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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: {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
750
|
-
"
|
|
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 (
|
|
764
|
-
"
|
|
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>.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
};
|