@dzhi/ocpg 0.12.0 → 0.14.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 +17 -11
- package/ocpg.ts +400 -42
- 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,26 +39,32 @@ 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_USER_ID` | _(unset)_ |
|
|
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
|
|
|
46
|
+
Set `OCPG_USER_ID` to a short identifier to enable **user scope**: memories stored with `scope: "user"` live under a `user:<id>` sentinel and are injected into every project's context (and deletable from any project, by design). Unset, user scope is disabled entirely.
|
|
47
|
+
|
|
48
|
+
## How injection picks memories
|
|
49
|
+
|
|
50
|
+
By default the block is **relevance-ranked, not recency-ranked**: the user's latest prompt is turned into a full-text query (OR of stemmed words) over **all projects**, ranked by relevance with a small same-project tiebreak, top 5 injected. Working on similar projects for different clients means a memory written anywhere can surface in any project. When nothing matches the prompt, it falls back to the latest memories (preferences first). Set `OCPG_INJECTION=recency` for the old blind-last-5 behavior.
|
|
51
|
+
|
|
52
|
+
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
|
+
|
|
47
54
|
## Tools
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
- `memory_recall` - search past memories (`query`, `tags`, `global`, `limit`)
|
|
51
|
-
- `memory_forget` - delete a memory of the current project by id
|
|
56
|
+
Four agent tools are registered: `memory_remember` (store), `memory_recall` (search), `memory_forget` (delete by id), and `memory_update` (rewrite an existing memory, keeping its original learned date). The agent reads their usage rules from the tool schemas - as the user, the things worth knowing are:
|
|
52
57
|
|
|
53
|
-
Memories are
|
|
58
|
+
- Memories are scoped to the project directory they were stored from; recall only sees them cross-project when the agent explicitly asks for `global` search (or stores them in user scope).
|
|
59
|
+
- `memory_forget` / `memory_update` only touch memories of the calling project - plus, when user scope is enabled, the shared user memories.
|
|
54
60
|
|
|
55
|
-
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.
|
|
61
|
+
Writes are capped at 4000 characters of content, 10 tags, and 64 characters per tag; oversized writes are rejected with the actual size rather than silently truncated. Memories carry a `type` (`preference`, `project_fact` default, or `episodic`); `preference` memories are injected ahead of newer facts.
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
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.
|
|
58
64
|
|
|
59
65
|
### Duplicate detection
|
|
60
66
|
|
|
61
|
-
`memory_remember` rejects
|
|
67
|
+
`memory_remember` rejects near-duplicates of existing memories in the same project instead of storing them.
|
|
62
68
|
|
|
63
69
|
This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
|
|
64
70
|
|
package/ocpg.ts
CHANGED
|
@@ -13,9 +13,16 @@ type DbConfig = {
|
|
|
13
13
|
database: string;
|
|
14
14
|
ssl: SslMode;
|
|
15
15
|
};
|
|
16
|
-
type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[] };
|
|
17
|
-
type RememberArgs = {
|
|
16
|
+
type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[]; scope?: "project" | "user" };
|
|
17
|
+
type RememberArgs = {
|
|
18
|
+
content: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
force?: boolean;
|
|
21
|
+
scope?: "project" | "user";
|
|
22
|
+
type?: MemoryType;
|
|
23
|
+
};
|
|
18
24
|
type ForgetArgs = { id: number };
|
|
25
|
+
type UpdateArgs = { id: number; content: string; tags?: string[]; type?: MemoryType };
|
|
19
26
|
|
|
20
27
|
// Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
|
|
21
28
|
// when the database is remote, otherwise the SCRAM handshake crosses the network
|
|
@@ -36,6 +43,22 @@ const defaultConfig: DbConfig = {
|
|
|
36
43
|
};
|
|
37
44
|
const password = process.env.OCPG_PASSWORD || "";
|
|
38
45
|
|
|
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
|
+
|
|
39
62
|
// Options-object constructor, not a URL string: Bun's SQL parses string URLs via
|
|
40
63
|
// url.parse(), which emits the DEP0169 DeprecationWarning at plugin load under opencode.
|
|
41
64
|
function makeSql(cfg: DbConfig): SQL {
|
|
@@ -125,6 +148,14 @@ async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
|
|
|
125
148
|
|
|
126
149
|
// --- Injection pipeline ---
|
|
127
150
|
|
|
151
|
+
// Injection ranking mode (plan follow-up: relevance over blind recency).
|
|
152
|
+
// "relevance" (default) scores all memories - every project - against the
|
|
153
|
+
// user's latest prompt via full-text search, falling back to recency when the
|
|
154
|
+
// prompt matches nothing; "recency" restores the old last-5 behavior via
|
|
155
|
+
// OCPG_INJECTION=recency. Resolved once at init, env-only.
|
|
156
|
+
let injectionMode: "relevance" | "recency" =
|
|
157
|
+
process.env.OCPG_INJECTION === "recency" ? "recency" : "relevance";
|
|
158
|
+
|
|
128
159
|
function truncateMemory(content: string): string {
|
|
129
160
|
if (content.length <= 600) return content;
|
|
130
161
|
return `${content.slice(0, 600)}…[truncated]`;
|
|
@@ -157,43 +188,123 @@ function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
|
157
188
|
return lines.join("\n");
|
|
158
189
|
}
|
|
159
190
|
|
|
160
|
-
// Keyed by
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
191
|
+
// Keyed by directory + prompt hash now that the block depends on the prompt
|
|
192
|
+
// (relevance mode): an identical prompt (model retries, re-requests) hits the
|
|
193
|
+
// cache; a new prompt queries afresh. An empty string is cached for
|
|
194
|
+
// no-match/empty prompts so they stop re-querying.
|
|
164
195
|
const injectionCache = new Map<string, string>();
|
|
165
196
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
197
|
+
// djb2 - just a stable key shortener; a same-hash different-prompt collision
|
|
198
|
+
// would serve a stale block, which remember/forget invalidation clears.
|
|
199
|
+
function hashQuery(text: string): string {
|
|
200
|
+
let h = 5381;
|
|
201
|
+
for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0;
|
|
202
|
+
return (h >>> 0).toString(36);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The latest user message is the retrieval signal: what the user is asking
|
|
206
|
+
// about right now is the best proxy for which memories matter. Text parts
|
|
207
|
+
// only; capped because a query is a query, not a transcript - FTS is not
|
|
208
|
+
// helped by thousands of characters.
|
|
209
|
+
function extractPromptQuery(
|
|
210
|
+
messages: ReadonlyArray<{ role: unknown; content: ReadonlyArray<{ type?: unknown; text?: unknown }> }>,
|
|
211
|
+
): string {
|
|
212
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
213
|
+
const message = messages[i];
|
|
214
|
+
if (message?.role !== "user") continue;
|
|
215
|
+
const text = message.content
|
|
216
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
217
|
+
.map((p) => p.text as string)
|
|
218
|
+
.join(" ")
|
|
219
|
+
.trim();
|
|
220
|
+
return text.length > 512 ? text.slice(0, 512) : text;
|
|
221
|
+
}
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Recency query shared by the recency mode and the no-match fallback: latest 5
|
|
226
|
+
// rows of the project (plus the user-scope sentinel when enabled), preferences
|
|
227
|
+
// first.
|
|
228
|
+
// Query builders are pure functions of the client so the benchmark
|
|
229
|
+
// (bench/run.ts) can execute the EXACT production SQL against bench
|
|
230
|
+
// databases - no drift between what is measured and what runs.
|
|
231
|
+
function buildRecencyQuery(client: SQL, directory: string) {
|
|
232
|
+
const projectCond = userScope
|
|
233
|
+
? client`WHERE (project = ${directory} OR project = ${userScope})`
|
|
234
|
+
: client`WHERE project = ${directory}`;
|
|
235
|
+
return client`
|
|
236
|
+
SELECT content, coalesce(tags, '{}') AS tags,
|
|
237
|
+
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
238
|
+
FROM memories
|
|
239
|
+
${projectCond}
|
|
240
|
+
ORDER BY (memory_type = 'preference') DESC, created_at DESC
|
|
241
|
+
LIMIT 5
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildRelevanceQuery(client: SQL, tsQuery: string, directory: string) {
|
|
246
|
+
// Relevance: full-text search across ALL projects - shared memory by
|
|
247
|
+
// design - with a small same-project boost to break rank ties toward
|
|
248
|
+
// locally stored memories.
|
|
249
|
+
return client`
|
|
250
|
+
SELECT content, coalesce(tags, '{}') AS tags,
|
|
251
|
+
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
252
|
+
FROM memories
|
|
253
|
+
WHERE search_vector @@ to_tsquery('english', ${tsQuery})
|
|
254
|
+
ORDER BY ts_rank(search_vector, to_tsquery('english', ${tsQuery}))
|
|
255
|
+
+ (CASE WHEN project = ${directory} THEN 0.01 ELSE 0 END) DESC,
|
|
256
|
+
created_at DESC
|
|
257
|
+
LIMIT 5
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The prompt as an OR of stemmed words: websearch_to_tsquery ANDs the terms,
|
|
262
|
+
// so one word the memory never uses would zero out the whole query (bench:
|
|
263
|
+
// recall 0.00-0.02 on multi-word queries). OR ranks by how many (and how
|
|
264
|
+
// rare) the matched terms are, and sanitizing to [a-z0-9]+ tokens keeps
|
|
265
|
+
// to_tsquery syntax-safe. Capped at 24 words to bound the query.
|
|
266
|
+
function orTsQuery(text: string): string {
|
|
267
|
+
const words = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
268
|
+
return words.slice(0, 24).join(" | ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The block depends on the directory plus (in relevance mode) the prompt,
|
|
272
|
+
// passed explicitly by the caller.
|
|
168
273
|
async function handleTransform(
|
|
169
274
|
output: { system: string[] },
|
|
170
275
|
directory: string,
|
|
276
|
+
prompt = "",
|
|
171
277
|
): Promise<void> {
|
|
172
278
|
if (!directory) return;
|
|
173
|
-
const
|
|
279
|
+
const query = injectionMode === "relevance" ? prompt.trim() : "";
|
|
280
|
+
const cacheKey = `${directory}\u0001${hashQuery(query)}`;
|
|
281
|
+
const cached = injectionCache.get(cacheKey);
|
|
174
282
|
if (cached !== undefined) {
|
|
175
283
|
if (cached) output.system.push(cached);
|
|
176
284
|
return;
|
|
177
285
|
}
|
|
178
286
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
287
|
+
let rows: InjectionRow[];
|
|
288
|
+
const tsQuery = orTsQuery(query);
|
|
289
|
+
if (tsQuery) {
|
|
290
|
+
rows = await withDeadline(
|
|
291
|
+
buildRelevanceQuery(sql, tsQuery, directory) as unknown as PromiseLike<InjectionRow[]>,
|
|
292
|
+
1000,
|
|
293
|
+
);
|
|
294
|
+
if (rows.length === 0) {
|
|
295
|
+
// No keyword match for this prompt - recency beats an empty block.
|
|
296
|
+
rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
rows = await withDeadline(buildRecencyQuery(sql, directory) as unknown as PromiseLike<InjectionRow[]>, 1000);
|
|
300
|
+
}
|
|
190
301
|
const block = formatBlock(rows, directory);
|
|
191
302
|
// Evict oldest entry when cache exceeds 32
|
|
192
303
|
if (injectionCache.size >= 32) {
|
|
193
304
|
const firstKey = injectionCache.keys().next().value;
|
|
194
305
|
if (firstKey !== undefined) injectionCache.delete(firstKey);
|
|
195
306
|
}
|
|
196
|
-
injectionCache.set(
|
|
307
|
+
injectionCache.set(cacheKey, block);
|
|
197
308
|
if (block) output.system.push(block);
|
|
198
309
|
} catch (e: unknown) {
|
|
199
310
|
logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -229,6 +340,19 @@ const MIN_CONTENT = 10;
|
|
|
229
340
|
const MAX_TAGS = 10;
|
|
230
341
|
const MAX_TAG_LENGTH = 64;
|
|
231
342
|
|
|
343
|
+
// --- Memory types (plan 2.1: defaulted, never required) ---
|
|
344
|
+
|
|
345
|
+
// The stored vocabulary mirrors the DB CHECK constraint (memories_type_check);
|
|
346
|
+
// rows predate the column, so "required" would break every existing caller -
|
|
347
|
+
// type is always defaulted. episodic is reserved for the (cut, opt-in) 1.3
|
|
348
|
+
// feature; remember accepts it so the vocabulary stays in one place.
|
|
349
|
+
const MEMORY_TYPES = ["preference", "project_fact", "episodic"] as const;
|
|
350
|
+
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
351
|
+
|
|
352
|
+
function resolveMemoryType(raw: unknown): MemoryType {
|
|
353
|
+
return MEMORY_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project_fact";
|
|
354
|
+
}
|
|
355
|
+
|
|
232
356
|
// Raw JSON Schema input is not coerced for us: a model sending "3" or null for
|
|
233
357
|
// limit would otherwise reach Postgres as LIMIT NaN.
|
|
234
358
|
function resolveLimit(raw: unknown): number {
|
|
@@ -257,11 +381,18 @@ async function recall(
|
|
|
257
381
|
): Promise<string> {
|
|
258
382
|
try {
|
|
259
383
|
const limit = resolveLimit(args.limit);
|
|
384
|
+
// scope: "user" addresses the shared user scope; global: true already
|
|
385
|
+
// covers user rows since it drops the project filter entirely.
|
|
386
|
+
if (args.scope === "user" && !userScope) {
|
|
387
|
+
return "ERROR: user scope is disabled (set OCPG_USER_ID to enable it).";
|
|
388
|
+
}
|
|
260
389
|
const projectCond = args.global
|
|
261
390
|
? sql``
|
|
262
|
-
:
|
|
391
|
+
: args.scope === "user"
|
|
392
|
+
? sql`AND project = ${userScope}`
|
|
393
|
+
: sql`AND project = ${ctx.directory}`;
|
|
263
394
|
const queryCond = args.query
|
|
264
|
-
? sql`AND search_vector @@
|
|
395
|
+
? sql`AND search_vector @@ to_tsquery('english', ${orTsQuery(args.query)})`
|
|
265
396
|
: sql``;
|
|
266
397
|
// Tags are not part of search_vector (it covers content only), so they are
|
|
267
398
|
// unreachable by query alone. Matches rows carrying ALL the given tags,
|
|
@@ -270,20 +401,34 @@ async function recall(
|
|
|
270
401
|
const tagCond = tagList.length
|
|
271
402
|
? sql`AND tags @> ${sql.array(tagList, "text")}`
|
|
272
403
|
: sql``;
|
|
273
|
-
// Relevance-ranked when searching;
|
|
404
|
+
// Relevance-ranked when searching; undirected browse uses a recency×
|
|
405
|
+
// frequency blend. The gravity form keeps zero-access rows ordered by pure
|
|
406
|
+
// recency (fresh installs have access_count = 0 everywhere) and lets access
|
|
407
|
+
// bumps resurface used memories without letting a single old favorite pin
|
|
408
|
+
// the top slot forever.
|
|
274
409
|
const orderBy = args.query
|
|
275
|
-
? sql`ORDER BY ts_rank(search_vector,
|
|
276
|
-
: sql`ORDER BY created_at DESC`;
|
|
410
|
+
? sql`ORDER BY ts_rank(search_vector, to_tsquery('english', ${orTsQuery(args.query)})) DESC`
|
|
411
|
+
: sql`ORDER BY (1 + access_count) / (GREATEST(EXTRACT(EPOCH FROM (now() - created_at)) / 86400, 0) + 2) DESC, created_at DESC`;
|
|
277
412
|
|
|
278
413
|
const rows = await sql`
|
|
279
414
|
SELECT id, content, coalesce(tags, '{}') AS tags,
|
|
280
415
|
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
281
|
-
project
|
|
416
|
+
project, memory_type
|
|
282
417
|
FROM memories
|
|
283
418
|
WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
|
|
284
419
|
${orderBy}
|
|
285
420
|
LIMIT ${limit}
|
|
286
|
-
` as MemoryRow[];
|
|
421
|
+
` as (MemoryRow & { memory_type: string })[];
|
|
422
|
+
|
|
423
|
+
// Access ranking is recall-only (plan 2.2): the injection path stays
|
|
424
|
+
// read-only because its per-directory cache would make increments biased.
|
|
425
|
+
// Fire-and-forget so the UPDATE never sits on the read path's latency.
|
|
426
|
+
const ids = rows.map((r) => r.id);
|
|
427
|
+
if (ids.length > 0) {
|
|
428
|
+
void sql`UPDATE memories SET access_count = access_count + 1, last_accessed_at = now() WHERE id = ANY(${sql.array(ids, "int8")})`.catch(
|
|
429
|
+
(e: unknown) => logError("access", `ocpg access bump failed: ${e instanceof Error ? e.message : String(e)}`),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
287
432
|
|
|
288
433
|
if (rows.length === 0) return "No memories found.";
|
|
289
434
|
|
|
@@ -291,7 +436,10 @@ async function recall(
|
|
|
291
436
|
.map((r) => {
|
|
292
437
|
const tags = r.tags ?? [];
|
|
293
438
|
const tagStr = tags.length ? ` (${tags.join(', ')})` : '';
|
|
294
|
-
|
|
439
|
+
// preference/episodic are worth surfacing; project_fact is the default
|
|
440
|
+
// every pre-column row carries, so printing it is pure noise.
|
|
441
|
+
const typeStr = r.memory_type === "project_fact" ? "" : ` [${r.memory_type}]`;
|
|
442
|
+
return `[${r.date}] [${r.project}]${typeStr}${tagStr}\n#${r.id}\n${r.content}`;
|
|
295
443
|
})
|
|
296
444
|
.join('\n---\n');
|
|
297
445
|
} catch (e: unknown) {
|
|
@@ -318,6 +466,9 @@ function validateWrite(args: RememberArgs): string | null {
|
|
|
318
466
|
if (oversized !== undefined) {
|
|
319
467
|
return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
|
|
320
468
|
}
|
|
469
|
+
if (args.type !== undefined && !MEMORY_TYPES.includes(args.type)) {
|
|
470
|
+
return `ERROR: type must be one of ${MEMORY_TYPES.join(", ")}.`;
|
|
471
|
+
}
|
|
321
472
|
return null;
|
|
322
473
|
}
|
|
323
474
|
|
|
@@ -338,43 +489,103 @@ async function remember(
|
|
|
338
489
|
|
|
339
490
|
// Tags are stored verbatim - project scoping lives in the project column, not tags.
|
|
340
491
|
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;
|
|
341
500
|
const basename = ctx.directory.split('/').pop() ?? ctx.directory;
|
|
342
501
|
|
|
343
502
|
if (!args.force) {
|
|
344
|
-
// Dedup:
|
|
503
|
+
// Dedup: target-scoped trigram similarity over the whole content. No
|
|
345
504
|
// trigram index - the project filter narrows to a few hundred rows, which
|
|
346
505
|
// similarity() scans in single-digit milliseconds.
|
|
347
506
|
const dedup = await sql`
|
|
348
507
|
SELECT id, round(similarity(content, ${args.content})::numeric, 2) AS score
|
|
349
508
|
FROM memories
|
|
350
|
-
WHERE project = ${
|
|
509
|
+
WHERE project = ${target}
|
|
351
510
|
AND (content = ${args.content} OR similarity(content, ${args.content}) >= ${DEDUP_SIMILARITY})
|
|
352
511
|
ORDER BY similarity(content, ${args.content}) DESC
|
|
353
512
|
LIMIT 1
|
|
354
513
|
` as { id: number; score: string }[];
|
|
355
514
|
|
|
356
515
|
if (dedup.length > 0) {
|
|
357
|
-
|
|
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.`;
|
|
358
518
|
}
|
|
359
519
|
}
|
|
360
520
|
|
|
361
521
|
// sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
|
|
362
522
|
// the element type hint is required for clean array storage.
|
|
363
523
|
const inserted = await sql`
|
|
364
|
-
INSERT INTO memories (content, tags, session_id, project)
|
|
365
|
-
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${
|
|
524
|
+
INSERT INTO memories (content, tags, session_id, project, memory_type)
|
|
525
|
+
VALUES (${args.content}, ${sql.array(tags, "text")}, ${ctx.sessionID}, ${target}, ${resolveMemoryType(args.type)})
|
|
366
526
|
RETURNING id
|
|
367
527
|
` as { id: number }[];
|
|
368
528
|
|
|
529
|
+
// The injection cache is keyed by directory and user rows are injected into
|
|
530
|
+
// it, so a user-scope write invalidates the calling project's entry too.
|
|
369
531
|
invalidateInjection(ctx.directory);
|
|
370
|
-
return
|
|
532
|
+
return scope === "user"
|
|
533
|
+
? `Stored memory #${inserted[0].id} for user scope (shared across projects).`
|
|
534
|
+
: `Stored memory #${inserted[0].id} for project ${basename}.`;
|
|
371
535
|
} catch (e: unknown) {
|
|
372
536
|
return toolError("remember", "remember", e);
|
|
373
537
|
}
|
|
374
538
|
}
|
|
375
539
|
|
|
540
|
+
// --- Keyword capture (plan 1.1, revised) ---
|
|
541
|
+
|
|
542
|
+
// Deterministic capture, no LLM: a trigger phrase in the prompt stores the text
|
|
543
|
+
// following it verbatim (minus the trigger) through the normal write path.
|
|
544
|
+
// Extracting "relevant content" instead would be a model judgment on the
|
|
545
|
+
// prompt-admission path - nondeterministic, and it violates the project rule
|
|
546
|
+
// that model judgment never becomes load-bearing (memory #1555).
|
|
547
|
+
const MEMORY_TRIGGER_RE =
|
|
548
|
+
/\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;
|
|
549
|
+
|
|
550
|
+
// Interrogative follow-ons are questions about the past ("remember when the
|
|
551
|
+
// pool broke?"), not storage requests. The list is deliberately narrow:
|
|
552
|
+
// "remember that when X happens, do Y" is imperative and must be captured, so
|
|
553
|
+
// "when" alone is not enough - only skip the bare question forms.
|
|
554
|
+
const INTERROGATIVE_RE = /^(?:when|what|where|why|how|who|whom|whose|which|did)\b/i;
|
|
555
|
+
|
|
556
|
+
function extractMemoryRequest(text: string): string | null {
|
|
557
|
+
const match = MEMORY_TRIGGER_RE.exec(text);
|
|
558
|
+
if (!match) return null;
|
|
559
|
+
const rest = text.slice(match.index + match[0].length).trim();
|
|
560
|
+
if (!rest || INTERROGATIVE_RE.test(rest)) return null;
|
|
561
|
+
return rest;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Fire-and-forget by design: prompt admission must not wait on a database
|
|
565
|
+
// write, and the prompt itself is never mutated on failure.
|
|
566
|
+
//
|
|
567
|
+
// Not exactly-once: the docs allow prompt hooks to run more than once under
|
|
568
|
+
// concurrent submissions. Dedup-on-write (trigram similarity) is the guard -
|
|
569
|
+
// no hook-side deduplication layer on top of it.
|
|
570
|
+
async function captureFromPrompt(
|
|
571
|
+
text: string,
|
|
572
|
+
directory: string,
|
|
573
|
+
sessionID: string,
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
const content = extractMemoryRequest(text);
|
|
576
|
+
if (!content) return;
|
|
577
|
+
try {
|
|
578
|
+
await remember({ content, tags: ["user-requested"] }, { directory, sessionID });
|
|
579
|
+
} catch (e: unknown) {
|
|
580
|
+
logError("capture", `ocpg keyword capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
376
584
|
// Project-scoped by construction: an agent can only delete what its own project
|
|
377
585
|
// can recall, so a poisoned id from another project silently matches nothing.
|
|
586
|
+
// When user scope is enabled the boundary deliberately widens to include the
|
|
587
|
+
// shared user sentinel - user memories are visible to every project of the
|
|
588
|
+
// user, so any of them can delete them. Documented in the tool description.
|
|
378
589
|
async function forget(
|
|
379
590
|
args: ForgetArgs,
|
|
380
591
|
ctx: { directory: string },
|
|
@@ -384,9 +595,12 @@ async function forget(
|
|
|
384
595
|
if (!Number.isInteger(id) || id <= 0) {
|
|
385
596
|
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
386
597
|
}
|
|
598
|
+
const projectCond = userScope
|
|
599
|
+
? sql`project IN (${ctx.directory}, ${userScope})`
|
|
600
|
+
: sql`project = ${ctx.directory}`;
|
|
387
601
|
const deleted = await sql`
|
|
388
602
|
DELETE FROM memories
|
|
389
|
-
WHERE id = ${id} AND
|
|
603
|
+
WHERE id = ${id} AND ${projectCond}
|
|
390
604
|
RETURNING id
|
|
391
605
|
` as { id: number }[];
|
|
392
606
|
|
|
@@ -400,8 +614,61 @@ async function forget(
|
|
|
400
614
|
}
|
|
401
615
|
}
|
|
402
616
|
|
|
617
|
+
// No dedup fall-through, by design: an update that lands close to another
|
|
618
|
+
// memory is an intentional correction, not a dupe to reject. updated_at is
|
|
619
|
+
// set; created_at is deliberately NOT bumped - the displayed date must keep
|
|
620
|
+
// saying when the memory was learned, not when it was last edited. Omitted
|
|
621
|
+
// tags/type are preserved, not reset to their defaults.
|
|
622
|
+
async function updateMemory(
|
|
623
|
+
args: UpdateArgs,
|
|
624
|
+
ctx: { directory: string },
|
|
625
|
+
): Promise<string> {
|
|
626
|
+
try {
|
|
627
|
+
const id = Number(args.id);
|
|
628
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
629
|
+
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
630
|
+
}
|
|
631
|
+
const invalid = validateWrite(args);
|
|
632
|
+
if (invalid) return invalid;
|
|
633
|
+
|
|
634
|
+
const projectCond = userScope
|
|
635
|
+
? sql`project IN (${ctx.directory}, ${userScope})`
|
|
636
|
+
: sql`project = ${ctx.directory}`;
|
|
637
|
+
const tags = args.tags ?? [];
|
|
638
|
+
const tagCond = Array.isArray(args.tags)
|
|
639
|
+
? sql`tags = ${sql.array(tags, "text")},`
|
|
640
|
+
: sql``;
|
|
641
|
+
const typeCond = args.type !== undefined
|
|
642
|
+
? sql`memory_type = ${resolveMemoryType(args.type)},`
|
|
643
|
+
: sql``;
|
|
644
|
+
const updated = await sql`
|
|
645
|
+
UPDATE memories
|
|
646
|
+
SET content = ${args.content},
|
|
647
|
+
${tagCond}
|
|
648
|
+
${typeCond}
|
|
649
|
+
updated_at = now()
|
|
650
|
+
WHERE id = ${id} AND ${projectCond}
|
|
651
|
+
RETURNING id
|
|
652
|
+
` as { id: number }[];
|
|
653
|
+
|
|
654
|
+
if (updated.length === 0) {
|
|
655
|
+
return `No memory #${id} in this project; nothing updated.`;
|
|
656
|
+
}
|
|
657
|
+
invalidateInjection(ctx.directory);
|
|
658
|
+
return `Updated memory #${id}.`;
|
|
659
|
+
} catch (e: unknown) {
|
|
660
|
+
return toolError("update", "update", e);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Clears every cache entry for the directory - relevance mode keys by
|
|
665
|
+
// directory + prompt hash, so a write invalidates them all.
|
|
403
666
|
function invalidateInjection(directory: string): void {
|
|
404
|
-
injectionCache.
|
|
667
|
+
for (const key of [...injectionCache.keys()]) {
|
|
668
|
+
if (key === directory || key.startsWith(`${directory}\u0001`)) {
|
|
669
|
+
injectionCache.delete(key);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
405
672
|
}
|
|
406
673
|
|
|
407
674
|
// V2 entrypoint: registers the system-context injection hook and the agent tools
|
|
@@ -419,18 +686,31 @@ const ocpg = Plugin.define({
|
|
|
419
686
|
void sql`SELECT 1`.catch(() => {});
|
|
420
687
|
|
|
421
688
|
// Inject project memories into every model request's system context.
|
|
422
|
-
//
|
|
689
|
+
// Relevance mode derives the retrieval query from the latest user message;
|
|
690
|
+
// handleTransform owns the cache (32-slot, keyed by directory + prompt).
|
|
423
691
|
await ctx.session.hook("context", async (event) => {
|
|
424
692
|
const output: { system: string[] } = { system: [] };
|
|
425
|
-
await handleTransform(output, directory);
|
|
693
|
+
await handleTransform(output, directory, extractPromptQuery(event.messages));
|
|
426
694
|
for (const text of output.system) event.system.push({ type: "text", text });
|
|
427
695
|
});
|
|
428
696
|
|
|
697
|
+
// Keyword capture (plan 1.1 revised): a trigger phrase ("remember this,
|
|
698
|
+
// ...") stores the following text verbatim through the normal write path -
|
|
699
|
+
// same validateWrite, same trigram dedup. No LLM call, and the prompt
|
|
700
|
+
// itself is never mutated.
|
|
701
|
+
await ctx.session.hook("prompt", (event) => {
|
|
702
|
+
void captureFromPrompt(event.prompt.text, directory, event.sessionID);
|
|
703
|
+
});
|
|
704
|
+
|
|
429
705
|
// Agent tools: recall + remember with dedup-on-write. Input schemas are raw
|
|
430
706
|
// JSON Schema (V2 contract); sizes are enforced in remember().
|
|
431
707
|
await ctx.tool.transform((editor) => {
|
|
432
708
|
editor.add({
|
|
433
709
|
name: "memory_recall",
|
|
710
|
+
// Direct (non-codemode) tool: memory ops are single-shot calls, not
|
|
711
|
+
// scriptable sequences - hiding them behind the execute sandbox only
|
|
712
|
+
// breaks direct invocation without adding value.
|
|
713
|
+
options: { codemode: false },
|
|
434
714
|
description:
|
|
435
715
|
"Search past memories stored for this project. Use before non-trivial work to check for relevant lessons, fixes, and decisions.",
|
|
436
716
|
input: {
|
|
@@ -440,9 +720,16 @@ const ocpg = Plugin.define({
|
|
|
440
720
|
tags: {
|
|
441
721
|
type: "array",
|
|
442
722
|
items: { type: "string" },
|
|
443
|
-
description:
|
|
723
|
+
description:
|
|
724
|
+
"Only return memories carrying all of these tags. Tags are not full-text " +
|
|
725
|
+
"searchable (search covers content only), so this filter is the only way to reach them.",
|
|
444
726
|
},
|
|
445
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
|
+
},
|
|
446
733
|
limit: { type: "number", description: "1-20, default 5" },
|
|
447
734
|
},
|
|
448
735
|
additionalProperties: false,
|
|
@@ -453,6 +740,7 @@ const ocpg = Plugin.define({
|
|
|
453
740
|
});
|
|
454
741
|
editor.add({
|
|
455
742
|
name: "memory_remember",
|
|
743
|
+
options: { codemode: false },
|
|
456
744
|
// This description is the only place the write policy is guaranteed to
|
|
457
745
|
// reach the model: it is in the tool schema every session, whereas the
|
|
458
746
|
// injected block is skipped entirely for projects with no memories and
|
|
@@ -468,12 +756,19 @@ const ocpg = Plugin.define({
|
|
|
468
756
|
type: "string",
|
|
469
757
|
description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
470
758
|
},
|
|
759
|
+
type: {
|
|
760
|
+
type: "string",
|
|
761
|
+
enum: [...MEMORY_TYPES],
|
|
762
|
+
description:
|
|
763
|
+
"preference = a standing user preference (these are injected first); " +
|
|
764
|
+
"project_fact (default) = decisions, fixes, env facts. Omit unless the memory is a preference.",
|
|
765
|
+
},
|
|
471
766
|
tags: {
|
|
472
767
|
type: "array",
|
|
473
768
|
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
474
769
|
maxItems: MAX_TAGS,
|
|
475
770
|
description:
|
|
476
|
-
"
|
|
771
|
+
"Fine-grained facets: decision, debug, env, architecture, workaround, " +
|
|
477
772
|
"language:<x>, framework:<x>, tool:<x>. Project scoping is automatic (a project " +
|
|
478
773
|
"column, not a tag) - never add project:<name>.",
|
|
479
774
|
},
|
|
@@ -482,6 +777,12 @@ const ocpg = Plugin.define({
|
|
|
482
777
|
description:
|
|
483
778
|
"Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
|
|
484
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',
|
|
785
|
+
},
|
|
485
786
|
},
|
|
486
787
|
required: ["content"],
|
|
487
788
|
additionalProperties: false,
|
|
@@ -492,8 +793,10 @@ const ocpg = Plugin.define({
|
|
|
492
793
|
});
|
|
493
794
|
editor.add({
|
|
494
795
|
name: "memory_forget",
|
|
796
|
+
options: { codemode: false },
|
|
495
797
|
description:
|
|
496
|
-
"Delete a memory
|
|
798
|
+
"Delete a memory by id (get ids from memory_recall). Use for memories that are wrong or obsolete; prefer storing a corrected memory when the old one is still useful history. " +
|
|
799
|
+
"Deletes match this project's memories and, when user scope is enabled, your shared user memories too - those are visible to all your projects by design, so any of them can delete them.",
|
|
497
800
|
input: {
|
|
498
801
|
type: "object",
|
|
499
802
|
properties: {
|
|
@@ -506,6 +809,39 @@ const ocpg = Plugin.define({
|
|
|
506
809
|
return { content: await forget(input as ForgetArgs, { directory }) };
|
|
507
810
|
},
|
|
508
811
|
});
|
|
812
|
+
editor.add({
|
|
813
|
+
name: "memory_update",
|
|
814
|
+
options: { codemode: false },
|
|
815
|
+
description:
|
|
816
|
+
"Rewrite an existing memory by id (get ids from memory_recall). Use when a memory is outdated but still worth keeping: the corrected content replaces the old, keeping the original learned date. " +
|
|
817
|
+
"Omitted tags/type are kept as-is. For obsolete memories use memory_forget; for genuinely new memories use memory_remember.",
|
|
818
|
+
input: {
|
|
819
|
+
type: "object",
|
|
820
|
+
properties: {
|
|
821
|
+
id: { type: "number", description: "The #id shown by memory_recall" },
|
|
822
|
+
content: {
|
|
823
|
+
type: "string",
|
|
824
|
+
description: `1-3 self-contained sentences replacing the old content (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
825
|
+
},
|
|
826
|
+
tags: {
|
|
827
|
+
type: "array",
|
|
828
|
+
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
829
|
+
maxItems: MAX_TAGS,
|
|
830
|
+
description: "Replaces the tag list; omit to keep the current tags",
|
|
831
|
+
},
|
|
832
|
+
type: {
|
|
833
|
+
type: "string",
|
|
834
|
+
enum: [...MEMORY_TYPES],
|
|
835
|
+
description: "Replaces the memory type; omit to keep the current type",
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
required: ["id", "content"],
|
|
839
|
+
additionalProperties: false,
|
|
840
|
+
},
|
|
841
|
+
execute: async (input) => {
|
|
842
|
+
return { content: await updateMemory(input as UpdateArgs, { directory }) };
|
|
843
|
+
},
|
|
844
|
+
});
|
|
509
845
|
});
|
|
510
846
|
|
|
511
847
|
// Close the SQL pool when the last plugin instance unloads.
|
|
@@ -525,14 +861,36 @@ const __internals = {
|
|
|
525
861
|
recall,
|
|
526
862
|
remember,
|
|
527
863
|
forget,
|
|
864
|
+
updateMemory,
|
|
865
|
+
extractMemoryRequest,
|
|
866
|
+
captureFromPrompt,
|
|
528
867
|
invalidateInjection,
|
|
529
868
|
resolveSslMode,
|
|
530
869
|
resolveLimit,
|
|
870
|
+
resolveMemoryType,
|
|
531
871
|
validateWrite,
|
|
532
872
|
logError,
|
|
873
|
+
resolveUserScope,
|
|
533
874
|
toolError,
|
|
534
875
|
rateLimitOk,
|
|
535
876
|
resetRateLimit,
|
|
877
|
+
get injectionMode() {
|
|
878
|
+
return injectionMode;
|
|
879
|
+
},
|
|
880
|
+
setInjectionMode(mode: "relevance" | "recency") {
|
|
881
|
+
injectionMode = mode;
|
|
882
|
+
},
|
|
883
|
+
extractPromptQuery,
|
|
884
|
+
hashQuery,
|
|
885
|
+
orTsQuery,
|
|
886
|
+
buildRecencyQuery,
|
|
887
|
+
buildRelevanceQuery,
|
|
888
|
+
get userScope() {
|
|
889
|
+
return userScope;
|
|
890
|
+
},
|
|
891
|
+
setUserScope(raw: string | undefined | null) {
|
|
892
|
+
userScope = resolveUserScope(raw ?? undefined);
|
|
893
|
+
},
|
|
536
894
|
retain,
|
|
537
895
|
dispose,
|
|
538
896
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhi/ocpg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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"
|