@dzhi/ocpg 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -17
- package/ocpg.ts +181 -143
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,40 +39,34 @@ export OCPG_SSL="disable"
|
|
|
39
39
|
| `OCPG_USER` | `ocpguser` |
|
|
40
40
|
| `OCPG_DB` | `ocpg` |
|
|
41
41
|
| `OCPG_SSL` | `disable` |
|
|
42
|
-
| `
|
|
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
|
+
Memories are **global**: recall, injection, dedup, deletion and updates see and touch every row. The `project` column records which project a memory came from and is shown in output - it is metadata, not a visibility boundary.
|
|
47
49
|
|
|
48
50
|
## How injection picks memories
|
|
49
51
|
|
|
50
|
-
By default the block is **relevance-ranked, not recency-ranked**: the user's latest prompt is turned into a full-text query (OR of stemmed words) over **all
|
|
52
|
+
By default the block is **relevance-ranked, not recency-ranked**: the user's latest prompt is turned into a full-text query (OR of stemmed words) over **all memories**, ranked by relevance with a small same-project tiebreak, top 5 injected - each line labeled with its origin project. When nothing matches the prompt, it falls back to the latest memories (preferences first). Set `OCPG_INJECTION=recency` for the old blind-last-5 behavior.
|
|
51
53
|
|
|
52
54
|
This is keyword relevance, not embedding-based semantic search - close phrasing wins, paraphrases may not. Note the OR ranking: common words in a prompt surface more rows; the ranking favors rows matching more distinctive terms.
|
|
53
55
|
|
|
54
56
|
## Tools
|
|
55
57
|
|
|
56
|
-
Four agent tools are registered: `memory_remember` (store), `memory_recall` (search), `memory_forget` (delete by id),
|
|
58
|
+
Four agent tools are registered: `memory_remember` (store), `memory_recall` (search), `memory_forget` (delete by id), `memory_update` (rewrite an existing memory, keeping its original learned date), and `memory_consolidate` (remove near-duplicates on demand). The agent reads their usage rules from the tool schemas - as the user, the things worth knowing are:
|
|
57
59
|
|
|
58
|
-
- Memories are
|
|
59
|
-
-
|
|
60
|
+
- Memories are shared across all projects: `memory_forget` / `memory_update` work on any row by id, from any project.
|
|
61
|
+
- Duplicate writes are **never rejected** - they land, the injection block collapses them, and `memory_consolidate` cleans them up when you ask: it keeps the newest of each >=80%-similar group and reports the removed texts so the agent can merge any unique fact back.
|
|
60
62
|
|
|
61
|
-
Writes are capped at 4000 characters of content, 10 tags, and 64 characters per tag; oversized writes are rejected with the actual size rather than silently truncated. Memories carry a `type` (`preference`, `project_fact` default, or `episodic`); `preference` memories
|
|
63
|
+
Writes are capped at 4000 characters of content, 10 tags, and 64 characters per tag; oversized writes are rejected with the actual size rather than silently truncated. Memories carry a `type` (`preference`, `project_fact` default, or `episodic`); `preference` memories come first in recency mode.
|
|
62
64
|
|
|
63
65
|
Saying "remember that ..." (or "don't forget ...", "keep in mind ...") in a prompt stores the text after the phrase verbatim, tagged `user-requested` - no model judgment involved.
|
|
64
66
|
|
|
65
|
-
###
|
|
66
|
-
|
|
67
|
-
`memory_remember` rejects near-duplicates of existing memories in the same project instead of storing them.
|
|
68
|
-
|
|
69
|
-
This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
|
|
70
|
-
|
|
71
|
-
```sql
|
|
72
|
-
CREATE EXTENSION pg_trgm;
|
|
73
|
-
```
|
|
67
|
+
### Duplicates
|
|
74
68
|
|
|
75
|
-
|
|
69
|
+
Writes are never rejected for duplicates. Near-duplicates (>=80% content similarity, measured on the real corpus - the old FTS-on-first-60-chars rule missed 28 pairs) are collapsed out of the injected block automatically, and `memory_consolidate` removes them on demand (keeps the newest of each group, reports removed texts for the agent to merge back). Needs the trgm index; fresh installs from [`deploy/`](./deploy) get it automatically, existing databases run the upgrade block in [`deploy/README.md`](./deploy/README.md).
|
|
76
70
|
|
|
77
71
|
If the database is unreachable, memory injection is skipped and the tools return a generic error - a slow or dead database never blocks a model request.
|
|
78
72
|
|
package/ocpg.ts
CHANGED
|
@@ -13,12 +13,10 @@ type DbConfig = {
|
|
|
13
13
|
database: string;
|
|
14
14
|
ssl: SslMode;
|
|
15
15
|
};
|
|
16
|
-
type RecallArgs = { query?: string;
|
|
16
|
+
type RecallArgs = { query?: string; limit?: number; tags?: string[] };
|
|
17
17
|
type RememberArgs = {
|
|
18
18
|
content: string;
|
|
19
19
|
tags?: string[];
|
|
20
|
-
force?: boolean;
|
|
21
|
-
scope?: "project" | "user";
|
|
22
20
|
type?: MemoryType;
|
|
23
21
|
};
|
|
24
22
|
type ForgetArgs = { id: number };
|
|
@@ -43,22 +41,6 @@ const defaultConfig: DbConfig = {
|
|
|
43
41
|
};
|
|
44
42
|
const password = process.env.OCPG_PASSWORD || "";
|
|
45
43
|
|
|
46
|
-
// --- User scope (plan 1.2, revised: sentinel value, no migration) ---
|
|
47
|
-
|
|
48
|
-
// User memories are ordinary rows with the sentinel project value `user:<id>`,
|
|
49
|
-
// written into the existing project column - identical semantics to a scope
|
|
50
|
-
// column with zero migration. OCPG_USER_ID is read once at init (env-only, no
|
|
51
|
-
// process spawning); unset means user scope is disabled entirely and every
|
|
52
|
-
// query degrades to exactly the pre-feature behavior.
|
|
53
|
-
//
|
|
54
|
-
// Mutable module state + a reset hook so tests can toggle it deterministically.
|
|
55
|
-
let userScope = resolveUserScope(process.env.OCPG_USER_ID);
|
|
56
|
-
|
|
57
|
-
function resolveUserScope(raw: string | undefined): string | null {
|
|
58
|
-
const id = raw?.trim();
|
|
59
|
-
return id ? `user:${id}` : null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
44
|
// Options-object constructor, not a URL string: Bun's SQL parses string URLs via
|
|
63
45
|
// url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
|
|
64
46
|
function makeSql(cfg: DbConfig): SQL {
|
|
@@ -87,8 +69,9 @@ export interface MemoryRow {
|
|
|
87
69
|
date: string;
|
|
88
70
|
}
|
|
89
71
|
|
|
90
|
-
//
|
|
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(
|
|
@@ -223,38 +243,37 @@ function extractPromptQuery(
|
|
|
223
243
|
}
|
|
224
244
|
|
|
225
245
|
// Recency query shared by the recency mode and the no-match fallback: latest 5
|
|
226
|
-
// rows
|
|
227
|
-
//
|
|
246
|
+
// rows across ALL memories, preferences first. Global by design - the project
|
|
247
|
+
// column records origin, not visibility.
|
|
228
248
|
// Query builders are pure functions of the client so the benchmark
|
|
229
249
|
// (bench/run.ts) can execute the EXACT production SQL against bench
|
|
230
250
|
// databases - no drift between what is measured and what runs.
|
|
231
|
-
function buildRecencyQuery(client: SQL
|
|
232
|
-
|
|
233
|
-
? client`WHERE (project = ${directory} OR project = ${userScope})`
|
|
234
|
-
: client`WHERE project = ${directory}`;
|
|
251
|
+
function buildRecencyQuery(client: SQL) {
|
|
252
|
+
// LIMIT 20: a candidate slice for collapseDupes, not the final block.
|
|
235
253
|
return client`
|
|
236
254
|
SELECT content, coalesce(tags, '{}') AS tags,
|
|
237
|
-
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
255
|
+
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
256
|
+
project
|
|
238
257
|
FROM memories
|
|
239
|
-
${projectCond}
|
|
240
258
|
ORDER BY (memory_type = 'preference') DESC, created_at DESC
|
|
241
|
-
LIMIT
|
|
259
|
+
LIMIT 20
|
|
242
260
|
`;
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
|
|
246
|
-
// Relevance: full-text search across ALL
|
|
247
|
-
//
|
|
248
|
-
//
|
|
264
|
+
// Relevance: full-text search across ALL memories - global by design - with
|
|
265
|
+
// a small same-project boost to break rank ties toward locally stored
|
|
266
|
+
// memories.
|
|
249
267
|
return client`
|
|
250
268
|
SELECT content, coalesce(tags, '{}') AS tags,
|
|
251
|
-
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
269
|
+
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
270
|
+
project
|
|
252
271
|
FROM memories
|
|
253
272
|
WHERE search_vector @@ to_tsquery('english', ${tsQuery})
|
|
254
273
|
ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
|
|
255
274
|
+ (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
|
|
256
275
|
created_at DESC
|
|
257
|
-
LIMIT
|
|
276
|
+
LIMIT 20
|
|
258
277
|
`;
|
|
259
278
|
}
|
|
260
279
|
|
|
@@ -293,12 +312,12 @@ async function handleTransform(
|
|
|
293
312
|
);
|
|
294
313
|
if (rows.length === 0) {
|
|
295
314
|
// No keyword match for this prompt - recency beats an empty block.
|
|
296
|
-
rows = await withDeadline(buildRecencyQuery(sql
|
|
315
|
+
rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
297
316
|
}
|
|
298
317
|
} else {
|
|
299
|
-
rows = await withDeadline(buildRecencyQuery(sql
|
|
318
|
+
rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
300
319
|
}
|
|
301
|
-
const block = formatBlock(rows, directory);
|
|
320
|
+
const block = formatBlock(collapseDupes(rows), directory);
|
|
302
321
|
// Evict oldest entry when cache exceeds 32
|
|
303
322
|
if (injectionCache.size >= 32) {
|
|
304
323
|
const firstKey = injectionCache.keys().next().value;
|
|
@@ -377,20 +396,11 @@ function toolError(kind: string, action: string, e: unknown): string {
|
|
|
377
396
|
|
|
378
397
|
async function recall(
|
|
379
398
|
args: RecallArgs,
|
|
380
|
-
ctx: { directory: string },
|
|
381
399
|
): Promise<string> {
|
|
382
400
|
try {
|
|
383
401
|
const limit = resolveLimit(args.limit);
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
if (args.scope === "user" && !userScope) {
|
|
387
|
-
return "ERROR: user scope is disabled (set OCPG_USER_ID to enable it).";
|
|
388
|
-
}
|
|
389
|
-
const projectCond = args.global
|
|
390
|
-
? sql``
|
|
391
|
-
: args.scope === "user"
|
|
392
|
-
? sql`AND project = ${userScope}`
|
|
393
|
-
: sql`AND project = ${ctx.directory}`;
|
|
402
|
+
// All memories are global: no project filter anywhere - the project column
|
|
403
|
+
// records origin (shown in output), not visibility.
|
|
394
404
|
const queryCond = args.query
|
|
395
405
|
? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
|
|
396
406
|
: sql``;
|
|
@@ -401,21 +411,21 @@ async function recall(
|
|
|
401
411
|
const tagCond = tagList.length
|
|
402
412
|
? sql`AND tags @> ${sql.array(tagList, "text")}`
|
|
403
413
|
: sql``;
|
|
404
|
-
// Relevance-ranked when searching; undirected browse
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
414
|
+
// Relevance-ranked when searching; undirected browse is plain recency
|
|
415
|
+
// (preferences first, matching the injection fallback). A frequency blend
|
|
416
|
+
// was tried and removed: bumping exactly the returned top-5 is a
|
|
417
|
+
// rich-get-richer loop - live corpus rows pinned the top slot after a few
|
|
418
|
+
// runs. access_count stays as data collection; no ranking consumes it.
|
|
409
419
|
const orderBy = args.query
|
|
410
420
|
? sql`ORDER BY ts_rank(search_vector, to_tsquery('english', ${orTsQuery(args.query)})) DESC`
|
|
411
|
-
: sql`ORDER BY (
|
|
421
|
+
: sql`ORDER BY (memory_type = 'preference') DESC, created_at DESC`;
|
|
412
422
|
|
|
413
423
|
const rows = await sql`
|
|
414
424
|
SELECT id, content, coalesce(tags, '{}') AS tags,
|
|
415
425
|
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
416
426
|
project, memory_type
|
|
417
427
|
FROM memories
|
|
418
|
-
WHERE 1=1 ${
|
|
428
|
+
WHERE 1=1 ${queryCond} ${tagCond}
|
|
419
429
|
${orderBy}
|
|
420
430
|
LIMIT ${limit}
|
|
421
431
|
` as (MemoryRow & { memory_type: string })[];
|
|
@@ -472,11 +482,11 @@ function validateWrite(args: RememberArgs): string | null {
|
|
|
472
482
|
return null;
|
|
473
483
|
}
|
|
474
484
|
|
|
475
|
-
// Trigram similarity threshold for
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
485
|
+
// Trigram similarity threshold for near-duplicate handling (consolidation and
|
|
486
|
+
// the injection collapse pass). Measured on a real 485-memory corpus: 0.8 is
|
|
487
|
+
// strict enough that only restatements collide. Writes never reject on it -
|
|
488
|
+
// duplicates are cleaned up by memory_consolidate and collapsed out of the
|
|
489
|
+
// injected block.
|
|
480
490
|
const DEDUP_SIMILARITY = 0.8;
|
|
481
491
|
|
|
482
492
|
async function remember(
|
|
@@ -487,51 +497,22 @@ async function remember(
|
|
|
487
497
|
const invalid = validateWrite(args);
|
|
488
498
|
if (invalid) return invalid;
|
|
489
499
|
|
|
490
|
-
// Tags are stored verbatim -
|
|
500
|
+
// Tags are stored verbatim - the project column records origin, not visibility.
|
|
491
501
|
const tags = args.tags ?? [];
|
|
492
|
-
const scope = args.scope === "user" ? "user" : "project";
|
|
493
|
-
if (scope === "user" && !userScope) {
|
|
494
|
-
return "ERROR: user scope is disabled (set OCPG_USER_ID to enable it).";
|
|
495
|
-
}
|
|
496
|
-
// The write target: the calling project's directory, or the shared user
|
|
497
|
-
// sentinel. Dedup is scoped to the target so a user write never collides
|
|
498
|
-
// with a project row (or vice versa).
|
|
499
|
-
const target = scope === "user" ? (userScope as string) : ctx.directory;
|
|
500
502
|
const basename = ctx.directory.split('/').pop() ?? ctx.directory;
|
|
501
503
|
|
|
502
|
-
if (!args.force) {
|
|
503
|
-
// Dedup: target-scoped trigram similarity over the whole content. No
|
|
504
|
-
// trigram index - the project filter narrows to a few hundred rows, which
|
|
505
|
-
// similarity() scans in single-digit milliseconds.
|
|
506
|
-
const dedup = await sql`
|
|
507
|
-
SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
|
|
508
|
-
FROM memories
|
|
509
|
-
WHERE project = ${target}
|
|
510
|
-
AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
|
|
511
|
-
ORDER BY similarity(content, ${args.content}) DESC
|
|
512
|
-
LIMIT 1
|
|
513
|
-
` as { id: number; score: string }[];
|
|
514
|
-
|
|
515
|
-
if (dedup.length > 0) {
|
|
516
|
-
const where = scope === "user" ? " in user scope" : "";
|
|
517
|
-
return `Similar memory already stored as #${dedup[0].id} (similarity ${dedup[0].score})${where}; skipping insert. Pass force: true to store it anyway.`;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
504
|
// sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
|
|
522
505
|
// the element type hint is required for clean array storage.
|
|
523
506
|
const inserted = await sql`
|
|
524
507
|
INSERT INTO memories (content, tags, session_id, project, memory_type)
|
|
525
|
-
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${
|
|
508
|
+
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory}, ${resolveMemoryType(args.type)})
|
|
526
509
|
RETURNING id
|
|
527
510
|
` as { id: number }[];
|
|
528
511
|
|
|
529
|
-
// The injection
|
|
530
|
-
//
|
|
512
|
+
// The injection block is global, but its cache is keyed by the calling
|
|
513
|
+
// directory + prompt; clear the directory's keys.
|
|
531
514
|
invalidateInjection(ctx.directory);
|
|
532
|
-
return
|
|
533
|
-
? `Stored memory #${inserted[0].id} for user scope (shared across projects).`
|
|
534
|
-
: `Stored memory #${inserted[0].id} for project ${basename}.`;
|
|
515
|
+
return `Stored memory #${inserted[0].id} (project ${basename}).`;
|
|
535
516
|
} catch (e: unknown) {
|
|
536
517
|
return toolError("remember", "remember", e);
|
|
537
518
|
}
|
|
@@ -581,11 +562,9 @@ async function captureFromPrompt(
|
|
|
581
562
|
}
|
|
582
563
|
}
|
|
583
564
|
|
|
584
|
-
//
|
|
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.
|
|
565
|
+
// Memories are global: any project can delete any row by id - recall shows
|
|
566
|
+
// ids across projects, so deletability matches visibility. The project column
|
|
567
|
+
// stays as origin metadata, not an authorization boundary.
|
|
589
568
|
async function forget(
|
|
590
569
|
args: ForgetArgs,
|
|
591
570
|
ctx: { directory: string },
|
|
@@ -595,17 +574,14 @@ async function forget(
|
|
|
595
574
|
if (!Number.isInteger(id) || id <= 0) {
|
|
596
575
|
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
597
576
|
}
|
|
598
|
-
const projectCond = userScope
|
|
599
|
-
? sql`project IN (${ctx.directory}, ${userScope})`
|
|
600
|
-
: sql`project = ${ctx.directory}`;
|
|
601
577
|
const deleted = await sql`
|
|
602
578
|
DELETE FROM memories
|
|
603
|
-
WHERE id = ${id}
|
|
579
|
+
WHERE id = ${id}
|
|
604
580
|
RETURNING id
|
|
605
581
|
` as { id: number }[];
|
|
606
582
|
|
|
607
583
|
if (deleted.length === 0) {
|
|
608
|
-
return `No memory #${id}
|
|
584
|
+
return `No memory #${id}; nothing deleted.`;
|
|
609
585
|
}
|
|
610
586
|
invalidateInjection(ctx.directory);
|
|
611
587
|
return `Deleted memory #${id}.`;
|
|
@@ -631,9 +607,6 @@ async function updateMemory(
|
|
|
631
607
|
const invalid = validateWrite(args);
|
|
632
608
|
if (invalid) return invalid;
|
|
633
609
|
|
|
634
|
-
const projectCond = userScope
|
|
635
|
-
? sql`project IN (${ctx.directory}, ${userScope})`
|
|
636
|
-
: sql`project = ${ctx.directory}`;
|
|
637
610
|
const tags = args.tags ?? [];
|
|
638
611
|
const tagCond = Array.isArray(args.tags)
|
|
639
612
|
? sql`tags = ${sql.array(tags, "text")},`
|
|
@@ -647,12 +620,12 @@ async function updateMemory(
|
|
|
647
620
|
${tagCond}
|
|
648
621
|
${typeCond}
|
|
649
622
|
updated_at = now()
|
|
650
|
-
WHERE id = ${id}
|
|
623
|
+
WHERE id = ${id}
|
|
651
624
|
RETURNING id
|
|
652
625
|
` as { id: number }[];
|
|
653
626
|
|
|
654
627
|
if (updated.length === 0) {
|
|
655
|
-
return `No memory #${id}
|
|
628
|
+
return `No memory #${id}; nothing updated.`;
|
|
656
629
|
}
|
|
657
630
|
invalidateInjection(ctx.directory);
|
|
658
631
|
return `Updated memory #${id}.`;
|
|
@@ -661,6 +634,74 @@ async function updateMemory(
|
|
|
661
634
|
}
|
|
662
635
|
}
|
|
663
636
|
|
|
637
|
+
// Deterministic consolidation, no model calls inside the plugin: find
|
|
638
|
+
// near-duplicate clusters (trigram similarity >= DEDUP_SIMILARITY over the
|
|
639
|
+
// whole content, global), keep the newest of each, delete the rest. The
|
|
640
|
+
// deleted texts are returned verbatim so the CALLING agent - itself a model -
|
|
641
|
+
// can merge any unique fact back into the survivor via memory_update. Merging
|
|
642
|
+
// is language synthesis, which is the caller's job, not the plugin's.
|
|
643
|
+
// Runs on demand (user-invoked), never on a schedule; capped at 25 clusters
|
|
644
|
+
// per run so a wildly-duplicated corpus cannot turn into one huge report.
|
|
645
|
+
async function consolidate(): Promise<string> {
|
|
646
|
+
try {
|
|
647
|
+
const rows = await sql`
|
|
648
|
+
SELECT id, content, coalesce(tags, '{}') AS tags, created_at
|
|
649
|
+
FROM memories
|
|
650
|
+
ORDER BY created_at DESC
|
|
651
|
+
` as { id: number; content: string; tags: string[]; created_at: Date }[];
|
|
652
|
+
|
|
653
|
+
// Greedy clustering newest-first: each row joins the first cluster whose
|
|
654
|
+
// representative (the newest member) it near-dupes. Trigram sets are built
|
|
655
|
+
// once (rebuilding per comparison made consolidate O(n^2) set-constructions,
|
|
656
|
+
// seconds at 5k rows), and a size-ratio prefilter skips pairs whose Jaccard
|
|
657
|
+
// can never reach the threshold.
|
|
658
|
+
type Entry = { row: (typeof rows)[number]; set: Set<string> };
|
|
659
|
+
const entries: Entry[] = rows.map((row) => ({ row, set: trigrams(row.content) }));
|
|
660
|
+
const clusters: Array<Array<Entry>> = [];
|
|
661
|
+
for (const entry of entries) {
|
|
662
|
+
const host = clusters.find((c) => {
|
|
663
|
+
const ra = c[0].set.size;
|
|
664
|
+
const rb = entry.set.size;
|
|
665
|
+
// Jaccard >= 0.8 is impossible when one set is much smaller; the
|
|
666
|
+
// comparison itself is the expensive part, so prefilter on sizes.
|
|
667
|
+
if (ra === 0 || rb === 0 || ra > rb * 4 || rb > ra * 4) return false;
|
|
668
|
+
return nearDupeSets(c[0].set, entry.set);
|
|
669
|
+
});
|
|
670
|
+
if (host) host.push(entry);
|
|
671
|
+
else clusters.push([entry]);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const multi = clusters.filter((c) => c.length > 1).slice(0, 25);
|
|
675
|
+
if (multi.length === 0) return "No duplicates found; nothing to consolidate.";
|
|
676
|
+
|
|
677
|
+
let removed = 0;
|
|
678
|
+
const report: string[] = [];
|
|
679
|
+
for (const cluster of multi) {
|
|
680
|
+
const survivor = cluster[0].row;
|
|
681
|
+
const removedRows = cluster.slice(1).map((e) => e.row);
|
|
682
|
+
for (const r of removedRows) {
|
|
683
|
+
await sql`DELETE FROM memories WHERE id = ${r.id}`;
|
|
684
|
+
}
|
|
685
|
+
removed += removedRows.length;
|
|
686
|
+
// Show what died so the calling agent can merge unique facts back into
|
|
687
|
+
// the survivor.
|
|
688
|
+
report.push(
|
|
689
|
+
`Kept #${survivor.id}: ${truncateMemory(survivor.content)}\n` +
|
|
690
|
+
removedRows.map((r) => ` removed #${r.id}: ${truncateMemory(r.content)}`).join("\n"),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (removed > 0) injectionCache.clear();
|
|
695
|
+
return (
|
|
696
|
+
`Removed ${removed} duplicate ${removed === 1 ? "memory" : "memories"} across ${multi.length} groups (kept the newest of each).\n` +
|
|
697
|
+
`Check the removed texts - if any carries a fact the kept memory lacks, merge it in with memory_update:\n\n` +
|
|
698
|
+
report.join("\n")
|
|
699
|
+
);
|
|
700
|
+
} catch (e: unknown) {
|
|
701
|
+
return toolError("consolidate", "consolidate", e);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
664
705
|
// Clears every cache entry for the directory - relevance mode keys by
|
|
665
706
|
// directory + prompt hash, so a write invalidates them all.
|
|
666
707
|
function invalidateInjection(directory: string): void {
|
|
@@ -712,7 +753,7 @@ const ocpg = Plugin.define({
|
|
|
712
753
|
// breaks direct invocation without adding value.
|
|
713
754
|
options: { codemode: false },
|
|
714
755
|
description:
|
|
715
|
-
"Search past memories
|
|
756
|
+
"Search past memories across all projects. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
|
|
716
757
|
input: {
|
|
717
758
|
type: "object",
|
|
718
759
|
properties: {
|
|
@@ -724,18 +765,12 @@ const ocpg = Plugin.define({
|
|
|
724
765
|
"Only return memories carrying all of these tags. Tags are not full-text " +
|
|
725
766
|
"searchable (search covers content only), so this filter is the only way to reach them.",
|
|
726
767
|
},
|
|
727
|
-
global: { type: "boolean", description: "Search across all projects (default: current project only)" },
|
|
728
|
-
scope: {
|
|
729
|
-
type: "string",
|
|
730
|
-
enum: ["project", "user"],
|
|
731
|
-
description: '"user" searches your shared cross-project memories instead of this project\'s (requires user scope to be enabled)',
|
|
732
|
-
},
|
|
733
768
|
limit: { type: "number", description: "1-20, default 5" },
|
|
734
769
|
},
|
|
735
770
|
additionalProperties: false,
|
|
736
771
|
},
|
|
737
772
|
execute: async (input) => {
|
|
738
|
-
return { content: await recall(input as RecallArgs
|
|
773
|
+
return { content: await recall(input as RecallArgs) };
|
|
739
774
|
},
|
|
740
775
|
});
|
|
741
776
|
editor.add({
|
|
@@ -746,7 +781,7 @@ const ocpg = Plugin.define({
|
|
|
746
781
|
// injected block is skipped entirely for projects with no memories and
|
|
747
782
|
// the user may have no project instructions at all.
|
|
748
783
|
description:
|
|
749
|
-
"Store a durable memory
|
|
784
|
+
"Store a durable memory shared across all projects. Use after user corrections (immediately), " +
|
|
750
785
|
"architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
|
|
751
786
|
"Do not store session progress, secrets, or anything the code itself already states.",
|
|
752
787
|
input: {
|
|
@@ -769,19 +804,8 @@ const ocpg = Plugin.define({
|
|
|
769
804
|
maxItems: MAX_TAGS,
|
|
770
805
|
description:
|
|
771
806
|
"Fine-grained facets: decision, debug, env, architecture, workaround, " +
|
|
772
|
-
"language:<x>, framework:<x>, tool:<x>.
|
|
773
|
-
"column, not a tag) - never add project:<name>.",
|
|
774
|
-
},
|
|
775
|
-
force: {
|
|
776
|
-
type: "boolean",
|
|
777
|
-
description:
|
|
778
|
-
"Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
|
|
779
|
-
},
|
|
780
|
-
scope: {
|
|
781
|
-
type: "string",
|
|
782
|
-
enum: ["project", "user"],
|
|
783
|
-
description:
|
|
784
|
-
'"user" stores for you across all projects (requires user scope to be enabled); default "project" stores for this project only',
|
|
807
|
+
"language:<x>, framework:<x>, tool:<x>. The origin project is recorded " +
|
|
808
|
+
"automatically (a project column, not a tag) - never add project:<name>.",
|
|
785
809
|
},
|
|
786
810
|
},
|
|
787
811
|
required: ["content"],
|
|
@@ -796,7 +820,7 @@ const ocpg = Plugin.define({
|
|
|
796
820
|
options: { codemode: false },
|
|
797
821
|
description:
|
|
798
822
|
"Delete a memory by id (get ids from memory_recall). Use for memories that are wrong or obsolete; prefer storing a corrected memory when the old one is still useful history. " +
|
|
799
|
-
"
|
|
823
|
+
"Memories are shared across projects, so any project can delete any of them.",
|
|
800
824
|
input: {
|
|
801
825
|
type: "object",
|
|
802
826
|
properties: {
|
|
@@ -842,6 +866,27 @@ const ocpg = Plugin.define({
|
|
|
842
866
|
return { content: await updateMemory(input as UpdateArgs, { directory }) };
|
|
843
867
|
},
|
|
844
868
|
});
|
|
869
|
+
editor.add({
|
|
870
|
+
name: "memory_consolidate",
|
|
871
|
+
options: { codemode: false },
|
|
872
|
+
// User-invoked cleanup, not a write-path gate: writes never reject on
|
|
873
|
+
// duplicates, so call this when the corpus has accumulated near-dupes.
|
|
874
|
+
// User-invoked cleanup, not a write-path gate: writes never reject on
|
|
875
|
+
// duplicates, so call this when the corpus has accumulated near-dupes.
|
|
876
|
+
// The deleted texts come back in the result so the calling agent can
|
|
877
|
+
// merge unique facts into the survivors via memory_update.
|
|
878
|
+
description:
|
|
879
|
+
"Remove near-duplicate memories: keeps the newest of each >=80%-similar content group anywhere in the store and deletes the rest, returning the removed texts. " +
|
|
880
|
+
"After running it, merge any unique fact from the removed texts into the kept memory via memory_update. Deterministic - run it when the user asks to tidy or consolidate memories.",
|
|
881
|
+
input: {
|
|
882
|
+
type: "object",
|
|
883
|
+
properties: {},
|
|
884
|
+
additionalProperties: false,
|
|
885
|
+
},
|
|
886
|
+
execute: async () => {
|
|
887
|
+
return { content: await consolidate() };
|
|
888
|
+
},
|
|
889
|
+
});
|
|
845
890
|
});
|
|
846
891
|
|
|
847
892
|
// Close the SQL pool when the last plugin instance unloads.
|
|
@@ -862,15 +907,14 @@ const __internals = {
|
|
|
862
907
|
remember,
|
|
863
908
|
forget,
|
|
864
909
|
updateMemory,
|
|
865
|
-
|
|
866
|
-
captureFromPrompt,
|
|
910
|
+
consolidate,
|
|
911
|
+
extractMemoryRequest, captureFromPrompt,
|
|
867
912
|
invalidateInjection,
|
|
868
913
|
resolveSslMode,
|
|
869
914
|
resolveLimit,
|
|
870
915
|
resolveMemoryType,
|
|
871
916
|
validateWrite,
|
|
872
917
|
logError,
|
|
873
|
-
resolveUserScope,
|
|
874
918
|
toolError,
|
|
875
919
|
rateLimitOk,
|
|
876
920
|
resetRateLimit,
|
|
@@ -885,12 +929,6 @@ const __internals = {
|
|
|
885
929
|
orTsQuery,
|
|
886
930
|
buildRecencyQuery,
|
|
887
931
|
buildRelevanceQuery,
|
|
888
|
-
get userScope() {
|
|
889
|
-
return userScope;
|
|
890
|
-
},
|
|
891
|
-
setUserScope(raw: string | undefined | null) {
|
|
892
|
-
userScope = resolveUserScope(raw ?? undefined);
|
|
893
|
-
},
|
|
894
932
|
retain,
|
|
895
933
|
dispose,
|
|
896
934
|
};
|