@log0u7/opencode-memory 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -5,7 +5,110 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.4.0] - 2026-10-14
9
+
10
+ ### Added
11
+
12
+ - Lesson feedback loop: the system guidance and the compaction prompt now nudge the agent
13
+ to save reusable lessons (`kind=lesson`), and lessons are injected first in later
14
+ sessions' inventory, ahead of recency noise (`store.list` gained an optional `kind`
15
+ filter).
16
+ - Optional LLM reflection at `session.idle`, off by default: with
17
+ `reflection.enabled: true` in the plugin options, the plugin distills at most
18
+ `maxLessons` durable lessons from the bounded session tail with one LLM call per
19
+ session and saves them as lessons. Options: `model` (`"provider/model"`), `maxLessons`
20
+ (3), `transcriptChars` (8000), `timeoutMs` (60000). Subagent sessions are skipped and the
21
+ run is best-effort: failures are silent.
22
+
23
+ - New `memory_stats` tool (both hosts): counts entries by kind for the project scope
24
+ (plus global) with a total, so the model can see how much memory exists, including
25
+ how many lessons reflection has written, without paging through `memory_list`.
26
+
27
+ ### Changed
28
+
29
+ - `pnpm verify` (and therefore CI) now runs the suite with coverage, so the coverage gate
30
+ the docs promise (90% lines/functions/statements, 80% branches) is actually enforced
31
+ instead of only configured.
32
+
33
+ ### Fixed
34
+
35
+ - The store database is created with restrictive permissions: the file is `0600`
36
+ (enforced on every open, regardless of the process umask, so databases created earlier
37
+ with a permissive umask are tightened too) and the directory is `0700` when the plugin
38
+ creates it, as `SECURITY.md` already promised.
39
+ - Secret detection now also refuses JWTs, PEM private key blocks, AWS access key ids,
40
+ GitLab personal access tokens, Google API keys, and Slack user/app tokens, in addition
41
+ to the existing OpenAI, GitHub, and Slack bot formats.
42
+ - `memory_forget` by id now deletes across scopes: ids are globally unique, so an exact
43
+ id match is precise anywhere, and closed worktrees stay cleanable from any project.
44
+ Query deletes keep the scope lock (a fuzzy match must never silently hit other
45
+ projects).
46
+ - Reflection no longer permanently skips a session whose first `session.idle` arrived
47
+ before the transcript reached the distillation floor: the session is marked reflected
48
+ only once a run is actually attempted, so a later, longer turn still gets exactly one
49
+ reflection.
50
+ - The README safety-limits note on `memory_forget` now matches the code: query deletes
51
+ stay in scope, id deletes are global.
52
+ - Reflection's LLM call is now bounded by a timeout (`reflection.timeoutMs`, default 60s):
53
+ a hung provider no longer wedges a session forever, and the abort reaches the SDK so the
54
+ v1 host still deletes its ephemeral child session.
55
+ - The v2 event stream now reconnects with a capped backoff after an error instead of
56
+ silently ending the reflection loop for good.
57
+ - After `dispose` (v1) or `cleanup` (v2), memory operations no longer reopen the SQLite
58
+ connection: a late tool call or in-flight reflection returns a readable error instead of
59
+ leaking a connection that nobody closes again.
60
+
61
+ ## [0.3.0] - 2026-10-14
62
+
63
+ ### Added
64
+
65
+ - Deterministic injection: the first request of each session carries a bounded list of the
66
+ project's most recent entries (titles, kinds, dates, no bodies, ~1200 characters worst
67
+ case), so using memory no longer depends on the model remembering to call
68
+ `memory_search`. The tool stays for deeper lookups.
69
+ - Compaction inventory: the compaction context now lists the stored entries next to the
70
+ save instruction, so the model summarises from an inventory instead of recall and stops
71
+ re-saving what is already stored.
72
+
73
+ ### Changed
74
+
75
+ - Both plugin entry points (`server` for OpenCode 1, `setup` for OpenCode 2) now delegate
76
+ to one shared core (`src/lib/memory-ops.ts`): tool behaviour, error-to-text mapping,
77
+ caps and rendering live in a single place, and each host is thin SDK glue. No
78
+ behavioural change.
79
+ - The system guidance no longer tells the model to search at task start (the injection
80
+ covers it); the credential warning and both tool mentions stay.
81
+ - CONTRIBUTING.md states the merge-policy rationale instead of a solo-maintainer
82
+ preference: `--no-ff` keeps one merge commit per PR, which reads as the PR list via
83
+ `git log --merges --first-parent main` and is a single atomic revert unit, with a
84
+ five-commit PR ceiling bounding the cost. Documentation only, no package change.
85
+ - `memory_save` rewrites an entry that repeats a scope and title instead of adding a
86
+ duplicate, which stopped the store from growing with every compaction.
87
+ - Saving purges rows past their `expires_at` instead of only hiding them at query time.
88
+
89
+ ### Fixed
90
+
91
+ - `memory_forget` by query or id could delete entries of other projects: deletes are now
92
+ scoped to the caller's scope (plus `global`), like `memory_search` and `memory_list`.
93
+ - A model-supplied `limit` crashed `memory_search`/`memory_list` (fractional, `NaN`) or
94
+ returned the whole store (`-1`, which SQLite reads as unbounded). Limits are clamped to
95
+ 1..50, and tool results are bounded: bodies render as 500-character previews.
96
+ - A credential in an entry `title` or `kind` bypassed the secret guard; the scan now covers
97
+ title, kind, body and tags, and `kind` has a length cap.
98
+ - Search returned nothing for partial queries (`"migration strategy"` found no entry
99
+ containing "migration", and `"migrat"` found nothing): a strict AND match is now retried
100
+ as a prefix OR match, and an empty query returns no results instead of failing.
101
+ - The two plugin entry points defaulted to different scopes for the same project (v1 used
102
+ the session worktree, v2 the plugin directory) and disagreed about trailing slashes.
103
+ Both now derive one canonical scope key.
104
+ - The published package could not be loaded by a Node host: `src/lib/sqlite.ts` called a
105
+ bare `require("node:sqlite")`, which is undefined in an ES module. The Node branch now
106
+ uses `createRequire(import.meta.url)`.
107
+
108
+ ### Removed
109
+
110
+ - README advertised an FTS5 "recency boost" that the code never implemented; the actual
111
+ ranking (BM25, newest first on ties) is documented instead.
9
112
 
