@gamaze/hicortex 0.19.6 → 0.20.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 +8 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -1
- package/dist/consolidate.d.ts +21 -0
- package/dist/consolidate.js +37 -0
- package/dist/dashboard.js +5 -2
- package/dist/identity-store.d.ts +3 -3
- package/dist/identity-store.js +2 -2
- package/dist/init.d.ts +3 -1
- package/dist/init.js +93 -1
- package/dist/llm-flight.d.ts +30 -0
- package/dist/llm-flight.js +190 -0
- package/dist/llm.d.ts +32 -0
- package/dist/llm.js +69 -2
- package/dist/nightly.d.ts +41 -0
- package/dist/nightly.js +87 -3
- package/dist/opencode-transcript-reader.d.ts +63 -0
- package/dist/opencode-transcript-reader.js +181 -0
- package/dist/status.js +23 -0
- package/dist/telemetry.d.ts +5 -5
- package/dist/telemetry.js +5 -5
- package/dist/uninstall.js +34 -0
- package/opencode-plugin/hicortex/index.ts +981 -0
- package/package.json +6 -4
- package/pi-extension/hicortex/README.md +56 -0
- package/pi-extension/hicortex/index.ts +888 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hicortex plugin for the opencode coding agent (#347) — recall + identity +
|
|
3
|
+
* lessons injection + memory tools, no capture.
|
|
4
|
+
*
|
|
5
|
+
* The Pi-extension architecture on opencode's plugin API: one dependency-free
|
|
6
|
+
* file (Node built-ins only, global fetch) that gives opencode agents the same
|
|
7
|
+
* memory experience as CC/Pi —
|
|
8
|
+
*
|
|
9
|
+
* experimental.chat.messages.transform → per model request, find the LAST
|
|
10
|
+
* user-role message; when its id is new for the
|
|
11
|
+
* session, POST /recall-index {session_id, prompt,
|
|
12
|
+
* project} (1 s timeout, fail-soft) and stash the
|
|
13
|
+
* returned block; then append ONE synthetic user
|
|
14
|
+
* message after the real one carrying the fenced
|
|
15
|
+
* block (cloned info, fresh id — existing entries are
|
|
16
|
+
* never mutated). Tool-loop requests of the same turn
|
|
17
|
+
* re-inject the stashed block. The channel was chosen
|
|
18
|
+
* by live verification on opencode 1.18.20: parts
|
|
19
|
+
* appended via the chat.message hook never reach the
|
|
20
|
+
* model call (0/3 runs completed), while a synthetic
|
|
21
|
+
* message from messages.transform provably does — and
|
|
22
|
+
* is NOT persisted to opencode's session store.
|
|
23
|
+
* experimental.chat.system.transform → append the `## Identity` (gated on
|
|
24
|
+
* the server-resolved clients containing "opencode")
|
|
25
|
+
* and Learnings blocks as ONE fenced system entry;
|
|
26
|
+
* per-session memoize-on-success; the fence guard
|
|
27
|
+
* prevents doubling.
|
|
28
|
+
* event → session.created / session.compacted POST
|
|
29
|
+
* /recall-index {session_id, reset:true} (the two
|
|
30
|
+
* events carry the id at different paths:
|
|
31
|
+
* properties.info.id vs properties.sessionID) and
|
|
32
|
+
* drop the session's caches.
|
|
33
|
+
* tool × 9 → the hicortex_* tools as direct REST proxies with
|
|
34
|
+
* plain-object args (no zod — verified live).
|
|
35
|
+
*
|
|
36
|
+
* Capture is NOT the plugin's job — the nightly reader on the server machine
|
|
37
|
+
* distills ~/.local/share/opencode/opencode.db centrally
|
|
38
|
+
* (opencode-transcript-reader).
|
|
39
|
+
*
|
|
40
|
+
* Verified against opencode 1.18.20 (hooks fire as above; the `experimental.*`
|
|
41
|
+
* names may drift upstream — a missing hook degrades to no injection, never an
|
|
42
|
+
* error). Fail-soft by construction: ANY failure (no config, timeout,
|
|
43
|
+
* non-2xx, parse error) injects nothing and never blocks a session — every
|
|
44
|
+
* injection-path fetch carries a 1000 ms timeout, tool fetches 10 s.
|
|
45
|
+
*
|
|
46
|
+
* Loose structural typing throughout (`input: any`, `output: any`, inline
|
|
47
|
+
* narrowing): this file must not depend on @opencode-ai/plugin types — the
|
|
48
|
+
* plugin has ZERO imports beyond Node built-ins so it works on any opencode
|
|
49
|
+
* install straight from the npm tarball.
|
|
50
|
+
*
|
|
51
|
+
* LOADER CONTRACT (#353): opencode's plugin loader invokes EVERY exported
|
|
52
|
+
* function in this file as a plugin factory. This file therefore exports
|
|
53
|
+
* exactly ONE runtime binding — the `HicortexPlugin` const — and every
|
|
54
|
+
* helper stays module-private. Do NOT add an export, not even an
|
|
55
|
+
* `export default` and not "just for the tests": one extra export is enough
|
|
56
|
+
* for the loader to call it with undefined and reject the whole plugin
|
|
57
|
+
* (0.21.0 shipped helper exports; the loader's `renderContextEntry(undefined)`
|
|
58
|
+
* crashed with `blocks.filter is not a function` and the plugin never loaded
|
|
59
|
+
* — live-isolated on opencode 1.18.23). The vitest suite drives this file
|
|
60
|
+
* the way the loader does — through the factory's hooks — never via helper
|
|
61
|
+
* imports, and a structural test pins the export surface.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { readFileSync } from "node:fs";
|
|
65
|
+
import { join, basename } from "node:path";
|
|
66
|
+
import { homedir } from "node:os";
|
|
67
|
+
|
|
68
|
+
const FETCH_TIMEOUT_MS = 1000;
|
|
69
|
+
const TOOL_TIMEOUT_MS = 10_000;
|
|
70
|
+
const DEFAULT_PORT = 8787;
|
|
71
|
+
/** Harness name this plugin injects for — self-gates on /identity `clients`. */
|
|
72
|
+
const THIS_HARNESS = "opencode";
|
|
73
|
+
const DEFAULT_LESSONS_LIMIT = 10;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* HTML-comment fence around every injected block. Doubles as the idempotency
|
|
77
|
+
* guard (system.transform skips a request whose system array already carries
|
|
78
|
+
* the end marker) and as the reader's self-echo skip: any text inside the
|
|
79
|
+
* fence is Hicortex injection, not conversation, and is excluded from capture
|
|
80
|
+
* (defense in depth — messages.transform output is not persisted anyway).
|
|
81
|
+
* Comments are invisible to the model, so the fence costs nothing on the wire.
|
|
82
|
+
*/
|
|
83
|
+
const CONTEXT_START = "<!-- hicortex-context-start -->";
|
|
84
|
+
const CONTEXT_END = "<!-- hicortex-context-end -->";
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Config resolution — duplicated from the CC hook's resolveConfig
|
|
88
|
+
// (learnings-identity.ts) because this file cannot import package code.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
interface OpencodeConfig {
|
|
92
|
+
serverUrl: string;
|
|
93
|
+
authToken: string | undefined;
|
|
94
|
+
/** Max lessons in the injected block (config lessonsLimit, default 10). */
|
|
95
|
+
lessonsLimit: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read ~/.hicortex/config.json and resolve the server URL + auth token, or
|
|
100
|
+
* null when there is no usable config (server not set up — fail soft).
|
|
101
|
+
* Mirrors resolveConfig: client mode → `serverUrl` (trailing slashes
|
|
102
|
+
* stripped), server mode → localhost:port. The auth token follows the
|
|
103
|
+
* server's precedence — config first, HICORTEX_AUTH_TOKEN env fills gaps.
|
|
104
|
+
*/
|
|
105
|
+
function resolveOpencodeConfig(): OpencodeConfig | null {
|
|
106
|
+
const home = process.env.HICORTEX_HOME ?? join(homedir(), ".hicortex");
|
|
107
|
+
let config: Record<string, unknown>;
|
|
108
|
+
try {
|
|
109
|
+
config = JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) as Record<string, unknown>;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return null;
|
|
114
|
+
|
|
115
|
+
const serverUrl = config.mode === "client" && typeof config.serverUrl === "string"
|
|
116
|
+
? config.serverUrl.replace(/\/+$/, "")
|
|
117
|
+
: `http://127.0.0.1:${typeof config.port === "number" ? config.port : DEFAULT_PORT}`;
|
|
118
|
+
|
|
119
|
+
const authToken = (typeof config.authToken === "string" && config.authToken
|
|
120
|
+
? config.authToken
|
|
121
|
+
: undefined) ?? process.env.HICORTEX_AUTH_TOKEN;
|
|
122
|
+
|
|
123
|
+
const rawLimit = config.lessonsLimit;
|
|
124
|
+
const lessonsLimit = typeof rawLimit === "number" && rawLimit > 0
|
|
125
|
+
? Math.floor(rawLimit)
|
|
126
|
+
: DEFAULT_LESSONS_LIMIT;
|
|
127
|
+
|
|
128
|
+
return { serverUrl, authToken, lessonsLimit };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function authHeaders(cfg: OpencodeConfig): Record<string, string> {
|
|
132
|
+
return cfg.authToken ? { "Authorization": `Bearer ${cfg.authToken}` } : {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Pure renderers — module-private (loader contract, see header); tests reach
|
|
137
|
+
// them through the hooks' observable output.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/** "user" → "User", "my_notes" → "My Notes" (mirrors titleCaseSection). */
|
|
141
|
+
function titleCaseSection(name: string): string {
|
|
142
|
+
return name
|
|
143
|
+
.split(/[-_]+/)
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
146
|
+
.join(" ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface IdentityResponse {
|
|
150
|
+
sections?: Record<string, unknown>;
|
|
151
|
+
clients?: unknown;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Render the `## Identity` block from a resolved section map, or null when
|
|
156
|
+
* every section is blank. Mirrors renderIdentityBlock (learnings-identity.ts)
|
|
157
|
+
* with one dependency-free simplification: section headings are the
|
|
158
|
+
* title-cased key (which matches the server's labels for the seeded
|
|
159
|
+
* sections); sections are rendered in the SERVER's wire order — the server
|
|
160
|
+
* already applies the #313 precedence when it builds the response, so the
|
|
161
|
+
* plugin does not re-sort.
|
|
162
|
+
*/
|
|
163
|
+
function renderIdentityBlock(sections: Record<string, unknown> | undefined): string | null {
|
|
164
|
+
if (!sections || typeof sections !== "object" || Array.isArray(sections)) return null;
|
|
165
|
+
const bodyParts: string[] = [];
|
|
166
|
+
for (const name of Object.keys(sections)) {
|
|
167
|
+
const body = sections[name];
|
|
168
|
+
if (typeof body !== "string" || body.trim() === "") continue;
|
|
169
|
+
bodyParts.push(`### ${titleCaseSection(name)}`, "", body.trim());
|
|
170
|
+
}
|
|
171
|
+
if (bodyParts.length === 0) return null;
|
|
172
|
+
return ["## Identity", "", ...bodyParts].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Gate a GET /identity response and render the block, or null when nothing
|
|
177
|
+
* should be injected: this harness ("opencode") not in the server-resolved
|
|
178
|
+
* `clients` list, or no non-empty sections. Mirrors gateAndRenderIdentity
|
|
179
|
+
* (the single CC/OC gate — keep the copies in sync).
|
|
180
|
+
*/
|
|
181
|
+
function gateAndRenderIdentity(data: IdentityResponse | null): string | null {
|
|
182
|
+
if (!data || typeof data !== "object") return null;
|
|
183
|
+
const clients = Array.isArray(data.clients) ? data.clients : [];
|
|
184
|
+
if (!clients.includes(THIS_HARNESS)) return null;
|
|
185
|
+
return renderIdentityBlock(data.sections);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface LessonsResponse {
|
|
189
|
+
lessons?: Array<{ content?: unknown }>;
|
|
190
|
+
index?: {
|
|
191
|
+
total?: number;
|
|
192
|
+
lessonCount?: number;
|
|
193
|
+
sourceCount?: number;
|
|
194
|
+
projects?: Array<{ name?: unknown; count?: unknown }>;
|
|
195
|
+
};
|
|
196
|
+
moduleIndex?: { domains?: Array<{ name?: unknown; keywords?: unknown[]; memoryCount?: number; lessonCount?: number; projects?: unknown[] }> } | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Render the `## Hicortex Memory` block from a GET /learnings response, or
|
|
201
|
+
* null on a shape we cannot render. Format follows the CC hook's
|
|
202
|
+
* fetchLessonsBlock (guidance lines + lesson lines + memory-index footer)
|
|
203
|
+
* with the dependency-free simplification the Hermes plugin also makes: a
|
|
204
|
+
* plain top-N slice instead of the package's domain-aware lesson selector
|
|
205
|
+
* (which this file cannot import). N = config lessonsLimit (default 10).
|
|
206
|
+
*/
|
|
207
|
+
function renderLessonsBlock(data: LessonsResponse | null, maxLessons: number): string | null {
|
|
208
|
+
if (!data || typeof data !== "object") return null;
|
|
209
|
+
const lessons = Array.isArray(data.lessons) ? data.lessons : [];
|
|
210
|
+
|
|
211
|
+
const lessonLines = lessons.slice(0, maxLessons).map((l) => {
|
|
212
|
+
const content = typeof l?.content === "string" ? l.content : "";
|
|
213
|
+
const typeMatch = content.match(/\*\*Type:\*\* (\w+)/);
|
|
214
|
+
const severityMatch = content.match(/\*\*Severity:\*\* (\w+)/);
|
|
215
|
+
// First line, with any legacy `## Lesson:` prefix stripped — new lessons
|
|
216
|
+
// are stored topic-first (memory_type carries the type).
|
|
217
|
+
const title = content.replace(/^##\s*Lesson:\s*/i, "").split("\n")[0].slice(0, 150);
|
|
218
|
+
const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
|
|
219
|
+
return `- ${title}${meta ? ` (${meta})` : ""}`;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const parts: string[] = ["## Hicortex Memory", ""];
|
|
223
|
+
parts.push("You have access to shared long-term memory across all agents and sessions.");
|
|
224
|
+
parts.push("BEFORE making decisions, search memory: `hicortex_search` for prior decisions on the same topic.");
|
|
225
|
+
parts.push("Use `hicortex_recent` at session start for recent project state.");
|
|
226
|
+
|
|
227
|
+
if (lessonLines.length > 0) {
|
|
228
|
+
parts.push("", "### Learnings (updated nightly)");
|
|
229
|
+
parts.push(...lessonLines);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const index = data.index ?? {};
|
|
233
|
+
const domains = data.moduleIndex?.domains ?? [];
|
|
234
|
+
if (domains.length > 0) {
|
|
235
|
+
parts.push("", "### Memory Index");
|
|
236
|
+
for (const domain of domains) {
|
|
237
|
+
const keywords = Array.isArray(domain.keywords) ? domain.keywords : [];
|
|
238
|
+
const kwStr = keywords.length > 0 ? `: ${keywords.join(", ")}` : "";
|
|
239
|
+
parts.push(`${domain.name} (${domain.memoryCount} memories, ${domain.lessonCount} Learnings)${kwStr}`);
|
|
240
|
+
if (Array.isArray(domain.projects) && domain.projects.length > 0) {
|
|
241
|
+
parts.push(` ${domain.projects.join(" | ")}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
|
|
245
|
+
} else if (Array.isArray(index.projects) && index.projects.length > 0) {
|
|
246
|
+
parts.push("", "### Memory Index");
|
|
247
|
+
parts.push(index.projects.map((p) => `${p.name}: ${p.count}`).join(" | "));
|
|
248
|
+
parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return parts.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build the /recall-index request body, or null when there is nothing to
|
|
256
|
+
* send (no session id, or an empty prompt). Mirrors the CC hook's
|
|
257
|
+
* buildHookRequest: project = basename(working directory) so retrieval can
|
|
258
|
+
* apply the soft project-affinity boost (retrieval-scoping: opencode is a
|
|
259
|
+
* cwd-based client and must send `project`).
|
|
260
|
+
*/
|
|
261
|
+
function buildRecallBody(
|
|
262
|
+
sessionId: string,
|
|
263
|
+
prompt: string,
|
|
264
|
+
cwd: string,
|
|
265
|
+
): Record<string, unknown> | null {
|
|
266
|
+
if (!sessionId || !prompt) return null;
|
|
267
|
+
const body: Record<string, unknown> = { session_id: sessionId, prompt };
|
|
268
|
+
const project = basename(cwd ?? "");
|
|
269
|
+
if (project) body.project = project;
|
|
270
|
+
return body;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Wrap one injected block in the marker fence (invisible to the model). */
|
|
274
|
+
function fenceBlock(block: string): string {
|
|
275
|
+
return `${CONTEXT_START}\n\n${block}\n\n${CONTEXT_END}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build the single fenced system entry from the identity/lessons blocks, or
|
|
280
|
+
* null when there is nothing to inject (no non-empty blocks). opencode's
|
|
281
|
+
* system is a string ARRAY — this is the one entry we push, so the blocks
|
|
282
|
+
* travel together inside one fence.
|
|
283
|
+
*/
|
|
284
|
+
function renderContextEntry(blocks: Array<string | null>): string | null {
|
|
285
|
+
const parts = blocks.filter((b): b is string => typeof b === "string" && b.trim() !== "");
|
|
286
|
+
if (parts.length === 0) return null;
|
|
287
|
+
return `${CONTEXT_START}\n\n${parts.join("\n\n")}\n\n${CONTEXT_END}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** True when a system array already carries our end marker → skip (no doubling). */
|
|
291
|
+
function systemAlreadyFenced(system: unknown): boolean {
|
|
292
|
+
return Array.isArray(system) && system.some((s) => typeof s === "string" && s.includes(CONTEXT_END));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Message-shape helpers (the model-request messages are structurally typed)
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The last REAL user message in a model request, with its index, or null.
|
|
301
|
+
* Skips our own synthetic recall message (fresh id on a cloned shape): the
|
|
302
|
+
* transform output is verified NOT to persist, but if one ever loops back
|
|
303
|
+
* into a request it must not be mistaken for the turn's prompt (defense in
|
|
304
|
+
* depth — same reason the reader skips fenced text).
|
|
305
|
+
*/
|
|
306
|
+
function lastUserMessage(
|
|
307
|
+
messages: unknown,
|
|
308
|
+
): { msg: Record<string, unknown>; index: number } | null {
|
|
309
|
+
if (!Array.isArray(messages)) return null;
|
|
310
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
311
|
+
const m = messages[i];
|
|
312
|
+
if (!m || typeof m !== "object" || (m as { role?: unknown }).role !== "user") continue;
|
|
313
|
+
const id = (m as { id?: unknown }).id;
|
|
314
|
+
if (id === "hicortex-recall" || (typeof id === "string" && id.endsWith("/hicortex-recall"))) continue;
|
|
315
|
+
return { msg: m as Record<string, unknown>, index: i };
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A message's text: a plain string content, or its text parts joined by "\n". */
|
|
321
|
+
function messageText(msg: unknown): string {
|
|
322
|
+
if (!msg || typeof msg !== "object") return "";
|
|
323
|
+
const content = (msg as { content?: unknown }).content;
|
|
324
|
+
if (typeof content === "string") return content;
|
|
325
|
+
if (!Array.isArray(content)) return "";
|
|
326
|
+
return content
|
|
327
|
+
.map((p) =>
|
|
328
|
+
p && typeof p === "object" && (p as { type?: unknown }).type === "text" && typeof (p as { text?: unknown }).text === "string"
|
|
329
|
+
? (p as { text: string }).text
|
|
330
|
+
: "")
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The session id opencode attaches to a message (info.sessionID), or null. */
|
|
336
|
+
function messageSessionId(msg: unknown): string | null {
|
|
337
|
+
const sid = (msg as { info?: { sessionID?: unknown } } | null)?.info?.sessionID;
|
|
338
|
+
// Without a session id the recall key would collapse to "" and merge every
|
|
339
|
+
// session on the server — inject nothing instead.
|
|
340
|
+
return typeof sid === "string" && sid ? sid : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build the synthetic user message that carries the recall block: a clone of
|
|
345
|
+
* the real message's `info` with a FRESH id and exactly one text part. Never
|
|
346
|
+
* mutates the real message; the fresh id keeps the synthetic entry distinct
|
|
347
|
+
* from the real one anywhere downstream.
|
|
348
|
+
*/
|
|
349
|
+
function buildRecallMessage(real: unknown, fenced: string): Record<string, unknown> | null {
|
|
350
|
+
if (!real || typeof real !== "object") return null;
|
|
351
|
+
const r = real as Record<string, unknown>;
|
|
352
|
+
const msg: Record<string, unknown> = {
|
|
353
|
+
role: "user",
|
|
354
|
+
content: [{ type: "text", text: fenced }],
|
|
355
|
+
};
|
|
356
|
+
if (r.info && typeof r.info === "object") msg.info = { ...r.info };
|
|
357
|
+
msg.id = typeof r.id === "string" && r.id ? `${r.id}/hicortex-recall` : "hicortex-recall";
|
|
358
|
+
return msg;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The session id of a dedup-reset event, or null when the event is not a
|
|
363
|
+
* reset or carries no id. The two events carry it at DIFFERENT paths:
|
|
364
|
+
* session.created → properties.info.id, session.compacted →
|
|
365
|
+
* properties.sessionID (the @opencode-ai/sdk Event union). `name` is read as
|
|
366
|
+
* a fallback discriminator so a rename upstream degrades to a no-op, not a
|
|
367
|
+
* wrong-shape post.
|
|
368
|
+
*/
|
|
369
|
+
function extractResetSessionId(event: unknown): string | null {
|
|
370
|
+
if (!event || typeof event !== "object") return null;
|
|
371
|
+
const ev = event as { type?: unknown; name?: unknown; properties?: unknown };
|
|
372
|
+
const type = typeof ev.type === "string" ? ev.type : typeof ev.name === "string" ? ev.name : "";
|
|
373
|
+
const props = ev.properties as Record<string, unknown> | null | undefined;
|
|
374
|
+
if (!props || typeof props !== "object") return null;
|
|
375
|
+
if (type === "session.created") {
|
|
376
|
+
const id = (props.info as { id?: unknown } | undefined)?.id;
|
|
377
|
+
return typeof id === "string" && id ? id : null;
|
|
378
|
+
}
|
|
379
|
+
if (type === "session.compacted") {
|
|
380
|
+
const id = props.sessionID;
|
|
381
|
+
return typeof id === "string" && id ? id : null;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The working directory a hook input carries, falling back to process cwd. */
|
|
387
|
+
function cwdFromInput(input: unknown): string {
|
|
388
|
+
const i = input as { directory?: unknown; cwd?: unknown } | null | undefined;
|
|
389
|
+
if (typeof i?.directory === "string" && i.directory) return i.directory;
|
|
390
|
+
if (typeof i?.cwd === "string" && i.cwd) return i.cwd;
|
|
391
|
+
return process.cwd();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Best-effort session id from a hook input (shape not stable upstream). */
|
|
395
|
+
function inputSessionId(input: unknown): string | null {
|
|
396
|
+
const i = input as { info?: { sessionID?: unknown }; sessionID?: unknown; session?: { id?: unknown } } | null | undefined;
|
|
397
|
+
const candidates = [i?.info?.sessionID, i?.sessionID, i?.session?.id];
|
|
398
|
+
for (const c of candidates) if (typeof c === "string" && c) return c;
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Session-scoped state
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Per-session caches. RECALL_BLOCKS/PROMPT_KEYS key the per-prompt fetch:
|
|
408
|
+
* a settled fetch (block, or a nothing-relevant null) is memoized for the
|
|
409
|
+
* prompt; tool-loop requests of the same turn re-inject from the stash
|
|
410
|
+
* instead of re-POSTing. IDENTITY/LESSONS memoize a SETTLED render (a block,
|
|
411
|
+
* or a gated/empty null); a FAILED fetch is NOT cached — it retries next
|
|
412
|
+
* request (the OC plugin's #313 memoize-only-on-success rule). Bounded: a
|
|
413
|
+
* long-lived opencode process accumulating sessions would otherwise grow the
|
|
414
|
+
* maps forever.
|
|
415
|
+
*/
|
|
416
|
+
const RECALL_BLOCKS = new Map<string, string | null>();
|
|
417
|
+
const PROMPT_KEYS = new Map<string, string>();
|
|
418
|
+
const IDENTITY_BLOCKS = new Map<string, string | null>();
|
|
419
|
+
const LESSONS_BLOCKS = new Map<string, string | null>();
|
|
420
|
+
/** In-flight / recently-fired dedup resets, keyed by session id (#316 ordering). */
|
|
421
|
+
const PENDING_RESETS = new Map<string, Promise<void>>();
|
|
422
|
+
const SESSION_STATE_CAP = 32;
|
|
423
|
+
/** Cache key when a hook input carries no session id (fail-soft freshness trade). */
|
|
424
|
+
const FALLBACK_SESSION_KEY = "__opencode__";
|
|
425
|
+
|
|
426
|
+
function pruneSessionState(): void {
|
|
427
|
+
if (
|
|
428
|
+
RECALL_BLOCKS.size > SESSION_STATE_CAP ||
|
|
429
|
+
IDENTITY_BLOCKS.size > SESSION_STATE_CAP ||
|
|
430
|
+
LESSONS_BLOCKS.size > SESSION_STATE_CAP
|
|
431
|
+
) {
|
|
432
|
+
RECALL_BLOCKS.clear();
|
|
433
|
+
PROMPT_KEYS.clear();
|
|
434
|
+
IDENTITY_BLOCKS.clear();
|
|
435
|
+
LESSONS_BLOCKS.clear();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function dropSessionState(sessionId: string): void {
|
|
440
|
+
RECALL_BLOCKS.delete(sessionId);
|
|
441
|
+
PROMPT_KEYS.delete(sessionId);
|
|
442
|
+
IDENTITY_BLOCKS.delete(sessionId);
|
|
443
|
+
LESSONS_BLOCKS.delete(sessionId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Fetch-and-memoize one block; a throw degrades to null WITHOUT caching. */
|
|
447
|
+
async function cachedBlock(
|
|
448
|
+
cache: Map<string, string | null>,
|
|
449
|
+
sessionId: string,
|
|
450
|
+
fetcher: () => Promise<string | null>,
|
|
451
|
+
): Promise<string | null> {
|
|
452
|
+
if (cache.has(sessionId)) return cache.get(sessionId) ?? null;
|
|
453
|
+
try {
|
|
454
|
+
const block = await fetcher();
|
|
455
|
+
cache.set(sessionId, block);
|
|
456
|
+
return block;
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Fetchers — every failure path THROWS to the caller's fail-soft catch
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
async function fetchRecallBlock(
|
|
467
|
+
cfg: OpencodeConfig,
|
|
468
|
+
sessionId: string,
|
|
469
|
+
prompt: string,
|
|
470
|
+
cwd: string,
|
|
471
|
+
): Promise<string | null> {
|
|
472
|
+
const body = buildRecallBody(sessionId, prompt, cwd);
|
|
473
|
+
if (!body) return null;
|
|
474
|
+
const resp = await fetch(`${cfg.serverUrl}/recall-index`, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
|
|
477
|
+
body: JSON.stringify(body),
|
|
478
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
479
|
+
});
|
|
480
|
+
// 404 = a pre-0.14 server with no /recall-index; any other non-2xx is the
|
|
481
|
+
// same outcome for injection purposes — nothing this turn (CC-hook
|
|
482
|
+
// contract; the server-side /search tool remains available).
|
|
483
|
+
if (!resp.ok) return null;
|
|
484
|
+
const data = await resp.json() as { block?: string | null };
|
|
485
|
+
return typeof data.block === "string" && data.block.trim() !== "" ? data.block : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function fetchIdentityBlock(cfg: OpencodeConfig): Promise<string | null> {
|
|
489
|
+
const resp = await fetch(`${cfg.serverUrl}/identity`, {
|
|
490
|
+
headers: authHeaders(cfg),
|
|
491
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
492
|
+
});
|
|
493
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
494
|
+
return gateAndRenderIdentity(await resp.json() as IdentityResponse);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function fetchLessonsBlock(cfg: OpencodeConfig): Promise<string | null> {
|
|
498
|
+
const resp = await fetch(`${cfg.serverUrl}/learnings`, {
|
|
499
|
+
headers: authHeaders(cfg),
|
|
500
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
501
|
+
});
|
|
502
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
503
|
+
const block = renderLessonsBlock(await resp.json() as LessonsResponse, cfg.lessonsLimit);
|
|
504
|
+
return block === null ? null : block;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* POST {session_id, reset:true}. Fail-soft: a lost reset only means some
|
|
509
|
+
* memories stay suppressed until the re-show window (recallReshowTurns)
|
|
510
|
+
* passes. Registered in PENDING_RESETS so the session's next recall fetch
|
|
511
|
+
* AWAITS it — the reset can never land AFTER the fetch and wipe the
|
|
512
|
+
* shown-set state that turn just built (the Hermes initialize() race,
|
|
513
|
+
* closed by ordering).
|
|
514
|
+
*/
|
|
515
|
+
function resetRecallDedup(cfg: OpencodeConfig, sessionId: string): Promise<void> {
|
|
516
|
+
const inFlight = PENDING_RESETS.get(sessionId);
|
|
517
|
+
if (inFlight) return inFlight;
|
|
518
|
+
const p = fetch(`${cfg.serverUrl}/recall-index`, {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
|
|
521
|
+
body: JSON.stringify({ session_id: sessionId, reset: true }),
|
|
522
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
523
|
+
})
|
|
524
|
+
.then(() => undefined)
|
|
525
|
+
.catch(() => {
|
|
526
|
+
/* fail-soft — never surface into the session */
|
|
527
|
+
})
|
|
528
|
+
.finally(() => {
|
|
529
|
+
if (PENDING_RESETS.get(sessionId) === p) PENDING_RESETS.delete(sessionId);
|
|
530
|
+
});
|
|
531
|
+
PENDING_RESETS.set(sessionId, p);
|
|
532
|
+
return p;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Tool plumbing — REST proxies (names/descriptions mirror the OC/Pi plugins;
|
|
537
|
+
// args are opencode's FLAT per-arg property maps, verified live on 1.18.20 —
|
|
538
|
+
// plain objects, no zod). `execute` returns a plain string.
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/** Canonical memory_type mapping — mirrors normalizeMemoryType (type-labels). */
|
|
542
|
+
const TO_CANONICAL_TYPE: Record<string, string> = {
|
|
543
|
+
fact: "knowledge", episode: "experience", decision: "decisions", lesson: "learnings",
|
|
544
|
+
knowledge: "knowledge", experience: "experience", decisions: "decisions", learnings: "learnings",
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/** Render labels for search/recent results — mirrors MEMORY_TYPE_LABELS. */
|
|
548
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
549
|
+
knowledge: "Knowledge", experience: "Experience", decisions: "Decisions", learnings: "Learnings",
|
|
550
|
+
fact: "Knowledge", episode: "Experience", decision: "Decisions", lesson: "Learnings",
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const NOT_CONFIGURED =
|
|
554
|
+
"Hicortex is not configured on this machine — run `npx @gamaze/hicortex init` " +
|
|
555
|
+
"(or create ~/.hicortex/config.json).";
|
|
556
|
+
|
|
557
|
+
/** Error results are plain text strings; never throw. */
|
|
558
|
+
function errorResult(message: string): string {
|
|
559
|
+
return `error: ${message}`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function serverGet(
|
|
563
|
+
cfg: OpencodeConfig,
|
|
564
|
+
path: string,
|
|
565
|
+
): Promise<{ data: any | null; status: number | null }> {
|
|
566
|
+
try {
|
|
567
|
+
const resp = await fetch(`${cfg.serverUrl}${path}`, {
|
|
568
|
+
headers: authHeaders(cfg),
|
|
569
|
+
signal: AbortSignal.timeout(TOOL_TIMEOUT_MS),
|
|
570
|
+
});
|
|
571
|
+
if (!resp.ok) return { data: null, status: resp.status };
|
|
572
|
+
return { data: await resp.json(), status: resp.status };
|
|
573
|
+
} catch {
|
|
574
|
+
return { data: null, status: null };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function serverPost(
|
|
579
|
+
cfg: OpencodeConfig,
|
|
580
|
+
path: string,
|
|
581
|
+
body: unknown,
|
|
582
|
+
): Promise<{ ok: boolean; status: number; data: any | null }> {
|
|
583
|
+
try {
|
|
584
|
+
const resp = await fetch(`${cfg.serverUrl}${path}`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
|
|
587
|
+
body: JSON.stringify(body),
|
|
588
|
+
signal: AbortSignal.timeout(TOOL_TIMEOUT_MS),
|
|
589
|
+
});
|
|
590
|
+
let data: any = null;
|
|
591
|
+
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
|
592
|
+
return { ok: resp.ok, status: resp.status, data };
|
|
593
|
+
} catch {
|
|
594
|
+
return { ok: false, status: 0, data: null };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Human-readable GET failure — mirrors describeGetFailure (skew vs down). */
|
|
599
|
+
function describeGetFailure(status: number | null): string {
|
|
600
|
+
if (status === null) return "server unreachable";
|
|
601
|
+
return `server returned HTTP ${status}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
interface SearchHit {
|
|
605
|
+
content?: unknown;
|
|
606
|
+
memory_type?: unknown;
|
|
607
|
+
score?: unknown;
|
|
608
|
+
effective_strength?: unknown;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function formatSearchResults(results: Array<SearchHit>): string {
|
|
612
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
613
|
+
return "No memories found.";
|
|
614
|
+
}
|
|
615
|
+
return results
|
|
616
|
+
.map((r) => {
|
|
617
|
+
const type = typeof r.memory_type === "string" ? String(r.memory_type) : "";
|
|
618
|
+
const label = TYPE_LABELS[type] ?? type ?? "—";
|
|
619
|
+
const score = typeof r.score === "number" ? r.score.toFixed(3) : "0.000";
|
|
620
|
+
const strength = typeof r.effective_strength === "number" ? r.effective_strength.toFixed(3) : "0.000";
|
|
621
|
+
const content = typeof r.content === "string" ? r.content.slice(0, 500) : "";
|
|
622
|
+
return `[${label}] (score: ${score}, strength: ${strength}) ${content}`;
|
|
623
|
+
})
|
|
624
|
+
.join("\n\n");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The nine tools. `execute` resolves config per call (no startup capture). */
|
|
628
|
+
function registerTools(): Record<string, unknown> {
|
|
629
|
+
return {
|
|
630
|
+
hicortex_search: {
|
|
631
|
+
description:
|
|
632
|
+
"Search long-term memory using semantic similarity. Returns the most relevant memories from past sessions.",
|
|
633
|
+
args: {
|
|
634
|
+
query: { type: "string", description: "Search query text (required)" },
|
|
635
|
+
limit: { type: "number", description: "Max results (default 5)" },
|
|
636
|
+
project: { type: "string", description: "Filter by project name" },
|
|
637
|
+
},
|
|
638
|
+
async execute(args: any): Promise<string> {
|
|
639
|
+
try {
|
|
640
|
+
const cfg = resolveOpencodeConfig();
|
|
641
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
642
|
+
const params = new URLSearchParams({ query: String(args?.query ?? "") });
|
|
643
|
+
if (args?.limit) params.set("limit", String(args.limit));
|
|
644
|
+
if (args?.project) params.set("project", String(args.project));
|
|
645
|
+
const { data, status } = await serverGet(cfg, `/search?${params}`);
|
|
646
|
+
if (!data) return errorResult(`Search failed: ${describeGetFailure(status)}`);
|
|
647
|
+
return formatSearchResults(data.results ?? []);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
return errorResult(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
hicortex_get: {
|
|
655
|
+
description:
|
|
656
|
+
"Fetch ONE memory's full content by id — use this to lazy-load entries from the '## Memory recall (auto)' index or from search results whose snippet was not enough. Fetching a memory marks it as used (strengthens it), so fetch entries that could change your action — not every shown one. When the memory shapes your answer, cite it as given in the response — mark a fetched memory `FETCHED` and a one-line entry cited unread `SNIPPET`; don't pass SNIPPET off as established.",
|
|
657
|
+
args: {
|
|
658
|
+
id: { type: "string", description: "Memory ID (required; as shown in the recall index or search results)" },
|
|
659
|
+
},
|
|
660
|
+
async execute(args: any): Promise<string> {
|
|
661
|
+
try {
|
|
662
|
+
if (!args?.id) return errorResult("id is required");
|
|
663
|
+
const cfg = resolveOpencodeConfig();
|
|
664
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
665
|
+
const params = new URLSearchParams({ id: String(args.id) });
|
|
666
|
+
const { data, status } = await serverGet(cfg, `/memory?${params}`);
|
|
667
|
+
if (status === 404) {
|
|
668
|
+
// Either no such memory (0.14+) or a pre-0.14 server with no
|
|
669
|
+
// /memory endpoint — the id hint covers the common case.
|
|
670
|
+
return errorResult(`Memory not found: ${args.id} (or the server predates 0.14 — upgrade the server)`);
|
|
671
|
+
}
|
|
672
|
+
if (!data) return errorResult(`Get failed: ${describeGetFailure(status)}`);
|
|
673
|
+
// Render the content BEHIND the server's citation string — the
|
|
674
|
+
// server-side rendering is the single provenance norm (0.14.1).
|
|
675
|
+
return `${data.citation ?? ""}\n\n${data.memory?.content ?? ""}`.trim();
|
|
676
|
+
} catch (err) {
|
|
677
|
+
return errorResult(`Get failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
|
|
682
|
+
hicortex_recent: {
|
|
683
|
+
description:
|
|
684
|
+
"Get recent memories, optionally filtered by project. Queryless recall of the latest memories by project, ranked by importance. Useful to catch up on what happened recently.",
|
|
685
|
+
args: {
|
|
686
|
+
project: { type: "string", description: "Filter by project name" },
|
|
687
|
+
limit: { type: "number", description: "Max results (default 10)" },
|
|
688
|
+
},
|
|
689
|
+
async execute(args: any): Promise<string> {
|
|
690
|
+
try {
|
|
691
|
+
const cfg = resolveOpencodeConfig();
|
|
692
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
693
|
+
const params = new URLSearchParams();
|
|
694
|
+
if (args?.project) params.set("project", String(args.project));
|
|
695
|
+
if (args?.limit) params.set("limit", String(args.limit));
|
|
696
|
+
const qs = params.toString();
|
|
697
|
+
const { data, status } = await serverGet(cfg, `/recent${qs ? `?${qs}` : ""}`);
|
|
698
|
+
if (!data) return errorResult(`Recent recall failed: ${describeGetFailure(status)}`);
|
|
699
|
+
return formatSearchResults(data.results ?? []);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return errorResult(`Recent recall failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
hicortex_ingest: {
|
|
707
|
+
description:
|
|
708
|
+
"Store a new memory in long-term storage. Use for Knowledge, Decisions, or Learnings.",
|
|
709
|
+
args: {
|
|
710
|
+
content: { type: "string", description: "Memory content to store (required)" },
|
|
711
|
+
project: { type: "string", description: "Project this memory belongs to" },
|
|
712
|
+
memory_type: {
|
|
713
|
+
type: "string",
|
|
714
|
+
description:
|
|
715
|
+
"Type of memory (default: Experience). Accepted: knowledge/experience/decisions/learnings (legacy fact/episode/decision/lesson also accepted, normalized to the canonical term).",
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
async execute(args: any): Promise<string> {
|
|
719
|
+
try {
|
|
720
|
+
const cfg = resolveOpencodeConfig();
|
|
721
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
722
|
+
const rawType = typeof args?.memory_type === "string" ? args.memory_type : "";
|
|
723
|
+
const result = await serverPost(cfg, "/ingest", {
|
|
724
|
+
content: args?.content,
|
|
725
|
+
source_agent: "opencode/manual",
|
|
726
|
+
project: args?.project,
|
|
727
|
+
memory_type: rawType ? (TO_CANONICAL_TYPE[rawType.toLowerCase()] ?? rawType) : "experience",
|
|
728
|
+
});
|
|
729
|
+
if (!result.ok) {
|
|
730
|
+
return errorResult(`Ingest failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
|
|
731
|
+
}
|
|
732
|
+
const id = result.data?.id ?? "unknown";
|
|
733
|
+
return `Memory stored (id: ${String(id).slice(0, 8)})`;
|
|
734
|
+
} catch (err) {
|
|
735
|
+
return errorResult(`Ingest failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
736
|
+
}
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
hicortex_lessons: {
|
|
741
|
+
description:
|
|
742
|
+
"Get actionable Learnings distilled from past sessions. Auto-generated insights about mistakes to avoid.",
|
|
743
|
+
args: {
|
|
744
|
+
project: { type: "string", description: "Filter by project name (optional)" },
|
|
745
|
+
},
|
|
746
|
+
async execute(_args: any): Promise<string> {
|
|
747
|
+
try {
|
|
748
|
+
const cfg = resolveOpencodeConfig();
|
|
749
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
750
|
+
const { data, status } = await serverGet(cfg, "/learnings");
|
|
751
|
+
if (!data) return errorResult(`Lessons fetch failed: ${describeGetFailure(status)}`);
|
|
752
|
+
const lessons = data.lessons ?? [];
|
|
753
|
+
if (lessons.length === 0) {
|
|
754
|
+
return "No Learnings found.";
|
|
755
|
+
}
|
|
756
|
+
return lessons.map((l: { content?: unknown }) =>
|
|
757
|
+
`- ${typeof l.content === "string" ? l.content.slice(0, 500) : ""}`).join("\n");
|
|
758
|
+
} catch (err) {
|
|
759
|
+
return errorResult(`Lessons fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
hicortex_index: {
|
|
765
|
+
description:
|
|
766
|
+
"Get the knowledge domain index — shows what topics and projects are stored in memory, grouped by domain.",
|
|
767
|
+
args: {},
|
|
768
|
+
async execute(_args: any): Promise<string> {
|
|
769
|
+
try {
|
|
770
|
+
const cfg = resolveOpencodeConfig();
|
|
771
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
772
|
+
const { data, status } = await serverGet(cfg, "/index");
|
|
773
|
+
if (!data) return errorResult(`Index fetch failed: ${describeGetFailure(status)}`);
|
|
774
|
+
return JSON.stringify(data);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
return errorResult(`Index fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
hicortex_graph: {
|
|
782
|
+
description:
|
|
783
|
+
"Query the memory knowledge graph — find connected memories, hub nodes, or paths between memories.",
|
|
784
|
+
args: {
|
|
785
|
+
operation: { type: "string", description: "Graph operation to perform: neighbors, hubs, or path (required)" },
|
|
786
|
+
id: { type: "string", description: "Memory ID (required for neighbors and path operations)" },
|
|
787
|
+
target_id: { type: "string", description: "Target memory ID (required for path operation)" },
|
|
788
|
+
limit: { type: "number", description: "Max results (default 10)" },
|
|
789
|
+
domain: { type: "string", description: "Filter hubs by domain" },
|
|
790
|
+
relationship: { type: "string", description: "Filter neighbors by relationship type (e.g., extends, relates_to; legacy data may also have CONTRADICTS, SUPERSEDES, updates)" },
|
|
791
|
+
},
|
|
792
|
+
async execute(args: any): Promise<string> {
|
|
793
|
+
try {
|
|
794
|
+
const cfg = resolveOpencodeConfig();
|
|
795
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
796
|
+
const params = new URLSearchParams({ op: String(args?.operation ?? "") });
|
|
797
|
+
if (args?.id) params.set("id", String(args.id));
|
|
798
|
+
if (args?.target_id) params.set("target_id", String(args.target_id));
|
|
799
|
+
if (args?.limit) params.set("limit", String(args.limit));
|
|
800
|
+
if (args?.domain) params.set("domain", String(args.domain));
|
|
801
|
+
if (args?.relationship) params.set("relationship", String(args.relationship));
|
|
802
|
+
const { data, status } = await serverGet(cfg, `/graph?${params}`);
|
|
803
|
+
if (!data) return errorResult(`Graph query failed: ${describeGetFailure(status)}`);
|
|
804
|
+
return JSON.stringify(data);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
return errorResult(`Graph query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
hicortex_update: {
|
|
812
|
+
description:
|
|
813
|
+
"Update an existing memory. Use after searching to fix incorrect information. If content changes, the embedding is re-computed.",
|
|
814
|
+
args: {
|
|
815
|
+
id: { type: "string", description: "Memory ID (required; from search results, first 8 chars or full UUID)" },
|
|
816
|
+
content: { type: "string", description: "New content text" },
|
|
817
|
+
project: { type: "string", description: "New project name" },
|
|
818
|
+
memory_type: {
|
|
819
|
+
type: "string",
|
|
820
|
+
description:
|
|
821
|
+
"New memory type. Accepted: knowledge/experience/decisions/learnings (legacy fact/episode/decision/lesson also accepted, normalized to the canonical term).",
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
async execute(args: any): Promise<string> {
|
|
825
|
+
try {
|
|
826
|
+
const cfg = resolveOpencodeConfig();
|
|
827
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
828
|
+
const rawType = typeof args?.memory_type === "string" ? args.memory_type : "";
|
|
829
|
+
const result = await serverPost(cfg, "/update", {
|
|
830
|
+
id: args?.id,
|
|
831
|
+
content: args?.content,
|
|
832
|
+
project: args?.project,
|
|
833
|
+
memory_type: rawType ? (TO_CANONICAL_TYPE[rawType.toLowerCase()] ?? rawType) : undefined,
|
|
834
|
+
});
|
|
835
|
+
if (result.status === 404) {
|
|
836
|
+
return errorResult(`Memory not found: ${args?.id}`);
|
|
837
|
+
}
|
|
838
|
+
if (!result.ok) {
|
|
839
|
+
return errorResult(`Update failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
|
|
840
|
+
}
|
|
841
|
+
const id = result.data?.id ?? args?.id;
|
|
842
|
+
return `Memory updated (id: ${String(id).slice(0, 8)})`;
|
|
843
|
+
} catch (err) {
|
|
844
|
+
return errorResult(`Update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
|
|
849
|
+
hicortex_delete: {
|
|
850
|
+
description:
|
|
851
|
+
"Permanently delete a memory and its links. Use when a memory is incorrect and should be removed entirely.",
|
|
852
|
+
args: {
|
|
853
|
+
id: { type: "string", description: "Memory ID (required; from search results, first 8 chars or full UUID)" },
|
|
854
|
+
},
|
|
855
|
+
async execute(args: any): Promise<string> {
|
|
856
|
+
try {
|
|
857
|
+
const cfg = resolveOpencodeConfig();
|
|
858
|
+
if (!cfg) return errorResult(NOT_CONFIGURED);
|
|
859
|
+
const result = await serverPost(cfg, "/delete", { id: args?.id });
|
|
860
|
+
if (result.status === 404) {
|
|
861
|
+
return errorResult(`Memory not found: ${args?.id}`);
|
|
862
|
+
}
|
|
863
|
+
if (!result.ok) {
|
|
864
|
+
return errorResult(`Delete failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
|
|
865
|
+
}
|
|
866
|
+
return `Memory deleted (id: ${String(args?.id).slice(0, 8)})`;
|
|
867
|
+
} catch (err) {
|
|
868
|
+
return errorResult(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Entry
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The ONE runtime export — opencode's loader invokes every exported function
|
|
881
|
+
* as a plugin factory, so this single factory IS the plugin (named
|
|
882
|
+
* `HicortexPlugin`; deliberately NO `export default` — a second binding of
|
|
883
|
+
* the same factory is a second factory call for the loader, #353). The
|
|
884
|
+
* single-export shape is live-verified on opencode 1.18.20 and 1.18.23;
|
|
885
|
+
* the 0.21.0 multi-export shape was live-refuted on 1.18.23 (helper called
|
|
886
|
+
* with undefined → plugin rejected). An async factory returning the hooks
|
|
887
|
+
* object; never throws — a broken plugin must not take down the agent loop.
|
|
888
|
+
*/
|
|
889
|
+
export const HicortexPlugin: any = async (_input: any): Promise<Record<string, unknown>> => {
|
|
890
|
+
try {
|
|
891
|
+
return {
|
|
892
|
+
// Dedup resets. The event hook receives { event } — narrowed
|
|
893
|
+
// structurally so either the wrapper or a bare event works.
|
|
894
|
+
event: async (input: any, _output: any): Promise<void> => {
|
|
895
|
+
try {
|
|
896
|
+
const sessionId = extractResetSessionId(input?.event ?? input);
|
|
897
|
+
if (!sessionId) return;
|
|
898
|
+
dropSessionState(sessionId);
|
|
899
|
+
const cfg = resolveOpencodeConfig();
|
|
900
|
+
if (cfg) void resetRecallDedup(cfg, sessionId);
|
|
901
|
+
} catch {
|
|
902
|
+
/* fail-soft */
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
|
|
906
|
+
// Pushed recall (the amended #347 channel — see the header). The
|
|
907
|
+
// transform output is rebuilt per model request from the STORED
|
|
908
|
+
// messages, so re-injecting the stashed block on tool-loop requests
|
|
909
|
+
// cannot double, and the injected message never reaches the store.
|
|
910
|
+
"experimental.chat.messages.transform": async (input: any, output: any): Promise<void> => {
|
|
911
|
+
try {
|
|
912
|
+
const messages = Array.isArray(output?.messages) ? output.messages : null;
|
|
913
|
+
if (!messages) return;
|
|
914
|
+
const last = lastUserMessage(messages);
|
|
915
|
+
if (!last) return;
|
|
916
|
+
const sessionId = messageSessionId(last.msg);
|
|
917
|
+
if (!sessionId) return;
|
|
918
|
+
const cfg = resolveOpencodeConfig();
|
|
919
|
+
if (!cfg) return;
|
|
920
|
+
pruneSessionState();
|
|
921
|
+
|
|
922
|
+
// Ordering (#316): a pending reset for THIS session registered
|
|
923
|
+
// before this fetch must complete first — bounded by the reset's
|
|
924
|
+
// own 1 s timeout, so a stalled server cannot stall the turn beyond it.
|
|
925
|
+
const pending = PENDING_RESETS.get(sessionId);
|
|
926
|
+
if (pending) await pending;
|
|
927
|
+
|
|
928
|
+
const prompt = messageText(last.msg);
|
|
929
|
+
// A tool-loop request repeats the SAME user message id — only a new
|
|
930
|
+
// id (falling back to the prompt text when ids are absent) is a new
|
|
931
|
+
// prompt worth a fetch.
|
|
932
|
+
const promptKey = typeof last.msg.id === "string" && last.msg.id ? last.msg.id : prompt;
|
|
933
|
+
if (PROMPT_KEYS.get(sessionId) !== promptKey) {
|
|
934
|
+
const block = prompt
|
|
935
|
+
? await fetchRecallBlock(cfg, sessionId, prompt, cwdFromInput(input)).catch(() => null)
|
|
936
|
+
: null;
|
|
937
|
+
RECALL_BLOCKS.set(sessionId, block);
|
|
938
|
+
PROMPT_KEYS.set(sessionId, promptKey);
|
|
939
|
+
}
|
|
940
|
+
const block = RECALL_BLOCKS.get(sessionId) ?? null;
|
|
941
|
+
if (!block) return;
|
|
942
|
+
const synthetic = buildRecallMessage(last.msg, fenceBlock(block));
|
|
943
|
+
if (synthetic) messages.splice(last.index + 1, 0, synthetic);
|
|
944
|
+
} catch {
|
|
945
|
+
// Total fail-soft: inject nothing, never block the request.
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
|
|
949
|
+
// Identity + lessons: ONE fenced entry appended to the system array.
|
|
950
|
+
// Fetched once per session (independent fail-soft); the fence guard
|
|
951
|
+
// prevents doubling when an entry already carries our end marker.
|
|
952
|
+
"experimental.chat.system.transform": async (input: any, output: any): Promise<void> => {
|
|
953
|
+
try {
|
|
954
|
+
const system = Array.isArray(output?.system) ? output.system : null;
|
|
955
|
+
if (!system || systemAlreadyFenced(system)) return;
|
|
956
|
+
const cfg = resolveOpencodeConfig();
|
|
957
|
+
if (!cfg) return;
|
|
958
|
+
pruneSessionState();
|
|
959
|
+
// The hook input's session shape is not stable across opencode
|
|
960
|
+
// versions — fall back to one process-wide cache key rather than
|
|
961
|
+
// skipping injection entirely.
|
|
962
|
+
const sessionId = inputSessionId(input) ?? FALLBACK_SESSION_KEY;
|
|
963
|
+
const [identityBlock, lessonsBlock] = await Promise.all([
|
|
964
|
+
cachedBlock(IDENTITY_BLOCKS, sessionId, () => fetchIdentityBlock(cfg)),
|
|
965
|
+
cachedBlock(LESSONS_BLOCKS, sessionId, () => fetchLessonsBlock(cfg)),
|
|
966
|
+
]);
|
|
967
|
+
const entry = renderContextEntry([identityBlock, lessonsBlock]);
|
|
968
|
+
if (entry) (system as unknown[]).push(entry);
|
|
969
|
+
} catch {
|
|
970
|
+
// Total fail-soft: inject nothing, never block the request.
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
|
|
974
|
+
tool: registerTools(),
|
|
975
|
+
};
|
|
976
|
+
} catch {
|
|
977
|
+
// Registration itself failed (unexpected opencode API change) — stay
|
|
978
|
+
// silent; the session must proceed without memory either way.
|
|
979
|
+
return {};
|
|
980
|
+
}
|
|
981
|
+
};
|