@gamaze/hicortex 0.19.6 → 0.20.1

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.
@@ -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
+ };