@dzhi/ocpg 0.12.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 +18 -18
- package/ocpg.ts +481 -85
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
Postgres-backed persistent memory plugin for [OpenCode](https://opencode.ai).
|
|
5
5
|
|
|
6
|
-
Uses the
|
|
6
|
+
Uses the `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`, `memory_type`, `access_count`, `last_accessed_at`, `updated_at`) and the `pg_trgm` extension. Injects the memories most relevant to what you're currently asking into the system prompt, captures deliberate "remember that..." prompts verbatim, and exposes `memory_recall` / `memory_remember` / `memory_forget` / `memory_update` tools.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -21,7 +21,7 @@ In `opencode.json`:
|
|
|
21
21
|
|
|
22
22
|
Need a Postgres instance? The [`deploy/`](./deploy) directory ships a hardened Docker Compose setup (localhost-only, `memories` schema auto-created on first boot) - see [`deploy/README.md`](./deploy/README.md).
|
|
23
23
|
|
|
24
|
-
Configure the connection via environment variables
|
|
24
|
+
Configure the connection via shell environment variables:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
export OCPG_HOST="localhost"
|
|
@@ -39,34 +39,34 @@ export OCPG_SSL="disable"
|
|
|
39
39
|
| `OCPG_USER` | `ocpguser` |
|
|
40
40
|
| `OCPG_DB` | `ocpg` |
|
|
41
41
|
| `OCPG_SSL` | `disable` |
|
|
42
|
-
|
|
43
|
-
**Password is env-only.** The plugin never reads config files, options, or external secret managers - set `OCPG_PASSWORD` in your shell environment (e.g. via direnv/.envrc, however you source your secrets).
|
|
42
|
+
| `OCPG_INJECTION`| `relevance` |
|
|
44
43
|
|
|
45
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.
|
|
46
45
|
|
|
47
|
-
##
|
|
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.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
- `memory_recall` - search past memories (`query`, `tags`, `global`, `limit`)
|
|
51
|
-
- `memory_forget` - delete a memory of the current project by id
|
|
50
|
+
## How injection picks memories
|
|
52
51
|
|
|
53
|
-
|
|
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.
|
|
54
53
|
|
|
55
|
-
|
|
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.
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
## Tools
|
|
58
57
|
|
|
59
|
-
|
|
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:
|
|
60
59
|
|
|
61
|
-
|
|
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.
|
|
62
62
|
|
|
63
|
-
|
|
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.
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
66
|
+
|
|
67
|
+
### Duplicates
|
|
68
68
|
|
|
69
|
-
|
|
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).
|
|
70
70
|
|
|
71
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.
|
|
72
72
|
|
package/ocpg.ts
CHANGED
|
@@ -13,9 +13,14 @@ type DbConfig = {
|
|
|
13
13
|
database: string;
|
|
14
14
|
ssl: SslMode;
|
|
15
15
|
};
|
|
16
|
-
type RecallArgs = { query?: string;
|
|
17
|
-
type RememberArgs = {
|
|
16
|
+
type RecallArgs = { query?: string; limit?: number; tags?: string[] };
|
|
17
|
+
type RememberArgs = {
|
|
18
|
+
content: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
type?: MemoryType;
|
|
21
|
+
};
|
|
18
22
|
type ForgetArgs = { id: number };
|
|
23
|
+
type UpdateArgs = { id: number; content: string; tags?: string[]; type?: MemoryType };
|
|
19
24
|
|
|
20
25
|
// Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
|
|
21
26
|
// when the database is remote, otherwise the SCRAM handshake crosses the network
|
|
@@ -64,8 +69,9 @@ export interface MemoryRow {
|
|
|
64
69
|
date: string;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
//
|
|
68
|
-
|
|
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">;
|
|
69
75
|
|
|
70
76
|
// --- Rate-limited error logging ---
|
|
71
77
|
|
|
@@ -125,6 +131,14 @@ async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
|
|
|
125
131
|
|
|
126
132
|
// --- Injection pipeline ---
|
|
127
133
|
|
|
134
|
+
// Injection ranking mode (plan follow-up: relevance over blind recency).
|
|
135
|
+
// "relevance" (default) scores all memories - every project - against the
|
|
136
|
+
// user's latest prompt via full-text search, falling back to recency when the
|
|
137
|
+
// prompt matches nothing; "recency" restores the old last-5 behavior via
|
|
138
|
+
// OCPG_INJECTION=recency. Resolved once at init, env-only.
|
|
139
|
+
let injectionMode: "relevance" | "recency" =
|
|
140
|
+
process.env.OCPG_INJECTION === "recency" ? "recency" : "relevance";
|
|
141
|
+
|
|
128
142
|
function truncateMemory(content: string): string {
|
|
129
143
|
if (content.length <= 600) return content;
|
|
130
144
|
return `${content.slice(0, 600)}…[truncated]`;
|
|
@@ -137,6 +151,40 @@ function sanitizeMemory(content: string): string {
|
|
|
137
151
|
return content.replaceAll("</persistent-project-memory>", "");
|
|
138
152
|
}
|
|
139
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
|
+
|
|
140
188
|
function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
141
189
|
if (rows.length === 0) return "";
|
|
142
190
|
const lines: string[] = [
|
|
@@ -147,7 +195,10 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
|
147
195
|
for (const row of rows) {
|
|
148
196
|
const tags = row.tags ?? [];
|
|
149
197
|
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
150
|
-
|
|
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))}`);
|
|
151
202
|
}
|
|
152
203
|
lines.push("");
|
|
153
204
|
lines.push(
|
|
@@ -157,43 +208,122 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
|
157
208
|
return lines.join("\n");
|
|
158
209
|
}
|
|
159
210
|
|
|
160
|
-
// Keyed by
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
211
|
+
// Keyed by directory + prompt hash now that the block depends on the prompt
|
|
212
|
+
// (relevance mode): an identical prompt (model retries, re-requests) hits the
|
|
213
|
+
// cache; a new prompt queries afresh. An empty string is cached for
|
|
214
|
+
// no-match/empty prompts so they stop re-querying.
|
|
164
215
|
const injectionCache = new Map<string, string>();
|
|
165
216
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
217
|
+
// djb2 - just a stable key shortener; a same-hash different-prompt collision
|
|
218
|
+
// would serve a stale block, which remember/forget invalidation clears.
|
|
219
|
+
function hashQuery(text: string): string {
|
|
220
|
+
let h = 5381;
|
|
221
|
+
for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0;
|
|
222
|
+
return (h >>> 0).toString(36);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The latest user message is the retrieval signal: what the user is asking
|
|
226
|
+
// about right now is the best proxy for which memories matter. Text parts
|
|
227
|
+
// only; capped because a query is a query, not a transcript - FTS is not
|
|
228
|
+
// helped by thousands of characters.
|
|
229
|
+
function extractPromptQuery(
|
|
230
|
+
messages: ReadonlyArray<{ role: unknown; content: ReadonlyArray<{ type?: unknown; text?: unknown }> }>,
|
|
231
|
+
): string {
|
|
232
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
233
|
+
const message = messages[i];
|
|
234
|
+
if (message?.role !== "user") continue;
|
|
235
|
+
const text = message.content
|
|
236
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
237
|
+
.map((p) => p.text as string)
|
|
238
|
+
.join(" ")
|
|
239
|
+
.trim();
|
|
240
|
+
return text.length > 512 ? text.slice(0, 512) : text;
|
|
241
|
+
}
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
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.
|
|
248
|
+
// Query builders are pure functions of the client so the benchmark
|
|
249
|
+
// (bench/run.ts) can execute the EXACT production SQL against bench
|
|
250
|
+
// databases - no drift between what is measured and what runs.
|
|
251
|
+
function buildRecencyQuery(client: SQL) {
|
|
252
|
+
// LIMIT 20: a candidate slice for collapseDupes, not the final block.
|
|
253
|
+
return client`
|
|
254
|
+
SELECT content, coalesce(tags, '{}') AS tags,
|
|
255
|
+
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
256
|
+
project
|
|
257
|
+
FROM memories
|
|
258
|
+
ORDER BY (memory_type = 'preference') DESC, created_at DESC
|
|
259
|
+
LIMIT 20
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
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.
|
|
267
|
+
return client`
|
|
268
|
+
SELECT content, coalesce(tags, '{}') AS tags,
|
|
269
|
+
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
270
|
+
project
|
|
271
|
+
FROM memories
|
|
272
|
+
WHERE search_vector @@ to_tsquery('english', ${tsQuery})
|
|
273
|
+
ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
|
|
274
|
+
+ (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
|
|
275
|
+
created_at DESC
|
|
276
|
+
LIMIT 20
|
|
277
|
+
`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The prompt as an OR of stemmed words: websearch_to_tsquery ANDs the terms,
|
|
281
|
+
// so one word the memory never uses would zero out the whole query (bench:
|
|
282
|
+
// recall 0.00-0.02 on multi-word queries). OR ranks by how many (and how
|
|
283
|
+
// rare) the matched terms are, and sanitizing to [a-z0-9]+ tokens keeps
|
|
284
|
+
// to_tsquery syntax-safe. Capped at 24 words to bound the query.
|
|
285
|
+
function orTsQuery(text: string): string {
|
|
286
|
+
const words = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
287
|
+
return words.slice(0, 24).join(" | ");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The block depends on the directory plus (in relevance mode) the prompt,
|
|
291
|
+
// passed explicitly by the caller.
|
|
168
292
|
async function handleTransform(
|
|
169
293
|
output: { system: string[] },
|
|
170
294
|
directory: string,
|
|
295
|
+
prompt = "",
|
|
171
296
|
): Promise<void> {
|
|
172
297
|
if (!directory) return;
|
|
173
|
-
const
|
|
298
|
+
const query = injectionMode === "relevance" ? prompt.trim() : "";
|
|
299
|
+
const cacheKey = `${directory}\u0001${hashQuery(query)}`;
|
|
300
|
+
const cached = injectionCache.get(cacheKey);
|
|
174
301
|
if (cached !== undefined) {
|
|
175
302
|
if (cached) output.system.push(cached);
|
|
176
303
|
return;
|
|
177
304
|
}
|
|
178
305
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
306
|
+
let rows: InjectionRow[];
|
|
307
|
+
const tsQuery = orTsQuery(query);
|
|
308
|
+
if (tsQuery) {
|
|
309
|
+
rows = await withDeadline(
|
|
310
|
+
buildRelevanceQuery(sql, tsQuery, directory) as unknown as PromiseLike<InjectionRow[]>,
|
|
311
|
+
1000,
|
|
312
|
+
);
|
|
313
|
+
if (rows.length === 0) {
|
|
314
|
+
// No keyword match for this prompt - recency beats an empty block.
|
|
315
|
+
rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
rows = await withDeadline(buildRecencyQuery(sql) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
319
|
+
}
|
|
320
|
+
const block = formatBlock(collapseDupes(rows), directory);
|
|
191
321
|
// Evict oldest entry when cache exceeds 32
|
|
192
322
|
if (injectionCache.size >= 32) {
|
|
193
323
|
const firstKey = injectionCache.keys().next().value;
|
|
194
324
|
if (firstKey !== undefined) injectionCache.delete(firstKey);
|
|
195
325
|
}
|
|
196
|
-
injectionCache.set(
|
|
326
|
+
injectionCache.set(cacheKey, block);
|
|
197
327
|
if (block) output.system.push(block);
|
|
198
328
|
} catch (e: unknown) {
|
|
199
329
|
logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -229,6 +359,19 @@ const MIN_CONTENT = 10;
|
|
|
229
359
|
const MAX_TAGS = 10;
|
|
230
360
|
const MAX_TAG_LENGTH = 64;
|
|
231
361
|
|
|
362
|
+
// --- Memory types (plan 2.1: defaulted, never required) ---
|
|
363
|
+
|
|
364
|
+
// The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
|
|
365
|
+
// 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
|
|
367
|
+
// feature; remember accepts it so the vocabulary stays in one place.
|
|
368
|
+
const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
|
|
369
|
+
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
370
|
+
|
|
371
|
+
function resolveMemoryType(raw: unknown): MemoryType {
|
|
372
|
+
return MEMORY_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project_fact";
|
|
373
|
+
}
|
|
374
|
+
|
|
232
375
|
// Raw JSON Schema input is not coerced for us: a model sending "3" or null for
|
|
233
376
|
// limit would otherwise reach Postgres as LIMIT NaN.
|
|
234
377
|
function resolveLimit(raw: unknown): number {
|
|
@@ -253,15 +396,13 @@ function toolError(kind: string, action: string, e: unknown): string {
|
|
|
253
396
|
|
|
254
397
|
async function recall(
|
|
255
398
|
args: RecallArgs,
|
|
256
|
-
ctx: { directory: string },
|
|
257
399
|
): Promise<string> {
|
|
258
400
|
try {
|
|
259
401
|
const limit = resolveLimit(args.limit);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
: 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.
|
|
263
404
|
const queryCond = args.query
|
|
264
|
-
? sql`AND search_vector @@
|
|
405
|
+
? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
|
|
265
406
|
: sql``;
|
|
266
407
|
// Tags are not part of search_vector (it covers content only), so they are
|
|
267
408
|
// unreachable by query alone. Matches rows carrying ALL the given tags,
|
|
@@ -270,20 +411,34 @@ async function recall(
|
|
|
270
411
|
const tagCond = tagList.length
|
|
271
412
|
? sql`AND tags @> ${sql.array(tagList, "text")}`
|
|
272
413
|
: sql``;
|
|
273
|
-
// Relevance-ranked when searching;
|
|
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.
|
|
274
419
|
const orderBy = args.query
|
|
275
|
-
? sql`ORDER BY ts_rank(search_vector,
|
|
276
|
-
: sql`ORDER BY created_at DESC`;
|
|
420
|
+
? sql`ORDER BY ts_rank(search_vector, to_tsquery('english', ${orTsQuery(args.query)})) DESC`
|
|
421
|
+
: sql`ORDER BY (memory_type = 'preference') DESC, created_at DESC`;
|
|
277
422
|
|
|
278
423
|
const rows = await sql`
|
|
279
424
|
SELECT id, content, coalesce(tags, '{}') AS tags,
|
|
280
425
|
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
281
|
-
project
|
|
426
|
+
project, memory_type
|
|
282
427
|
FROM memories
|
|
283
|
-
WHERE 1=1 ${
|
|
428
|
+
WHERE 1=1 ${queryCond} ${tagCond}
|
|
284
429
|
${orderBy}
|
|
285
430
|
LIMIT ${limit}
|
|
286
|
-
` as MemoryRow[];
|
|
431
|
+
` as (MemoryRow & { memory_type: string })[];
|
|
432
|
+
|
|
433
|
+
// Access ranking is recall-only (plan 2.2): the injection path stays
|
|
434
|
+
// read-only because its per-directory cache would make increments biased.
|
|
435
|
+
// Fire-and-forget so the UPDATE never sits on the read path's latency.
|
|
436
|
+
const ids = rows.map((r) => r.id);
|
|
437
|
+
if (ids.length > 0) {
|
|
438
|
+
void sql`UPDATE memories SET access_count = access_count + 1, last_accessed_at = now() WHERE id = ANY(${sql.array(ids, "int8")})`.catch(
|
|
439
|
+
(e: unknown) => logError("access", `ocpg access bump failed: ${e instanceof Error ? e.message : String(e)}`),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
287
442
|
|
|
288
443
|
if (rows.length === 0) return "No memories found.";
|
|
289
444
|
|
|
@@ -291,7 +446,10 @@ async function recall(
|
|
|
291
446
|
.map((r) => {
|
|
292
447
|
const tags = r.tags ?? [];
|
|
293
448
|
const tagStr = tags.length ? ` (${tags.join(', ')})` : '';
|
|
294
|
-
|
|
449
|
+
// preference/episodic are worth surfacing; project_fact is the default
|
|
450
|
+
// every pre-column row carries, so printing it is pure noise.
|
|
451
|
+
const typeStr = r.memory_type === "project_fact" ? "" : ` [${r.memory_type}]`;
|
|
452
|
+
return `[${r.date}] [${r.project}]${typeStr}${tagStr}\n#${r.id}\n${r.content}`;
|
|
295
453
|
})
|
|
296
454
|
.join('\n---\n');
|
|
297
455
|
} catch (e: unknown) {
|
|
@@ -318,14 +476,17 @@ function validateWrite(args: RememberArgs): string | null {
|
|
|
318
476
|
if (oversized !== undefined) {
|
|
319
477
|
return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
|
|
320
478
|
}
|
|
479
|
+
if (args.type !== undefined && !MEMORY_TYPES.includes(args.type)) {
|
|
480
|
+
return `ERROR: type must be one of ${MEMORY_TYPES.join(", ")}.`;
|
|
481
|
+
}
|
|
321
482
|
return null;
|
|
322
483
|
}
|
|
323
484
|
|
|
324
|
-
// Trigram similarity threshold for
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
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.
|
|
329
490
|
const DEDUP_SIMILARITY = 0.8;
|
|
330
491
|
|
|
331
492
|
async function remember(
|
|
@@ -336,45 +497,74 @@ async function remember(
|
|
|
336
497
|
const invalid = validateWrite(args);
|
|
337
498
|
if (invalid) return invalid;
|
|
338
499
|
|
|
339
|
-
// Tags are stored verbatim -
|
|
500
|
+
// Tags are stored verbatim - the project column records origin, not visibility.
|
|
340
501
|
const tags = args.tags ?? [];
|
|
341
502
|
const basename = ctx.directory.split('/').pop() ?? ctx.directory;
|
|
342
503
|
|
|
343
|
-
if (!args.force) {
|
|
344
|
-
// Dedup: project-scoped trigram similarity over the whole content. No
|
|
345
|
-
// trigram index - the project filter narrows to a few hundred rows, which
|
|
346
|
-
// similarity() scans in single-digit milliseconds.
|
|
347
|
-
const dedup = await sql`
|
|
348
|
-
SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
|
|
349
|
-
FROM memories
|
|
350
|
-
WHERE project = ${ctx.directory}
|
|
351
|
-
AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
|
|
352
|
-
ORDER BY similarity(content, ${args.content}) DESC
|
|
353
|
-
LIMIT 1
|
|
354
|
-
` as { id: number; score: string }[];
|
|
355
|
-
|
|
356
|
-
if (dedup.length > 0) {
|
|
357
|
-
return `Similar memory already stored as #${dedup[0].id} (similarity ${dedup[0].score}) for this project; skipping insert. Pass force: true to store it anyway.`;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
504
|
// sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
|
|
362
505
|
// the element type hint is required for clean array storage.
|
|
363
506
|
const inserted = await sql`
|
|
364
|
-
INSERT INTO memories (content, tags, session_id, project)
|
|
365
|
-
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory})
|
|
507
|
+
INSERT INTO memories (content, tags, session_id, project, memory_type)
|
|
508
|
+
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${ctx.directory}, ${resolveMemoryType(args.type)})
|
|
366
509
|
RETURNING id
|
|
367
510
|
` as { id: number }[];
|
|
368
511
|
|
|
512
|
+
// The injection block is global, but its cache is keyed by the calling
|
|
513
|
+
// directory + prompt; clear the directory's keys.
|
|
369
514
|
invalidateInjection(ctx.directory);
|
|
370
|
-
return `Stored memory #${inserted[0].id}
|
|
515
|
+
return `Stored memory #${inserted[0].id} (project ${basename}).`;
|
|
371
516
|
} catch (e: unknown) {
|
|
372
517
|
return toolError("remember", "remember", e);
|
|
373
518
|
}
|
|
374
519
|
}
|
|
375
520
|
|
|
376
|
-
//
|
|
377
|
-
|
|
521
|
+
// --- Keyword capture (plan 1.1, revised) ---
|
|
522
|
+
|
|
523
|
+
// Deterministic capture, no LLM: a trigger phrase in the prompt stores the text
|
|
524
|
+
// following it verbatim (minus the trigger) through the normal write path.
|
|
525
|
+
// Extracting "relevant content" instead would be a model judgment on the
|
|
526
|
+
// prompt-admission path - nondeterministic, and it violates the project rule
|
|
527
|
+
// that model judgment never becomes load-bearing (memory #1555).
|
|
528
|
+
const MEMORY_TRIGGER_RE =
|
|
529
|
+
/\b(?:remember(?:\s+(?:this|that|to))?|do(?:n'?| no)t forget(?:\s+(?:this|that|to))?|keep (?:this|that )?in mind(?: that)?)\b\s*[:,]?\s*/i;
|
|
530
|
+
|
|
531
|
+
// Interrogative follow-ons are questions about the past ("remember when the
|
|
532
|
+
// pool broke?"), not storage requests. The list is deliberately narrow:
|
|
533
|
+
// "remember that when X happens, do Y" is imperative and must be captured, so
|
|
534
|
+
// "when" alone is not enough - only skip the bare question forms.
|
|
535
|
+
const INTERROGATIVE_RE = /^(?:when|what|where|why|how|who|whom|whose|which|did)\b/i;
|
|
536
|
+
|
|
537
|
+
function extractMemoryRequest(text: string): string | null {
|
|
538
|
+
const match = MEMORY_TRIGGER_RE.exec(text);
|
|
539
|
+
if (!match) return null;
|
|
540
|
+
const rest = text.slice(match.index + match[0].length).trim();
|
|
541
|
+
if (!rest || INTERROGATIVE_RE.test(rest)) return null;
|
|
542
|
+
return rest;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Fire-and-forget by design: prompt admission must not wait on a database
|
|
546
|
+
// write, and the prompt itself is never mutated on failure.
|
|
547
|
+
//
|
|
548
|
+
// Not exactly-once: the docs allow prompt hooks to run more than once under
|
|
549
|
+
// concurrent submissions. Dedup-on-write (trigram similarity) is the guard -
|
|
550
|
+
// no hook-side deduplication layer on top of it.
|
|
551
|
+
async function captureFromPrompt(
|
|
552
|
+
text: string,
|
|
553
|
+
directory: string,
|
|
554
|
+
sessionID: string,
|
|
555
|
+
): Promise<void> {
|
|
556
|
+
const content = extractMemoryRequest(text);
|
|
557
|
+
if (!content) return;
|
|
558
|
+
try {
|
|
559
|
+
await remember({ content, tags: ["user-requested"] }, { directory, sessionID });
|
|
560
|
+
} catch (e: unknown) {
|
|
561
|
+
logError("capture", `ocpg keyword capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
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.
|
|
378
568
|
async function forget(
|
|
379
569
|
args: ForgetArgs,
|
|
380
570
|
ctx: { directory: string },
|
|
@@ -386,12 +576,12 @@ async function forget(
|
|
|
386
576
|
}
|
|
387
577
|
const deleted = await sql`
|
|
388
578
|
DELETE FROM memories
|
|
389
|
-
WHERE id = ${id}
|
|
579
|
+
WHERE id = ${id}
|
|
390
580
|
RETURNING id
|
|
391
581
|
` as { id: number }[];
|
|
392
582
|
|
|
393
583
|
if (deleted.length === 0) {
|
|
394
|
-
return `No memory #${id}
|
|
584
|
+
return `No memory #${id}; nothing deleted.`;
|
|
395
585
|
}
|
|
396
586
|
invalidateInjection(ctx.directory);
|
|
397
587
|
return `Deleted memory #${id}.`;
|
|
@@ -400,8 +590,126 @@ async function forget(
|
|
|
400
590
|
}
|
|
401
591
|
}
|
|
402
592
|
|
|
593
|
+
// No dedup fall-through, by design: an update that lands close to another
|
|
594
|
+
// memory is an intentional correction, not a dupe to reject. updated_at is
|
|
595
|
+
// set; created_at is deliberately NOT bumped - the displayed date must keep
|
|
596
|
+
// saying when the memory was learned, not when it was last edited. Omitted
|
|
597
|
+
// tags/type are preserved, not reset to their defaults.
|
|
598
|
+
async function updateMemory(
|
|
599
|
+
args: UpdateArgs,
|
|
600
|
+
ctx: { directory: string },
|
|
601
|
+
): Promise<string> {
|
|
602
|
+
try {
|
|
603
|
+
const id = Number(args.id);
|
|
604
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
605
|
+
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
606
|
+
}
|
|
607
|
+
const invalid = validateWrite(args);
|
|
608
|
+
if (invalid) return invalid;
|
|
609
|
+
|
|
610
|
+
const tags = args.tags ?? [];
|
|
611
|
+
const tagCond = Array.isArray(args.tags)
|
|
612
|
+
? sql`tags = ${sql.array(tags, "text")},`
|
|
613
|
+
: sql``;
|
|
614
|
+
const typeCond = args.type !== undefined
|
|
615
|
+
? sql`memory_type = ${resolveMemoryType(args.type)},`
|
|
616
|
+
: sql``;
|
|
617
|
+
const updated = await sql`
|
|
618
|
+
UPDATE memories
|
|
619
|
+
SET content = ${args.content},
|
|
620
|
+
${tagCond}
|
|
621
|
+
${typeCond}
|
|
622
|
+
updated_at = now()
|
|
623
|
+
WHERE id = ${id}
|
|
624
|
+
RETURNING id
|
|
625
|
+
` as { id: number }[];
|
|
626
|
+
|
|
627
|
+
if (updated.length === 0) {
|
|
628
|
+
return `No memory #${id}; nothing updated.`;
|
|
629
|
+
}
|
|
630
|
+
invalidateInjection(ctx.directory);
|
|
631
|
+
return `Updated memory #${id}.`;
|
|
632
|
+
} catch (e: unknown) {
|
|
633
|
+
return toolError("update", "update", e);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
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
|
+
|
|
705
|
+
// Clears every cache entry for the directory - relevance mode keys by
|
|
706
|
+
// directory + prompt hash, so a write invalidates them all.
|
|
403
707
|
function invalidateInjection(directory: string): void {
|
|
404
|
-
injectionCache.
|
|
708
|
+
for (const key of [...injectionCache.keys()]) {
|
|
709
|
+
if (key === directory || key.startsWith(`${directory}\u0001`)) {
|
|
710
|
+
injectionCache.delete(key);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
405
713
|
}
|
|
406
714
|
|
|
407
715
|
// V2 entrypoint: registers the system-context injection hook and the agent tools
|
|
@@ -419,20 +727,33 @@ const ocpg = Plugin.define({
|
|
|
419
727
|
void sql`SELECT 1`.catch(() => {});
|
|
420
728
|
|
|
421
729
|
// Inject project memories into every model request's system context.
|
|
422
|
-
//
|
|
730
|
+
// Relevance mode derives the retrieval query from the latest user message;
|
|
731
|
+
// handleTransform owns the cache (32-slot, keyed by directory + prompt).
|
|
423
732
|
await ctx.session.hook("context", async (event) => {
|
|
424
733
|
const output: { system: string[] } = { system: [] };
|
|
425
|
-
await handleTransform(output, directory);
|
|
734
|
+
await handleTransform(output, directory, extractPromptQuery(event.messages));
|
|
426
735
|
for (const text of output.system) event.system.push({ type: "text", text });
|
|
427
736
|
});
|
|
428
737
|
|
|
738
|
+
// Keyword capture (plan 1.1 revised): a trigger phrase ("remember this,
|
|
739
|
+
// ...") stores the following text verbatim through the normal write path -
|
|
740
|
+
// same validateWrite, same trigram dedup. No LLM call, and the prompt
|
|
741
|
+
// itself is never mutated.
|
|
742
|
+
await ctx.session.hook("prompt", (event) => {
|
|
743
|
+
void captureFromPrompt(event.prompt.text, directory, event.sessionID);
|
|
744
|
+
});
|
|
745
|
+
|
|
429
746
|
// Agent tools: recall + remember with dedup-on-write. Input schemas are raw
|
|
430
747
|
// JSON Schema (V2 contract); sizes are enforced in remember().
|
|
431
748
|
await ctx.tool.transform((editor) => {
|
|
432
749
|
editor.add({
|
|
433
750
|
name: "memory_recall",
|
|
751
|
+
// Direct (non-codemode) tool: memory ops are single-shot calls, not
|
|
752
|
+
// scriptable sequences - hiding them behind the execute sandbox only
|
|
753
|
+
// breaks direct invocation without adding value.
|
|
754
|
+
options: { codemode: false },
|
|
434
755
|
description:
|
|
435
|
-
"Search past memories
|
|
756
|
+
"Search past memories across all projects. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
|
|
436
757
|
input: {
|
|
437
758
|
type: "object",
|
|
438
759
|
properties: {
|
|
@@ -440,25 +761,27 @@ const ocpg = Plugin.define({
|
|
|
440
761
|
tags: {
|
|
441
762
|
type: "array",
|
|
442
763
|
items: { type: "string" },
|
|
443
|
-
description:
|
|
764
|
+
description:
|
|
765
|
+
"Only return memories carrying all of these tags. Tags are not full-text " +
|
|
766
|
+
"searchable (search covers content only), so this filter is the only way to reach them.",
|
|
444
767
|
},
|
|
445
|
-
global: { type: "boolean", description: "Search across all projects (default: current project only)" },
|
|
446
768
|
limit: { type: "number", description: "1-20, default 5" },
|
|
447
769
|
},
|
|
448
770
|
additionalProperties: false,
|
|
449
771
|
},
|
|
450
772
|
execute: async (input) => {
|
|
451
|
-
return { content: await recall(input as RecallArgs
|
|
773
|
+
return { content: await recall(input as RecallArgs) };
|
|
452
774
|
},
|
|
453
775
|
});
|
|
454
776
|
editor.add({
|
|
455
777
|
name: "memory_remember",
|
|
778
|
+
options: { codemode: false },
|
|
456
779
|
// This description is the only place the write policy is guaranteed to
|
|
457
780
|
// reach the model: it is in the tool schema every session, whereas the
|
|
458
781
|
// injected block is skipped entirely for projects with no memories and
|
|
459
782
|
// the user may have no project instructions at all.
|
|
460
783
|
description:
|
|
461
|
-
"Store a durable memory
|
|
784
|
+
"Store a durable memory shared across all projects. Use after user corrections (immediately), " +
|
|
462
785
|
"architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
|
|
463
786
|
"Do not store session progress, secrets, or anything the code itself already states.",
|
|
464
787
|
input: {
|
|
@@ -468,19 +791,21 @@ const ocpg = Plugin.define({
|
|
|
468
791
|
type: "string",
|
|
469
792
|
description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
470
793
|
},
|
|
794
|
+
type: {
|
|
795
|
+
type: "string",
|
|
796
|
+
enum: [...MEMORY_TYPES],
|
|
797
|
+
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.",
|
|
800
|
+
},
|
|
471
801
|
tags: {
|
|
472
802
|
type: "array",
|
|
473
803
|
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
474
804
|
maxItems: MAX_TAGS,
|
|
475
805
|
description:
|
|
476
|
-
"
|
|
477
|
-
"language:<x>, framework:<x>, tool:<x>.
|
|
478
|
-
"column, not a tag) - never add project:<name>.",
|
|
479
|
-
},
|
|
480
|
-
force: {
|
|
481
|
-
type: "boolean",
|
|
482
|
-
description:
|
|
483
|
-
"Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
|
|
806
|
+
"Fine-grained facets: decision, debug, env, architecture, workaround, " +
|
|
807
|
+
"language:<x>, framework:<x>, tool:<x>. The origin project is recorded " +
|
|
808
|
+
"automatically (a project column, not a tag) - never add project:<name>.",
|
|
484
809
|
},
|
|
485
810
|
},
|
|
486
811
|
required: ["content"],
|
|
@@ -492,8 +817,10 @@ const ocpg = Plugin.define({
|
|
|
492
817
|
});
|
|
493
818
|
editor.add({
|
|
494
819
|
name: "memory_forget",
|
|
820
|
+
options: { codemode: false },
|
|
495
821
|
description:
|
|
496
|
-
"Delete a memory
|
|
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. " +
|
|
823
|
+
"Memories are shared across projects, so any project can delete any of them.",
|
|
497
824
|
input: {
|
|
498
825
|
type: "object",
|
|
499
826
|
properties: {
|
|
@@ -506,6 +833,60 @@ const ocpg = Plugin.define({
|
|
|
506
833
|
return { content: await forget(input as ForgetArgs, { directory }) };
|
|
507
834
|
},
|
|
508
835
|
});
|
|
836
|
+
editor.add({
|
|
837
|
+
name: "memory_update",
|
|
838
|
+
options: { codemode: false },
|
|
839
|
+
description:
|
|
840
|
+
"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.",
|
|
842
|
+
input: {
|
|
843
|
+
type: "object",
|
|
844
|
+
properties: {
|
|
845
|
+
id: { type: "number", description: "The #id shown by memory_recall" },
|
|
846
|
+
content: {
|
|
847
|
+
type: "string",
|
|
848
|
+
description: `1-3 self-contained sentences replacing the old content (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
849
|
+
},
|
|
850
|
+
tags: {
|
|
851
|
+
type: "array",
|
|
852
|
+
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
853
|
+
maxItems: MAX_TAGS,
|
|
854
|
+
description: "Replaces the tag list; omit to keep the current tags",
|
|
855
|
+
},
|
|
856
|
+
type: {
|
|
857
|
+
type: "string",
|
|
858
|
+
enum: [...MEMORY_TYPES],
|
|
859
|
+
description: "Replaces the memory type; omit to keep the current type",
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
required: ["id", "content"],
|
|
863
|
+
additionalProperties: false,
|
|
864
|
+
},
|
|
865
|
+
execute: async (input) => {
|
|
866
|
+
return { content: await updateMemory(input as UpdateArgs, { directory }) };
|
|
867
|
+
},
|
|
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
|
+
});
|
|
509
890
|
});
|
|
510
891
|
|
|
511
892
|
// Close the SQL pool when the last plugin instance unloads.
|
|
@@ -525,14 +906,29 @@ const __internals = {
|
|
|
525
906
|
recall,
|
|
526
907
|
remember,
|
|
527
908
|
forget,
|
|
909
|
+
updateMemory,
|
|
910
|
+
consolidate,
|
|
911
|
+
extractMemoryRequest, captureFromPrompt,
|
|
528
912
|
invalidateInjection,
|
|
529
913
|
resolveSslMode,
|
|
530
914
|
resolveLimit,
|
|
915
|
+
resolveMemoryType,
|
|
531
916
|
validateWrite,
|
|
532
917
|
logError,
|
|
533
918
|
toolError,
|
|
534
919
|
rateLimitOk,
|
|
535
920
|
resetRateLimit,
|
|
921
|
+
get injectionMode() {
|
|
922
|
+
return injectionMode;
|
|
923
|
+
},
|
|
924
|
+
setInjectionMode(mode: "relevance" | "recency") {
|
|
925
|
+
injectionMode = mode;
|
|
926
|
+
},
|
|
927
|
+
extractPromptQuery,
|
|
928
|
+
hashQuery,
|
|
929
|
+
orTsQuery,
|
|
930
|
+
buildRecencyQuery,
|
|
931
|
+
buildRelevanceQuery,
|
|
536
932
|
retain,
|
|
537
933
|
dispose,
|
|
538
934
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhi/ocpg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Postgres-backed persistent memory plugin for OpenCode",
|
|
6
6
|
"main": "ocpg.ts",
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"check": "biome check .",
|
|
9
9
|
"format": "biome format --write .",
|
|
10
10
|
"typecheck": "tsc --noEmit",
|
|
11
|
-
"test": "bun test"
|
|
11
|
+
"test": "bun test",
|
|
12
|
+
"bench:generate": "bun bench/generate.ts",
|
|
13
|
+
"bench": "bun bench/run.ts"
|
|
12
14
|
},
|
|
13
15
|
"exports": {
|
|
14
16
|
".": "./ocpg.ts"
|