@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,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
|
+
}
|