10
113
  ## [0.2.0] - 2026-10-12
11
114
 
package/README.md CHANGED
@@ -8,7 +8,14 @@
8
8
 
9
9
  Working memory for [OpenCode](https://opencode.ai): a shared SQLite store that all sessions on a machine read and write, so decisions, facts, and compaction summaries survive session boundaries.
10
10
 
11
- Every session gets four tools (`memory_save`, `memory_search`, `memory_list`, `memory_forget`). Entries are scoped per project worktree (plus a `global` scope), searched with SQLite FTS5 (BM25 ranking with a recency boost). 100% local, no cloud, no API keys.
11
+ Every session gets five tools (`memory_save`, `memory_search`, `memory_list`, `memory_forget`, `memory_stats`). Entries are scoped per project worktree (plus a `global` scope), searched with SQLite FTS5 (BM25 ranking, strict match first with a prefix fallback). Recent entries are also injected into the prompt: once per session, and again during compaction. 100% local, no cloud, no API keys.
12
+
13
+ ## Safety limits
14
+
15
+ - Results are capped: at most 50 entries per call, and each body is rendered as a 500-character preview. A tool result has a bounded size whatever the model asks for.
16
+ - `memory_save` rewrites an entry that repeats a scope and title instead of stacking duplicates, and purges expired rows.
17
+ - `memory_forget` by query only deletes inside the caller's scope (plus the shared `global` scope); by id it deletes the entry wherever it lives, since ids are globally unique.
18
+ - Entries that look like secrets (`sk-…`, `ghp_…`, `xoxb-…`) are refused, in the title, kind, body and tags alike.
12
19
 
13
20
  ## Install
14
21
 
@@ -41,6 +48,7 @@ memory_save(kind="decision", title="Use WAL mode", body="...", tags=["db"])
41
48
  memory_search("postgres migration strategy")
42
49
  memory_list()
43
50
  memory_forget(id="...")
51
+ memory_stats()
44
52
  ```
45
53
 
46
54
  ### Tools
@@ -48,17 +56,40 @@ memory_forget(id="...")
48
56
  | Tool | Args | Description |
49
57
  |---|---|---|
50
58
  | `memory_save` | `kind`, `title`, `body`, `scope?`, `tags?`, `expires_at?` | Save an entry. Default scope is the session worktree. |
51
- | `memory_search` | `query`, `scope?`, `limit?` | Hybrid search: FTS5 BM25 + recency boost. |
59
+ | `memory_search` | `query`, `scope?`, `limit?` | FTS5 BM25 search; strict match first, prefix OR fallback when nothing matches. |
52
60
  | `memory_list` | `scope?`, `limit?` | Recent entries, newest first. |
53
61
  | `memory_forget` | `id` or `query` | Delete one entry by id, or all matching a query. |
62
+ | `memory_stats` | `scope?` | Count entries by kind (plus global) with a total; checks how much memory exists. |
63
+
64
+ Entry `kind` is free-form; conventions that work well: `decision`, `fact`, `preference`, `gotcha`, `lesson`, `compaction`.
65
+
66
+ ### Feedback loop
67
+
68
+ The agent is nudged to write memory itself: the system prompt asks for durable decisions, facts, preferences and gotchas as they emerge, and the compaction prompt repeats the instruction next to the store inventory. Reusable lessons (`kind=lesson`: a non-obvious approach that clearly failed or succeeded, and why) are injected first in later sessions, so known mistakes are not retried.
69
+
70
+ Optional LLM reflection, off by default: on `session.idle` the plugin sends the last `transcriptChars` of the session to one bounded LLM call and saves at most `maxLessons` distilled lessons through the same store (the secret guard applies). Subagent sessions are skipped and at most one reflection runs per session.
71
+
72
+ ```json
73
+ {
74
+ "plugin": [
75
+ ["@log0u7/opencode-memory", { "reflection": { "enabled": true } }]
76
+ ]
77
+ }
78
+ ```
79
+
80
+ Options: `enabled` (default `false`), `model` (`"provider/model"`, default: the host's model), `maxLessons` (default 3, cap 10), `transcriptChars` (default 8000).
54
81
 
55
- Entry `kind` is free-form; conventions that work well: `decision`, `fact`, `preference`, `gotcha`, `compaction`.
82
+ Cost per reflection: ~2k input tokens (the bounded transcript) and at most ~300 output tokens, at most once per session: roughly $0.001 with a small model, $0.01 with a frontier model (indicative). The nudges and injections are plain text: they cost nothing.
56
83
 
57
84
  ### Notes
58
85
 
59
86
  - All sessions on the machine share one store (WAL mode, concurrent-safe).
60
- - Entries that look like secrets (`sk-…`, `ghp_…`, `xoxb-…`) are refused.
61
- - On session compaction, the plugin asks the compaction prompt to emit durable decisions so the model can persist them.
87
+ - Entries that look like secrets (`sk-…`, `ghp_…`, `xoxb-…`) are refused, in the title, kind, body and tags.
88
+ - Every repeated `memory_save` of the same title in the same scope rewrites the entry, so the store does not grow with duplicates, and expired rows are purged on save.
89
+ - `memory_forget` by query never leaves the scope it is called from, so a project cannot fuzzily delete another project's entries; by id it deletes anywhere (ids are globally unique), which keeps closed worktrees cleanable.
90
+ - On the first request of a session, the most recent entries are injected into the system prompt as a bounded list (titles only), so the model does not have to remember to search. `memory_search` remains the tool for deeper lookups.
91
+ - On session compaction, the plugin lists the stored entries in the compaction context and asks the compaction prompt to emit durable decisions, so the model summarises from an inventory instead of recall.
92
+ - Injections stay bounded (~1200 characters worst case) and nothing is injected, nor any store file created, when memory is unused.
62
93
 
63
94
  ## Development
64
95
 
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAGA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAE1D,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,IAAI,IAAI,CAAC;CACf,CAAC;AA8BF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAWnD"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAGA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAE1D,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,IAAI,IAAI,CAAC;CACf,CAAC;AAsCF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CA2BnD"}
package/dist/lib/db.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { openSqlite } from "./sqlite.js";
4
4
  const SCHEMA = `
@@ -27,15 +27,38 @@ CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
27
27
  INSERT INTO entries_fts(entries_fts, rowid, title, body, tags) VALUES ('delete', old.rowid, old.title, old.body, old.tags);
28
28
  INSERT INTO entries_fts(rowid, title, body, tags) VALUES (new.rowid, new.title, new.body, new.tags);
29
29
  END;
30
+ CREATE INDEX IF NOT EXISTS entries_scope_title ON entries(scope, lower(trim(title)));
30
31
  `;
32
+ // SECURITY.md promises restrictive permissions whatever the user's umask is, so the
33
+ // directory and files are mode-enforced explicitly instead of left to the umask.
34
+ // POSIX only: chmodSync on Windows flips a read-only bit, which would corrupt usage.
35
+ // Directories the plugin did not create are left to their owner: the db file is
36
+ // 0600 anyway, so a loose parent only leaks file names.
37
+ const POSIX = process.platform !== "win32";
31
38
  export function openMemoryDb(path) {
32
39
  if (path !== ":memory:") {
33
- mkdirSync(dirname(path), { recursive: true });
40
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
41
  }
35
42
  const conn = openSqlite(path);
36
43
  conn.run("PRAGMA journal_mode = WAL");
37
44
  conn.run("PRAGMA busy_timeout = 5000");
38
45
  conn.exec(SCHEMA);
46
+ if (POSIX && path !== ":memory:") {
47
+ // Also fixes databases created earlier with a permissive umask.
48
+ chmodSync(path, 0o600);
49
+ for (const suffix of ["-wal", "-shm"]) {
50
+ try {
51
+ chmodSync(`${path}${suffix}`, 0o600);
52
+ }
53
+ catch (error) {
54
+ // Only tolerate a sidecar that is not written yet (or already
55
+ // checkpointed away); anything else is a real failure.
56
+ if (error.code !== "ENOENT") {
57
+ throw error;
58
+ }
59
+ }
60
+ }
61
+ }
39
62
  return { conn, close: () => conn.close() };
40
63
  }
41
64
  //# sourceMappingURL=db.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,UAAU,EAAmB,MAAM,aAAa,CAAC;AAO1D,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;CA0Bd,CAAC;AAEF,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;IACvC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAElB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;AAC7C,CAAC"}
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,UAAU,EAAmB,MAAM,aAAa,CAAC;AAO1D,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2Bd,CAAC;AAEF,oFAAoF;AACpF,iFAAiF;AACjF,qFAAqF;AACrF,gFAAgF;AAChF,wDAAwD;AACxD,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC;AAE3C,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7D,CAAC;IAED,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;IACvC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAElB,IAAI,KAAK,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACjC,gEAAgE;QAChE,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACvB,KAAK,MAAM,MAAM,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;YACtC,IAAI,CAAC;gBACH,SAAS,CAAC,GAAG,IAAI,GAAG,MAAM,EAAE,EAAE,KAAK,CAAC,CAAC;YACvC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,8DAA8D;gBAC9D,uDAAuD;gBACvD,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;oBACvD,MAAM,KAAK,CAAC;gBACd,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;AAC7C,CAAC"}
@@ -0,0 +1,58 @@
1
+ import type { MemoryDb } from "./db.js";
2
+ export type SaveArgs = {
3
+ kind: string;
4
+ title: string;
5
+ body: string;
6
+ tags?: string[] | undefined;
7
+ expires_at?: number | null | undefined;
8
+ };
9
+ export type SearchArgs = {
10
+ query: string;
11
+ limit?: number | undefined;
12
+ };
13
+ export type ListArgs = {
14
+ limit?: number | undefined;
15
+ };
16
+ export type ForgetArgs = {
17
+ id?: string | undefined;
18
+ query?: string | undefined;
19
+ };
20
+ /** One shared behaviour behind both plugin entry points. Every operation returns the
21
+ * model-facing text directly, so a caller cannot bypass caps or error handling. */
22
+ export type MemoryOps = {
23
+ save: (args: SaveArgs, scope: string) => string;
24
+ search: (args: SearchArgs, scope: string) => string;
25
+ list: (args: ListArgs, scope: string) => string;
26
+ forget: (args: ForgetArgs, scope: string) => string;
27
+ stats: (scope: string) => string;
28
+ };
29
+ export declare function createMemoryOps(getDb: () => MemoryDb): MemoryOps;
30
+ /** How much of the store is summarised into a prompt, worst case. */
31
+ export declare const INVENTORY_MAX_CHARS = 1200;
32
+ /** Tool descriptions shared by both entry points so the two hosts cannot drift. */
33
+ export declare const TOOL_DESCRIPTIONS: {
34
+ readonly save: "Save a durable entry to shared working memory (decisions, facts, preferences, gotchas, lessons). Never store credentials.";
35
+ readonly search: "Search shared working memory (all sessions on this machine). Results include this project's scope and global entries.";
36
+ readonly list: "List recent memory entries for a scope (default: this project), newest first.";
37
+ readonly forget: "Delete memory entries by id (any scope: ids are globally unique), or all entries matching a query. Query deletes stay inside this project's scope (plus global entries); an entry saved under another scope needs its id, or that scope, to be removed.";
38
+ readonly stats: "Count memory entries by kind for this project (plus global entries), with a total. Use it to see how much memory already exists, for example how many lessons reflection has written.";
39
+ };
40
+ /**
41
+ * Produces at most one injection per session key, and only on the first request where
42
+ * the producer has something: a session that starts before any entry exists still gets
43
+ * its inventory as soon as one is available. Keys are capped so a long-lived host
44
+ * cannot grow the set forever; an absent sessionID (v1 marks it optional) degrades to
45
+ * one injection per plugin instance, never per request.
46
+ */
47
+ export declare function oncePerSession(): {
48
+ inject: (key: string | undefined, produce: () => string | null) => string | null;
49
+ reset: () => void;
50
+ };
51
+ /**
52
+ * Bounded summary of recent entries, injected into the first request of a session and
53
+ * into the compaction context. Lessons are promoted above recency noise so a stored
54
+ * lesson survives a chatty store. `null` when there is nothing worth injecting, so a
55
+ * caller pushes nothing at all.
56
+ */
57
+ export declare function memoryInventory(getDb: () => MemoryDb, dbPath: string, scope: string): string | null;
58
+ //# sourceMappingURL=memory-ops.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-ops.d.ts","sourceRoot":"","sources":["../../src/lib/memory-ops.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACxC,CAAC;AACF,MAAM,MAAM,UAAU,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AACvE,MAAM,MAAM,QAAQ,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AACtD,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjF;mFACmF;AACnF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAChD,MAAM,EAAE,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpD,IAAI,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAChD,MAAM,EAAE,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC,CAAC;AAEF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,QAAQ,GAAG,SAAS,CAqDhE;AAED,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,OAAO,CAAC;AAIxC,mFAAmF;AACnF,eAAO,MAAM,iBAAiB;aAC5B,IAAI,EAAE,2HAA2H;aACjI,MAAM,EACJ,uHAAuH;aACzH,IAAI,EAAE,+EAA+E;aACrF,MAAM,EACJ,yPAAyP;aAC3P,KAAK,EACH,uLAAuL;CACjL,CAAC;AAEX;;;;;;GAMG;AACH,wBAAgB,cAAc;kBAGP,MAAM,GAAG,SAAS,WAAW,MAAM,MAAM,GAAG,IAAI,KAAG,MAAM,GAAG,IAAI;;EAgBtF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,QAAQ,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,IAAI,CAyBf"}
@@ -0,0 +1,138 @@
1
+ import { existsSync } from "node:fs";
2
+ import { forget, list, renderEntries, save, search, SecretDetectedError, stats } from "./store.js";
3
+ export function createMemoryOps(getDb) {
4
+ return {
5
+ save: (args, scope) => run(() => {
6
+ const entry = save(getDb(), {
7
+ scope,
8
+ kind: args.kind,
9
+ title: args.title,
10
+ body: args.body,
11
+ tags: args.tags ?? [],
12
+ expires_at: args.expires_at ?? null,
13
+ });
14
+ return `saved ${entry.id} (${entry.scope})`;
15
+ }),
16
+ search: (args, scope) => run(() => {
17
+ const hits = search(getDb(), {
18
+ query: args.query,
19
+ scope,
20
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
21
+ });
22
+ return hits.length === 0 ? "no matching memory entries" : renderEntries(hits);
23
+ }),
24
+ list: (args, scope) => run(() => {
25
+ const entries = list(getDb(), {
26
+ scope,
27
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
28
+ });
29
+ return entries.length === 0 ? "no memory entries" : renderEntries(entries);
30
+ }),
31
+ forget: (args, scope) => run(() => {
32
+ if (args.id !== undefined) {
33
+ const deleted = forget(getDb(), { id: args.id, scope });
34
+ return `deleted ${deleted} entr${deleted === 1 ? "y" : "ies"}`;
35
+ }
36
+ if (args.query !== undefined) {
37
+ const deleted = forget(getDb(), { query: args.query, scope });
38
+ return `deleted ${deleted} entr${deleted === 1 ? "y" : "ies"}`;
39
+ }
40
+ return "provide id or query";
41
+ }),
42
+ stats: (scope) => run(() => {
43
+ const counts = stats(getDb(), { scope });
44
+ if (counts.length === 0) {
45
+ return "no memory entries";
46
+ }
47
+ const total = counts.reduce((sum, row) => sum + row.count, 0);
48
+ return `${counts.map((row) => `${row.kind}: ${row.count}`).join(", ")}, total: ${total}`;
49
+ }),
50
+ };
51
+ }
52
+ /** How much of the store is summarised into a prompt, worst case. */
53
+ export const INVENTORY_MAX_CHARS = 1200;
54
+ const INVENTORY_HEADER = "Memory already stored for this project (lessons first, then newest):";
55
+ /** Tool descriptions shared by both entry points so the two hosts cannot drift. */
56
+ export const TOOL_DESCRIPTIONS = {
57
+ save: "Save a durable entry to shared working memory (decisions, facts, preferences, gotchas, lessons). Never store credentials.",
58
+ search: "Search shared working memory (all sessions on this machine). Results include this project's scope and global entries.",
59
+ list: "List recent memory entries for a scope (default: this project), newest first.",
60
+ forget: "Delete memory entries by id (any scope: ids are globally unique), or all entries matching a query. Query deletes stay inside this project's scope (plus global entries); an entry saved under another scope needs its id, or that scope, to be removed.",
61
+ stats: "Count memory entries by kind for this project (plus global entries), with a total. Use it to see how much memory already exists, for example how many lessons reflection has written.",
62
+ };
63
+ /**
64
+ * Produces at most one injection per session key, and only on the first request where
65
+ * the producer has something: a session that starts before any entry exists still gets
66
+ * its inventory as soon as one is available. Keys are capped so a long-lived host
67
+ * cannot grow the set forever; an absent sessionID (v1 marks it optional) degrades to
68
+ * one injection per plugin instance, never per request.
69
+ */
70
+ export function oncePerSession() {
71
+ const seen = new Set();
72
+ const MAX_KEYS = 512;
73
+ const inject = (key, produce) => {
74
+ const k = key ?? "__instance__";
75
+ if (seen.has(k)) {
76
+ return null;
77
+ }
78
+ const produced = produce();
79
+ if (produced === null) {
80
+ return null;
81
+ }
82
+ seen.add(k);
83
+ if (seen.size > MAX_KEYS) {
84
+ seen.clear();
85
+ }
86
+ return produced;
87
+ };
88
+ return { inject, reset: () => seen.clear() };
89
+ }
90
+ /**
91
+ * Bounded summary of recent entries, injected into the first request of a session and
92
+ * into the compaction context. Lessons are promoted above recency noise so a stored
93
+ * lesson survives a chatty store. `null` when there is nothing worth injecting, so a
94
+ * caller pushes nothing at all.
95
+ */
96
+ export function memoryInventory(getDb, dbPath, scope) {
97
+ if (!existsSync(dbPath)) {
98
+ return null;
99
+ }
100
+ const lessons = list(getDb(), { scope, limit: 50, kind: "lesson" });
101
+ const others = list(getDb(), { scope, limit: 50 });
102
+ if (lessons.length === 0 && others.length === 0) {
103
+ return null;
104
+ }
105
+ const seen = new Set(lessons.map((entry) => entry.id));
106
+ const ordered = [...lessons, ...others.filter((entry) => !seen.has(entry.id))];
107
+ const lines = [INVENTORY_HEADER];
108
+ let size = INVENTORY_HEADER.length;
109
+ for (const entry of ordered) {
110
+ const line = `- [${entry.kind}] ${entry.title} (updated ${dayOf(entry.updated_at)})`;
111
+ if (size + line.length + 1 > INVENTORY_MAX_CHARS) {
112
+ break;
113
+ }
114
+ lines.push(line);
115
+ size += line.length + 1;
116
+ }
117
+ return lines.join("\n");
118
+ }
119
+ function dayOf(epochMs) {
120
+ return new Date(epochMs).toISOString().slice(0, 10);
121
+ }
122
+ // Tool results are shown to the model: caught errors become readable text, so a
123
+ // rejected save is guidance instead of a crashed tool call.
124
+ function run(fn) {
125
+ try {
126
+ return fn();
127
+ }
128
+ catch (error) {
129
+ if (error instanceof SecretDetectedError) {
130
+ return "refused: the content looks like a credential; secrets must never be stored in memory";
131
+ }
132
+ if (error instanceof Error) {
133
+ return `error: ${error.message}`;
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+ //# sourceMappingURL=memory-ops.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-ops.js","sourceRoot":"","sources":["../../src/lib/memory-ops.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Optional end-of-turn reflection: distills durable lessons from a session tail with
3
+ * one bounded LLM call and saves them as `lesson` entries. Best-effort by design:
4
+ * a disabled config wires nothing, a failing run is swallowed, and each session is
5
+ * reflected at most once.
6
+ */
7
+ export type ReflectionConfig = {
8
+ enabled: boolean;
9
+ model?: string | undefined;
10
+ maxLessons: number;
11
+ transcriptChars: number;
12
+ timeoutMs: number;
13
+ };
14
+ export type ReflectionDeps = {
15
+ isSubagent: (sessionID: string) => Promise<boolean>;
16
+ transcript: (sessionID: string) => Promise<string>;
17
+ generate: (prompt: string, sessionID: string, signal: AbortSignal) => Promise<string>;
18
+ saveLesson: (title: string, body: string) => void;
19
+ };
20
+ export declare function parseReflectionConfig(raw: unknown): ReflectionConfig | null;
21
+ export declare function distillPrompt(transcript: string, maxLessons: number): string;
22
+ export declare function parseLessons(text: string, max: number): Array<{
23
+ title: string;
24
+ body: string;
25
+ }>;
26
+ export declare function createReflection(config: ReflectionConfig, deps: ReflectionDeps): (sessionID: string | undefined) => void;
27
+ //# sourceMappingURL=reflection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reflection.d.ts","sourceRoot":"","sources":["../../src/lib/reflection.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACpD,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACtF,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnD,CAAC;AAcF,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAyB3E;AASD,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAU5E;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA8B9F;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,cAAc,GACnB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAoDzC"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Optional end-of-turn reflection: distills durable lessons from a session tail with
3
+ * one bounded LLM call and saves them as `lesson` entries. Best-effort by design:
4
+ * a disabled config wires nothing, a failing run is swallowed, and each session is
5
+ * reflected at most once.
6
+ */
7
+ const MAX_LESSONS_DEFAULT = 3;
8
+ const MAX_LESSONS_CAP = 10;
9
+ const TRANSCRIPT_CHARS_DEFAULT = 8000;
10
+ const TRANSCRIPT_CHARS_MIN = 500;
11
+ const TRANSCRIPT_CHARS_MAX = 50000;
12
+ const TIMEOUT_MS_DEFAULT = 60000;
13
+ const TIMEOUT_MS_MIN = 1000;
14
+ const TIMEOUT_MS_MAX = 600000;
15
+ /** Below this much transcript there is nothing to distill; saves the LLM call. */
16
+ const TRANSCRIPT_MIN_CHARS = 256;
17
+ const MAX_TRACKED = 512;
18
+ export function parseReflectionConfig(raw) {
19
+ if (raw === null || typeof raw !== "object" || raw.enabled !== true) {
20
+ return null;
21
+ }
22
+ const rawConfig = raw;
23
+ return {
24
+ enabled: true,
25
+ model: typeof rawConfig.model === "string" && rawConfig.model.includes("/")
26
+ ? rawConfig.model
27
+ : undefined,
28
+ maxLessons: clamp(rawConfig.maxLessons, 1, MAX_LESSONS_CAP, MAX_LESSONS_DEFAULT),
29
+ transcriptChars: clamp(rawConfig.transcriptChars, TRANSCRIPT_CHARS_MIN, TRANSCRIPT_CHARS_MAX, TRANSCRIPT_CHARS_DEFAULT),
30
+ timeoutMs: clamp(rawConfig.timeoutMs, TIMEOUT_MS_MIN, TIMEOUT_MS_MAX, TIMEOUT_MS_DEFAULT),
31
+ };
32
+ }
33
+ function clamp(value, min, max, fallback) {
34
+ if (typeof value !== "number" || !Number.isFinite(value)) {
35
+ return fallback;
36
+ }
37
+ return Math.min(max, Math.max(min, Math.round(value)));
38
+ }
39
+ export function distillPrompt(transcript, maxLessons) {
40
+ return (`You are the persistent-memory reflection for this project's OpenCode sessions. Below is the tail of one ` +
41
+ `coding session transcript. Extract at most ${maxLessons} durable, reusable lessons: non-obvious approaches ` +
42
+ `that clearly failed and why, non-obvious approaches that clearly worked and why, or constraints discovered ` +
43
+ `the hard way. Exclude anything trivially re-derivable from the code, anything session-specific, and any ` +
44
+ `credential. Reply with ONLY a JSON array, no markdown fences and no prose: ` +
45
+ `[{"title": "max 120 chars", "body": "the lesson, max 8000 chars"}]. Reply [] when nothing qualifies.\n\n` +
46
+ `SESSION TRANSCRIPT:\n${transcript}`);
47
+ }
48
+ export function parseLessons(text, max) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ const fenced = text
55
+ .replace(/^```(?:json)?/m, "")
56
+ .replace(/```$/m, "")
57
+ .trim();
58
+ try {
59
+ parsed = JSON.parse(fenced);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ if (!Array.isArray(parsed)) {
66
+ return [];
67
+ }
68
+ const lessons = [];
69
+ for (const item of parsed.slice(0, max)) {
70
+ if (item === null || typeof item !== "object") {
71
+ continue;
72
+ }
73
+ const { title, body } = item;
74
+ if (typeof title !== "string" || typeof body !== "string" || !title.trim() || !body.trim()) {
75
+ continue;
76
+ }
77
+ lessons.push({ title: title.slice(0, 120), body: body.slice(0, 8000) });
78
+ }
79
+ return lessons;
80
+ }
81
+ export function createReflection(config, deps) {
82
+ const done = new Set();
83
+ const inFlight = new Set();
84
+ const run = async (sessionID) => {
85
+ // Only a session we actually tried to reflect is marked done. A too-short
86
+ // transcript leaves it unsettled, so a later, longer idle can still reflect it
87
+ // once: idle fires at the end of every turn, not once per session.
88
+ let settled = false;
89
+ try {
90
+ if (await deps.isSubagent(sessionID)) {
91
+ settled = true;
92
+ return;
93
+ }
94
+ const tail = (await deps.transcript(sessionID)).slice(-config.transcriptChars);
95
+ if (tail.length < Math.min(TRANSCRIPT_MIN_CHARS, config.transcriptChars)) {
96
+ return;
97
+ }
98
+ settled = true;
99
+ // Bound the LLM call: a hung provider must not wedge a session forever. The
100
+ // hosts forward this signal to the SDK, so the abort also unblocks their own
101
+ // cleanup (the v1 host deletes the ephemeral child session in its `finally`).
102
+ const reply = await deps.generate(distillPrompt(tail, config.maxLessons), sessionID, AbortSignal.timeout(config.timeoutMs));
103
+ for (const lesson of parseLessons(reply, config.maxLessons)) {
104
+ deps.saveLesson(lesson.title, lesson.body);
105
+ }
106
+ }
107
+ catch {
108
+ // Reflection is best-effort: never surface a failure to the host. A failed
109
+ // attempt still settles the session so a broken pipeline is not retried forever.
110
+ settled = true;
111
+ }
112
+ finally {
113
+ inFlight.delete(sessionID);
114
+ if (settled) {
115
+ done.add(sessionID);
116
+ if (done.size > MAX_TRACKED) {
117
+ done.clear();
118
+ }
119
+ }
120
+ }
121
+ };
122
+ return (sessionID) => {
123
+ if (!sessionID || !config.enabled || done.has(sessionID) || inFlight.has(sessionID)) {
124
+ return;
125
+ }
126
+ inFlight.add(sessionID);
127
+ void run(sessionID);
128
+ };
129
+ }
130
+ //# sourceMappingURL=reflection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reflection.js","sourceRoot":"","sources":["../../src/lib/reflection.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;IACvD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAC5D,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC3C,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,IAAI,IAAI,CAAC;CACf;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAanD"}
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;IACvD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAC5D,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC3C,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,IAAI,IAAI,CAAC;CACf;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAanD"}