opencode-harnmem 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +45 -2
- package/index.js +512 -0
- package/lib/engine.mjs +72 -0
- package/lib/format.mjs +119 -0
- package/lib/settings.mjs +131 -0
- package/package.json +23 -3
- package/vendor/core/assets.d.ts +52 -0
- package/vendor/core/assets.d.ts.map +1 -0
- package/vendor/core/assets.js +227 -0
- package/vendor/core/assets.js.map +1 -0
- package/vendor/core/config.d.ts +36 -0
- package/vendor/core/config.d.ts.map +1 -0
- package/vendor/core/config.js +134 -0
- package/vendor/core/config.js.map +1 -0
- package/vendor/core/consolidate.d.ts +14 -0
- package/vendor/core/consolidate.d.ts.map +1 -0
- package/vendor/core/consolidate.js +237 -0
- package/vendor/core/consolidate.js.map +1 -0
- package/vendor/core/data/synonyms.en.txt +61 -0
- package/vendor/core/data/synonyms.zh.txt +157 -0
- package/vendor/core/extract.d.ts +49 -0
- package/vendor/core/extract.d.ts.map +1 -0
- package/vendor/core/extract.js +179 -0
- package/vendor/core/extract.js.map +1 -0
- package/vendor/core/frontmatter.d.ts +31 -0
- package/vendor/core/frontmatter.d.ts.map +1 -0
- package/vendor/core/frontmatter.js +372 -0
- package/vendor/core/frontmatter.js.map +1 -0
- package/vendor/core/harnmem.d.ts +87 -0
- package/vendor/core/harnmem.d.ts.map +1 -0
- package/vendor/core/harnmem.js +918 -0
- package/vendor/core/harnmem.js.map +1 -0
- package/vendor/core/hot-context.d.ts +28 -0
- package/vendor/core/hot-context.d.ts.map +1 -0
- package/vendor/core/hot-context.js +179 -0
- package/vendor/core/hot-context.js.map +1 -0
- package/vendor/core/index.d.ts +16 -0
- package/vendor/core/index.d.ts.map +1 -0
- package/vendor/core/index.js +10 -0
- package/vendor/core/index.js.map +1 -0
- package/vendor/core/indexer.d.ts +107 -0
- package/vendor/core/indexer.d.ts.map +1 -0
- package/vendor/core/indexer.js +282 -0
- package/vendor/core/indexer.js.map +1 -0
- package/vendor/core/inspect.d.ts +278 -0
- package/vendor/core/inspect.d.ts.map +1 -0
- package/vendor/core/inspect.js +643 -0
- package/vendor/core/inspect.js.map +1 -0
- package/vendor/core/lifecycle.d.ts +50 -0
- package/vendor/core/lifecycle.d.ts.map +1 -0
- package/vendor/core/lifecycle.js +249 -0
- package/vendor/core/lifecycle.js.map +1 -0
- package/vendor/core/project.d.ts +41 -0
- package/vendor/core/project.d.ts.map +1 -0
- package/vendor/core/project.js +197 -0
- package/vendor/core/project.js.map +1 -0
- package/vendor/core/prompts/README.md +96 -0
- package/vendor/core/prompts/consolidate-digest.md +31 -0
- package/vendor/core/prompts/extract.md +80 -0
- package/vendor/core/prompts/migrate.md +96 -0
- package/vendor/core/prompts/synonyms.md +43 -0
- package/vendor/core/prompts/write-policy.md +12 -0
- package/vendor/core/retrieval.d.ts +95 -0
- package/vendor/core/retrieval.d.ts.map +1 -0
- package/vendor/core/retrieval.js +569 -0
- package/vendor/core/retrieval.js.map +1 -0
- package/vendor/core/store.d.ts +145 -0
- package/vendor/core/store.d.ts.map +1 -0
- package/vendor/core/store.js +511 -0
- package/vendor/core/store.js.map +1 -0
- package/vendor/core/suggest.d.ts +128 -0
- package/vendor/core/suggest.d.ts.map +1 -0
- package/vendor/core/suggest.js +384 -0
- package/vendor/core/suggest.js.map +1 -0
- package/vendor/core/text.d.ts +80 -0
- package/vendor/core/text.d.ts.map +1 -0
- package/vendor/core/text.js +253 -0
- package/vendor/core/text.js.map +1 -0
- package/vendor/core/types.d.ts +453 -0
- package/vendor/core/types.d.ts.map +1 -0
- package/vendor/core/types.js +14 -0
- package/vendor/core/types.js.map +1 -0
- package/vendor/core/util.d.ts +38 -0
- package/vendor/core/util.d.ts.map +1 -0
- package/vendor/core/util.js +248 -0
- package/vendor/core/util.js.map +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HarnMem contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-harnmem — HarnMem memory for opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-harness persistent memory for [opencode](https://opencode.ai) v2.x, powered by [`@harnmem/core`](../../README.md). One store (`~/.harnmem/`), shared with the pi / DSH / Claude / Codex adapters: a preference or decision captured in one harness is visible in every other one on the next session start.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Stable injection** — a `session.hook("context")` callback pushes a byte-stable snapshot of your hottest memories as a system part. Empty store ⇒ zero parts pushed. The snapshot is computed once per session and only rebuilt after a write or at day rollover, so prompt caching stays intact across turns.
|
|
8
|
+
- **Five native tools** — `memory_search`, `memory_read`, `memory_write`, `memory_forget`, `memory_status` registered through `tool.transform`. No MCP child process: the tools run in-process against the vendored core.
|
|
9
|
+
- **Per-turn journaling** — every `session.hook("prompt")` appends the transcript tail (capped: 400 messages × 4,000 chars) to the shared session journal, deduplicated by signature, so another harness (or a future `harnmem` CLI extraction pass) can mine this session later. Two caveats, both inherent to the hook firing before the current turn's messages exist: single-turn CLI runs journal nothing, and the **final turn of any session is captured only when that session continues later** (`--continue` / TUI resume). Extraction with an in-process LLM (`ctx.generate.text`) is planned for v0.2; the journal is written today regardless.
|
|
10
|
+
- **Subagent-safe** — child sessions (`parentID` set) run read-only: no injection, no journaling, and the write tool answers read-only instead of writing.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Add the package to your `opencode.json`:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{ "$schema": "https://opencode.ai/config.json", "plugin": ["opencode-harnmem"] }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
From a checkout, point the entry at the package directory (verified to load local packages):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "plugin": ["D:/projects/harnmem/packages/opencode-harnmem"] }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`@harnmem/core` is vendored into `vendor/core/` at build time (`pnpm build` in this package), so the plugin directory is self-contained. Requires opencode >= 2 (the `{ id, setup }` plugin contract); on opencode 1.x the plugin prints one line and does not load.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
Environment first, then files — malformed config is ignored, never fatal:
|
|
31
|
+
|
|
32
|
+
1. `HARNMEM_DISABLED`, `HARNMEM_READONLY`, `HARNMEM_DEBUG`, `HARNMEM_AUTO_EXTRACT`, `HARNMEM_INJECT`, `HARNMEM_OPENCODE_MAX_INJECT_TOKENS`, `HARNMEM_HOME`
|
|
33
|
+
2. `~/.harnmem/config.json` (shared with every adapter)
|
|
34
|
+
3. `<project>/.harnmem.json`
|
|
35
|
+
|
|
36
|
+
Injection is capped at 10,000 bytes with deterministic line-based truncation (same rule as the Claude adapter).
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Tools map bare non-object arguments onto their single required key (probed: opencode can deliver `args` as a raw string) and every tool call degrades to a friendly text result instead of an error.
|
|
41
|
+
- All failures are swallowed at the plugin boundary: a broken store can never wedge a session.
|
|
42
|
+
- Design and verification evidence: `docs/research/opencode-plugin.md` (contract probe log included).
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT — see [LICENSE](./LICENSE).
|
package/index.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-harnmem: HarnMem plugin for opencode v2 (domain plugin API).
|
|
3
|
+
*
|
|
4
|
+
* Contract: docs/design/07-adapter-contract.md (v1). Verified against the
|
|
5
|
+
* opencode v2.1.4 binary — see docs/research/opencode-plugin.md for the
|
|
6
|
+
* full probe evidence. Loading shape (hard requirement, probed):
|
|
7
|
+
*
|
|
8
|
+
* export default { id, setup(ctx) } // plain object, setup = async fn
|
|
9
|
+
*
|
|
10
|
+
* Four layers, all in-process (no MCP server, no transcript files):
|
|
11
|
+
* 1. Injection — ctx.session.hook("context"): push one byte-stable part
|
|
12
|
+
* into inp.system on every LLM request (cached per session).
|
|
13
|
+
* 2. Tools — ctx.tool.transform: native memory_write/search/read/
|
|
14
|
+
* forget/status with plain JSON-Schema args (no zod).
|
|
15
|
+
* 3. Lifecycle — ctx.session.hook("prompt"): journal the transcript tail
|
|
16
|
+
* via eng.sessionEnd each turn; abrupt exits lose at most
|
|
17
|
+
* the current turn (no end-hook needed).
|
|
18
|
+
* 4. Config — lib/settings.mjs (env > ~/.harnmem/config.json > project).
|
|
19
|
+
*
|
|
20
|
+
* Every failure is swallowed with a debug log: the session must always
|
|
21
|
+
* proceed (adapter contract: extraction/injection is bounded, never fatal).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { openEngine } from "./lib/engine.mjs";
|
|
25
|
+
import { resolveSettings } from "./lib/settings.mjs";
|
|
26
|
+
import { formatWrite, formatSearch, formatRead, formatForget, formatStats } from "./lib/format.mjs";
|
|
27
|
+
|
|
28
|
+
const MAX_INJECT_BYTES = 10_000;
|
|
29
|
+
const MAX_MESSAGES = 400;
|
|
30
|
+
const MAX_TEXT_CHARS = 4000;
|
|
31
|
+
// Per-(session, cwd, mode) engines: core keeps session-scoped mutable state
|
|
32
|
+
// (write provenance `source.session`, modelWrites/extracted suppression
|
|
33
|
+
// counters), so sharing one engine across sessions would misattribute writes
|
|
34
|
+
// and leak suppression across sessions. Read-only (subagent) engines are keyed
|
|
35
|
+
// separately from read-write ones so a child session can never poison the
|
|
36
|
+
// main session's write path with a read-only engine (review round 2, M1/M2).
|
|
37
|
+
const MAX_ENGINES = 8;
|
|
38
|
+
|
|
39
|
+
const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
|
|
40
|
+
const CATEGORIES = ["profile", "preference", "decision", "convention", "lesson", "pitfall", "state", "external"];
|
|
41
|
+
const SCOPES = ["auto", "user", "project"];
|
|
42
|
+
/** memory_search scope: SearchOptions.scope has no "auto" mode (write-only concept). */
|
|
43
|
+
const SEARCH_SCOPES = ["user", "project", "all"];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Major version from an opencode version string ("2.1.4" -> 2). Returns null
|
|
47
|
+
* when unparseable (callers treat null as "unknown, proceed").
|
|
48
|
+
*/
|
|
49
|
+
export function pluginVersionMajor(version) {
|
|
50
|
+
const m = /^(\d+)/.exec(String(version ?? ""));
|
|
51
|
+
return m ? Number(m[1]) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Deterministic truncation to the injection byte cap (same rule as claude). */
|
|
55
|
+
export function capBytes(text, maxBytes = MAX_INJECT_BYTES) {
|
|
56
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
57
|
+
const suffix = "\n<!-- harnmem: snapshot truncated to fit the injection cap -->";
|
|
58
|
+
const budget = maxBytes - Buffer.byteLength(suffix, "utf8");
|
|
59
|
+
const out = [];
|
|
60
|
+
let used = 0;
|
|
61
|
+
for (const line of text.split("\n")) {
|
|
62
|
+
const cost = Buffer.byteLength(line, "utf8") + 1;
|
|
63
|
+
if (used + cost > budget) break;
|
|
64
|
+
out.push(line);
|
|
65
|
+
used += cost;
|
|
66
|
+
}
|
|
67
|
+
return out.join("\n") + suffix;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* opencode session.context() messages -> core TranscriptMessage[]
|
|
72
|
+
* ({role, text, ts?}). Tolerant by design: unknown shapes are skipped,
|
|
73
|
+
* never fatal. Reasoning blocks are excluded (same rule as the claude
|
|
74
|
+
* transcript parser); text is truncated per message and the tail is kept.
|
|
75
|
+
*/
|
|
76
|
+
export function toCoreMessages(raw) {
|
|
77
|
+
const out = [];
|
|
78
|
+
if (!Array.isArray(raw)) return out;
|
|
79
|
+
for (const m of raw) {
|
|
80
|
+
if (!m || typeof m !== "object") continue;
|
|
81
|
+
const role = m.type;
|
|
82
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
83
|
+
let text = typeof m.text === "string" ? m.text : "";
|
|
84
|
+
if (Array.isArray(m.content)) {
|
|
85
|
+
const parts = [];
|
|
86
|
+
for (const block of m.content) {
|
|
87
|
+
if (block && block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
88
|
+
}
|
|
89
|
+
if (parts.length) text = parts.join("\n");
|
|
90
|
+
}
|
|
91
|
+
text = String(text || "").trim();
|
|
92
|
+
if (!text) continue;
|
|
93
|
+
if (text.length > MAX_TEXT_CHARS) text = text.slice(0, MAX_TEXT_CHARS);
|
|
94
|
+
let ts;
|
|
95
|
+
const t = m.time?.created ?? m.time?.created_at;
|
|
96
|
+
if (typeof t === "number" && Number.isFinite(t)) ts = t;
|
|
97
|
+
else if (typeof t === "string") {
|
|
98
|
+
const parsed = Date.parse(t);
|
|
99
|
+
if (Number.isFinite(parsed)) ts = parsed;
|
|
100
|
+
}
|
|
101
|
+
out.push({ role, text, ...(ts !== undefined ? { ts } : {}) });
|
|
102
|
+
}
|
|
103
|
+
return out.slice(-MAX_MESSAGES);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tools may receive a bare JSON value instead of an object when the model
|
|
108
|
+
* emits non-object arguments (probed: args `"test"` for a single-string
|
|
109
|
+
* schema). Map a bare value onto the single required property; otherwise
|
|
110
|
+
* pass objects through untouched.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeArgs(args, requiredKey) {
|
|
113
|
+
if (args === null || args === undefined) return {};
|
|
114
|
+
if (typeof args === "object" && !Array.isArray(args)) return args;
|
|
115
|
+
if (requiredKey && (typeof args === "string" || typeof args === "number" || typeof args === "boolean")) {
|
|
116
|
+
return { [requiredKey]: args };
|
|
117
|
+
}
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Testable plugin core. `deps` overrides are for unit tests only. */
|
|
122
|
+
export function createPluginState(deps = {}) {
|
|
123
|
+
const state = {
|
|
124
|
+
ctx: null,
|
|
125
|
+
fallbackCwd: process.cwd(),
|
|
126
|
+
engines: new Map(), // `${mode}:${cwd}#${sessionId}` -> engine (session-owned, LRU-capped)
|
|
127
|
+
opening: new Map(), // same key -> in-flight open promise (concurrent first-touch dedupe)
|
|
128
|
+
sessions: new Map(), // sessionID -> {id, cwd, readOnly, injectText, journalSig}
|
|
129
|
+
settingsCache: new Map(), // cwd -> resolved settings (process-lifetime by design; see resolveSettings)
|
|
130
|
+
deps,
|
|
131
|
+
|
|
132
|
+
debug(msg, data) {
|
|
133
|
+
try {
|
|
134
|
+
// Plugin-level gate: the fallback-cwd settings decide verbosity.
|
|
135
|
+
if (!this._debug) return;
|
|
136
|
+
process.stderr.write(`[harnmem:debug] ${msg}${data === undefined ? "" : " " + JSON.stringify(data)}\n`);
|
|
137
|
+
} catch {
|
|
138
|
+
// best effort
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
settingsFor(cwd) {
|
|
143
|
+
let settings = this.settingsCache.get(cwd);
|
|
144
|
+
if (!settings) {
|
|
145
|
+
settings = resolveSettings(cwd, process.env);
|
|
146
|
+
this.settingsCache.set(cwd, settings);
|
|
147
|
+
if (settings.debug) this._debug = true;
|
|
148
|
+
}
|
|
149
|
+
return settings;
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async sessionInfo(sessionId) {
|
|
153
|
+
let sess = this.sessions.get(sessionId);
|
|
154
|
+
if (sess) return sess;
|
|
155
|
+
sess = { id: sessionId, cwd: this.fallbackCwd, readOnly: false, injectText: undefined, journalSig: null };
|
|
156
|
+
try {
|
|
157
|
+
// Probed: parameter is an object; the record carries per-session cwd.
|
|
158
|
+
const rec = await this.ctx.session.get({ sessionID: sessionId });
|
|
159
|
+
if (rec && typeof rec === "object") {
|
|
160
|
+
if (rec.parentID) sess.readOnly = true; // child/subagent session
|
|
161
|
+
const dir = rec.location?.directory ?? rec.directory;
|
|
162
|
+
if (typeof dir === "string" && dir.trim()) sess.cwd = dir;
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.debug("session.get.failed", { sessionId, error: String(err?.message || err) });
|
|
166
|
+
}
|
|
167
|
+
this.sessions.set(sessionId, sess);
|
|
168
|
+
return sess;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async engineFor(sess) {
|
|
172
|
+
const key = `${sess.readOnly ? "ro" : "rw"}:${sess.cwd}#${sess.id}`;
|
|
173
|
+
const cached = this.engines.get(key);
|
|
174
|
+
if (cached) {
|
|
175
|
+
// Refresh recency: Maps iterate in insertion order, so re-inserting
|
|
176
|
+
// turns the FIFO structure into a real LRU.
|
|
177
|
+
this.engines.delete(key);
|
|
178
|
+
this.engines.set(key, cached);
|
|
179
|
+
return cached;
|
|
180
|
+
}
|
|
181
|
+
const inflight = this.opening.get(key);
|
|
182
|
+
if (inflight) return inflight; // concurrent first touch: one open only
|
|
183
|
+
const settings = this.settingsFor(sess.cwd);
|
|
184
|
+
const open = this.deps.openEngine || openEngine;
|
|
185
|
+
const opened = open(settings, {
|
|
186
|
+
cwd: sess.cwd,
|
|
187
|
+
sessionId: sess.id,
|
|
188
|
+
readOnly: sess.readOnly || settings.readOnly === true,
|
|
189
|
+
})
|
|
190
|
+
.then((eng) => {
|
|
191
|
+
this.engines.set(key, eng);
|
|
192
|
+
if (this.engines.size > MAX_ENGINES) {
|
|
193
|
+
const oldest = this.engines.keys().next().value;
|
|
194
|
+
const victim = this.engines.get(oldest);
|
|
195
|
+
this.engines.delete(oldest);
|
|
196
|
+
if (victim && victim !== eng) {
|
|
197
|
+
// Evicting an engine still used by a live session is safe:
|
|
198
|
+
// core close() only flushes advisory usage counters; the next
|
|
199
|
+
// touch for that key simply reopens.
|
|
200
|
+
Promise.resolve(victim.close()).catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return eng;
|
|
204
|
+
})
|
|
205
|
+
.finally(() => {
|
|
206
|
+
this.opening.delete(key);
|
|
207
|
+
});
|
|
208
|
+
this.opening.set(key, opened);
|
|
209
|
+
return opened;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/** Byte-stable injection text per session; "" means inject nothing. */
|
|
213
|
+
async injectTextFor(sessionId) {
|
|
214
|
+
const sess = await this.sessionInfo(sessionId);
|
|
215
|
+
if (sess.injectText !== undefined) return sess.injectText;
|
|
216
|
+
const settings = this.settingsFor(sess.cwd);
|
|
217
|
+
// Static gates resolve immediately; there is nothing to retry later.
|
|
218
|
+
if (!settings.enabled || !settings.inject || sess.readOnly) {
|
|
219
|
+
sess.injectText = "";
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
// Engine path: leave injectText undefined until success so a transient
|
|
223
|
+
// openEngine failure (file lock, double-instance race) is retried on
|
|
224
|
+
// the next request instead of caching "" forever (review round 2, M2).
|
|
225
|
+
const eng = await this.engineFor(sess);
|
|
226
|
+
const block = await eng.sessionStart({ sessionId, cwd: sess.cwd, reason: "session_start" });
|
|
227
|
+
const text = capBytes(block?.text || "");
|
|
228
|
+
this.debug("session-start", {
|
|
229
|
+
sessionId,
|
|
230
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
231
|
+
ids: block?.ids?.length ?? 0,
|
|
232
|
+
readOnly: sess.readOnly,
|
|
233
|
+
});
|
|
234
|
+
sess.injectText = text; // empty store: inject nothing (0-byte rule)
|
|
235
|
+
return text;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Journal the transcript tail at turn start. The prompt hook fires
|
|
240
|
+
* before generation, so each call captures everything up to the previous
|
|
241
|
+
* turn; core writes the session journal even when extraction is
|
|
242
|
+
* skipped (no_llm), which is what later extraction/consolidation reads.
|
|
243
|
+
*/
|
|
244
|
+
async journalTurn(sessionId, reason = "turn") {
|
|
245
|
+
const sess = await this.sessionInfo(sessionId);
|
|
246
|
+
this.debug("journal.enter", { sessionId, readOnly: sess.readOnly });
|
|
247
|
+
if (sess.readOnly) return;
|
|
248
|
+
const settings = this.settingsFor(sess.cwd);
|
|
249
|
+
if (!settings.enabled) return;
|
|
250
|
+
const raw = await this.ctx.session.context({ sessionID: sessionId });
|
|
251
|
+
const messages = toCoreMessages(raw);
|
|
252
|
+
const last = messages[messages.length - 1];
|
|
253
|
+
const sig = messages.length === 0 ? "0" : `${messages.length}:${last.text.slice(0, 80)}`;
|
|
254
|
+
if (sig === sess.journalSig) return;
|
|
255
|
+
const eng = await this.engineFor(sess);
|
|
256
|
+
const result = await eng.sessionEnd({ sessionId, cwd: sess.cwd, reason, messages });
|
|
257
|
+
// Only remember the signature after sessionEnd succeeded, so a failed
|
|
258
|
+
// write is retried on the next turn instead of being silently dropped.
|
|
259
|
+
sess.journalSig = sig;
|
|
260
|
+
this.debug("journal", { sessionId, status: result?.status, messages: messages.length });
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async onPrompt(inp) {
|
|
264
|
+
try {
|
|
265
|
+
this.debug("prompt.enter", { keys: inp && typeof inp === "object" ? Object.keys(inp) : String(inp) });
|
|
266
|
+
const sessionId = inp?.sessionID;
|
|
267
|
+
if (typeof sessionId !== "string" || !sessionId) return;
|
|
268
|
+
await this.journalTurn(sessionId, "turn");
|
|
269
|
+
} catch (err) {
|
|
270
|
+
this.debug("prompt.journal.failed", { error: String(err?.message || err) });
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async onContext(inp) {
|
|
275
|
+
try {
|
|
276
|
+
const sessionId = inp?.sessionID;
|
|
277
|
+
if (typeof sessionId !== "string" || !sessionId) return;
|
|
278
|
+
if (!Array.isArray(inp.system)) return;
|
|
279
|
+
const text = await this.injectTextFor(sessionId);
|
|
280
|
+
if (text) inp.system.push({ type: "text", text });
|
|
281
|
+
} catch (err) {
|
|
282
|
+
this.debug("context.inject.failed", { error: String(err?.message || err) });
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
/** Shared tool body: settings gate + engine + error containment. */
|
|
287
|
+
async toolRun(toolName, args, tc, fn) {
|
|
288
|
+
const fail = (msg) => ({ title: toolName, content: [{ type: "text", text: msg }], metadata: {} });
|
|
289
|
+
try {
|
|
290
|
+
const sessionId = tc?.sessionID;
|
|
291
|
+
if (typeof sessionId !== "string" || !sessionId) return fail(`${toolName}: no session context`);
|
|
292
|
+
const sess = await this.sessionInfo(sessionId);
|
|
293
|
+
const settings = this.settingsFor(sess.cwd);
|
|
294
|
+
if (!settings.enabled) return fail("harnmem: memory store is disabled (HARNMEM_DISABLED or config)");
|
|
295
|
+
const eng = await this.engineFor(sess);
|
|
296
|
+
return await fn(eng);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.debug("tool.failed", { tool: toolName, error: String(err?.message || err) });
|
|
299
|
+
return fail(`${toolName} failed: ${String(err?.message || err)}`);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
async toolWrite(rawArgs, tc) {
|
|
304
|
+
const args = normalizeArgs(rawArgs);
|
|
305
|
+
return this.toolRun("memory_write", args, tc, async (eng) => {
|
|
306
|
+
const input = { ...args };
|
|
307
|
+
// Clamp only real numbers; null/junk must be dropped entirely so
|
|
308
|
+
// core's defaults apply (importance 5 / confidence 0.7), not 0.
|
|
309
|
+
const clampNum = (v, lo, hi) => {
|
|
310
|
+
if (v === undefined || v === null) return undefined;
|
|
311
|
+
const n = Number(v);
|
|
312
|
+
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : undefined;
|
|
313
|
+
};
|
|
314
|
+
const imp = clampNum(input.importance, 0, 10);
|
|
315
|
+
if (imp === undefined) delete input.importance;
|
|
316
|
+
else input.importance = imp;
|
|
317
|
+
const conf = clampNum(input.confidence, 0, 1);
|
|
318
|
+
if (conf === undefined) delete input.confidence;
|
|
319
|
+
else input.confidence = conf;
|
|
320
|
+
const result = await eng.write(input);
|
|
321
|
+
if (["NEW", "MERGE", "SUPERSEDE", "LINK"].includes(result.action)) eng.noteModelWrite();
|
|
322
|
+
this.debug("tool.write", { id: result.id, action: result.action });
|
|
323
|
+
return { title: "memory_write", content: [{ type: "text", text: formatWrite(result) }], metadata: { id: result.id, action: result.action } };
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async toolSearch(rawArgs, tc) {
|
|
328
|
+
const args = normalizeArgs(rawArgs, "query");
|
|
329
|
+
return this.toolRun("memory_search", args, tc, async (eng) => {
|
|
330
|
+
const scope = args.scope === "auto" ? "all" : args.scope; // defensive: "auto" was a write-only concept
|
|
331
|
+
const result = await eng.search({
|
|
332
|
+
query: args.query,
|
|
333
|
+
limit: args.limit,
|
|
334
|
+
scope,
|
|
335
|
+
project: args.project,
|
|
336
|
+
includeArchived: args.include_archived,
|
|
337
|
+
includeHistory: args.include_history,
|
|
338
|
+
asOf: args.as_of,
|
|
339
|
+
entity: args.entity,
|
|
340
|
+
debug: args.debug,
|
|
341
|
+
});
|
|
342
|
+
this.debug("tool.search", { query: args.query, hits: result.results?.length, abstained: result.abstained });
|
|
343
|
+
return { title: "memory_search", content: [{ type: "text", text: formatSearch(result) }], metadata: { total: result.total, abstained: result.abstained } };
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
async toolRead(rawArgs, tc) {
|
|
348
|
+
const args = normalizeArgs(rawArgs, "id");
|
|
349
|
+
return this.toolRun("memory_read", args, tc, async (eng) => {
|
|
350
|
+
const result = await eng.read(args.id, { asOf: args.as_of, includeHistory: args.include_history });
|
|
351
|
+
this.debug("tool.read", { id: args.id, found: result !== null });
|
|
352
|
+
return { title: "memory_read", content: [{ type: "text", text: formatRead(result, args.id) }], metadata: { found: result !== null } };
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async toolForget(rawArgs, tc) {
|
|
357
|
+
const args = normalizeArgs(rawArgs, "id");
|
|
358
|
+
return this.toolRun("memory_forget", args, tc, async (eng) => {
|
|
359
|
+
const result = await eng.forget(args.id, { hard: args.hard === true });
|
|
360
|
+
this.debug("tool.forget", { id: args.id, mode: result.mode, ok: result.ok });
|
|
361
|
+
return { title: "memory_forget", content: [{ type: "text", text: formatForget(result) }], metadata: { ok: result.ok, mode: result.mode } };
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async toolStatus(rawArgs, tc) {
|
|
366
|
+
const args = normalizeArgs(rawArgs);
|
|
367
|
+
return this.toolRun("memory_status", args, tc, async (eng) => {
|
|
368
|
+
const stats = await eng.stats();
|
|
369
|
+
return { title: "memory_status", content: [{ type: "text", text: formatStats(stats) }], metadata: { version: stats.version } };
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
closeAll() {
|
|
374
|
+
for (const eng of this.engines.values()) {
|
|
375
|
+
try {
|
|
376
|
+
// close() is async (flushes advisory usage counters); swallow its
|
|
377
|
+
// rejection too — the try/catch below only covers sync throws, and
|
|
378
|
+
// an unhandled rejection at exit would surface as host noise.
|
|
379
|
+
Promise.resolve(eng.close()).catch(() => {});
|
|
380
|
+
} catch {
|
|
381
|
+
// best effort on exit
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
this.engines.clear();
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
return state;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const TOOL_DESCRIPTIONS = {
|
|
391
|
+
memory_write:
|
|
392
|
+
"Store a durable memory (user preference, feedback/lesson, project fact, reference). " +
|
|
393
|
+
"Use for facts worth remembering across sessions; do not store transient chat. " +
|
|
394
|
+
"Returns a one-line outcome (NEW/SKIP/MERGE/SUPERSEDE/LINK).",
|
|
395
|
+
memory_search:
|
|
396
|
+
"Search long-term memory by lexical/semantic-free BM25 over titles, descriptions, tags, entities and bodies. " +
|
|
397
|
+
"Use before asking the user to repeat something or before making project decisions.",
|
|
398
|
+
memory_read: "Open one memory record by id (frontmatter summary + body + backlinks).",
|
|
399
|
+
memory_forget:
|
|
400
|
+
"Forget a memory. Default moves it to trash (restorable); hard=true deletes permanently. " +
|
|
401
|
+
"Use when the user corrects a fact or asks to forget something.",
|
|
402
|
+
memory_status: "Show memory store health: shards, counts, capacities and pending events. Useful before large writes.",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const strEnum = (values, description) => ({ type: "string", enum: [...values], ...(description ? { description } : {}) });
|
|
406
|
+
const strArr = (description) => ({ type: "array", items: { type: "string" }, ...(description ? { description } : {}) });
|
|
407
|
+
// Probed flat-args convention (probe v11): reg.add args is a flat map of
|
|
408
|
+
// property schemas; fields marked `optional: true` are optional, bare fields
|
|
409
|
+
// are required. No zod, no {type:"object",properties} wrapper.
|
|
410
|
+
const opt = (schema, description) => ({ ...schema, ...(description ? { description } : {}), optional: true });
|
|
411
|
+
|
|
412
|
+
const TOOL_ARGS = {
|
|
413
|
+
memory_write: {
|
|
414
|
+
type: strEnum(MEMORY_TYPES, "Memory type."),
|
|
415
|
+
title: { type: "string", description: "Short title (<=80 chars), shown in the hot index." },
|
|
416
|
+
content: { type: "string", description: "Full memory body in markdown." },
|
|
417
|
+
description: { type: "string", description: "One-line description shown in search results and the hot index." },
|
|
418
|
+
category: opt(strEnum(CATEGORIES), "Memory category."),
|
|
419
|
+
scope: opt(strEnum(SCOPES), "user = global, project = this project, auto = decide by content."),
|
|
420
|
+
project: opt({ type: "string" }),
|
|
421
|
+
tags: opt(strArr()),
|
|
422
|
+
entities: opt(strArr()),
|
|
423
|
+
importance: opt({ type: "number" }, "0..10 (higher = more important), default 5"),
|
|
424
|
+
confidence: opt({ type: "number" }, "0..1, default 0.7"),
|
|
425
|
+
pinned: opt({ type: "boolean" }, "Always inject into the hot index."),
|
|
426
|
+
links: opt(strArr("memory id")),
|
|
427
|
+
supersedes: opt(strArr("memory id this replaces")),
|
|
428
|
+
evidence: opt(strArr()),
|
|
429
|
+
sensitive: opt({ type: "boolean" }, "Do not inject into the prompt; retrieval only."),
|
|
430
|
+
},
|
|
431
|
+
memory_search: {
|
|
432
|
+
query: { type: "string", description: "Keywords, names, or a short natural-language query." },
|
|
433
|
+
limit: opt({ type: "number" }, "Max results, default 8."),
|
|
434
|
+
scope: opt(strEnum(SEARCH_SCOPES), "Search scope."),
|
|
435
|
+
project: opt({ type: "string" }),
|
|
436
|
+
include_archived: opt({ type: "boolean" }),
|
|
437
|
+
include_history: opt({ type: "boolean" }),
|
|
438
|
+
as_of: opt({ type: "string" }, "ISO date or epoch-ms: only facts valid at that instant."),
|
|
439
|
+
entity: opt({ type: "string" }),
|
|
440
|
+
debug: opt({ type: "boolean" }, "Return match diagnostics with the results."),
|
|
441
|
+
},
|
|
442
|
+
memory_read: {
|
|
443
|
+
id: { type: "string", description: "Memory id, e.g. mem_..." },
|
|
444
|
+
as_of: opt({ type: "string" }, "ISO date or epoch-ms: view the record as it was at that instant."),
|
|
445
|
+
include_history: opt({ type: "boolean" }, "Include superseded versions."),
|
|
446
|
+
},
|
|
447
|
+
memory_forget: {
|
|
448
|
+
id: { type: "string", description: "Memory id, e.g. mem_..." },
|
|
449
|
+
hard: opt({ type: "boolean" }, "true = permanent delete; default move to trash"),
|
|
450
|
+
},
|
|
451
|
+
memory_status: {},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
export default {
|
|
455
|
+
id: "opencode-harnmem",
|
|
456
|
+
setup: async (ctx) => {
|
|
457
|
+
const major = pluginVersionMajor(ctx?.app?.version);
|
|
458
|
+
if (major !== null && major < 2) {
|
|
459
|
+
process.stderr.write(`[harnmem] opencode ${ctx?.app?.version} detected; this plugin requires opencode >= 2.x — not loaded\n`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const state = createPluginState();
|
|
464
|
+
state.ctx = ctx;
|
|
465
|
+
state.fallbackCwd = (typeof ctx?.location?.directory === "string" && ctx.location.directory.trim()) || process.cwd();
|
|
466
|
+
state.settingsFor(state.fallbackCwd); // prime debug gate
|
|
467
|
+
|
|
468
|
+
// 1+3. Injection per LLM request + journaling per turn.
|
|
469
|
+
const disposers = [];
|
|
470
|
+
try {
|
|
471
|
+
disposers.push(await ctx.session.hook("context", (inp) => state.onContext(inp)));
|
|
472
|
+
} catch (err) {
|
|
473
|
+
state.debug("register.context.failed", { error: String(err?.message || err) });
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
disposers.push(await ctx.session.hook("prompt", (inp) => state.onPrompt(inp)));
|
|
477
|
+
} catch (err) {
|
|
478
|
+
state.debug("register.prompt.failed", { error: String(err?.message || err) });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 2. Native tools (probed shape: async execute -> {title, content, metadata}).
|
|
482
|
+
try {
|
|
483
|
+
disposers.push(
|
|
484
|
+
await ctx.tool.transform((reg) => {
|
|
485
|
+
reg.add({ name: "memory_write", description: TOOL_DESCRIPTIONS.memory_write, args: TOOL_ARGS.memory_write, execute: (a, tc) => state.toolWrite(a, tc) });
|
|
486
|
+
reg.add({ name: "memory_search", description: TOOL_DESCRIPTIONS.memory_search, args: TOOL_ARGS.memory_search, execute: (a, tc) => state.toolSearch(a, tc) });
|
|
487
|
+
reg.add({ name: "memory_read", description: TOOL_DESCRIPTIONS.memory_read, args: TOOL_ARGS.memory_read, execute: (a, tc) => state.toolRead(a, tc) });
|
|
488
|
+
reg.add({ name: "memory_forget", description: TOOL_DESCRIPTIONS.memory_forget, args: TOOL_ARGS.memory_forget, execute: (a, tc) => state.toolForget(a, tc) });
|
|
489
|
+
reg.add({ name: "memory_status", description: TOOL_DESCRIPTIONS.memory_status, args: TOOL_ARGS.memory_status, execute: (a, tc) => state.toolStatus(a, tc) });
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
state.debug("register.tools.failed", { error: String(err?.message || err) });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
state.debug("setup.done", { disposers: disposers.length, cwd: state.fallbackCwd, version: ctx?.app?.version });
|
|
497
|
+
|
|
498
|
+
// Engines hold file handles/FTS writers; close them when the host exits.
|
|
499
|
+
// Note: the exit event runs sync code only — core close() flushes advisory
|
|
500
|
+
// usage counters asynchronously and that flush is lost at exit (harmless:
|
|
501
|
+
// journals and memories are written at operation time). This handler is a
|
|
502
|
+
// best-effort gesture, not a durability mechanism.
|
|
503
|
+
process.once("exit", () => state.closeAll());
|
|
504
|
+
|
|
505
|
+
// Disposers are collected but intentionally NOT returned: the probed v2
|
|
506
|
+
// binary accepted `setup` returning void, and the semantics of a returned
|
|
507
|
+
// dispose object are unverified — an unexpected return shape is a bigger
|
|
508
|
+
// compatibility risk than missing hot-reload cleanup. Revisit when the
|
|
509
|
+
// v2 setup contract is documented.
|
|
510
|
+
return;
|
|
511
|
+
},
|
|
512
|
+
};
|
package/lib/engine.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine construction for the opencode adapter.
|
|
3
|
+
*
|
|
4
|
+
* The only constraint face is @harnmem/core (vendored at build time into
|
|
5
|
+
* vendor/core so the plugin directory is self-contained once installed).
|
|
6
|
+
* See docs/design/07-adapter-contract.md: adapters never contain
|
|
7
|
+
* retrieval/storage logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const CORE_INDEX = path.join(here, "..", "vendor", "core", "index.js");
|
|
15
|
+
|
|
16
|
+
let corePromise = null;
|
|
17
|
+
async function core() {
|
|
18
|
+
if (!corePromise) {
|
|
19
|
+
corePromise = import(pathToFileURL(CORE_INDEX).href).catch((err) => {
|
|
20
|
+
// A transient import failure (locked file, AV scan) must not poison
|
|
21
|
+
// every later open in this process — clear so the next call retries.
|
|
22
|
+
corePromise = null;
|
|
23
|
+
throw err;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return corePromise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open a HarnMem engine. `opts.readOnly` forces read-only mode (subagent
|
|
31
|
+
* sessions); `opts.sessionId`/`opts.cwd` anchor provenance and project
|
|
32
|
+
* resolution. A SCHEMA_NEWER failure downgrades to read-only instead of
|
|
33
|
+
* breaking the session (contract §open).
|
|
34
|
+
*/
|
|
35
|
+
export async function openEngine(settings, opts = {}) {
|
|
36
|
+
const mod = await core();
|
|
37
|
+
const base = {
|
|
38
|
+
home: settings.home,
|
|
39
|
+
harness: "opencode",
|
|
40
|
+
sessionId: opts.sessionId || undefined,
|
|
41
|
+
cwd: opts.cwd || process.cwd(),
|
|
42
|
+
project: typeof settings.project === "string" && settings.project.trim() ? settings.project.trim() : undefined,
|
|
43
|
+
readOnly: opts.readOnly === true || settings.readOnly === true,
|
|
44
|
+
budget: { injectTokens: settings.maxInjectTokens },
|
|
45
|
+
write: { autoExtract: settings.autoExtract === true },
|
|
46
|
+
log:
|
|
47
|
+
settings.debug === true
|
|
48
|
+
? (level, msg, data) => {
|
|
49
|
+
try {
|
|
50
|
+
process.stderr.write(`[harnmem:${level}] ${msg}${data === undefined ? "" : " " + JSON.stringify(data)}\n`);
|
|
51
|
+
} catch {
|
|
52
|
+
// stderr is best effort
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
return await mod.HarnMem.open(base);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Forward-compat per docs/design/07-adapter-contract.md: a FUTURE core
|
|
61
|
+
// will throw code=SCHEMA_NEWER when its store was written by a newer
|
|
62
|
+
// schema; today's core has no such error yet, so this branch is dormant.
|
|
63
|
+
if (err && err.code === "SCHEMA_NEWER") {
|
|
64
|
+
return mod.HarnMem.open({ ...base, readOnly: true });
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function coreApi() {
|
|
71
|
+
return core();
|
|
72
|
+
}
|