@dzhi/ocpg 0.8.0 → 0.11.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 +47 -7
- package/ocpg.ts +280 -58
- package/package.json +12 -1
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 existing `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`). Injects recent project memories into the system prompt and exposes `memory_recall` / `memory_remember` tools.
|
|
6
|
+
Uses the existing `memories` table (`content`, `tags`, `session_id`, `project`, `created_at`, `search_vector`) and the `pg_trgm` extension. Injects recent project memories into the system prompt and exposes `memory_recall` / `memory_remember` / `memory_forget` tools.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -19,9 +19,9 @@ In `opencode.json`:
|
|
|
19
19
|
|
|
20
20
|
## Connecting to the database
|
|
21
21
|
|
|
22
|
-
Need a Postgres instance? The [`deploy/`](./deploy) directory ships a hardened Docker Compose setup (localhost-only, `memories` schema auto-created on first boot)
|
|
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, e.g. in `~/.zshenv`. Any Postgres user and database name will do
|
|
24
|
+
Configure the connection via environment variables, e.g. in `~/.zshenv`. Any Postgres user and database name will do - use whatever names fit your setup and mirror them here:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
export OCPG_HOST="localhost"
|
|
@@ -29,6 +29,7 @@ export OCPG_PORT="5432"
|
|
|
29
29
|
export OCPG_USER="ocpguser"
|
|
30
30
|
export OCPG_PASSWORD="your-postgres-password"
|
|
31
31
|
export OCPG_DB="ocpg"
|
|
32
|
+
export OCPG_SSL="disable"
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
| Env var | Default |
|
|
@@ -37,12 +38,51 @@ export OCPG_DB="ocpg"
|
|
|
37
38
|
| `OCPG_PORT` | `5432` |
|
|
38
39
|
| `OCPG_USER` | `ocpguser` |
|
|
39
40
|
| `OCPG_DB` | `ocpg` |
|
|
41
|
+
| `OCPG_SSL` | `disable` |
|
|
40
42
|
|
|
41
|
-
**Password is env-only.** The plugin never reads config files, options, or external secret managers
|
|
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).
|
|
44
|
+
|
|
45
|
+
`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.
|
|
42
46
|
|
|
43
47
|
## Tools
|
|
44
48
|
|
|
45
|
-
- `memory_remember`
|
|
46
|
-
- `memory_recall`
|
|
49
|
+
- `memory_remember` - store a memory (`content`, `tags`, `force`); rejects near-duplicates within the project
|
|
50
|
+
- `memory_recall` - search past memories (`query`, `tags`, `global`, `limit`)
|
|
51
|
+
- `memory_forget` - delete a memory of the current project by id
|
|
52
|
+
|
|
53
|
+
Memories are project-scoped by working directory; use `global: true` on recall to search across projects. `tags` on recall matches rows carrying *all* the given tags — tags aren't covered by full-text search, so this is the only way to reach them.
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
57
|
+
`memory_forget` only ever deletes within the calling project, so an id from another project matches nothing.
|
|
58
|
+
|
|
59
|
+
### Duplicate detection
|
|
60
|
+
|
|
61
|
+
`memory_remember` rejects a new memory when the project already holds one with trigram similarity ≥ 0.8, reporting the score and the existing id. Pass `force: true` to store it anyway.
|
|
62
|
+
|
|
63
|
+
This needs the `pg_trgm` extension. Fresh installs from [`deploy/`](./deploy) get it automatically; on an existing database run once:
|
|
64
|
+
|
|
65
|
+
```sql
|
|
66
|
+
CREATE EXTENSION pg_trgm;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Without it, `memory_remember` returns an error naming this exact fix.
|
|
70
|
+
|
|
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
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bun install
|
|
77
|
+
bun run check # biome lint
|
|
78
|
+
bun run typecheck # tsc --noEmit
|
|
79
|
+
bun test # integration suite, needs a live Postgres with pg_trgm
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Enable the commit hooks once per clone ([pre-commit](https://pre-commit.com)):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pre-commit install
|
|
86
|
+
```
|
|
47
87
|
|
|
48
|
-
|
|
88
|
+
It runs lint and typecheck on commits that touch `.ts` files. `bun test` is left out of the hook because it writes to a real database — CI runs it against a throwaway Postgres service container instead.
|
package/ocpg.ts
CHANGED
|
@@ -3,21 +3,36 @@ import { SQL } from "bun";
|
|
|
3
3
|
import { Plugin } from "@opencode/plugin";
|
|
4
4
|
|
|
5
5
|
// --- DB config: env > defaults. Plugin options are not read; password is deliberately env-only (never in config).
|
|
6
|
+
const SSL_MODES = ["disable", "prefer", "require", "verify-ca", "verify-full"] as const;
|
|
7
|
+
type SslMode = (typeof SSL_MODES)[number];
|
|
8
|
+
|
|
6
9
|
type DbConfig = {
|
|
7
10
|
host: string;
|
|
8
11
|
port: number;
|
|
9
12
|
user: string;
|
|
10
13
|
database: string;
|
|
14
|
+
ssl: SslMode;
|
|
11
15
|
};
|
|
12
|
-
type RecallArgs = { query?: string; global?: boolean; limit?: number };
|
|
13
|
-
type RememberArgs = { content: string; tags?: string[] };
|
|
16
|
+
type RecallArgs = { query?: string; global?: boolean; limit?: number; tags?: string[] };
|
|
17
|
+
type RememberArgs = { content: string; tags?: string[]; force?: boolean };
|
|
18
|
+
type ForgetArgs = { id: number };
|
|
19
|
+
|
|
20
|
+
// Defaults to "disable" so the common localhost setup is unchanged; set OCPG_SSL
|
|
21
|
+
// when the database is remote, otherwise the SCRAM handshake crosses the network
|
|
22
|
+
// in plaintext.
|
|
23
|
+
function resolveSslMode(raw: string | undefined): SslMode {
|
|
24
|
+
if (!raw) return "disable";
|
|
25
|
+
const mode = raw.toLowerCase() as SslMode;
|
|
26
|
+
return SSL_MODES.includes(mode) ? mode : "disable";
|
|
27
|
+
}
|
|
14
28
|
|
|
15
|
-
// Defaults resolved ONCE at module init
|
|
29
|
+
// Defaults resolved ONCE at module init - env-only, no process spawning.
|
|
16
30
|
const defaultConfig: DbConfig = {
|
|
17
31
|
host: process.env.OCPG_HOST || "localhost",
|
|
18
32
|
port: Number(process.env.OCPG_PORT) || 5432,
|
|
19
33
|
user: process.env.OCPG_USER || "ocpguser",
|
|
20
34
|
database: process.env.OCPG_DB || "ocpg",
|
|
35
|
+
ssl: resolveSslMode(process.env.OCPG_SSL),
|
|
21
36
|
};
|
|
22
37
|
const password = process.env.OCPG_PASSWORD || "";
|
|
23
38
|
|
|
@@ -30,11 +45,16 @@ function makeSql(cfg: DbConfig): SQL {
|
|
|
30
45
|
username: cfg.user,
|
|
31
46
|
password,
|
|
32
47
|
database: cfg.database,
|
|
48
|
+
ssl: cfg.ssl,
|
|
33
49
|
max: 2,
|
|
50
|
+
// A dead database must fail fast: this pool is queried from the session
|
|
51
|
+
// context hook, which sits in front of every model request.
|
|
52
|
+
connectionTimeout: 3,
|
|
53
|
+
idleTimeout: 30,
|
|
34
54
|
});
|
|
35
55
|
}
|
|
36
56
|
|
|
37
|
-
|
|
57
|
+
const sql = makeSql(defaultConfig);
|
|
38
58
|
|
|
39
59
|
export interface MemoryRow {
|
|
40
60
|
id: number;
|
|
@@ -44,6 +64,9 @@ export interface MemoryRow {
|
|
|
44
64
|
date: string;
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
// The injection query selects neither id nor project - they are never rendered.
|
|
68
|
+
type InjectionRow = Pick<MemoryRow, "content" | "tags" | "date">;
|
|
69
|
+
|
|
47
70
|
// --- Rate-limited error logging ---
|
|
48
71
|
|
|
49
72
|
const lastLogTime = new Map<string, number>();
|
|
@@ -62,19 +85,60 @@ function resetRateLimit(): void {
|
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
// V2 plugins have no client.app.log; console.error from plugin code lands in the server log.
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
// Rate limiting is per kind so a failing recall does not mute injection errors.
|
|
89
|
+
function logError(kind: string, message: string): void {
|
|
90
|
+
if (!rateLimitOk(kind)) return;
|
|
67
91
|
console.error(`[ocpg] ${message}`);
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
// --- Query deadline ---
|
|
95
|
+
|
|
96
|
+
class DeadlineError extends Error {
|
|
97
|
+
constructor(ms: number) {
|
|
98
|
+
super(`query exceeded ${ms}ms deadline`);
|
|
99
|
+
this.name = "DeadlineError";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Guards the injection query, which runs in front of every model request: a
|
|
104
|
+
// hung database must degrade to "no memories" rather than stall the turn.
|
|
105
|
+
//
|
|
106
|
+
// This races instead of cancelling. Bun documents query.cancel(), but on bun
|
|
107
|
+
// 1.4.2 it is a no-op for an in-flight Postgres query - verified against
|
|
108
|
+
// SELECT pg_sleep(5), which ran the full 5s under both .execute()+.cancel()
|
|
109
|
+
// and bare .cancel(). So the query is abandoned, not aborted: the caller is
|
|
110
|
+
// freed on time while the connection stays busy until the server finishes.
|
|
111
|
+
async function withDeadline<T>(query: PromiseLike<T>, ms: number): Promise<T> {
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
114
|
+
timer = setTimeout(() => reject(new DeadlineError(ms)), ms);
|
|
115
|
+
});
|
|
116
|
+
// An abandoned query that later rejects would otherwise surface as an
|
|
117
|
+
// unhandled rejection and take the process down.
|
|
118
|
+
Promise.resolve(query).catch(() => {});
|
|
119
|
+
try {
|
|
120
|
+
return await Promise.race([query, deadline]);
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
70
126
|
// --- Injection pipeline ---
|
|
71
127
|
|
|
72
128
|
function truncateMemory(content: string): string {
|
|
73
129
|
if (content.length <= 600) return content;
|
|
74
|
-
return content.slice(0, 600)
|
|
130
|
+
return `${content.slice(0, 600)}…[truncated]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Memory content is interpolated verbatim into the system prompt; a stored
|
|
134
|
+
// memory containing the closing tag would otherwise end the block early and
|
|
135
|
+
// have its remainder read as top-level instructions.
|
|
136
|
+
function sanitizeMemory(content: string): string {
|
|
137
|
+
return content.replaceAll("</persistent-project-memory>", "");
|
|
75
138
|
}
|
|
76
139
|
|
|
77
|
-
function formatBlock(rows:
|
|
140
|
+
function formatBlock(rows: InjectionRow[], projectDir: string): string {
|
|
141
|
+
if (rows.length === 0) return "";
|
|
78
142
|
const lines: string[] = [
|
|
79
143
|
"<persistent-project-memory>",
|
|
80
144
|
`Project: ${projectDir}`,
|
|
@@ -83,7 +147,7 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
|
|
|
83
147
|
for (const row of rows) {
|
|
84
148
|
const tags = row.tags ?? [];
|
|
85
149
|
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
86
|
-
lines.push(`- [${row.date}]${tagStr} ${truncateMemory(row.content)}`);
|
|
150
|
+
lines.push(`- [${row.date}]${tagStr} ${sanitizeMemory(truncateMemory(row.content))}`);
|
|
87
151
|
}
|
|
88
152
|
lines.push("");
|
|
89
153
|
lines.push(
|
|
@@ -93,62 +157,119 @@ function formatBlock(rows: MemoryRow[], projectDir: string): string {
|
|
|
93
157
|
return lines.join("\n");
|
|
94
158
|
}
|
|
95
159
|
|
|
160
|
+
// Keyed by project directory, not session: the query depends only on the
|
|
161
|
+
// directory, so every session in a project shares one entry and a remember in
|
|
162
|
+
// any session invalidates it for all of them. An empty string is cached for
|
|
163
|
+
// projects with no memories so they stop re-querying, and nothing is injected.
|
|
96
164
|
const injectionCache = new Map<string, string>();
|
|
97
165
|
|
|
166
|
+
// Session-independent by design: the block depends only on the project
|
|
167
|
+
// directory, so the hook passes nothing else.
|
|
98
168
|
async function handleTransform(
|
|
99
|
-
input: { sessionID?: string; model?: unknown },
|
|
100
169
|
output: { system: string[] },
|
|
101
170
|
directory: string,
|
|
102
171
|
): Promise<void> {
|
|
103
|
-
if (!
|
|
104
|
-
const
|
|
105
|
-
const cached = injectionCache.get(sid);
|
|
172
|
+
if (!directory) return;
|
|
173
|
+
const cached = injectionCache.get(directory);
|
|
106
174
|
if (cached !== undefined) {
|
|
107
|
-
output.system.push(cached);
|
|
175
|
+
if (cached) output.system.push(cached);
|
|
108
176
|
return;
|
|
109
177
|
}
|
|
110
178
|
try {
|
|
111
|
-
const rows = await
|
|
179
|
+
const rows = await withDeadline(
|
|
180
|
+
sql`
|
|
112
181
|
SELECT content, coalesce(tags, '{}') AS tags,
|
|
113
182
|
to_char(created_at, 'YYYY-MM-DD') AS date
|
|
114
183
|
FROM memories
|
|
115
184
|
WHERE project = ${directory}
|
|
116
185
|
ORDER BY created_at DESC
|
|
117
186
|
LIMIT 5
|
|
118
|
-
` as
|
|
187
|
+
` as unknown as PromiseLike<InjectionRow[]>,
|
|
188
|
+
1000,
|
|
189
|
+
);
|
|
119
190
|
const block = formatBlock(rows, directory);
|
|
120
191
|
// Evict oldest entry when cache exceeds 32
|
|
121
192
|
if (injectionCache.size >= 32) {
|
|
122
|
-
const firstKey = injectionCache.keys().next().value
|
|
123
|
-
injectionCache.delete(firstKey);
|
|
193
|
+
const firstKey = injectionCache.keys().next().value;
|
|
194
|
+
if (firstKey !== undefined) injectionCache.delete(firstKey);
|
|
124
195
|
}
|
|
125
|
-
injectionCache.set(
|
|
126
|
-
output.system.push(block);
|
|
196
|
+
injectionCache.set(directory, block);
|
|
197
|
+
if (block) output.system.push(block);
|
|
127
198
|
} catch (e: unknown) {
|
|
128
|
-
logError(`ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
199
|
+
logError("inject", `ocpg injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
129
200
|
}
|
|
130
201
|
}
|
|
131
202
|
|
|
132
203
|
// --- Dispose ---
|
|
133
204
|
|
|
205
|
+
// One opencode process evaluates this module once but runs setup() once per
|
|
206
|
+
// project location, so instances share the pool. Verified against opencode
|
|
207
|
+
// v2.0.3: four locations were live in a single pid with one module instance.
|
|
208
|
+
// Without the refcount, a config change in one project disposes that instance
|
|
209
|
+
// and closes the pool out from under every other live project.
|
|
210
|
+
let instances = 0;
|
|
211
|
+
|
|
212
|
+
function retain(): void {
|
|
213
|
+
instances++;
|
|
214
|
+
}
|
|
215
|
+
|
|
134
216
|
async function dispose(): Promise<void> {
|
|
217
|
+
if (instances > 0) instances--;
|
|
218
|
+
if (instances > 0) return;
|
|
135
219
|
await sql.close().catch(() => {});
|
|
136
220
|
}
|
|
137
221
|
|
|
138
222
|
// --- Agent tools: recall + remember with dedup-on-write ---
|
|
139
223
|
|
|
224
|
+
// Write caps: these are abuse guards, not style rules. Measured against a real
|
|
225
|
+
// 485-memory corpus (p95 content 517 chars, p95 5 tags, longest tag 26 chars),
|
|
226
|
+
// so ordinary memories never come close.
|
|
227
|
+
const MAX_CONTENT = 4000;
|
|
228
|
+
const MIN_CONTENT = 10;
|
|
229
|
+
const MAX_TAGS = 10;
|
|
230
|
+
const MAX_TAG_LENGTH = 64;
|
|
231
|
+
|
|
232
|
+
// Raw JSON Schema input is not coerced for us: a model sending "3" or null for
|
|
233
|
+
// limit would otherwise reach Postgres as LIMIT NaN.
|
|
234
|
+
function resolveLimit(raw: unknown): number {
|
|
235
|
+
const n = Number(raw);
|
|
236
|
+
if (!Number.isFinite(n)) return 5;
|
|
237
|
+
return Math.min(Math.max(Math.trunc(n), 1), 20);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The model sees a generic failure; the operator sees the real message in the
|
|
241
|
+
// server log. Raw driver errors carry host, user and schema details that should
|
|
242
|
+
// not end up in a transcript sent to the provider.
|
|
243
|
+
function toolError(kind: string, action: string, e: unknown): string {
|
|
244
|
+
logError(kind, `ocpg ${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
245
|
+
// 42883 = undefined_function. The only way to hit it here is a database
|
|
246
|
+
// without pg_trgm, which is worth naming: the fix is one statement and the
|
|
247
|
+
// generic message would send the operator hunting.
|
|
248
|
+
if (String((e as { errno?: unknown })?.errno) === "42883") {
|
|
249
|
+
return "ERROR: this database is missing the pg_trgm extension, which memory_remember needs for duplicate detection. Run: CREATE EXTENSION pg_trgm;";
|
|
250
|
+
}
|
|
251
|
+
return `ERROR: memory store unavailable (${action} failed; see opencode server log).`;
|
|
252
|
+
}
|
|
253
|
+
|
|
140
254
|
async function recall(
|
|
141
255
|
args: RecallArgs,
|
|
142
256
|
ctx: { directory: string },
|
|
143
257
|
): Promise<string> {
|
|
144
258
|
try {
|
|
145
|
-
const limit =
|
|
259
|
+
const limit = resolveLimit(args.limit);
|
|
146
260
|
const projectCond = args.global
|
|
147
261
|
? sql``
|
|
148
262
|
: sql`AND project = ${ctx.directory}`;
|
|
149
263
|
const queryCond = args.query
|
|
150
264
|
? sql`AND search_vector @@ websearch_to_tsquery('english', ${args.query})`
|
|
151
265
|
: sql``;
|
|
266
|
+
// Tags are not part of search_vector (it covers content only), so they are
|
|
267
|
+
// unreachable by query alone. Matches rows carrying ALL the given tags,
|
|
268
|
+
// served by idx_memories_tags.
|
|
269
|
+
const tagList = Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === "string" && t) : [];
|
|
270
|
+
const tagCond = tagList.length
|
|
271
|
+
? sql`AND tags @> ${sql.array(tagList, "text")}`
|
|
272
|
+
: sql``;
|
|
152
273
|
// Relevance-ranked when searching; recency-ordered for a plain project browse.
|
|
153
274
|
const orderBy = args.query
|
|
154
275
|
? sql`ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', ${args.query})) DESC`
|
|
@@ -159,7 +280,7 @@ async function recall(
|
|
|
159
280
|
to_char(created_at, 'YYYY-MM-DD') AS date,
|
|
160
281
|
project
|
|
161
282
|
FROM memories
|
|
162
|
-
WHERE 1=1 ${projectCond} ${queryCond}
|
|
283
|
+
WHERE 1=1 ${projectCond} ${queryCond} ${tagCond}
|
|
163
284
|
${orderBy}
|
|
164
285
|
LIMIT ${limit}
|
|
165
286
|
` as MemoryRow[];
|
|
@@ -174,41 +295,67 @@ async function recall(
|
|
|
174
295
|
})
|
|
175
296
|
.join('\n---\n');
|
|
176
297
|
} catch (e: unknown) {
|
|
177
|
-
|
|
178
|
-
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
298
|
+
return toolError("recall", "recall", e);
|
|
179
299
|
}
|
|
180
300
|
}
|
|
181
301
|
|
|
302
|
+
// Rejects rather than truncates: a clipped memory loses its tail silently,
|
|
303
|
+
// while an error reports the actual size and lets the agent retry shorter.
|
|
304
|
+
function validateWrite(args: RememberArgs): string | null {
|
|
305
|
+
const content = typeof args.content === "string" ? args.content : "";
|
|
306
|
+
if (content.length < MIN_CONTENT) {
|
|
307
|
+
return `ERROR: content must be at least ${MIN_CONTENT} characters.`;
|
|
308
|
+
}
|
|
309
|
+
if (content.length > MAX_CONTENT) {
|
|
310
|
+
return `ERROR: content is ${content.length} characters, max ${MAX_CONTENT}; store the essentials in 1-3 sentences and retry.`;
|
|
311
|
+
}
|
|
312
|
+
const tags = args.tags ?? [];
|
|
313
|
+
if (!Array.isArray(tags)) return "ERROR: tags must be an array of strings.";
|
|
314
|
+
if (tags.length > MAX_TAGS) {
|
|
315
|
+
return `ERROR: ${tags.length} tags given, max ${MAX_TAGS}.`;
|
|
316
|
+
}
|
|
317
|
+
const oversized = tags.find((t) => typeof t !== "string" || t.length > MAX_TAG_LENGTH);
|
|
318
|
+
if (oversized !== undefined) {
|
|
319
|
+
return `ERROR: each tag must be a string of at most ${MAX_TAG_LENGTH} characters.`;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Trigram similarity threshold for dedup-on-write. Measured on a real
|
|
325
|
+
// 485-memory corpus: the previous rule (FTS on the first 60 characters) let 28
|
|
326
|
+
// pairs at >=0.8 similarity through because they differed in their opening
|
|
327
|
+
// words, while wrongly rejecting ~1% of genuinely distinct memories. 0.8 is
|
|
328
|
+
// strict enough that only restatements collide.
|
|
329
|
+
const DEDUP_SIMILARITY = 0.8;
|
|
330
|
+
|
|
182
331
|
async function remember(
|
|
183
332
|
args: RememberArgs,
|
|
184
333
|
ctx: { directory: string; sessionID: string },
|
|
185
334
|
): Promise<string> {
|
|
186
335
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
336
|
+
const invalid = validateWrite(args);
|
|
337
|
+
if (invalid) return invalid;
|
|
190
338
|
|
|
191
|
-
// Tags are stored verbatim
|
|
339
|
+
// Tags are stored verbatim - project scoping lives in the project column, not tags.
|
|
192
340
|
const tags = args.tags ?? [];
|
|
193
341
|
const basename = ctx.directory.split('/').pop() ?? ctx.directory;
|
|
194
342
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return `Similar memory already stored as #${dedup[0].id} for this project; skipping insert.`;
|
|
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
|
+
}
|
|
212
359
|
}
|
|
213
360
|
|
|
214
361
|
// sql.array(tags) alone encodes text[] with quoted elements under bun 1.4.2;
|
|
@@ -219,16 +366,42 @@ async function remember(
|
|
|
219
366
|
RETURNING id
|
|
220
367
|
` as { id: number }[];
|
|
221
368
|
|
|
222
|
-
invalidateInjection(ctx.
|
|
369
|
+
invalidateInjection(ctx.directory);
|
|
223
370
|
return `Stored memory #${inserted[0].id} for project ${basename}.`;
|
|
224
371
|
} catch (e: unknown) {
|
|
225
|
-
|
|
226
|
-
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
372
|
+
return toolError("remember", "remember", e);
|
|
227
373
|
}
|
|
228
374
|
}
|
|
229
375
|
|
|
230
|
-
|
|
231
|
-
|
|
376
|
+
// Project-scoped by construction: an agent can only delete what its own project
|
|
377
|
+
// can recall, so a poisoned id from another project silently matches nothing.
|
|
378
|
+
async function forget(
|
|
379
|
+
args: ForgetArgs,
|
|
380
|
+
ctx: { directory: string },
|
|
381
|
+
): Promise<string> {
|
|
382
|
+
try {
|
|
383
|
+
const id = Number(args.id);
|
|
384
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
385
|
+
return "ERROR: id must be a positive integer (the #id shown by memory_recall).";
|
|
386
|
+
}
|
|
387
|
+
const deleted = await sql`
|
|
388
|
+
DELETE FROM memories
|
|
389
|
+
WHERE id = ${id} AND project = ${ctx.directory}
|
|
390
|
+
RETURNING id
|
|
391
|
+
` as { id: number }[];
|
|
392
|
+
|
|
393
|
+
if (deleted.length === 0) {
|
|
394
|
+
return `No memory #${id} in this project; nothing deleted.`;
|
|
395
|
+
}
|
|
396
|
+
invalidateInjection(ctx.directory);
|
|
397
|
+
return `Deleted memory #${id}.`;
|
|
398
|
+
} catch (e: unknown) {
|
|
399
|
+
return toolError("forget", "forget", e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function invalidateInjection(directory: string): void {
|
|
404
|
+
injectionCache.delete(directory);
|
|
232
405
|
}
|
|
233
406
|
|
|
234
407
|
// V2 entrypoint: registers the system-context injection hook and the agent tools
|
|
@@ -239,17 +412,22 @@ const ocpg = Plugin.define({
|
|
|
239
412
|
id: "ocpg",
|
|
240
413
|
async setup(ctx) {
|
|
241
414
|
const directory = ctx.location.directory;
|
|
415
|
+
retain();
|
|
416
|
+
|
|
417
|
+
// Open the pool before the first turn needs it: Bun connects lazily, so
|
|
418
|
+
// otherwise the TCP + SCRAM handshake is paid inside the first context hook.
|
|
419
|
+
void sql`SELECT 1`.catch(() => {});
|
|
242
420
|
|
|
243
421
|
// Inject project memories into every model request's system context.
|
|
244
|
-
// handleTransform owns the per-
|
|
422
|
+
// handleTransform owns the per-directory cache (32-slot, invalidated on remember).
|
|
245
423
|
await ctx.session.hook("context", async (event) => {
|
|
246
424
|
const output: { system: string[] } = { system: [] };
|
|
247
|
-
await handleTransform(
|
|
425
|
+
await handleTransform(output, directory);
|
|
248
426
|
for (const text of output.system) event.system.push({ type: "text", text });
|
|
249
427
|
});
|
|
250
428
|
|
|
251
429
|
// Agent tools: recall + remember with dedup-on-write. Input schemas are raw
|
|
252
|
-
// JSON Schema (V2 contract);
|
|
430
|
+
// JSON Schema (V2 contract); sizes are enforced in remember().
|
|
253
431
|
await ctx.tool.transform((editor) => {
|
|
254
432
|
editor.add({
|
|
255
433
|
name: "memory_recall",
|
|
@@ -259,6 +437,11 @@ const ocpg = Plugin.define({
|
|
|
259
437
|
type: "object",
|
|
260
438
|
properties: {
|
|
261
439
|
query: { type: "string", description: "Full-text search string; omit for the latest memories" },
|
|
440
|
+
tags: {
|
|
441
|
+
type: "array",
|
|
442
|
+
items: { type: "string" },
|
|
443
|
+
description: "Only return memories carrying all of these tags",
|
|
444
|
+
},
|
|
262
445
|
global: { type: "boolean", description: "Search across all projects (default: current project only)" },
|
|
263
446
|
limit: { type: "number", description: "1-20, default 5" },
|
|
264
447
|
},
|
|
@@ -270,18 +453,33 @@ const ocpg = Plugin.define({
|
|
|
270
453
|
});
|
|
271
454
|
editor.add({
|
|
272
455
|
name: "memory_remember",
|
|
456
|
+
// This description is the only place the write policy is guaranteed to
|
|
457
|
+
// reach the model: it is in the tool schema every session, whereas the
|
|
458
|
+
// injected block is skipped entirely for projects with no memories and
|
|
459
|
+
// the user may have no project instructions at all.
|
|
273
460
|
description:
|
|
274
|
-
"Store a memory for this project. Use after user corrections
|
|
461
|
+
"Store a durable memory for this project. Use after user corrections (immediately), " +
|
|
462
|
+
"architecture decisions, non-trivial fixes, environment facts, and stated preferences. " +
|
|
463
|
+
"Do not store session progress, secrets, or anything the code itself already states.",
|
|
275
464
|
input: {
|
|
276
465
|
type: "object",
|
|
277
466
|
properties: {
|
|
278
|
-
content: {
|
|
467
|
+
content: {
|
|
468
|
+
type: "string",
|
|
469
|
+
description: `1-3 self-contained sentences capturing the why (${MIN_CONTENT}-${MAX_CONTENT} characters)`,
|
|
470
|
+
},
|
|
279
471
|
tags: {
|
|
280
472
|
type: "array",
|
|
281
|
-
items: { type: "string" },
|
|
473
|
+
items: { type: "string", maxLength: MAX_TAG_LENGTH },
|
|
474
|
+
maxItems: MAX_TAGS,
|
|
282
475
|
description:
|
|
283
476
|
"Category prefixes: preference, decision, debug, env, architecture, workaround, language:<x>, framework:<x>, tool:<x>",
|
|
284
477
|
},
|
|
478
|
+
force: {
|
|
479
|
+
type: "boolean",
|
|
480
|
+
description:
|
|
481
|
+
"Store even if a similar memory exists (use only after a dedup rejection you judge to be wrong)",
|
|
482
|
+
},
|
|
285
483
|
},
|
|
286
484
|
required: ["content"],
|
|
287
485
|
additionalProperties: false,
|
|
@@ -290,9 +488,25 @@ const ocpg = Plugin.define({
|
|
|
290
488
|
return { content: await remember(input as RememberArgs, { directory, sessionID: tool.sessionID }) };
|
|
291
489
|
},
|
|
292
490
|
});
|
|
491
|
+
editor.add({
|
|
492
|
+
name: "memory_forget",
|
|
493
|
+
description:
|
|
494
|
+
"Delete a memory of this project 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.",
|
|
495
|
+
input: {
|
|
496
|
+
type: "object",
|
|
497
|
+
properties: {
|
|
498
|
+
id: { type: "number", description: "The #id shown by memory_recall" },
|
|
499
|
+
},
|
|
500
|
+
required: ["id"],
|
|
501
|
+
additionalProperties: false,
|
|
502
|
+
},
|
|
503
|
+
execute: async (input) => {
|
|
504
|
+
return { content: await forget(input as ForgetArgs, { directory }) };
|
|
505
|
+
},
|
|
506
|
+
});
|
|
293
507
|
});
|
|
294
508
|
|
|
295
|
-
// Close the SQL pool when the plugin unloads.
|
|
509
|
+
// Close the SQL pool when the last plugin instance unloads.
|
|
296
510
|
return dispose;
|
|
297
511
|
},
|
|
298
512
|
});
|
|
@@ -302,14 +516,22 @@ const __internals = {
|
|
|
302
516
|
return sql;
|
|
303
517
|
},
|
|
304
518
|
truncateMemory,
|
|
519
|
+
sanitizeMemory,
|
|
520
|
+
withDeadline,
|
|
305
521
|
formatBlock,
|
|
306
522
|
handleTransform,
|
|
307
523
|
recall,
|
|
308
524
|
remember,
|
|
525
|
+
forget,
|
|
309
526
|
invalidateInjection,
|
|
527
|
+
resolveSslMode,
|
|
528
|
+
resolveLimit,
|
|
529
|
+
validateWrite,
|
|
310
530
|
logError,
|
|
531
|
+
toolError,
|
|
311
532
|
rateLimitOk,
|
|
312
533
|
resetRateLimit,
|
|
534
|
+
retain,
|
|
313
535
|
dispose,
|
|
314
536
|
};
|
|
315
537
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhi/ocpg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Postgres-backed persistent memory plugin for OpenCode",
|
|
6
6
|
"main": "ocpg.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"check": "biome check .",
|
|
9
|
+
"format": "biome format --write .",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
7
13
|
"exports": {
|
|
8
14
|
".": "./ocpg.ts"
|
|
9
15
|
},
|
|
@@ -23,5 +29,10 @@
|
|
|
23
29
|
"repository": {
|
|
24
30
|
"type": "git",
|
|
25
31
|
"url": "https://github.com/pentago/ocpg.git"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "^2.5.14",
|
|
35
|
+
"@types/bun": "^1.4.2",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
26
37
|
}
|
|
27
38
|
}
|