@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,888 @@
1
+ /**
2
+ * Hicortex extension for the Pi coding agent (#348) — recall + identity +
3
+ * lessons injection, no capture.
4
+ *
5
+ * The OpenClaw-plugin architecture on Pi's extension API: one dependency-free
6
+ * file (Node built-ins only, global fetch) that gives Pi agents the same
7
+ * memory experience as CC/OC —
8
+ *
9
+ * before_agent_start → POST /recall-index per prompt; the returned index
10
+ * block is injected as a custom message alongside the
11
+ * user message (the push-recall channel), and the
12
+ * identity + lessons blocks are appended to the
13
+ * system prompt for THIS TURN (identity/lessons
14
+ * channel). Pi resets a systemPrompt override to the
15
+ * base prompt on any turn no extension returns one
16
+ * (verified against pi 0.84.3), so the blocks are
17
+ * re-applied EVERY turn from a session-scoped cache.
18
+ * session_start → POST /recall-index {reset:true} (fresh context
19
+ * window — the server's per-session shown-set is
20
+ * stale by definition) and drop the block caches.
21
+ * session_compact → same reset + cache drop: the rebuilt window may
22
+ * have dropped the injected blocks.
23
+ * registerTool × 9 → the hicortex_* tools as direct REST proxies.
24
+ *
25
+ * Capture is NOT the extension's job — the nightly reader on the server
26
+ * machine distills `~/.pi/agent/sessions/` centrally (pi-transcript-reader).
27
+ *
28
+ * Fail-soft by construction: ANY failure (no config, timeout, non-2xx, parse
29
+ * error) injects nothing and never blocks a session — every injection-path
30
+ * fetch carries a 1000 ms timeout (the CC-hook budget), tool fetches 10 s
31
+ * (the OC serverGet budget). No ctx.ui calls anywhere, so print mode
32
+ * (`pi -p`) is safe by construction.
33
+ *
34
+ * Loose structural typing throughout (`pi: any`, inline narrowing): Pi
35
+ * publishes its extension types under a package this file must not depend
36
+ * on — the extension has ZERO imports beyond Node built-ins so it works on
37
+ * any Pi install straight from the npm tarball.
38
+ */
39
+
40
+ import { readFileSync } from "node:fs";
41
+ import { join, basename } from "node:path";
42
+ import { homedir } from "node:os";
43
+
44
+ const FETCH_TIMEOUT_MS = 1000;
45
+ const TOOL_TIMEOUT_MS = 10_000;
46
+ const DEFAULT_PORT = 8787;
47
+ /** Harness name this extension injects for — self-gates on /identity `clients`. */
48
+ const THIS_HARNESS = "pi";
49
+ const DEFAULT_LESSONS_LIMIT = 10;
50
+
51
+ /**
52
+ * HTML-comment fence around the appended identity/lessons blocks. The end
53
+ * marker is the idempotency guard: pi resets a systemPrompt override to base
54
+ * on any turn no extension returns one, so we re-append every turn — but if a
55
+ * future pi PERSISTS an override (or the incoming base already carries our
56
+ * block), appending again would double it. Comments are invisible to the
57
+ * model, so the fence costs nothing on the wire.
58
+ */
59
+ const CONTEXT_START = "<!-- hicortex-context-start -->";
60
+ const CONTEXT_END = "<!-- hicortex-context-end -->";
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Config resolution — duplicated from the CC hook's resolveConfig
64
+ // (learnings-identity.ts) because this file cannot import package code.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface PiConfig {
68
+ serverUrl: string;
69
+ authToken: string | undefined;
70
+ /** Max lessons in the injected block (config lessonsLimit, default 10). */
71
+ lessonsLimit: number;
72
+ }
73
+
74
+ /**
75
+ * Read ~/.hicortex/config.json and resolve the server URL + auth token, or
76
+ * null when there is no usable config (server not set up — fail soft).
77
+ * Mirrors resolveConfig: client mode → `serverUrl` (trailing slashes
78
+ * stripped), server mode → localhost:port. The auth token follows the
79
+ * server's precedence — config first, HICORTEX_AUTH_TOKEN env fills gaps.
80
+ */
81
+ export function resolvePiConfig(): PiConfig | null {
82
+ const home = process.env.HICORTEX_HOME ?? join(homedir(), ".hicortex");
83
+ let config: Record<string, unknown>;
84
+ try {
85
+ config = JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) as Record<string, unknown>;
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!config || typeof config !== "object" || Array.isArray(config)) return null;
90
+
91
+ const serverUrl = config.mode === "client" && typeof config.serverUrl === "string"
92
+ ? config.serverUrl.replace(/\/+$/, "")
93
+ : `http://127.0.0.1:${typeof config.port === "number" ? config.port : DEFAULT_PORT}`;
94
+
95
+ const authToken = (typeof config.authToken === "string" && config.authToken
96
+ ? config.authToken
97
+ : undefined) ?? process.env.HICORTEX_AUTH_TOKEN;
98
+
99
+ const rawLimit = config.lessonsLimit;
100
+ const lessonsLimit = typeof rawLimit === "number" && rawLimit > 0
101
+ ? Math.floor(rawLimit)
102
+ : DEFAULT_LESSONS_LIMIT;
103
+
104
+ return { serverUrl, authToken, lessonsLimit };
105
+ }
106
+
107
+ function authHeaders(cfg: PiConfig): Record<string, string> {
108
+ return cfg.authToken ? { "Authorization": `Bearer ${cfg.authToken}` } : {};
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Pure renderers — exported so tests exercise them directly
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** "user" → "User", "my_notes" → "My Notes" (mirrors titleCaseSection). */
116
+ export function titleCaseSection(name: string): string {
117
+ return name
118
+ .split(/[-_]+/)
119
+ .filter(Boolean)
120
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
121
+ .join(" ");
122
+ }
123
+
124
+ export interface IdentityResponse {
125
+ sections?: Record<string, unknown>;
126
+ clients?: unknown;
127
+ }
128
+
129
+ /**
130
+ * Render the `## Identity` block from a resolved section map, or null when
131
+ * every section is blank. Mirrors renderIdentityBlock (learnings-identity.ts)
132
+ * with one dependency-free simplification: section headings are the
133
+ * title-cased key (which matches the server's labels for the seeded
134
+ * sections); sections are rendered in the SERVER's wire order — the server
135
+ * already applies the #313 precedence when it builds the response, so the
136
+ * extension does not re-sort.
137
+ */
138
+ export function renderIdentityBlock(sections: Record<string, unknown> | undefined): string | null {
139
+ if (!sections || typeof sections !== "object" || Array.isArray(sections)) return null;
140
+ const bodyParts: string[] = [];
141
+ for (const name of Object.keys(sections)) {
142
+ const body = sections[name];
143
+ if (typeof body !== "string" || body.trim() === "") continue;
144
+ bodyParts.push(`### ${titleCaseSection(name)}`, "", body.trim());
145
+ }
146
+ if (bodyParts.length === 0) return null;
147
+ return ["## Identity", "", ...bodyParts].join("\n");
148
+ }
149
+
150
+ /**
151
+ * Gate a GET /identity response and render the block, or null when nothing
152
+ * should be injected: this harness ("pi") not in the server-resolved
153
+ * `clients` list, or no non-empty sections. Mirrors gateAndRenderIdentity
154
+ * (the single CC/OC gate — keep the three in sync).
155
+ */
156
+ export function gateAndRenderIdentity(data: IdentityResponse | null): string | null {
157
+ if (!data || typeof data !== "object") return null;
158
+ const clients = Array.isArray(data.clients) ? data.clients : [];
159
+ if (!clients.includes(THIS_HARNESS)) return null;
160
+ return renderIdentityBlock(data.sections);
161
+ }
162
+
163
+ export interface LessonsResponse {
164
+ lessons?: Array<{ content?: unknown }>;
165
+ index?: {
166
+ total?: number;
167
+ lessonCount?: number;
168
+ sourceCount?: number;
169
+ projects?: Array<{ name?: unknown; count?: unknown }>;
170
+ };
171
+ moduleIndex?: { domains?: Array<{ name?: unknown; keywords?: unknown[]; memoryCount?: number; lessonCount?: number; projects?: unknown[] }> } | null;
172
+ }
173
+
174
+ /**
175
+ * Render the `## Hicortex Memory` block from a GET /learnings response, or
176
+ * null on a shape we cannot render. Format follows the CC hook's
177
+ * fetchLessonsBlock (guidance lines + lesson lines + memory-index footer)
178
+ * with the dependency-free simplification the Hermes plugin also makes: a
179
+ * plain top-N slice instead of the package's domain-aware lesson selector
180
+ * (which this file cannot import). N = config lessonsLimit (default 10).
181
+ */
182
+ export function renderLessonsBlock(data: LessonsResponse | null, maxLessons: number): string | null {
183
+ if (!data || typeof data !== "object") return null;
184
+ const lessons = Array.isArray(data.lessons) ? data.lessons : [];
185
+
186
+ const lessonLines = lessons.slice(0, maxLessons).map((l) => {
187
+ const content = typeof l?.content === "string" ? l.content : "";
188
+ const typeMatch = content.match(/\*\*Type:\*\* (\w+)/);
189
+ const severityMatch = content.match(/\*\*Severity:\*\* (\w+)/);
190
+ // First line, with any legacy `## Lesson:` prefix stripped — new lessons
191
+ // are stored topic-first (memory_type carries the type).
192
+ const title = content.replace(/^##\s*Lesson:\s*/i, "").split("\n")[0].slice(0, 150);
193
+ const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
194
+ return `- ${title}${meta ? ` (${meta})` : ""}`;
195
+ });
196
+
197
+ const parts: string[] = ["## Hicortex Memory", ""];
198
+ parts.push("You have access to shared long-term memory across all agents and sessions.");
199
+ parts.push("BEFORE making decisions, search memory: `hicortex_search` for prior decisions on the same topic.");
200
+ parts.push("Use `hicortex_recent` at session start for recent project state.");
201
+
202
+ if (lessonLines.length > 0) {
203
+ parts.push("", "### Learnings (updated nightly)");
204
+ parts.push(...lessonLines);
205
+ }
206
+
207
+ const index = data.index ?? {};
208
+ const domains = data.moduleIndex?.domains ?? [];
209
+ if (domains.length > 0) {
210
+ parts.push("", "### Memory Index");
211
+ for (const domain of domains) {
212
+ const keywords = Array.isArray(domain.keywords) ? domain.keywords : [];
213
+ const kwStr = keywords.length > 0 ? `: ${keywords.join(", ")}` : "";
214
+ parts.push(`${domain.name} (${domain.memoryCount} memories, ${domain.lessonCount} Learnings)${kwStr}`);
215
+ if (Array.isArray(domain.projects) && domain.projects.length > 0) {
216
+ parts.push(` ${domain.projects.join(" | ")}`);
217
+ }
218
+ }
219
+ parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
220
+ } else if (Array.isArray(index.projects) && index.projects.length > 0) {
221
+ parts.push("", "### Memory Index");
222
+ parts.push(index.projects.map((p) => `${p.name}: ${p.count}`).join(" | "));
223
+ parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
224
+ }
225
+
226
+ return parts.join("\n");
227
+ }
228
+
229
+ /**
230
+ * Build the /recall-index request body, or null when there is nothing to
231
+ * send (no session id, or an empty prompt). Mirrors the CC hook's
232
+ * buildHookRequest: project = basename(cwd) so retrieval can apply the soft
233
+ * project-affinity boost.
234
+ */
235
+ export function buildRecallBody(
236
+ sessionId: string,
237
+ prompt: string,
238
+ cwd: string,
239
+ ): Record<string, unknown> | null {
240
+ if (!sessionId || !prompt) return null;
241
+ const body: Record<string, unknown> = { session_id: sessionId, prompt };
242
+ const project = basename(cwd ?? "");
243
+ if (project) body.project = project;
244
+ return body;
245
+ }
246
+
247
+ /**
248
+ * Append the identity/lessons blocks to a base system prompt behind a marker
249
+ * fence, or undefined when there is nothing to append (no non-empty blocks,
250
+ * or the base ALREADY carries our end marker — the not-doubled guard; see
251
+ * CONTEXT_END). The caller returns the result as `{systemPrompt}` EVERY turn
252
+ * because pi resets an override to base whenever no extension supplies one.
253
+ */
254
+ export function appendContextBlocks(
255
+ basePrompt: unknown,
256
+ blocks: Array<string | null>,
257
+ ): string | undefined {
258
+ const base = typeof basePrompt === "string" ? basePrompt : "";
259
+ const parts = blocks.filter((b): b is string => typeof b === "string" && b.trim() !== "");
260
+ if (parts.length === 0) return undefined;
261
+ if (base.includes(CONTEXT_END)) return undefined;
262
+ return `${base}\n\n${CONTEXT_START}\n\n${parts.join("\n\n")}\n\n${CONTEXT_END}`;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Session-scoped state
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Per-session caches: a SETTLED render (a block, or a gated/empty null) is
271
+ * memoized for the session; a FAILED fetch is NOT — it retries next turn
272
+ * (the OC plugin's #313 memoize-only-on-success rule). Bounded: a long-lived
273
+ * pi process accumulating sessions would otherwise grow the maps forever.
274
+ */
275
+ const IDENTITY_BLOCKS = new Map<string, string | null>();
276
+ const LESSONS_BLOCKS = new Map<string, string | null>();
277
+ /** In-flight / recently-fired dedup resets, keyed by session id (#316 ordering). */
278
+ const PENDING_RESETS = new Map<string, Promise<void>>();
279
+ const SESSION_STATE_CAP = 32;
280
+
281
+ /** Test seam: wipe the module-level session state between cases. */
282
+ export function __resetSessionState(): void {
283
+ IDENTITY_BLOCKS.clear();
284
+ LESSONS_BLOCKS.clear();
285
+ PENDING_RESETS.clear();
286
+ }
287
+
288
+ function pruneSessionState(): void {
289
+ if (IDENTITY_BLOCKS.size > SESSION_STATE_CAP || LESSONS_BLOCKS.size > SESSION_STATE_CAP) {
290
+ IDENTITY_BLOCKS.clear();
291
+ LESSONS_BLOCKS.clear();
292
+ }
293
+ }
294
+
295
+ function dropSessionState(sessionId: string): void {
296
+ IDENTITY_BLOCKS.delete(sessionId);
297
+ LESSONS_BLOCKS.delete(sessionId);
298
+ }
299
+
300
+ function getSessionId(ctx: unknown): string | null {
301
+ const sid = (ctx as { sessionManager?: { getSessionId?: () => unknown } } | null)
302
+ ?.sessionManager?.getSessionId?.();
303
+ // Without a session id the recall key would collapse to "" and merge every
304
+ // session on the server — inject nothing instead (OC warns; pi has no
305
+ // logger we may safely use in print mode, so this fails silently).
306
+ return typeof sid === "string" && sid ? sid : null;
307
+ }
308
+
309
+ /** Fetch-and-memoize one block; a throw degrades to null WITHOUT caching. */
310
+ async function cachedBlock(
311
+ cache: Map<string, string | null>,
312
+ sessionId: string,
313
+ fetcher: () => Promise<string | null>,
314
+ ): Promise<string | null> {
315
+ if (cache.has(sessionId)) return cache.get(sessionId) ?? null;
316
+ try {
317
+ const block = await fetcher();
318
+ cache.set(sessionId, block);
319
+ return block;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Fetchers — every failure path THROWS to the caller's fail-soft catch
327
+ // ---------------------------------------------------------------------------
328
+
329
+ async function fetchRecallBlock(
330
+ cfg: PiConfig,
331
+ sessionId: string,
332
+ prompt: string,
333
+ cwd: string,
334
+ ): Promise<string | null> {
335
+ const body = buildRecallBody(sessionId, prompt, cwd);
336
+ if (!body) return null;
337
+ const resp = await fetch(`${cfg.serverUrl}/recall-index`, {
338
+ method: "POST",
339
+ headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
340
+ body: JSON.stringify(body),
341
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
342
+ });
343
+ // 404 = a pre-0.14 server with no /recall-index; any other non-2xx is the
344
+ // same outcome for injection purposes — nothing this turn (CC-hook
345
+ // contract; the server-side /search tool remains available).
346
+ if (!resp.ok) return null;
347
+ const data = await resp.json() as { block?: string | null };
348
+ return typeof data.block === "string" && data.block.trim() !== "" ? data.block : null;
349
+ }
350
+
351
+ async function fetchIdentityBlock(cfg: PiConfig): Promise<string | null> {
352
+ const resp = await fetch(`${cfg.serverUrl}/identity`, {
353
+ headers: authHeaders(cfg),
354
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
355
+ });
356
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
357
+ return gateAndRenderIdentity(await resp.json() as IdentityResponse);
358
+ }
359
+
360
+ async function fetchLessonsBlock(cfg: PiConfig): Promise<string | null> {
361
+ const resp = await fetch(`${cfg.serverUrl}/learnings`, {
362
+ headers: authHeaders(cfg),
363
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
364
+ });
365
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
366
+ const block = renderLessonsBlock(await resp.json() as LessonsResponse, cfg.lessonsLimit);
367
+ return block === null ? null : block;
368
+ }
369
+
370
+ /**
371
+ * POST {session_id, reset:true}. Fail-soft: a lost reset only means some
372
+ * memories stay suppressed until the re-show window (recallReshowTurns)
373
+ * passes. Registered in PENDING_RESETS so the session's next recall fetch
374
+ * AWAITS it — the reset can never land AFTER the fetch and wipe the
375
+ * shown-set state that turn just built (the Hermes initialize() race,
376
+ * closed by ordering).
377
+ */
378
+ function resetRecallDedup(cfg: PiConfig, sessionId: string): Promise<void> {
379
+ const inFlight = PENDING_RESETS.get(sessionId);
380
+ if (inFlight) return inFlight;
381
+ const p = fetch(`${cfg.serverUrl}/recall-index`, {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
384
+ body: JSON.stringify({ session_id: sessionId, reset: true }),
385
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
386
+ })
387
+ .then(() => undefined)
388
+ .catch(() => {
389
+ /* fail-soft — never surface into the session */
390
+ })
391
+ .finally(() => {
392
+ if (PENDING_RESETS.get(sessionId) === p) PENDING_RESETS.delete(sessionId);
393
+ });
394
+ PENDING_RESETS.set(sessionId, p);
395
+ return p;
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Tool plumbing — REST proxies (names/descriptions/schemas mirror the OC
400
+ // plugin verbatim; Pi validates plain JSON Schema, no TypeBox needed)
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /** Canonical memory_type mapping — mirrors normalizeMemoryType (type-labels). */
404
+ const TO_CANONICAL_TYPE: Record<string, string> = {
405
+ fact: "knowledge", episode: "experience", decision: "decisions", lesson: "learnings",
406
+ knowledge: "knowledge", experience: "experience", decisions: "decisions", learnings: "learnings",
407
+ };
408
+
409
+ /** Render labels for search/recent results — mirrors MEMORY_TYPE_LABELS. */
410
+ const TYPE_LABELS: Record<string, string> = {
411
+ knowledge: "Knowledge", experience: "Experience", decisions: "Decisions", learnings: "Learnings",
412
+ fact: "Knowledge", episode: "Experience", decision: "Decisions", lesson: "Learnings",
413
+ };
414
+
415
+ const NOT_CONFIGURED =
416
+ "Hicortex is not configured on this machine — run `npx @gamaze/hicortex init` " +
417
+ "(or create ~/.hicortex/config.json).";
418
+
419
+ function textResult(text: string): { content: Array<{ type: string; text: string }>; details: undefined } {
420
+ return { content: [{ type: "text", text }], details: undefined };
421
+ }
422
+
423
+ /** Error results are plain text (pi renders the content array); never throw. */
424
+ function errorResult(message: string) {
425
+ return textResult(`error: ${message}`);
426
+ }
427
+
428
+ async function serverGet(
429
+ cfg: PiConfig,
430
+ path: string,
431
+ ): Promise<{ data: any | null; status: number | null }> {
432
+ try {
433
+ const resp = await fetch(`${cfg.serverUrl}${path}`, {
434
+ headers: authHeaders(cfg),
435
+ signal: AbortSignal.timeout(TOOL_TIMEOUT_MS),
436
+ });
437
+ if (!resp.ok) return { data: null, status: resp.status };
438
+ return { data: await resp.json(), status: resp.status };
439
+ } catch {
440
+ return { data: null, status: null };
441
+ }
442
+ }
443
+
444
+ async function serverPost(
445
+ cfg: PiConfig,
446
+ path: string,
447
+ body: unknown,
448
+ ): Promise<{ ok: boolean; status: number; data: any | null }> {
449
+ try {
450
+ const resp = await fetch(`${cfg.serverUrl}${path}`, {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json", ...authHeaders(cfg) },
453
+ body: JSON.stringify(body),
454
+ signal: AbortSignal.timeout(TOOL_TIMEOUT_MS),
455
+ });
456
+ let data: any = null;
457
+ try { data = await resp.json(); } catch { /* non-JSON body */ }
458
+ return { ok: resp.ok, status: resp.status, data };
459
+ } catch {
460
+ return { ok: false, status: 0, data: null };
461
+ }
462
+ }
463
+
464
+ /** Human-readable GET failure — mirrors describeGetFailure (skew vs down). */
465
+ function describeGetFailure(status: number | null): string {
466
+ if (status === null) return "server unreachable";
467
+ return `server returned HTTP ${status}`;
468
+ }
469
+
470
+ interface SearchHit {
471
+ content?: unknown;
472
+ memory_type?: unknown;
473
+ score?: unknown;
474
+ effective_strength?: unknown;
475
+ }
476
+
477
+ function formatSearchResults(results: Array<SearchHit>) {
478
+ if (!Array.isArray(results) || results.length === 0) {
479
+ return textResult("No memories found.");
480
+ }
481
+ const text = results
482
+ .map((r) => {
483
+ const type = typeof r.memory_type === "string" ? String(r.memory_type) : "";
484
+ const label = TYPE_LABELS[type] ?? type ?? "—";
485
+ const score = typeof r.score === "number" ? r.score.toFixed(3) : "0.000";
486
+ const strength = typeof r.effective_strength === "number" ? r.effective_strength.toFixed(3) : "0.000";
487
+ const content = typeof r.content === "string" ? r.content.slice(0, 500) : "";
488
+ return `[${label}] (score: ${score}, strength: ${strength}) ${content}`;
489
+ })
490
+ .join("\n\n");
491
+ return textResult(text);
492
+ }
493
+
494
+ /** The nine tools. `execute` resolves config per call (no startup capture). */
495
+ function registerTools(pi: any): void {
496
+ pi.registerTool({
497
+ name: "hicortex_search",
498
+ label: "Hicortex: search memory",
499
+ description:
500
+ "Search long-term memory using semantic similarity. Returns the most relevant memories from past sessions.",
501
+ parameters: {
502
+ type: "object",
503
+ properties: {
504
+ query: { type: "string", description: "Search query text" },
505
+ limit: { type: "number", description: "Max results (default 5)" },
506
+ project: { type: "string", description: "Filter by project name" },
507
+ },
508
+ required: ["query"],
509
+ },
510
+ async execute(_toolCallId: string, args: any) {
511
+ try {
512
+ const cfg = resolvePiConfig();
513
+ if (!cfg) return errorResult(NOT_CONFIGURED);
514
+ const params = new URLSearchParams({ query: String(args?.query ?? "") });
515
+ if (args?.limit) params.set("limit", String(args.limit));
516
+ if (args?.project) params.set("project", String(args.project));
517
+ const { data, status } = await serverGet(cfg, `/search?${params}`);
518
+ if (!data) return errorResult(`Search failed: ${describeGetFailure(status)}`);
519
+ return formatSearchResults(data.results ?? []);
520
+ } catch (err) {
521
+ return errorResult(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
522
+ }
523
+ },
524
+ });
525
+
526
+ pi.registerTool({
527
+ name: "hicortex_get",
528
+ label: "Hicortex: get memory",
529
+ description:
530
+ "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.",
531
+ parameters: {
532
+ type: "object",
533
+ properties: {
534
+ id: { type: "string", description: "Memory ID (as shown in the recall index or search results)" },
535
+ },
536
+ required: ["id"],
537
+ },
538
+ async execute(_toolCallId: string, args: any) {
539
+ try {
540
+ if (!args?.id) return errorResult("id is required");
541
+ const cfg = resolvePiConfig();
542
+ if (!cfg) return errorResult(NOT_CONFIGURED);
543
+ const params = new URLSearchParams({ id: String(args.id) });
544
+ const { data, status } = await serverGet(cfg, `/memory?${params}`);
545
+ if (status === 404) {
546
+ // Either no such memory (0.14+) or a pre-0.14 server with no
547
+ // /memory endpoint — the id hint covers the common case.
548
+ return errorResult(`Memory not found: ${args.id} (or the server predates 0.14 — upgrade the server)`);
549
+ }
550
+ if (!data) return errorResult(`Get failed: ${describeGetFailure(status)}`);
551
+ // Render the content BEHIND the server's citation string — the
552
+ // server-side rendering is the single provenance norm (0.14.1).
553
+ const text = `${data.citation ?? ""}\n\n${data.memory?.content ?? ""}`.trim();
554
+ return textResult(text);
555
+ } catch (err) {
556
+ return errorResult(`Get failed: ${err instanceof Error ? err.message : String(err)}`);
557
+ }
558
+ },
559
+ });
560
+
561
+ pi.registerTool({
562
+ name: "hicortex_recent",
563
+ label: "Hicortex: recent memories",
564
+ description:
565
+ "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.",
566
+ parameters: {
567
+ type: "object",
568
+ properties: {
569
+ project: { type: "string", description: "Filter by project name" },
570
+ limit: { type: "number", description: "Max results (default 10)" },
571
+ },
572
+ },
573
+ async execute(_toolCallId: string, args: any) {
574
+ try {
575
+ const cfg = resolvePiConfig();
576
+ if (!cfg) return errorResult(NOT_CONFIGURED);
577
+ const params = new URLSearchParams();
578
+ if (args?.project) params.set("project", String(args.project));
579
+ if (args?.limit) params.set("limit", String(args.limit));
580
+ const qs = params.toString();
581
+ const { data, status } = await serverGet(cfg, `/recent${qs ? `?${qs}` : ""}`);
582
+ if (!data) return errorResult(`Recent recall failed: ${describeGetFailure(status)}`);
583
+ return formatSearchResults(data.results ?? []);
584
+ } catch (err) {
585
+ return errorResult(`Recent recall failed: ${err instanceof Error ? err.message : String(err)}`);
586
+ }
587
+ },
588
+ });
589
+
590
+ pi.registerTool({
591
+ name: "hicortex_ingest",
592
+ label: "Hicortex: save memory",
593
+ description:
594
+ "Store a new memory in long-term storage. Use for Knowledge, Decisions, or Learnings.",
595
+ parameters: {
596
+ type: "object",
597
+ properties: {
598
+ content: { type: "string", description: "Memory content to store" },
599
+ project: { type: "string", description: "Project this memory belongs to" },
600
+ memory_type: {
601
+ type: "string",
602
+ enum: ["knowledge", "experience", "decisions", "learnings", "fact", "episode", "decision", "lesson"],
603
+ description: "Type of memory (default: Experience). Accepted: Knowledge/Experience/Decisions/Learnings (legacy raw enum also accepted, normalized to the canonical term).",
604
+ },
605
+ },
606
+ required: ["content"],
607
+ },
608
+ async execute(_toolCallId: string, args: any) {
609
+ try {
610
+ const cfg = resolvePiConfig();
611
+ if (!cfg) return errorResult(NOT_CONFIGURED);
612
+ const rawType = typeof args?.memory_type === "string" ? args.memory_type : "";
613
+ const result = await serverPost(cfg, "/ingest", {
614
+ content: args?.content,
615
+ source_agent: "pi/manual",
616
+ project: args?.project,
617
+ memory_type: rawType ? (TO_CANONICAL_TYPE[rawType.toLowerCase()] ?? rawType) : "experience",
618
+ });
619
+ if (!result.ok) {
620
+ return errorResult(`Ingest failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
621
+ }
622
+ const id = result.data?.id ?? "unknown";
623
+ return textResult(`Memory stored (id: ${String(id).slice(0, 8)})`);
624
+ } catch (err) {
625
+ return errorResult(`Ingest failed: ${err instanceof Error ? err.message : String(err)}`);
626
+ }
627
+ },
628
+ });
629
+
630
+ pi.registerTool({
631
+ name: "hicortex_lessons",
632
+ label: "Hicortex: learnings",
633
+ description:
634
+ "Get actionable Learnings distilled from past sessions. Auto-generated insights about mistakes to avoid.",
635
+ parameters: {
636
+ type: "object",
637
+ properties: {
638
+ project: { type: "string", description: "Filter by project name (optional)" },
639
+ },
640
+ },
641
+ async execute(_toolCallId: string, _args: any) {
642
+ try {
643
+ const cfg = resolvePiConfig();
644
+ if (!cfg) return errorResult(NOT_CONFIGURED);
645
+ const { data, status } = await serverGet(cfg, "/learnings");
646
+ if (!data) return errorResult(`Lessons fetch failed: ${describeGetFailure(status)}`);
647
+ const lessons = data.lessons ?? [];
648
+ if (lessons.length === 0) {
649
+ return textResult("No Learnings found.");
650
+ }
651
+ const text = lessons.map((l: { content?: unknown }) =>
652
+ `- ${typeof l.content === "string" ? l.content.slice(0, 500) : ""}`).join("\n");
653
+ return textResult(text);
654
+ } catch (err) {
655
+ return errorResult(`Lessons fetch failed: ${err instanceof Error ? err.message : String(err)}`);
656
+ }
657
+ },
658
+ });
659
+
660
+ pi.registerTool({
661
+ name: "hicortex_index",
662
+ label: "Hicortex: memory index",
663
+ description:
664
+ "Get the knowledge domain index — shows what topics and projects are stored in memory, grouped by domain.",
665
+ parameters: {
666
+ type: "object",
667
+ properties: {},
668
+ },
669
+ async execute(_toolCallId: string, _args: any) {
670
+ try {
671
+ const cfg = resolvePiConfig();
672
+ if (!cfg) return errorResult(NOT_CONFIGURED);
673
+ const { data, status } = await serverGet(cfg, "/index");
674
+ if (!data) return errorResult(`Index fetch failed: ${describeGetFailure(status)}`);
675
+ return textResult(JSON.stringify(data));
676
+ } catch (err) {
677
+ return errorResult(`Index fetch failed: ${err instanceof Error ? err.message : String(err)}`);
678
+ }
679
+ },
680
+ });
681
+
682
+ pi.registerTool({
683
+ name: "hicortex_graph",
684
+ label: "Hicortex: memory graph",
685
+ description:
686
+ "Query the memory knowledge graph — find connected memories, hub nodes, or paths between memories.",
687
+ parameters: {
688
+ type: "object",
689
+ properties: {
690
+ operation: {
691
+ type: "string",
692
+ enum: ["neighbors", "hubs", "path"],
693
+ description: "Graph operation to perform",
694
+ },
695
+ id: { type: "string", description: "Memory ID (required for neighbors and path operations)" },
696
+ target_id: { type: "string", description: "Target memory ID (required for path operation)" },
697
+ limit: { type: "number", description: "Max results (default 10)" },
698
+ domain: { type: "string", description: "Filter hubs by domain" },
699
+ relationship: { type: "string", description: "Filter neighbors by relationship type (e.g., extends, relates_to; legacy data may also have CONTRADICTS, SUPERSEDES, updates)" },
700
+ },
701
+ required: ["operation"],
702
+ },
703
+ async execute(_toolCallId: string, args: any) {
704
+ try {
705
+ const cfg = resolvePiConfig();
706
+ if (!cfg) return errorResult(NOT_CONFIGURED);
707
+ const params = new URLSearchParams({ op: String(args?.operation ?? "") });
708
+ if (args?.id) params.set("id", String(args.id));
709
+ if (args?.target_id) params.set("target_id", String(args.target_id));
710
+ if (args?.limit) params.set("limit", String(args.limit));
711
+ if (args?.domain) params.set("domain", String(args.domain));
712
+ if (args?.relationship) params.set("relationship", String(args.relationship));
713
+ const { data, status } = await serverGet(cfg, `/graph?${params}`);
714
+ if (!data) return errorResult(`Graph query failed: ${describeGetFailure(status)}`);
715
+ return textResult(JSON.stringify(data));
716
+ } catch (err) {
717
+ return errorResult(`Graph query failed: ${err instanceof Error ? err.message : String(err)}`);
718
+ }
719
+ },
720
+ });
721
+
722
+ pi.registerTool({
723
+ name: "hicortex_update",
724
+ label: "Hicortex: update memory",
725
+ description:
726
+ "Update an existing memory. Use after searching to fix incorrect information. If content changes, the embedding is re-computed.",
727
+ parameters: {
728
+ type: "object",
729
+ properties: {
730
+ id: { type: "string", description: "Memory ID (from search results, first 8 chars or full UUID)" },
731
+ content: { type: "string", description: "New content text" },
732
+ project: { type: "string", description: "New project name" },
733
+ memory_type: {
734
+ type: "string",
735
+ enum: ["knowledge", "experience", "decisions", "learnings", "fact", "episode", "decision", "lesson"],
736
+ description: "New memory type. Accepted: Knowledge/Experience/Decisions/Learnings (legacy raw enum also accepted, normalized to the canonical term).",
737
+ },
738
+ },
739
+ required: ["id"],
740
+ },
741
+ async execute(_toolCallId: string, args: any) {
742
+ try {
743
+ const cfg = resolvePiConfig();
744
+ if (!cfg) return errorResult(NOT_CONFIGURED);
745
+ const rawType = typeof args?.memory_type === "string" ? args.memory_type : "";
746
+ const result = await serverPost(cfg, "/update", {
747
+ id: args?.id,
748
+ content: args?.content,
749
+ project: args?.project,
750
+ memory_type: rawType ? (TO_CANONICAL_TYPE[rawType.toLowerCase()] ?? rawType) : undefined,
751
+ });
752
+ if (result.status === 404) {
753
+ return errorResult(`Memory not found: ${args?.id}`);
754
+ }
755
+ if (!result.ok) {
756
+ return errorResult(`Update failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
757
+ }
758
+ const id = result.data?.id ?? args?.id;
759
+ return textResult(`Memory updated (id: ${String(id).slice(0, 8)})`);
760
+ } catch (err) {
761
+ return errorResult(`Update failed: ${err instanceof Error ? err.message : String(err)}`);
762
+ }
763
+ },
764
+ });
765
+
766
+ pi.registerTool({
767
+ name: "hicortex_delete",
768
+ label: "Hicortex: delete memory",
769
+ description:
770
+ "Permanently delete a memory and its links. Use when a memory is incorrect and should be removed entirely.",
771
+ parameters: {
772
+ type: "object",
773
+ properties: {
774
+ id: { type: "string", description: "Memory ID (from search results, first 8 chars or full UUID)" },
775
+ },
776
+ required: ["id"],
777
+ },
778
+ async execute(_toolCallId: string, args: any) {
779
+ try {
780
+ const cfg = resolvePiConfig();
781
+ if (!cfg) return errorResult(NOT_CONFIGURED);
782
+ const result = await serverPost(cfg, "/delete", { id: args?.id });
783
+ if (result.status === 404) {
784
+ return errorResult(`Memory not found: ${args?.id}`);
785
+ }
786
+ if (!result.ok) {
787
+ return errorResult(`Delete failed: ${result.data?.error ?? `HTTP ${result.status}`}`);
788
+ }
789
+ return textResult(`Memory deleted (id: ${String(args?.id).slice(0, 8)})`);
790
+ } catch (err) {
791
+ return errorResult(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
792
+ }
793
+ },
794
+ });
795
+ }
796
+
797
+ // ---------------------------------------------------------------------------
798
+ // Entry
799
+ // ---------------------------------------------------------------------------
800
+
801
+ /**
802
+ * The one export pi calls. Registers the event handlers and the nine tools;
803
+ * never throws — a broken extension must not take down the agent loop.
804
+ */
805
+ export default function hicortexExtension(pi: any): void {
806
+ try {
807
+ // session_start (startup|reload|new|resume|fork): the context window was
808
+ // (re)built, so the server's per-session shown-set is stale and the
809
+ // cached blocks may no longer be in the window. The reset is
810
+ // fire-and-forget (registered in PENDING_RESETS, caught) — the first
811
+ // before_agent_start AWAITS it before fetching recall, so it can never
812
+ // land after that fetch and wipe the state the turn just built.
813
+ pi.on("session_start", (_event: unknown, ctx: unknown) => {
814
+ try {
815
+ const sessionId = getSessionId(ctx);
816
+ if (!sessionId) return;
817
+ dropSessionState(sessionId);
818
+ const cfg = resolvePiConfig();
819
+ if (cfg) void resetRecallDedup(cfg, sessionId);
820
+ } catch {
821
+ /* fail-soft */
822
+ }
823
+ });
824
+
825
+ // Compaction rebuilt the window: reset the dedup again and force a
826
+ // re-fetch of the standing blocks next turn.
827
+ pi.on("session_compact", (_event: unknown, ctx: unknown) => {
828
+ try {
829
+ const sessionId = getSessionId(ctx);
830
+ if (!sessionId) return;
831
+ dropSessionState(sessionId);
832
+ const cfg = resolvePiConfig();
833
+ if (cfg) void resetRecallDedup(cfg, sessionId);
834
+ } catch {
835
+ /* fail-soft */
836
+ }
837
+ });
838
+
839
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
840
+ try {
841
+ const cfg = resolvePiConfig();
842
+ if (!cfg) return;
843
+ const sessionId = getSessionId(ctx);
844
+ if (!sessionId) return;
845
+ pruneSessionState();
846
+
847
+ // Ordering (#316): a pending reset for THIS session registered
848
+ // before this fetch must complete first — bounded by the reset's own
849
+ // 1 s timeout, so a stalled server cannot stall the turn beyond it.
850
+ const pending = PENDING_RESETS.get(sessionId);
851
+ if (pending) await pending;
852
+
853
+ const prompt = typeof event?.prompt === "string" ? event.prompt : "";
854
+ let message: { customType: string; content: string; display: boolean } | undefined;
855
+ if (prompt) {
856
+ const block = await fetchRecallBlock(cfg, sessionId, prompt, ctx?.cwd ?? "").catch(() => null);
857
+ if (block) {
858
+ // display:false — the index is FOR the model; pi hides it from
859
+ // the transcript UI but sends it alongside the user message.
860
+ message = { customType: "hicortex-recall", content: block, display: false };
861
+ }
862
+ }
863
+
864
+ // Identity + lessons: fetched once per session (independent
865
+ // fail-soft), re-APPLIED every turn — pi resets a systemPrompt
866
+ // override to base whenever no extension returns one.
867
+ const [identityBlock, lessonsBlock] = await Promise.all([
868
+ cachedBlock(IDENTITY_BLOCKS, sessionId, () => fetchIdentityBlock(cfg)),
869
+ cachedBlock(LESSONS_BLOCKS, sessionId, () => fetchLessonsBlock(cfg)),
870
+ ]);
871
+ const systemPrompt = appendContextBlocks(event?.systemPrompt, [identityBlock, lessonsBlock]);
872
+
873
+ const result: { message?: unknown; systemPrompt?: string } = {};
874
+ if (message) result.message = message;
875
+ if (systemPrompt !== undefined) result.systemPrompt = systemPrompt;
876
+ return Object.keys(result).length > 0 ? result : undefined;
877
+ } catch {
878
+ // Total fail-soft: inject nothing, never block the turn.
879
+ return undefined;
880
+ }
881
+ });
882
+
883
+ registerTools(pi);
884
+ } catch {
885
+ // Registration itself failed (unexpected pi API change) — stay silent;
886
+ // the session must proceed without memory either way.
887
+ }
888
+ }