opencode-self-improvement 0.1.1 → 0.2.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 +64 -3
- package/package.json +4 -4
- package/src/capture.ts +175 -0
- package/src/config.ts +13 -0
- package/src/index.ts +275 -24
- package/src/inject.ts +59 -0
- package/src/portable.ts +121 -0
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -10,14 +10,56 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
|
|
|
10
10
|
- **`memory_*` tools** — remember facts, decisions, and commands the agent should
|
|
11
11
|
keep across sessions (`memory_init`, `memory_remember`, `memory_recall`,
|
|
12
12
|
`memory_update`, `memory_forget`, `memory_list`, `memory_undo`,
|
|
13
|
-
`memory_evidence`).
|
|
13
|
+
`memory_evidence`, `memory_export`, `memory_import`, `memory_decay`).
|
|
14
14
|
- **`/repo-init`** — for each repo: indexes the code graph and remembers the
|
|
15
15
|
test/build commands, keyed to the git head. The command installs itself; no
|
|
16
16
|
setup beyond the plugin.
|
|
17
|
+
- **Memory-aware compaction** — durable memories are handed to the compaction
|
|
18
|
+
summarizer, so a long session doesn't get an amnesia wipe exactly when it
|
|
19
|
+
needs its memory most.
|
|
20
|
+
- **Export / import (JSONL)** — `memory_export` / `memory_import` back the store
|
|
21
|
+
up or move it between machines. Every imported row re-enters through the same
|
|
22
|
+
write gates, so an export can't smuggle junk in.
|
|
23
|
+
- **Decay** — `memory_decay` retires stale open threads. Dry run by default;
|
|
24
|
+
supersede-not-delete, snapshot-first, undoable.
|
|
25
|
+
- **Auto-capture (opt-in)** — with `autoCapture` on, session-idle events get one
|
|
26
|
+
cheap model pass that proposes memory candidates; every candidate still faces
|
|
27
|
+
the same write gates. Off by default, so the install stays zero-token.
|
|
17
28
|
- Automatically de-dupes similar memories and avoids contradictions by searching for existing memories before saving new ones.
|
|
18
29
|
- Automatically looks for insights when you complete the verification gates (tests, linting, etc)
|
|
19
30
|
- Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
|
|
20
31
|
|
|
32
|
+
## Token cost, on purpose
|
|
33
|
+
|
|
34
|
+
Memory plugins have a reputation for burning tokens and busting the provider's
|
|
35
|
+
prompt cache on every message. This one is built the other way around:
|
|
36
|
+
|
|
37
|
+
- **Epoch-frozen injection.** The "Relevant durable memory" block is computed
|
|
38
|
+
once per epoch — first prompt of a session, a change to the memory store, or
|
|
39
|
+
a compaction — and stays byte-identical in between. A stable system prefix
|
|
40
|
+
means the provider's prompt cache survives ordinary turns instead of being
|
|
41
|
+
invalidated on every message.
|
|
42
|
+
- **Hard character budget.** The block is capped at 1200 characters by default
|
|
43
|
+
(see Options) and trimmed at whole-memory boundaries — never cut mid-sentence.
|
|
44
|
+
- **Evidence is never injected**, and the standing system digest is two lines.
|
|
45
|
+
|
|
46
|
+
Measured on a deterministic workload (50 memories, 20 prompts, 3 mid-session
|
|
47
|
+
store writes) using the plugin's real injection code:
|
|
48
|
+
|
|
49
|
+
| Policy | Recomputes | Injected chars (20 turns) | Avg/turn | Worst block |
|
|
50
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
51
|
+
| A — recompute per prompt, uncapped (pre-0.2) | 20 | 27748 | 1387.4 | 1575 |
|
|
52
|
+
| B — epoch freeze + 1200-char cap (0.2) | 4 | 20654 | 1032.7 | 1157 |
|
|
53
|
+
|
|
54
|
+
Policy B injects 25.6% fewer characters — and the bigger saving isn't even in
|
|
55
|
+
the character count: B's block is byte-identical between store changes, so the
|
|
56
|
+
prompt cache stays warm across ordinary turns. Reproduce (byte-identical
|
|
57
|
+
output):
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
bun bench/inject-cost.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
21
63
|
## Quick start
|
|
22
64
|
|
|
23
65
|
1. Add the plugin to your Opencode config (see install below) and restart Opencode.
|
|
@@ -37,7 +79,7 @@ Add a `plugin` entry to your opencode config —
|
|
|
37
79
|
```json
|
|
38
80
|
{
|
|
39
81
|
"$schema": "https://opencode.ai/config.json",
|
|
40
|
-
"plugin": ["opencode-self-improvement@0.1.
|
|
82
|
+
"plugin": ["opencode-self-improvement@0.1.1"]
|
|
41
83
|
}
|
|
42
84
|
```
|
|
43
85
|
|
|
@@ -51,7 +93,7 @@ bump the version in the config and restart. On Opencode v2 you can also manage
|
|
|
51
93
|
it with the CLI:
|
|
52
94
|
|
|
53
95
|
```sh
|
|
54
|
-
opencode plugin add opencode-self-improvement@0.1.
|
|
96
|
+
opencode plugin add opencode-self-improvement@0.1.1
|
|
55
97
|
opencode plugin update opencode-self-improvement
|
|
56
98
|
```
|
|
57
99
|
|
|
@@ -77,6 +119,24 @@ After changes, `git pull` and restart opencode.
|
|
|
77
119
|
|
|
78
120
|
</details>
|
|
79
121
|
|
|
122
|
+
## Options
|
|
123
|
+
|
|
124
|
+
Zero configuration is the default. To tune the plugin, use the object form of
|
|
125
|
+
the plugin entry in `opencode.json(c)` instead of the plain string:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200 } }]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Option | Default | What it does |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `injectBudget` | `1200` | Character cap on the injected relevance block, trimmed at whole-memory boundaries. Env fallback: `OPENCODE_MEMORY_INJECT_BUDGET`. |
|
|
136
|
+
| `autoCapture` | `false` (off) | When on, each session-idle event makes one cheap model pass that proposes memory candidates. Every candidate still goes through the 4-gate write pipeline — capture can never bypass the gates. Off by default: the zero-token install stays zero-token. |
|
|
137
|
+
| `captureModel` | — | `"provider/id"` model used for auto-capture. Required when `autoCapture` is on; a missing or invalid value disables capture with a startup warning. |
|
|
138
|
+
| `captureMax` | `3` | Maximum candidates proposed per idle event. |
|
|
139
|
+
|
|
80
140
|
## Code graph (optional)
|
|
81
141
|
|
|
82
142
|
`/repo-init` also indexes the repo into a queryable knowledge graph via
|
|
@@ -124,5 +184,6 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
|
|
|
124
184
|
| `/repo-init` missing from the command list | The self-install couldn't write to `~/.config/opencode/commands/` (e.g. read-only config dir). Create it, restart, or just ask the model to "init memory for this repo" — it calls the same tool. |
|
|
125
185
|
| Init says `Graph: unavailable` | `codebase-memory-mcp` couldn't start (not installed, or its once-only download is blocked). Install pinned `0.11.0` via the one-liner above, or point `OPENCODE_CBM_BIN` at the binary. Scripts are still remembered without it. |
|
|
126
186
|
| Stale plugin after an update | Bump the pinned version in the `plugin` entry (or `opencode plugin update opencode-self-improvement` on v2) and restart opencode. |
|
|
187
|
+
| Auto-capture does nothing | `autoCapture` needs a valid `captureModel` in `provider/id` form — a bad one disables capture with a startup warning. Candidates rejected by the write gates are logged as evidence, not errors; check `memory_evidence`. |
|
|
127
188
|
| Reset all memories | Delete `~/.config/opencode/memory/`. |
|
|
128
189
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -33,13 +33,12 @@
|
|
|
33
33
|
"README.md",
|
|
34
34
|
"LICENSE"
|
|
35
35
|
],
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"@opencode-ai/plugin": "1.18.31"
|
|
38
|
-
},
|
|
39
36
|
"optionalDependencies": {
|
|
40
37
|
"codebase-memory-mcp": "0.11.0"
|
|
41
38
|
},
|
|
42
39
|
"devDependencies": {
|
|
40
|
+
"@opencode/plugin": "^2.0.32",
|
|
41
|
+
"@semantic-release/git": "^11.0.1",
|
|
43
42
|
"@types/bun": "latest",
|
|
44
43
|
"semantic-release": "^25.0.9",
|
|
45
44
|
"typescript": "^7.0.2"
|
|
@@ -50,6 +49,7 @@
|
|
|
50
49
|
"scripts": {
|
|
51
50
|
"test": "bun test",
|
|
52
51
|
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
|
|
52
|
+
"bench": "bun bench/inject-cost.ts",
|
|
53
53
|
"memory-view": "bun run src/viewer/server.ts"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Opt-in auto-capture core (Phase 4). Pure and dependency-free: no opencode
|
|
2
|
+
// imports, no filesystem, no ambient clock — the transcript, the model call
|
|
3
|
+
// and time are all injected by the caller (src/index.ts), so everything here
|
|
4
|
+
// is unit-testable. Every candidate that leaves this module still flows
|
|
5
|
+
// through runWriteGate in index.ts: capture NEVER writes memories directly,
|
|
6
|
+
// so dupes/stale/canonical rules keep the moat (same as model-authored writes).
|
|
7
|
+
// All parsing functions are TOTAL: any malformed input yields [] / "" / null,
|
|
8
|
+
// never a throw.
|
|
9
|
+
import { STATUSES, TYPES } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export interface CaptureOptions {
|
|
12
|
+
model: string; // "provider/id"
|
|
13
|
+
max: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CaptureDeps {
|
|
17
|
+
generate: (prompt: string) => Promise<string>;
|
|
18
|
+
sessionText: string;
|
|
19
|
+
now: () => Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CaptureCandidate {
|
|
23
|
+
type: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
content: string;
|
|
26
|
+
status?: "open" | "settled";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Prompt for the cheap capture model. The output contract is a bare JSON
|
|
30
|
+
// array so parseCandidates can trust it; anything else parses to [].
|
|
31
|
+
export function buildCapturePrompt(transcript: string, max = 3): string {
|
|
32
|
+
return [
|
|
33
|
+
"You mine one coding-agent session transcript for durable memories worth keeping across future sessions.",
|
|
34
|
+
"",
|
|
35
|
+
`Output ONLY a JSON array of at most ${max} objects (a fenced json block is fine), each:`,
|
|
36
|
+
'{"type": "...", "scope": "...", "content": "...", "status": "..."}',
|
|
37
|
+
"",
|
|
38
|
+
"Rules:",
|
|
39
|
+
`- type: one of ${TYPES.join(", ")}.`,
|
|
40
|
+
"- scope: short area name (e.g. auth, api, build).",
|
|
41
|
+
'- status: "open" for a live planning thread; otherwise "settled".',
|
|
42
|
+
"- content: one self-contained fact, 20-600 characters, useful in a NEW session.",
|
|
43
|
+
"- Keep: decisions made, working commands, user preferences, blockers hit, durable context, conventions.",
|
|
44
|
+
"- Skip: anything derivable from the code or git history, small talk, and details that only mattered inside this conversation.",
|
|
45
|
+
"- If nothing is worth remembering, output [ ].",
|
|
46
|
+
"",
|
|
47
|
+
"Transcript:",
|
|
48
|
+
transcript,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VALID_TYPES = new Set<string>(TYPES);
|
|
53
|
+
|
|
54
|
+
// Remove markdown code-fence marker lines, keeping the fenced payload.
|
|
55
|
+
function stripFences(text: string): string {
|
|
56
|
+
return text.replace(/^[ \t]*```[^\n]*$/gm, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// First balanced [...] span, string-literal aware. null when absent or
|
|
60
|
+
// unbalanced (a model that truncates mid-array yields nothing, not garbage).
|
|
61
|
+
function firstJsonArray(text: string): string | null {
|
|
62
|
+
const start = text.indexOf("[");
|
|
63
|
+
if (start === -1) return null;
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let inString = false;
|
|
66
|
+
let escaped = false;
|
|
67
|
+
for (let i = start; i < text.length; i++) {
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (inString) {
|
|
70
|
+
if (escaped) escaped = false;
|
|
71
|
+
else if (ch === "\\") escaped = true;
|
|
72
|
+
else if (ch === '"') inString = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"') inString = true;
|
|
76
|
+
else if (ch === "[") depth++;
|
|
77
|
+
else if (ch === "]") {
|
|
78
|
+
depth--;
|
|
79
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Extract memory candidates from a model reply. Never throws: any malformed
|
|
86
|
+
// input yields []. Items missing fields, with unknown types, or with content
|
|
87
|
+
// outside 20-600 chars are dropped; the result is clamped to `max`.
|
|
88
|
+
export function parseCandidates(raw: string, max: number): CaptureCandidate[] {
|
|
89
|
+
try {
|
|
90
|
+
if (typeof raw !== "string" || !raw.trim() || !(max > 0)) return [];
|
|
91
|
+
const span = firstJsonArray(stripFences(raw));
|
|
92
|
+
if (!span) return [];
|
|
93
|
+
const parsed = JSON.parse(span);
|
|
94
|
+
if (!Array.isArray(parsed)) return [];
|
|
95
|
+
const out: CaptureCandidate[] = [];
|
|
96
|
+
for (const item of parsed) {
|
|
97
|
+
if (out.length >= max) break;
|
|
98
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
99
|
+
const o = item as Record<string, unknown>;
|
|
100
|
+
const type = typeof o.type === "string" ? o.type.trim() : "";
|
|
101
|
+
const scope = typeof o.scope === "string" ? o.scope.trim() : "";
|
|
102
|
+
const content = typeof o.content === "string" ? o.content.trim() : "";
|
|
103
|
+
if (!type || !scope || !content) continue;
|
|
104
|
+
if (!VALID_TYPES.has(type)) continue;
|
|
105
|
+
if (content.length < 20 || content.length > 600) continue;
|
|
106
|
+
const status = o.status === "open" || o.status === "settled" ? o.status : undefined;
|
|
107
|
+
out.push(status ? { type, scope, content, status } : { type, scope, content });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// "provider/id" -> { providerID, id }, splitting on the FIRST "/". Returns
|
|
116
|
+
// null when the string has no slash or either half is empty — the caller then
|
|
117
|
+
// skips capture entirely rather than guessing a model.
|
|
118
|
+
export function parseModelRef(ref: unknown): { providerID: string; id: string } | null {
|
|
119
|
+
if (typeof ref !== "string") return null;
|
|
120
|
+
const i = ref.indexOf("/");
|
|
121
|
+
if (i <= 0) return null;
|
|
122
|
+
const providerID = ref.slice(0, i).trim();
|
|
123
|
+
const id = ref.slice(i + 1).trim();
|
|
124
|
+
if (!providerID || !id) return null;
|
|
125
|
+
return { providerID, id };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Render an array of session-context messages of unknown shape into
|
|
129
|
+
// "role: text" lines. Defensive by construction (the API shape varies and is
|
|
130
|
+
// not ours to trust): non-arrays and junk entries are skipped; role comes
|
|
131
|
+
// from info.role | role, text from parts[].text | text | content (strings
|
|
132
|
+
// only); capped at 40 user turns. Never throws.
|
|
133
|
+
const TRANSCRIPT_USER_TURN_CAP = 40;
|
|
134
|
+
|
|
135
|
+
export function flattenTranscript(msgs: unknown): string {
|
|
136
|
+
try {
|
|
137
|
+
if (!Array.isArray(msgs)) return "";
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
let userTurns = 0;
|
|
140
|
+
for (const msg of msgs) {
|
|
141
|
+
if (!msg || typeof msg !== "object") continue;
|
|
142
|
+
const m = msg as Record<string, unknown>;
|
|
143
|
+
const info =
|
|
144
|
+
m.info && typeof m.info === "object" ? (m.info as Record<string, unknown>) : undefined;
|
|
145
|
+
const role =
|
|
146
|
+
typeof info?.role === "string" ? info.role : typeof m.role === "string" ? m.role : "";
|
|
147
|
+
let text = "";
|
|
148
|
+
if (Array.isArray(m.parts)) {
|
|
149
|
+
text = m.parts
|
|
150
|
+
.map((p) =>
|
|
151
|
+
p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined,
|
|
152
|
+
)
|
|
153
|
+
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
|
154
|
+
.join(" ");
|
|
155
|
+
}
|
|
156
|
+
if (!text) {
|
|
157
|
+
for (const k of ["text", "content"]) {
|
|
158
|
+
if (typeof m[k] === "string") {
|
|
159
|
+
text = m[k] as string;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!role || !text.trim()) continue;
|
|
165
|
+
if (role === "user") {
|
|
166
|
+
userTurns++;
|
|
167
|
+
if (userTurns > TRANSCRIPT_USER_TURN_CAP) break;
|
|
168
|
+
}
|
|
169
|
+
lines.push(`${role}: ${text.trim()}`);
|
|
170
|
+
}
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
} catch {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -41,6 +41,19 @@ export function dateStr(d: Date = new Date()): string {
|
|
|
41
41
|
return d.toISOString().split("T")[0];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Character budget for the injected "Relevant durable memory" block (Phase 2:
|
|
45
|
+
// cache-safe epoch injection). Overridable via OPENCODE_MEMORY_INJECT_BUDGET
|
|
46
|
+
// (plugin options take precedence — see src/index.ts); falls back to 1200 on
|
|
47
|
+
// missing or invalid values.
|
|
48
|
+
export function injectBudget(): number {
|
|
49
|
+
const raw = process.env.OPENCODE_MEMORY_INJECT_BUDGET;
|
|
50
|
+
if (raw) {
|
|
51
|
+
const n = Number.parseInt(raw, 10);
|
|
52
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
53
|
+
}
|
|
54
|
+
return 1200;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
// Optional live-graph binary. When present the resolver cross-checks the local
|
|
45
58
|
// source scan against the codebase-memory-mcp file graph; when absent the local
|
|
46
59
|
// scan alone is authoritative (install-from-scratch requirement).
|
package/src/index.ts
CHANGED
|
@@ -6,26 +6,35 @@
|
|
|
6
6
|
// V2 port: registers tools via `ctx.tool.transform` (JSON Schema), mirrors the
|
|
7
7
|
// V1 hooks onto the OpenCode V2 session/tool hook API, and registers the
|
|
8
8
|
// /repo-init command via `ctx.command.transform`. The plugin contract is a
|
|
9
|
-
// default export object `{ id, setup(ctx) }` (OpenCode >= 2.0)
|
|
10
|
-
//
|
|
9
|
+
// default export object `{ id, setup(ctx) }` (OpenCode >= 2.0), pinned at
|
|
10
|
+
// compile time against the real SDK type (`@opencode/plugin`, type-only — see
|
|
11
|
+
// the note on `plugin` below). All business logic still lives in the decoupled
|
|
12
|
+
// helper modules.
|
|
13
|
+
import type { Cleanup, Plugin } from "@opencode/plugin/promise/plugin";
|
|
11
14
|
import { join } from "node:path";
|
|
12
|
-
import { evidenceDir, memoryRoot, dateStr } from "./config.ts";
|
|
15
|
+
import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
|
|
16
|
+
import { STATUSES, TYPES } from "./types.ts";
|
|
13
17
|
import {
|
|
14
18
|
clearSuperseded,
|
|
15
19
|
isActive,
|
|
16
20
|
loadAll,
|
|
17
21
|
markSuperseded,
|
|
18
22
|
resolveMemoryTarget,
|
|
23
|
+
snapshotFiles,
|
|
19
24
|
type Memory,
|
|
20
25
|
} from "./memory.ts";
|
|
21
26
|
import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts";
|
|
22
27
|
import { pruneMemory } from "./prune.ts";
|
|
28
|
+
import { exportMemories, findStaleOpen, importMemories } from "./portable.ts";
|
|
23
29
|
import { listSnapshots, restoreSnapshot } from "./undo.ts";
|
|
24
30
|
import { record } from "./evidence.ts";
|
|
25
31
|
import {
|
|
32
|
+
capToBudget,
|
|
33
|
+
compactionAnchor,
|
|
26
34
|
contextualPull,
|
|
27
35
|
formatMemory,
|
|
28
36
|
relevantKnowledge,
|
|
37
|
+
storeVersion,
|
|
29
38
|
systemDigest,
|
|
30
39
|
} from "./inject.ts";
|
|
31
40
|
import { formatInit, runInit } from "./init.ts";
|
|
@@ -43,9 +52,7 @@ import {
|
|
|
43
52
|
} from "./triggers.ts";
|
|
44
53
|
import { getCurrent, setCurrent } from "./state.ts";
|
|
45
54
|
import { readLines } from "./logfmt.ts";
|
|
46
|
-
|
|
47
|
-
const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
|
|
48
|
-
const STATUSES = ["open", "settled"] as const;
|
|
55
|
+
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
49
56
|
|
|
50
57
|
// --- minimal JSON Schema helpers (OpenCode V2 tools take JSON Schema) --------
|
|
51
58
|
type JSONSchema = Record<string, unknown>;
|
|
@@ -88,6 +95,7 @@ interface HookReg {
|
|
|
88
95
|
}
|
|
89
96
|
interface PluginCtx {
|
|
90
97
|
location?: { directory?: string };
|
|
98
|
+
options?: Record<string, unknown>;
|
|
91
99
|
tool?: {
|
|
92
100
|
transform: (cb: (editor: ToolEditorLike) => void) => Promise<unknown>;
|
|
93
101
|
hook: (name: string, cb: (event: any) => Promise<void> | void) => Promise<HookReg | void>;
|
|
@@ -102,6 +110,13 @@ interface PluginCtx {
|
|
|
102
110
|
text: string;
|
|
103
111
|
delivery?: "steer" | "queue";
|
|
104
112
|
}) => Promise<unknown>;
|
|
113
|
+
context?: (input: { sessionID: string }) => Promise<unknown>;
|
|
114
|
+
};
|
|
115
|
+
event?: {
|
|
116
|
+
subscribe: (o?: { signal?: AbortSignal }) => AsyncIterable<unknown>;
|
|
117
|
+
};
|
|
118
|
+
generate?: {
|
|
119
|
+
text: (input: unknown) => Promise<{ text?: string } | string>;
|
|
105
120
|
};
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -133,11 +148,17 @@ function resultMeta(result: unknown): Record<string, unknown> {
|
|
|
133
148
|
|
|
134
149
|
const PLUGIN_ID = "opencode-self-improvement";
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
151
|
+
// The real setup implementation, written against the loose local PluginCtx
|
|
152
|
+
// (the only members of the V2 context this plugin actually touches).
|
|
153
|
+
const impl = {
|
|
154
|
+
async setup(ctx: PluginCtx): Promise<Cleanup | void> {
|
|
139
155
|
const liveRoot = ctx?.location?.directory ?? process.cwd();
|
|
140
156
|
|
|
157
|
+
// Character budget for the epoch-frozen injection block: plugin options
|
|
158
|
+
// (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
|
|
159
|
+
// then the 1200 default.
|
|
160
|
+
const budget = Number(ctx.options?.injectBudget) || injectBudget();
|
|
161
|
+
|
|
141
162
|
// Mirror the /repo-init slash command into opencode's global commands dir
|
|
142
163
|
// (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
|
|
143
164
|
// ~/.config/opencode/commands, so this is the canonical V2 install path.
|
|
@@ -425,11 +446,124 @@ const plugin = {
|
|
|
425
446
|
};
|
|
426
447
|
},
|
|
427
448
|
});
|
|
449
|
+
|
|
450
|
+
editor.add({
|
|
451
|
+
name: "memory_export",
|
|
452
|
+
description:
|
|
453
|
+
"Export active memories as a JSONL file (one JSON object per line: {ts,type,scope,content,status,tags,issue}) for backup or transfer to another machine. Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
|
|
454
|
+
options: direct,
|
|
455
|
+
input: obj({
|
|
456
|
+
scope: withDesc(str(), "Only export memories in this exact scope"),
|
|
457
|
+
file: withDesc(str(), "Destination path (default: <memory-root>/exports/)"),
|
|
458
|
+
}),
|
|
459
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
460
|
+
const res = await exportMemories({ scope: args.scope, file: args.file });
|
|
461
|
+
return { content: `Exported ${res.count} memories -> ${res.path}` };
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
editor.add({
|
|
466
|
+
name: "memory_import",
|
|
467
|
+
description:
|
|
468
|
+
"Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted.",
|
|
469
|
+
options: direct,
|
|
470
|
+
input: obj({ file: withDesc(str(), "Path to a .jsonl export file") }, ["file"]),
|
|
471
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
472
|
+
try {
|
|
473
|
+
const t = await importMemories(args.file, (c) =>
|
|
474
|
+
runWriteGate(
|
|
475
|
+
{ ...c, tags: [...new Set([...(c.tags ?? []), "import"])] },
|
|
476
|
+
gateContext(),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
return {
|
|
480
|
+
content:
|
|
481
|
+
`Imported ${args.file}: ${t.promoted} promoted, ${t.collapsed} duplicates collapsed, ` +
|
|
482
|
+
`${t.blocked} blocked by the gate, ${t.skipped} malformed lines skipped.`,
|
|
483
|
+
};
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return {
|
|
486
|
+
content: `memory_import failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
editor.add({
|
|
493
|
+
name: "memory_decay",
|
|
494
|
+
description:
|
|
495
|
+
"Retire stale open threads (status=open older than olderThanDays) by superseding them — never deleting; rows stay recoverable via memory_undo. DRY RUN BY DEFAULT: without an explicit dryRun=false this only LISTS what would retire and mutates nothing (fail-closed). A real run snapshots every affected file before the first mutation.",
|
|
496
|
+
options: direct,
|
|
497
|
+
input: obj({
|
|
498
|
+
olderThanDays: withDesc(
|
|
499
|
+
{ type: "number", default: 14 },
|
|
500
|
+
"Age threshold in days (default 14)",
|
|
501
|
+
),
|
|
502
|
+
dryRun: withDesc(
|
|
503
|
+
{ type: "boolean", default: true },
|
|
504
|
+
"Preview only (default true). Set false to actually supersede.",
|
|
505
|
+
),
|
|
506
|
+
}),
|
|
507
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
508
|
+
const days = Number(args.olderThanDays) > 0 ? Number(args.olderThanDays) : 14;
|
|
509
|
+
const dryRun = args.dryRun !== false; // only an explicit false mutates
|
|
510
|
+
const stale = await findStaleOpen(days);
|
|
511
|
+
if (!stale.length) {
|
|
512
|
+
return { content: `No active open threads older than ${days} days.` };
|
|
513
|
+
}
|
|
514
|
+
const listing = stale
|
|
515
|
+
.map((m) => ` ${m.ts} ${m.type}/${m.scope}: ${m.content.slice(0, 100)}`)
|
|
516
|
+
.join("\n");
|
|
517
|
+
if (dryRun) {
|
|
518
|
+
await record(
|
|
519
|
+
"decay_dry_run",
|
|
520
|
+
{ days, stale: stale.length, mem: stale.map((m) => m.ts).join(",") },
|
|
521
|
+
false,
|
|
522
|
+
);
|
|
523
|
+
return {
|
|
524
|
+
content:
|
|
525
|
+
`Dry run: ${stale.length} open thread(s) older than ${days} days:\n${listing}\n` +
|
|
526
|
+
"Re-run with dryRun=false to supersede them (snapshot first; recoverable via memory_undo).",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
// Snapshot-before-mutate: the SAME helper prune() uses in memory.ts,
|
|
530
|
+
// one whole-file copy per affected file, taken before the first
|
|
531
|
+
// mutation. markSuperseded also snapshots internally per row; this
|
|
532
|
+
// pass guarantees a pre-decay copy exists even if a later row fails.
|
|
533
|
+
const snapshot = await snapshotFiles([...new Set(stale.map((m) => m.file))]);
|
|
534
|
+
const retiredTs: string[] = [];
|
|
535
|
+
for (const m of stale) {
|
|
536
|
+
// Idempotent (a no-op on already-superseded rows), so a re-run
|
|
537
|
+
// cannot double-mark. No successor row exists — decay retires.
|
|
538
|
+
const ok = await markSuperseded(m.ts, { superseded_by: "decay", reason: "decay" });
|
|
539
|
+
if (ok) retiredTs.push(m.ts);
|
|
540
|
+
}
|
|
541
|
+
await record(
|
|
542
|
+
"decay_applied",
|
|
543
|
+
{
|
|
544
|
+
days,
|
|
545
|
+
retired: retiredTs.length,
|
|
546
|
+
mem: retiredTs.join(",") || "-",
|
|
547
|
+
snapshot: snapshot.join(",") || "-",
|
|
548
|
+
},
|
|
549
|
+
false,
|
|
550
|
+
);
|
|
551
|
+
return {
|
|
552
|
+
content:
|
|
553
|
+
`Decayed ${retiredTs.length} open thread(s) older than ${days} days ` +
|
|
554
|
+
`(superseded, not deleted; snapshot: ${snapshot[0] ?? "-"}). ` +
|
|
555
|
+
"Recover with memory_undo.",
|
|
556
|
+
};
|
|
557
|
+
},
|
|
558
|
+
});
|
|
428
559
|
});
|
|
429
560
|
|
|
430
|
-
// Per-session
|
|
431
|
-
//
|
|
432
|
-
|
|
561
|
+
// Per-session frozen injection epochs: the relevance block plus the store
|
|
562
|
+
// fingerprint it was computed against. Recomputed ONLY on the first prompt
|
|
563
|
+
// of a session, when the store changes, or after a compaction (see the
|
|
564
|
+
// prompt/compaction hooks) — so the system prefix, and with it the
|
|
565
|
+
// provider prompt cache, survives ordinary turns.
|
|
566
|
+
const epoch = new Map<string, { version: string; block: string }>();
|
|
433
567
|
|
|
434
568
|
// ---- hooks (all fail-closed) -------------------------------------------
|
|
435
569
|
// Bookkeeping: track the active session/tool/call before a tool runs.
|
|
@@ -469,41 +603,158 @@ const plugin = {
|
|
|
469
603
|
const parts: string[] = [];
|
|
470
604
|
await systemDigest({ sessionID: event?.sessionID }, { system: parts });
|
|
471
605
|
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
472
|
-
// Deliver this
|
|
606
|
+
// Deliver this epoch's relevance-ranked memory via the system channel.
|
|
473
607
|
// The UI never renders system parts, unlike appended prompt text.
|
|
474
608
|
const sid = event?.sessionID;
|
|
475
|
-
const knowledge = typeof sid === "string" ?
|
|
609
|
+
const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
|
|
476
610
|
if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
|
|
477
611
|
}),
|
|
478
612
|
);
|
|
479
613
|
|
|
480
|
-
// Memory-first injection
|
|
481
|
-
//
|
|
482
|
-
//
|
|
614
|
+
// Memory-first injection, frozen per epoch (cache-safe). The block is
|
|
615
|
+
// relevance-scored against the prompt that STARTED the epoch — accepted
|
|
616
|
+
// staleness, traded for a stable system prefix across ordinary turns. An
|
|
617
|
+
// epoch is invalidated only on first prompt, store change, or compaction.
|
|
618
|
+
// Never mutate event.prompt.text — it is stored verbatim and rendered in
|
|
619
|
+
// the UI.
|
|
483
620
|
await ctx.session?.hook("prompt", (event: any) =>
|
|
484
621
|
guarded("session.prompt (inject)", async () => {
|
|
485
622
|
const sid = event?.sessionID;
|
|
486
623
|
if (typeof sid === "string") {
|
|
487
624
|
markTurn(sid);
|
|
488
625
|
await checkNewSession(sid);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
626
|
+
const version = await storeVersion();
|
|
627
|
+
const cached = epoch.get(sid);
|
|
628
|
+
if (!cached || cached.version !== version) {
|
|
629
|
+
const text = event?.prompt?.text ?? "";
|
|
630
|
+
const raw = await relevantKnowledge(text, 5); // widen candidate pool
|
|
631
|
+
const block = raw ? capToBudget(raw, budget) : "";
|
|
632
|
+
epoch.set(sid, { version, block });
|
|
633
|
+
await record(
|
|
634
|
+
"epoch_recompute",
|
|
635
|
+
{
|
|
636
|
+
session: sid,
|
|
637
|
+
reason: cached ? "store_changed" : "first",
|
|
638
|
+
chars: block.length,
|
|
639
|
+
},
|
|
640
|
+
false,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
495
643
|
}
|
|
496
644
|
}),
|
|
497
645
|
);
|
|
498
646
|
|
|
499
|
-
// Session-convergence rollup at compaction.
|
|
647
|
+
// Session-convergence rollup at compaction. The epoch is dropped so the
|
|
648
|
+
// next prompt re-scores against the post-compaction store. Memory-aware
|
|
649
|
+
// compaction: the durable-memory anchor is pushed onto the hook's system
|
|
650
|
+
// parts (visible to the summarizer model, which runs AFTER hooks) so the
|
|
651
|
+
// summary preserves the facts. Never sets event.result — that would skip
|
|
652
|
+
// the summarizer call entirely.
|
|
500
653
|
await ctx.session?.hook("compaction", (event: any) =>
|
|
501
654
|
guarded("session.compaction (onCompacting)", async () => {
|
|
502
655
|
await onCompacting({ sessionID: event?.sessionID });
|
|
656
|
+
if (typeof event?.sessionID === "string") epoch.delete(event.sessionID);
|
|
657
|
+
const anchor = await compactionAnchor();
|
|
658
|
+
if (anchor) event?.system?.push?.({ type: "text", text: anchor });
|
|
503
659
|
}),
|
|
504
660
|
);
|
|
661
|
+
|
|
662
|
+
// ---- opt-in auto-capture on session idle --------------------------------
|
|
663
|
+
// Default OFF: zero-token installs never subscribe to anything. When
|
|
664
|
+
// enabled (options.autoCapture === true AND a valid captureModel), each
|
|
665
|
+
// session.idle event runs ONE cheap ctx.generate.text call whose reply is
|
|
666
|
+
// parsed into candidates that go through the SAME runWriteGate as
|
|
667
|
+
// model-authored writes — capture can never bypass the moat, and it only
|
|
668
|
+
// ever writes through the gate. A per-session cooldown bounds the cost.
|
|
669
|
+
// Every failure inside the loop is swallowed: capture must never be able
|
|
670
|
+
// to break a session.
|
|
671
|
+
let captureCleanup: Cleanup | undefined;
|
|
672
|
+
if (ctx.options?.autoCapture === true) {
|
|
673
|
+
const captureModel =
|
|
674
|
+
typeof ctx.options.captureModel === "string" ? ctx.options.captureModel : "";
|
|
675
|
+
const captureMax = Number(ctx.options.captureMax) || 3;
|
|
676
|
+
const modelRef = parseModelRef(captureModel);
|
|
677
|
+
if (!modelRef) {
|
|
678
|
+
// Invalid/missing model: skip registering the whole subscription
|
|
679
|
+
// rather than guess (and warn once, at setup).
|
|
680
|
+
console.warn(
|
|
681
|
+
`[opencode-self-improvement] autoCapture is on but captureModel ${JSON.stringify(captureModel)} is invalid (expected "provider/id"); auto-capture disabled.`,
|
|
682
|
+
);
|
|
683
|
+
} else if (!ctx.event?.subscribe || !ctx.generate?.text || !ctx.session?.context) {
|
|
684
|
+
console.warn(
|
|
685
|
+
"[opencode-self-improvement] autoCapture is on but this OpenCode context lacks event.subscribe/generate.text/session.context; auto-capture disabled.",
|
|
686
|
+
);
|
|
687
|
+
} else {
|
|
688
|
+
const controller = new AbortController();
|
|
689
|
+
const lastRun = new Map<string, number>();
|
|
690
|
+
const eventApi = ctx.event;
|
|
691
|
+
const generateApi = ctx.generate;
|
|
692
|
+
// Arrow wrapper: keep the receiver bound. The harness fakes are closures,
|
|
693
|
+
// but a real client method that reads `this` would break if detached —
|
|
694
|
+
// and the fail-closed catch below would hide it. Optional call: the
|
|
695
|
+
// setup guard proves it exists, but property narrowing does not survive
|
|
696
|
+
// into the closure, and undefined degrades safely (empty transcript).
|
|
697
|
+
const contextFn = (input: { sessionID: string }) => ctx.session?.context?.(input);
|
|
698
|
+
void (async () => {
|
|
699
|
+
try {
|
|
700
|
+
for await (const raw of eventApi.subscribe({ signal: controller.signal })) {
|
|
701
|
+
const event = raw as Record<string, any>;
|
|
702
|
+
if (event?.type !== "session.idle") continue;
|
|
703
|
+
const sid = event.sessionID ?? event.properties?.sessionID;
|
|
704
|
+
if (typeof sid !== "string") continue;
|
|
705
|
+
const now = Date.now();
|
|
706
|
+
if (now - (lastRun.get(sid) ?? 0) < 5 * 60_000) continue; // 5-min cooldown
|
|
707
|
+
lastRun.set(sid, now);
|
|
708
|
+
try {
|
|
709
|
+
const msgs = await contextFn({ sessionID: sid });
|
|
710
|
+
const transcript = flattenTranscript(msgs);
|
|
711
|
+
if (transcript.length < 200) continue; // too thin to mine reliably
|
|
712
|
+
const out = await generateApi.text({
|
|
713
|
+
model: modelRef,
|
|
714
|
+
prompt: buildCapturePrompt(transcript.slice(-8000), captureMax),
|
|
715
|
+
});
|
|
716
|
+
const text = typeof out === "string" ? out : out?.text ?? "";
|
|
717
|
+
for (const c of parseCandidates(text, captureMax)) {
|
|
718
|
+
const outcome = await runWriteGate(
|
|
719
|
+
{ ...c, tags: ["auto-capture"] },
|
|
720
|
+
gateContext(),
|
|
721
|
+
);
|
|
722
|
+
await record(
|
|
723
|
+
`auto_capture_${outcome.action}`,
|
|
724
|
+
{ session: sid, type: c.type, scope: c.scope, reason: outcome.reason ?? "" },
|
|
725
|
+
false,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
// fail-closed: a broken capture attempt is invisible to the session
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
// the subscription itself ended (abort or host error); nothing to do
|
|
734
|
+
}
|
|
735
|
+
})();
|
|
736
|
+
captureCleanup = () => controller.abort();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Single cleanup returned by setup: tears down the capture subscription.
|
|
740
|
+
// Future teardowns merge into this same function.
|
|
741
|
+
return captureCleanup;
|
|
505
742
|
},
|
|
506
743
|
};
|
|
507
744
|
|
|
745
|
+
// The plain object IS the V2 contract ({ id, setup }); the SDK's `Plugin` type
|
|
746
|
+
// pins its shape at compile time. The import is deliberately type-only:
|
|
747
|
+
// opencode installs npm plugins with runtime dependencies only (verified in
|
|
748
|
+
// ~/.cache/opencode/npm/<pkg>@<ver>/), so a value import of @opencode/plugin —
|
|
749
|
+
// a devDependency here — would fail to resolve at load time for published
|
|
750
|
+
// consumers, and making it a runtime dependency would pull effect +
|
|
751
|
+
// @opencode/client into every install, the exact dead-weight problem dropping
|
|
752
|
+
// the V1 SDK fixes. `Plugin.define` is an identity function at runtime, so this
|
|
753
|
+
// annotation buys the same compile-time contract for zero runtime cost.
|
|
754
|
+
const plugin: Plugin = {
|
|
755
|
+
id: PLUGIN_ID,
|
|
756
|
+
setup: async (ctx) => impl.setup(ctx as unknown as PluginCtx),
|
|
757
|
+
};
|
|
758
|
+
|
|
508
759
|
export const SelfImprovement = plugin;
|
|
509
760
|
export default plugin;
|
package/src/inject.ts
CHANGED
|
@@ -135,6 +135,65 @@ export async function relevantKnowledge(message: string, limit = 3): Promise<str
|
|
|
135
135
|
return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Deterministic fingerprint of the active store: `${activeCount}:${maxActiveTs}`.
|
|
139
|
+
// Cheap change detector for the per-session epoch cache in index.ts: the frozen
|
|
140
|
+
// injection block is recomputed only when this string changes (or after a
|
|
141
|
+
// compaction). Evidence and superseded rows never affect it (I3, memory.ts).
|
|
142
|
+
export async function storeVersion(): Promise<string> {
|
|
143
|
+
const mems = (await loadAll()).filter(isActive);
|
|
144
|
+
let max = "";
|
|
145
|
+
for (const m of mems) if (m.ts > max) max = m.ts;
|
|
146
|
+
return `${mems.length}:${max}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Trim an injection block to `budget` characters at WHOLE-line boundaries:
|
|
150
|
+
// keep lines while the joined length stays <= budget, drop the rest, never cut
|
|
151
|
+
// mid-line. Returns "" when even the header line exceeds the budget (the block
|
|
152
|
+
// is not worth injecting).
|
|
153
|
+
export function capToBudget(block: string, budget: number): string {
|
|
154
|
+
const kept: string[] = [];
|
|
155
|
+
let len = 0;
|
|
156
|
+
for (const line of block.split("\n")) {
|
|
157
|
+
const add = kept.length ? line.length + 1 : line.length;
|
|
158
|
+
if (len + add > budget) break;
|
|
159
|
+
kept.push(line);
|
|
160
|
+
len += add;
|
|
161
|
+
}
|
|
162
|
+
return kept.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Memory-aware compaction (Phase 3): a durable-memory block pushed onto the
|
|
166
|
+
// V2 compaction hook's `system` parts, where the summarizer model can see it
|
|
167
|
+
// (OpenCode appends its own summary prompt AFTER hooks). Deterministic, zero
|
|
168
|
+
// extra model calls; fixes "compaction amnesia". Composition: up to 10 open
|
|
169
|
+
// threads newest-first, then up to 5 newest settled memories, total rows
|
|
170
|
+
// capped by `limit`; the whole block capped at 2500 chars at whole-line
|
|
171
|
+
// boundaries. "" when the store has no active memories.
|
|
172
|
+
const ANCHOR_OPEN_LIMIT = 10;
|
|
173
|
+
const ANCHOR_SETTLED_LIMIT = 5;
|
|
174
|
+
const ANCHOR_BUDGET = 2500;
|
|
175
|
+
|
|
176
|
+
export async function compactionAnchor(limit = 12): Promise<string> {
|
|
177
|
+
const mems = (await loadAll()).filter(isActive);
|
|
178
|
+
if (!mems.length) return "";
|
|
179
|
+
const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
|
|
180
|
+
const open = mems
|
|
181
|
+
.filter((m) => m.status === "open")
|
|
182
|
+
.sort(byNewest)
|
|
183
|
+
.slice(0, Math.min(ANCHOR_OPEN_LIMIT, Math.max(0, limit)));
|
|
184
|
+
const settled = mems
|
|
185
|
+
.filter((m) => m.status !== "open")
|
|
186
|
+
.sort(byNewest)
|
|
187
|
+
.slice(0, Math.min(ANCHOR_SETTLED_LIMIT, Math.max(0, limit - open.length)));
|
|
188
|
+
const rows = [...open, ...settled];
|
|
189
|
+
if (!rows.length) return "";
|
|
190
|
+
const block =
|
|
191
|
+
"## Durable memory to preserve in this summary\n" +
|
|
192
|
+
"Preserve these facts verbatim (dates, commands, ids) in the compaction summary:\n" +
|
|
193
|
+
rows.map(formatMemory).join("\n");
|
|
194
|
+
return capToBudget(block, ANCHOR_BUDGET);
|
|
195
|
+
}
|
|
196
|
+
|
|
138
197
|
export async function contextualPull(input: PullInput, output: PullOutput): Promise<void> {
|
|
139
198
|
const tool = typeof input.tool === "string" ? input.tool : "";
|
|
140
199
|
if (!["grep", "glob", "read"].includes(tool)) return;
|
package/src/portable.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Portability (Phase 5): JSONL export/import of the durable store, plus the
|
|
2
|
+
// stale-open-thread query that backs memory_decay. Import NEVER writes: every
|
|
3
|
+
// row flows through the caller-provided gate callback (index.ts wires it to
|
|
4
|
+
// runWriteGate), so moving memories between machines cannot bypass the 4-gate
|
|
5
|
+
// moat. Decay is supersede-not-delete and snapshot-first, mirroring prune.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { memoryRoot } from "./config.ts";
|
|
8
|
+
import { isActive, loadAll, type Memory } from "./memory.ts";
|
|
9
|
+
import { TYPES } from "./types.ts";
|
|
10
|
+
import type { WriteCandidate } from "./gates.ts";
|
|
11
|
+
|
|
12
|
+
// --- export -------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface ExportOptions {
|
|
15
|
+
/** Only export rows from this exact scope. */
|
|
16
|
+
scope?: string;
|
|
17
|
+
/** Destination path; defaults to <memory-root>/exports/<stamp>.jsonl. */
|
|
18
|
+
file?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ExportResult {
|
|
22
|
+
path: string;
|
|
23
|
+
count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Filesystem-safe YYYY-MM-DDTHHMMSS (no colons).
|
|
27
|
+
function stamp(d: Date = new Date()): string {
|
|
28
|
+
return d.toISOString().slice(0, 19).replace(/:/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Write the ACTIVE store as JSONL — one JSON object per line carrying
|
|
32
|
+
// {ts,type,scope,content,status,tags,issue}. Superseded rows are not exported:
|
|
33
|
+
// the export is the live store, and history stays auditable where it lives
|
|
34
|
+
// (the original logfmt files + snapshots).
|
|
35
|
+
export async function exportMemories(opts: ExportOptions = {}): Promise<ExportResult> {
|
|
36
|
+
let mems = (await loadAll()).filter(isActive);
|
|
37
|
+
if (opts.scope) mems = mems.filter((m) => m.scope === opts.scope);
|
|
38
|
+
const path = opts.file ?? join(memoryRoot(), "exports", `${stamp()}.jsonl`);
|
|
39
|
+
const lines = mems.map((m) =>
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
ts: m.ts,
|
|
42
|
+
type: m.type,
|
|
43
|
+
scope: m.scope,
|
|
44
|
+
content: m.content,
|
|
45
|
+
status: m.status,
|
|
46
|
+
tags: m.tags,
|
|
47
|
+
issue: m.issue,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
await Bun.write(path, lines.length ? lines.join("\n") + "\n" : "");
|
|
51
|
+
return { path, count: mems.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- import -------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface ImportTally {
|
|
57
|
+
promoted: number;
|
|
58
|
+
collapsed: number;
|
|
59
|
+
blocked: number;
|
|
60
|
+
skipped: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A line is malformed (skipped, never gated) unless it is a JSON object with a
|
|
64
|
+
// known type, a non-empty scope, and non-empty content. Optional fields are
|
|
65
|
+
// sanitized rather than fatal: an unusable status/tags/issue is dropped.
|
|
66
|
+
function parseRow(line: string): WriteCandidate | null {
|
|
67
|
+
let row: unknown;
|
|
68
|
+
try {
|
|
69
|
+
row = JSON.parse(line);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
74
|
+
const r = row as Record<string, unknown>;
|
|
75
|
+
if (typeof r.type !== "string" || !(TYPES as readonly string[]).includes(r.type)) return null;
|
|
76
|
+
if (typeof r.scope !== "string" || !r.scope.trim()) return null;
|
|
77
|
+
if (typeof r.content !== "string" || !r.content.trim()) return null;
|
|
78
|
+
const c: WriteCandidate = { type: r.type, scope: r.scope, content: r.content };
|
|
79
|
+
if (r.status === "open" || r.status === "settled") c.status = r.status;
|
|
80
|
+
if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
|
|
81
|
+
if (typeof r.issue === "string" && r.issue) c.issue = r.issue;
|
|
82
|
+
return c;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read a JSONL export and run every valid row through `gate`. The CALLER owns
|
|
86
|
+
// promotion (index.ts passes runWriteGate with the "import" tag), so imported
|
|
87
|
+
// rows face the same canonical/stale/dedup/ambiguity gates as any other write.
|
|
88
|
+
// Blocked and collapsed rows are normal outcomes, tallied rather than raised.
|
|
89
|
+
// Only a missing file throws.
|
|
90
|
+
export async function importMemories(
|
|
91
|
+
path: string,
|
|
92
|
+
gate: (c: WriteCandidate) => Promise<{ action: string }>,
|
|
93
|
+
): Promise<ImportTally> {
|
|
94
|
+
const file = Bun.file(path);
|
|
95
|
+
if (!(await file.exists())) throw new Error(`import file not found: ${path}`);
|
|
96
|
+
const tally: ImportTally = { promoted: 0, collapsed: 0, blocked: 0, skipped: 0 };
|
|
97
|
+
for (const line of (await file.text()).split("\n")) {
|
|
98
|
+
if (!line.trim()) continue;
|
|
99
|
+
const c = parseRow(line);
|
|
100
|
+
if (!c) {
|
|
101
|
+
tally.skipped++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const outcome = await gate(c);
|
|
105
|
+
if (outcome.action === "promoted") tally.promoted++;
|
|
106
|
+
else if (outcome.action === "collapsed") tally.collapsed++;
|
|
107
|
+
else tally.blocked++;
|
|
108
|
+
}
|
|
109
|
+
return tally;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- decay support --------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// Active open threads with ts older than `days` days, oldest first (both sides
|
|
115
|
+
// are UTC-Z ISO strings, so plain lexicographic compare is correct).
|
|
116
|
+
export async function findStaleOpen(days: number): Promise<Memory[]> {
|
|
117
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
118
|
+
return (await loadAll())
|
|
119
|
+
.filter((m) => isActive(m) && m.status === "open" && m.ts < cutoff)
|
|
120
|
+
.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
121
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Shared memory-row enums. Lives outside index.ts so the pure capture core
|
|
2
|
+
// (src/capture.ts) can reference the admissible values without importing the
|
|
3
|
+
// plugin entry. index.ts feeds these into the tool JSON Schemas; capture.ts
|
|
4
|
+
// uses them to prompt and validate.
|
|
5
|
+
export const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
|
|
6
|
+
export const STATUSES = ["open", "settled"] as const;
|