@gamaze/hicortex 0.4.5 → 0.5.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 +4 -4
- package/dist/distiller.js +52 -19
- package/dist/extensions.d.ts +47 -0
- package/dist/extensions.js +22 -0
- package/dist/features.d.ts +1 -1
- package/dist/features.js +16 -2
- package/dist/llm.d.ts +19 -0
- package/dist/llm.js +30 -0
- package/dist/nightly.js +94 -13
- package/dist/pi-transcript-reader.d.ts +44 -0
- package/dist/pi-transcript-reader.js +159 -0
- package/dist/pro-loader.d.ts +33 -0
- package/dist/pro-loader.js +187 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Your agents learn from every session — successes and mistakes. Hicortex captures experiences, distills lessons, and applies them automatically. Connect multiple agents to shared memory and they improve together, overnight.
|
|
4
4
|
|
|
5
|
-
Works with **Claude Code**
|
|
5
|
+
Works with **Claude Code**, **Pi**, **OpenClaw**, and any MCP-compatible agent.
|
|
6
6
|
|
|
7
7
|
**Website:** [hicortex.gamaze.com](https://hicortex.gamaze.com) · **Docs:** [hicortex.gamaze.com/docs](https://hicortex.gamaze.com/docs/)
|
|
8
8
|
|
|
@@ -39,8 +39,8 @@ openclaw gateway restart
|
|
|
39
39
|
|
|
40
40
|
| When | What | How |
|
|
41
41
|
|------|------|-----|
|
|
42
|
-
| Agent start | Recent lessons injected into context | CLAUDE.md
|
|
43
|
-
| Agent end | Conversation captured | CC: nightly transcript scan / OC: hook |
|
|
42
|
+
| Agent start | Recent lessons injected into context | CLAUDE.md / EXPERIENCE.md / OC hook |
|
|
43
|
+
| Agent end | Conversation captured | CC + Pi: nightly transcript scan / OC: hook |
|
|
44
44
|
| Nightly | Distill → score → reflect → link → inject | Automatic pipeline |
|
|
45
45
|
|
|
46
46
|
## Agent Tools (MCP)
|
|
@@ -159,7 +159,7 @@ Canonical location: `~/.hicortex/hicortex.db`. Existing OC installations at `~/.
|
|
|
159
159
|
## Development
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
|
-
cd packages/
|
|
162
|
+
cd packages/hicortex
|
|
163
163
|
npm install
|
|
164
164
|
npm run build
|
|
165
165
|
npm test
|
package/dist/distiller.js
CHANGED
|
@@ -175,16 +175,25 @@ function extractConversationText(messages) {
|
|
|
175
175
|
continue;
|
|
176
176
|
if (m.isSidechain)
|
|
177
177
|
continue;
|
|
178
|
-
// Extract
|
|
179
|
-
//
|
|
180
|
-
|
|
178
|
+
// Extract the message role from whichever format we're dealing with:
|
|
179
|
+
// OC hook: m.role = "user" | "assistant"
|
|
180
|
+
// CC JSONL: m.type = "user" | "assistant"
|
|
181
|
+
// Pi JSONL: m.message.role = "user" | "assistant" | "toolResult"
|
|
182
|
+
const nestedMsg = m.message;
|
|
183
|
+
const msgRole = String(m.role ?? nestedMsg?.role ?? m.type ?? "");
|
|
184
|
+
// Skip tool results — they're noisy (file contents, command output) and
|
|
185
|
+
// add bulk without much extractable knowledge for distillation.
|
|
186
|
+
if (msgRole === "toolResult" || msgRole === "tool_result")
|
|
187
|
+
continue;
|
|
188
|
+
// Extract content — OC has content at top level; CC/Pi have message.content
|
|
189
|
+
const content = m.content ?? nestedMsg?.content;
|
|
181
190
|
if (content === undefined || content === null)
|
|
182
191
|
continue;
|
|
183
192
|
let text = extractTextFromContent(content);
|
|
184
193
|
text = cleanMessageContent(text);
|
|
185
194
|
if (text.length < 20)
|
|
186
195
|
continue;
|
|
187
|
-
const role =
|
|
196
|
+
const role = msgRole === "user" ? "USER" : "ASSISTANT";
|
|
188
197
|
parts.push(`${role}: ${text}`);
|
|
189
198
|
}
|
|
190
199
|
return parts.join("\n\n");
|
|
@@ -205,15 +214,24 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
205
214
|
}
|
|
206
215
|
// Use provided chunk size or default to no chunking
|
|
207
216
|
const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
|
|
208
|
-
// If transcript fits in one chunk, distill directly
|
|
217
|
+
// If transcript fits in one chunk, distill directly (errors propagate)
|
|
209
218
|
if (transcript.length <= chunkSize) {
|
|
210
219
|
return distillChunk(llm, transcript, projectName, date);
|
|
211
220
|
}
|
|
212
|
-
// Chunk large transcripts and distill each segment
|
|
221
|
+
// Chunk large transcripts and distill each segment.
|
|
222
|
+
//
|
|
223
|
+
// Partial success policy:
|
|
224
|
+
// - If SOME chunks succeed and SOME fail, return the partial results and
|
|
225
|
+
// log a warning. The caller gets *something* and can decide whether
|
|
226
|
+
// to count this as success.
|
|
227
|
+
// - If ALL chunks fail, throw — no useful output, and the caller needs
|
|
228
|
+
// to know this session hit a transient error.
|
|
213
229
|
const chunks = splitIntoChunks(transcript, chunkSize);
|
|
214
230
|
console.log(`[hicortex] Chunking ${transcript.length} chars into ${chunks.length} segments`);
|
|
215
231
|
const allEntries = [];
|
|
216
232
|
const seen = new Set();
|
|
233
|
+
let chunkFailures = 0;
|
|
234
|
+
let lastError = null;
|
|
217
235
|
for (let i = 0; i < chunks.length; i++) {
|
|
218
236
|
console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
219
237
|
try {
|
|
@@ -230,30 +248,45 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
230
248
|
catch (err) {
|
|
231
249
|
const msg = err instanceof Error ? err.message : String(err);
|
|
232
250
|
console.error(`[hicortex] Chunk ${i + 1} failed: ${msg}`);
|
|
233
|
-
|
|
251
|
+
chunkFailures++;
|
|
252
|
+
lastError = err instanceof Error ? err : new Error(msg);
|
|
234
253
|
}
|
|
235
254
|
}
|
|
255
|
+
// If every chunk failed, the session wasn't actually processed. Throw so
|
|
256
|
+
// the nightly pipeline knows to retry this session next run.
|
|
257
|
+
if (chunkFailures === chunks.length) {
|
|
258
|
+
throw lastError ?? new Error("All distillation chunks failed");
|
|
259
|
+
}
|
|
260
|
+
if (chunkFailures > 0) {
|
|
261
|
+
console.warn(`[hicortex] Partial distillation: ${chunks.length - chunkFailures}/${chunks.length} chunks succeeded`);
|
|
262
|
+
}
|
|
236
263
|
return allEntries;
|
|
237
264
|
}
|
|
238
265
|
/**
|
|
239
266
|
* Distill a single chunk of conversation text.
|
|
267
|
+
*
|
|
268
|
+
* Behaviour contract:
|
|
269
|
+
* - Returns `[]` for legitimate empty results (NO_EXTRACT, empty LLM response,
|
|
270
|
+
* transcript produced no entries). These are terminal states — the chunk was
|
|
271
|
+
* processed successfully, there's just nothing worth keeping.
|
|
272
|
+
* - Throws for transient errors (LLM unreachable, HTTP 4xx/5xx, timeout, model
|
|
273
|
+
* not found, rate limit). These MUST propagate so the nightly pipeline can
|
|
274
|
+
* distinguish "nothing to extract" from "try again later" and avoid
|
|
275
|
+
* advancing the last-run watermark past sessions it never actually processed.
|
|
240
276
|
*/
|
|
241
277
|
async function distillChunk(llm, transcript, projectName, date) {
|
|
242
278
|
const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
|
|
243
|
-
try
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
catch (err) {
|
|
253
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
254
|
-
console.error(`[hicortex] Distillation LLM error: ${msg}`);
|
|
279
|
+
// NOTE: Intentionally no try/catch here. Transient LLM errors (network
|
|
280
|
+
// failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
|
|
281
|
+
// so the nightly pipeline can treat them as "retry later" instead of
|
|
282
|
+
// "processed successfully with zero extractions".
|
|
283
|
+
const result = await llm.completeDistill(prompt);
|
|
284
|
+
if (!result)
|
|
285
|
+
return [];
|
|
286
|
+
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
255
287
|
return [];
|
|
256
288
|
}
|
|
289
|
+
return parseDistilledEntries(result);
|
|
257
290
|
}
|
|
258
291
|
/**
|
|
259
292
|
* Split transcript text into chunks at natural boundaries (double newlines).
|
package/dist/extensions.d.ts
CHANGED
|
@@ -123,4 +123,51 @@ export declare function setExtensions(ext: Partial<typeof activeExtensions>): vo
|
|
|
123
123
|
export declare function getLessonSelector(): LessonSelector;
|
|
124
124
|
/** Get the active PromptStrategy (default unless Pro is loaded). */
|
|
125
125
|
export declare function getPromptStrategy(): PromptStrategy;
|
|
126
|
+
/**
|
|
127
|
+
* The object passed to a Pro package's `activate()` function at boot.
|
|
128
|
+
* Pro packages receive this to register their extensions and access OSS
|
|
129
|
+
* runtime APIs they need.
|
|
130
|
+
*
|
|
131
|
+
* Design rationale: Pro code never STATICALLY imports from the OSS client.
|
|
132
|
+
* All runtime access is through this context object. This has two benefits:
|
|
133
|
+
* 1. Pro bundles are self-contained — they don't have `require("../...")`
|
|
134
|
+
* calls that would break when the tarball is installed to
|
|
135
|
+
* ~/.hicortex/pro/ at runtime.
|
|
136
|
+
* 2. Pro code can only access what the OSS client exposes here, so the
|
|
137
|
+
* blast radius of a malicious/buggy Pro release is contained.
|
|
138
|
+
*
|
|
139
|
+
* Type-only imports of `LessonSelector`, `PromptStrategy` etc. in Pro code
|
|
140
|
+
* are fine — they're erased at compile time and produce no runtime imports.
|
|
141
|
+
*/
|
|
142
|
+
export interface ProActivationContext {
|
|
143
|
+
/** Register a lesson selector implementation. */
|
|
144
|
+
setSelector(selector: LessonSelector): void;
|
|
145
|
+
/** Register a prompt strategy implementation. */
|
|
146
|
+
setPrompts(prompts: PromptStrategy): void;
|
|
147
|
+
/** The version of the OSS host (from package.json). Pro can use this
|
|
148
|
+
* to gate features against host compatibility. */
|
|
149
|
+
hostVersion: string;
|
|
150
|
+
/** Log through the OSS logging surface so Pro logs get the [hicortex]
|
|
151
|
+
* prefix and unified formatting. */
|
|
152
|
+
log(message: string): void;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The shape Pro packages must export as their default export.
|
|
156
|
+
* See `packages/hicortex/src/pro/index.ts` for the reference impl.
|
|
157
|
+
*/
|
|
158
|
+
export interface ProPackage {
|
|
159
|
+
/** Called once at OSS boot if a Pro license is valid and the Pro
|
|
160
|
+
* tarball has been downloaded + extracted. Should register extensions
|
|
161
|
+
* via the context and return. Errors abort Pro activation but do not
|
|
162
|
+
* abort the OSS host. */
|
|
163
|
+
activate(ctx: ProActivationContext): void | Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build an activation context for a Pro package. Called from the Pro
|
|
167
|
+
* loader in features.ts / pro-loader.ts.
|
|
168
|
+
*
|
|
169
|
+
* Keep this function small — it's the ONLY surface a Pro package gets.
|
|
170
|
+
* Expanding it expands the attack surface, so add fields deliberately.
|
|
171
|
+
*/
|
|
172
|
+
export declare function createProActivationContext(hostVersion: string): ProActivationContext;
|
|
126
173
|
export {};
|
package/dist/extensions.js
CHANGED
|
@@ -34,6 +34,7 @@ exports.defaultPromptStrategy = exports.defaultLessonSelector = void 0;
|
|
|
34
34
|
exports.setExtensions = setExtensions;
|
|
35
35
|
exports.getLessonSelector = getLessonSelector;
|
|
36
36
|
exports.getPromptStrategy = getPromptStrategy;
|
|
37
|
+
exports.createProActivationContext = createProActivationContext;
|
|
37
38
|
/**
|
|
38
39
|
* Default LessonSelector — preserves current OSS behaviour exactly.
|
|
39
40
|
* `slice(0, maxLessons)` over the candidate pool, no re-ranking.
|
|
@@ -152,3 +153,24 @@ function getLessonSelector() {
|
|
|
152
153
|
function getPromptStrategy() {
|
|
153
154
|
return activeExtensions.prompts;
|
|
154
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Build an activation context for a Pro package. Called from the Pro
|
|
158
|
+
* loader in features.ts / pro-loader.ts.
|
|
159
|
+
*
|
|
160
|
+
* Keep this function small — it's the ONLY surface a Pro package gets.
|
|
161
|
+
* Expanding it expands the attack surface, so add fields deliberately.
|
|
162
|
+
*/
|
|
163
|
+
function createProActivationContext(hostVersion) {
|
|
164
|
+
return {
|
|
165
|
+
setSelector(selector) {
|
|
166
|
+
activeExtensions = { ...activeExtensions, selector };
|
|
167
|
+
},
|
|
168
|
+
setPrompts(prompts) {
|
|
169
|
+
activeExtensions = { ...activeExtensions, prompts };
|
|
170
|
+
},
|
|
171
|
+
hostVersion,
|
|
172
|
+
log(message) {
|
|
173
|
+
console.log(`[hicortex][pro] ${message}`);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/features.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { LicenseInfo } from "./types.js";
|
|
|
22
22
|
* After this returns, sync getters (isPro, lessonsLimit, etc.) are deterministic
|
|
23
23
|
* and reflect the user's actual tier — no more "free during validation window".
|
|
24
24
|
*/
|
|
25
|
-
export declare function initFeatures(licenseKey: string | undefined, stateDir?: string): Promise<void>;
|
|
25
|
+
export declare function initFeatures(licenseKey: string | undefined, stateDir?: string, hostVersion?: string): Promise<void>;
|
|
26
26
|
/** Are we on a paid tier (Pro, Team, Lifetime)? */
|
|
27
27
|
export declare function isPro(): boolean;
|
|
28
28
|
/** Memory count cap. -1 = unlimited (paid). */
|
package/dist/features.js
CHANGED
|
@@ -54,7 +54,7 @@ function persistTier(stateDir, info) {
|
|
|
54
54
|
* After this returns, sync getters (isPro, lessonsLimit, etc.) are deterministic
|
|
55
55
|
* and reflect the user's actual tier — no more "free during validation window".
|
|
56
56
|
*/
|
|
57
|
-
async function initFeatures(licenseKey, stateDir = DEFAULT_STATE_DIR) {
|
|
57
|
+
async function initFeatures(licenseKey, stateDir = DEFAULT_STATE_DIR, hostVersion = "0.0.0") {
|
|
58
58
|
if (initialized)
|
|
59
59
|
return;
|
|
60
60
|
initialized = true;
|
|
@@ -66,7 +66,7 @@ async function initFeatures(licenseKey, stateDir = DEFAULT_STATE_DIR) {
|
|
|
66
66
|
else {
|
|
67
67
|
currentFeatures = FREE_FEATURES;
|
|
68
68
|
}
|
|
69
|
-
// Step 2: No key → free tier, done
|
|
69
|
+
// Step 2: No key → free tier, done. Pro loader is only run for paid tiers.
|
|
70
70
|
if (!licenseKey)
|
|
71
71
|
return;
|
|
72
72
|
// Step 3: Validate
|
|
@@ -96,6 +96,20 @@ async function initFeatures(licenseKey, stateDir = DEFAULT_STATE_DIR) {
|
|
|
96
96
|
// Keep persisted features
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
+
// Step 4: If the current tier is paid, try to load the Pro extension bundle.
|
|
100
|
+
// This is best-effort — if loading fails (network, missing tarball, bad
|
|
101
|
+
// extraction, Pro package throws on activate), OSS defaults remain in effect
|
|
102
|
+
// and the host keeps running. No user-visible crash.
|
|
103
|
+
if (isPro()) {
|
|
104
|
+
try {
|
|
105
|
+
const { loadPro } = await import("./pro-loader.js");
|
|
106
|
+
await loadPro(licenseKey, stateDir, hostVersion);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
console.warn(`[hicortex][pro] Pro loader failed to import: ${msg}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
99
113
|
}
|
|
100
114
|
// ---------------------------------------------------------------------------
|
|
101
115
|
// Public API — sync getters used everywhere in the codebase
|
package/dist/llm.d.ts
CHANGED
|
@@ -69,6 +69,25 @@ export declare function claudeCliConfig(claudePath: string): LlmConfig;
|
|
|
69
69
|
* Returns the model name if available, null otherwise.
|
|
70
70
|
*/
|
|
71
71
|
export declare function probeOllama(baseUrl?: string): Promise<string | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Pre-flight health check for a specific Ollama endpoint + model.
|
|
74
|
+
* Returns { ok, reason } so callers can log a clear abort message.
|
|
75
|
+
*
|
|
76
|
+
* - `ok: true` — endpoint reachable AND the requested model appears in
|
|
77
|
+
* `/api/tags`. Safe to proceed with a batch distillation run.
|
|
78
|
+
* - `ok: false, reason: "unreachable"` — network failure or non-2xx.
|
|
79
|
+
* - `ok: false, reason: "model_missing"` — endpoint is up but the
|
|
80
|
+
* model isn't listed (the exact case that caused data loss when
|
|
81
|
+
* mhac-pro's Ollama didn't have the distill model loaded).
|
|
82
|
+
*
|
|
83
|
+
* Matches on exact name OR name prefix ("qwen3.5:35b" matches "qwen3.5:35b-a3b").
|
|
84
|
+
*/
|
|
85
|
+
export declare function probeOllamaModel(baseUrl: string, modelName: string): Promise<{
|
|
86
|
+
ok: true;
|
|
87
|
+
} | {
|
|
88
|
+
ok: false;
|
|
89
|
+
reason: "unreachable" | "model_missing";
|
|
90
|
+
}>;
|
|
72
91
|
/**
|
|
73
92
|
* For batch operations (nightly pipeline), prefer Ollama when available.
|
|
74
93
|
* Claude CLI has strict rate limits that kill batch distillation.
|
package/dist/llm.js
CHANGED
|
@@ -26,6 +26,7 @@ exports.resolveLlmConfigForCC = resolveLlmConfigForCC;
|
|
|
26
26
|
exports.findClaudeBinary = findClaudeBinary;
|
|
27
27
|
exports.claudeCliConfig = claudeCliConfig;
|
|
28
28
|
exports.probeOllama = probeOllama;
|
|
29
|
+
exports.probeOllamaModel = probeOllamaModel;
|
|
29
30
|
exports.preferOllamaForBatch = preferOllamaForBatch;
|
|
30
31
|
const node_fs_1 = require("node:fs");
|
|
31
32
|
const node_path_1 = require("node:path");
|
|
@@ -367,6 +368,35 @@ async function probeOllama(baseUrl = "http://localhost:11434") {
|
|
|
367
368
|
return null;
|
|
368
369
|
}
|
|
369
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Pre-flight health check for a specific Ollama endpoint + model.
|
|
373
|
+
* Returns { ok, reason } so callers can log a clear abort message.
|
|
374
|
+
*
|
|
375
|
+
* - `ok: true` — endpoint reachable AND the requested model appears in
|
|
376
|
+
* `/api/tags`. Safe to proceed with a batch distillation run.
|
|
377
|
+
* - `ok: false, reason: "unreachable"` — network failure or non-2xx.
|
|
378
|
+
* - `ok: false, reason: "model_missing"` — endpoint is up but the
|
|
379
|
+
* model isn't listed (the exact case that caused data loss when
|
|
380
|
+
* mhac-pro's Ollama didn't have the distill model loaded).
|
|
381
|
+
*
|
|
382
|
+
* Matches on exact name OR name prefix ("qwen3.5:35b" matches "qwen3.5:35b-a3b").
|
|
383
|
+
*/
|
|
384
|
+
async function probeOllamaModel(baseUrl, modelName) {
|
|
385
|
+
try {
|
|
386
|
+
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, {
|
|
387
|
+
signal: AbortSignal.timeout(5000),
|
|
388
|
+
});
|
|
389
|
+
if (!resp.ok)
|
|
390
|
+
return { ok: false, reason: "unreachable" };
|
|
391
|
+
const data = (await resp.json());
|
|
392
|
+
const models = data.models ?? [];
|
|
393
|
+
const found = models.some((m) => m.name === modelName || m.name.startsWith(modelName + ":"));
|
|
394
|
+
return found ? { ok: true } : { ok: false, reason: "model_missing" };
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return { ok: false, reason: "unreachable" };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
370
400
|
/**
|
|
371
401
|
* For batch operations (nightly pipeline), prefer Ollama when available.
|
|
372
402
|
* Claude CLI has strict rate limits that kill batch distillation.
|
package/dist/nightly.js
CHANGED
|
@@ -54,6 +54,7 @@ const storage = __importStar(require("./storage.js"));
|
|
|
54
54
|
const distiller_js_1 = require("./distiller.js");
|
|
55
55
|
const consolidate_js_1 = require("./consolidate.js");
|
|
56
56
|
const transcript_reader_js_1 = require("./transcript-reader.js");
|
|
57
|
+
const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
|
|
57
58
|
const claude_md_js_1 = require("./claude-md.js");
|
|
58
59
|
const features_js_1 = require("./features.js");
|
|
59
60
|
const extensions_js_1 = require("./extensions.js");
|
|
@@ -171,17 +172,41 @@ async function runNightly(options = {}) {
|
|
|
171
172
|
? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
|
|
172
173
|
: llmConfig.distillModel ?? "";
|
|
173
174
|
console.log(`[hicortex] LLM: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}`);
|
|
174
|
-
// Step 1: Read new CC
|
|
175
|
+
// Step 1: Read new transcripts (CC + Pi)
|
|
175
176
|
const since = readLastRun();
|
|
176
|
-
console.log(`[hicortex] Reading
|
|
177
|
-
const
|
|
178
|
-
|
|
177
|
+
console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
|
|
178
|
+
const ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since);
|
|
179
|
+
const piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since);
|
|
180
|
+
const batches = [...ccBatches, ...piBatches];
|
|
181
|
+
if (ccBatches.length > 0)
|
|
182
|
+
console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
|
|
183
|
+
if (piBatches.length > 0)
|
|
184
|
+
console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
|
|
185
|
+
console.log(`[hicortex] Total: ${batches.length} new session(s)`);
|
|
179
186
|
if (batches.length === 0 && !dryRun) {
|
|
180
187
|
// Still run consolidation — there may be unscored memories from OC
|
|
181
|
-
console.log(`[hicortex] No new
|
|
188
|
+
console.log(`[hicortex] No new transcripts. Running consolidation only.`);
|
|
182
189
|
}
|
|
183
190
|
// Step 2: Distill each session
|
|
184
191
|
let memoriesIngested = 0;
|
|
192
|
+
let hadTransientFailure = false;
|
|
193
|
+
// Pre-flight health check for a remote distill endpoint.
|
|
194
|
+
// If the distill provider is Ollama on a remote host and that host (or the
|
|
195
|
+
// required model) is unreachable, abort BEFORE touching any sessions —
|
|
196
|
+
// prevents the data-loss bug where lastRun advances past sessions that
|
|
197
|
+
// were never actually processed.
|
|
198
|
+
if (batches.length > 0 && llmConfig.distillBaseUrl && (llmConfig.distillProvider ?? llmConfig.provider) === "ollama") {
|
|
199
|
+
const distillModel = llmConfig.distillModel ?? llmConfig.model;
|
|
200
|
+
const health = await (0, llm_js_1.probeOllamaModel)(llmConfig.distillBaseUrl, distillModel);
|
|
201
|
+
if (!health.ok) {
|
|
202
|
+
const reason = health.reason === "unreachable"
|
|
203
|
+
? `distill endpoint unreachable (${llmConfig.distillBaseUrl})`
|
|
204
|
+
: `distill model not loaded (${distillModel} missing on ${llmConfig.distillBaseUrl})`;
|
|
205
|
+
console.error(`[hicortex] ABORT: ${reason} — will retry next run, lastRun unchanged`);
|
|
206
|
+
hadTransientFailure = true;
|
|
207
|
+
batches.length = 0; // Skip the distillation loop entirely
|
|
208
|
+
}
|
|
209
|
+
}
|
|
185
210
|
// Detect safe chunk size based on model context window
|
|
186
211
|
const chunkSize = await (0, distiller_js_1.detectChunkSize)(llmConfig.provider, llmConfig.distillModel ?? llmConfig.model, llmConfig.baseUrl);
|
|
187
212
|
for (const batch of batches) {
|
|
@@ -190,6 +215,20 @@ async function runNightly(options = {}) {
|
|
|
190
215
|
console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): too short`);
|
|
191
216
|
continue;
|
|
192
217
|
}
|
|
218
|
+
// Server-mode per-session dedup: skip sessions already in the DB.
|
|
219
|
+
// Client mode gets this for free via the server's /ingest endpoint;
|
|
220
|
+
// server mode writes directly via storage.insertMemory and needs
|
|
221
|
+
// an explicit check. This makes retries of previously-failed runs
|
|
222
|
+
// idempotent.
|
|
223
|
+
if (!dryRun) {
|
|
224
|
+
const existing = db
|
|
225
|
+
.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ?")
|
|
226
|
+
.get(batch.sessionId);
|
|
227
|
+
if (existing.c > 0) {
|
|
228
|
+
console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): already ingested`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
193
232
|
console.log(`[hicortex] Distilling ${batch.sessionId.slice(0, 8)} (${batch.projectName}, ${batch.date})`);
|
|
194
233
|
if (dryRun) {
|
|
195
234
|
console.log(`[hicortex] [dry-run] Would distill ${transcript.length} chars`);
|
|
@@ -224,7 +263,8 @@ async function runNightly(options = {}) {
|
|
|
224
263
|
}
|
|
225
264
|
catch (err) {
|
|
226
265
|
const msg = err instanceof Error ? err.message : String(err);
|
|
227
|
-
console.error(`[hicortex] Distillation failed: ${msg}`);
|
|
266
|
+
console.error(`[hicortex] Distillation failed: ${msg} — will retry next run`);
|
|
267
|
+
hadTransientFailure = true;
|
|
228
268
|
}
|
|
229
269
|
}
|
|
230
270
|
console.log(`[hicortex] Distillation complete: ${memoriesIngested} new memories`);
|
|
@@ -235,14 +275,28 @@ async function runNightly(options = {}) {
|
|
|
235
275
|
console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
|
|
236
276
|
(report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
|
|
237
277
|
}
|
|
238
|
-
// Step 4: Inject lessons into CLAUDE.md
|
|
278
|
+
// Step 4: Inject lessons into the target file (CLAUDE.md or EXPERIENCE.md
|
|
279
|
+
// or custom path — configurable via lessonTarget in config.json)
|
|
239
280
|
if (!dryRun) {
|
|
240
|
-
const
|
|
241
|
-
|
|
281
|
+
const lessonTarget = savedConfig?.lessonTarget;
|
|
282
|
+
const injection = await (0, claude_md_js_1.injectLessons)(db, {
|
|
283
|
+
claudeMdPath: lessonTarget,
|
|
284
|
+
stateDir,
|
|
285
|
+
});
|
|
286
|
+
console.log(`[hicortex] Lessons updated: ${injection.lessonsCount} lessons at ${injection.path}`);
|
|
242
287
|
}
|
|
243
288
|
// Step 5: Update last-run timestamp
|
|
289
|
+
// CRITICAL: only advance lastRun if every session was processed without
|
|
290
|
+
// a transient failure. Otherwise failed sessions would be permanently
|
|
291
|
+
// lost — they'd be older than the new lastRun and never retried.
|
|
244
292
|
if (!dryRun) {
|
|
245
|
-
|
|
293
|
+
if (hadTransientFailure) {
|
|
294
|
+
console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
|
|
295
|
+
`They will be retried on the next run.`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
writeLastRun();
|
|
299
|
+
}
|
|
246
300
|
}
|
|
247
301
|
console.log(`[hicortex] Nightly pipeline complete.`);
|
|
248
302
|
}
|
|
@@ -329,6 +383,22 @@ async function runClientNightly(config, dryRun) {
|
|
|
329
383
|
writeLastRun();
|
|
330
384
|
return;
|
|
331
385
|
}
|
|
386
|
+
// Pre-flight health check for a remote distill endpoint (client mode).
|
|
387
|
+
// If the distill provider is Ollama on a remote host and the required model
|
|
388
|
+
// isn't loaded, abort BEFORE touching any sessions — same data-loss fix
|
|
389
|
+
// as server mode.
|
|
390
|
+
let hadTransientFailure = false;
|
|
391
|
+
if (llmConfig.distillBaseUrl && (llmConfig.distillProvider ?? llmConfig.provider) === "ollama") {
|
|
392
|
+
const distillModel = llmConfig.distillModel ?? llmConfig.model;
|
|
393
|
+
const health = await (0, llm_js_1.probeOllamaModel)(llmConfig.distillBaseUrl, distillModel);
|
|
394
|
+
if (!health.ok) {
|
|
395
|
+
const reason = health.reason === "unreachable"
|
|
396
|
+
? `distill endpoint unreachable (${llmConfig.distillBaseUrl})`
|
|
397
|
+
: `distill model not loaded (${distillModel} missing on ${llmConfig.distillBaseUrl})`;
|
|
398
|
+
console.error(`[hicortex] ABORT: ${reason} — will retry next run, lastRun unchanged`);
|
|
399
|
+
return; // Don't touch lastRun; next trigger retries the same sessions
|
|
400
|
+
}
|
|
401
|
+
}
|
|
332
402
|
// Distill each session and POST to server
|
|
333
403
|
let memoriesIngested = 0;
|
|
334
404
|
let sessionsSent = 0;
|
|
@@ -388,6 +458,7 @@ async function runClientNightly(config, dryRun) {
|
|
|
388
458
|
}
|
|
389
459
|
else {
|
|
390
460
|
console.error(`[hicortex] Ingest failed (${resp.status}): ${result.error}`);
|
|
461
|
+
hadTransientFailure = true;
|
|
391
462
|
}
|
|
392
463
|
}
|
|
393
464
|
if (sessionCount > 0) {
|
|
@@ -396,7 +467,8 @@ async function runClientNightly(config, dryRun) {
|
|
|
396
467
|
}
|
|
397
468
|
}
|
|
398
469
|
catch (err) {
|
|
399
|
-
console.error(`[hicortex]
|
|
470
|
+
console.error(`[hicortex] Distillation failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
|
|
471
|
+
hadTransientFailure = true;
|
|
400
472
|
}
|
|
401
473
|
}
|
|
402
474
|
// Inject lessons from server into CLAUDE.md
|
|
@@ -408,8 +480,17 @@ async function runClientNightly(config, dryRun) {
|
|
|
408
480
|
console.error(`[hicortex] CLAUDE.md injection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
409
481
|
}
|
|
410
482
|
}
|
|
411
|
-
if
|
|
412
|
-
|
|
483
|
+
// Only advance lastRun if every session was processed without a transient
|
|
484
|
+
// failure. Otherwise failed sessions would be permanently lost.
|
|
485
|
+
if (!dryRun) {
|
|
486
|
+
if (hadTransientFailure) {
|
|
487
|
+
console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
|
|
488
|
+
`They will be retried on the next run.`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
writeLastRun();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
413
494
|
console.log(`[hicortex] Client nightly complete: ${memoriesIngested} memories from ${sessionsSent} sessions → ${serverUrl}`);
|
|
414
495
|
}
|
|
415
496
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi agent transcript reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads session .jsonl files from ~/.pi/agent/sessions/ in the Pi coding
|
|
5
|
+
* agent's format. Returns the same TranscriptBatch shape as the CC reader
|
|
6
|
+
* (transcript-reader.ts) so the downstream distillation pipeline is
|
|
7
|
+
* format-agnostic.
|
|
8
|
+
*
|
|
9
|
+
* Pi JSONL format (version 3):
|
|
10
|
+
* session — session header: {id, cwd, timestamp, version}
|
|
11
|
+
* model_change — provider + model switch (skip for distillation)
|
|
12
|
+
* thinking_level_change — thinking mode (skip)
|
|
13
|
+
* message — user/assistant/toolResult conversation entries
|
|
14
|
+
* custom — extension events (skip)
|
|
15
|
+
* custom_message — extension messages (skip)
|
|
16
|
+
*
|
|
17
|
+
* Directory layout:
|
|
18
|
+
* ~/.pi/agent/sessions/
|
|
19
|
+
* --home-agents-Agents-raider--/
|
|
20
|
+
* 2026-04-10T18-37-44-615Z_<uuid>.jsonl
|
|
21
|
+
* 2026-04-11T07-51-28-282Z_<uuid>.jsonl
|
|
22
|
+
* --home-agents-Development-MAIC--/
|
|
23
|
+
* ...
|
|
24
|
+
*
|
|
25
|
+
* The encoded-cwd uses double-dash separators: /home/agents/Agents/raider
|
|
26
|
+
* becomes --home-agents-Agents-raider--. The session header's `cwd` field
|
|
27
|
+
* is the canonical path; the directory name is a filesystem-safe encoding.
|
|
28
|
+
*/
|
|
29
|
+
export interface TranscriptBatch {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
projectName: string;
|
|
32
|
+
date: string;
|
|
33
|
+
entries: unknown[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read Pi session transcripts modified after `since`.
|
|
37
|
+
*
|
|
38
|
+
* Scans the Pi sessions directory for .jsonl files, filters by mtime,
|
|
39
|
+
* parses each into a TranscriptBatch.
|
|
40
|
+
*
|
|
41
|
+
* @param since Only return sessions with mtime > this date
|
|
42
|
+
* @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
|
|
43
|
+
*/
|
|
44
|
+
export declare function readPiTranscripts(since: Date, sessionsDir?: string): TranscriptBatch[];
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pi agent transcript reader.
|
|
4
|
+
*
|
|
5
|
+
* Reads session .jsonl files from ~/.pi/agent/sessions/ in the Pi coding
|
|
6
|
+
* agent's format. Returns the same TranscriptBatch shape as the CC reader
|
|
7
|
+
* (transcript-reader.ts) so the downstream distillation pipeline is
|
|
8
|
+
* format-agnostic.
|
|
9
|
+
*
|
|
10
|
+
* Pi JSONL format (version 3):
|
|
11
|
+
* session — session header: {id, cwd, timestamp, version}
|
|
12
|
+
* model_change — provider + model switch (skip for distillation)
|
|
13
|
+
* thinking_level_change — thinking mode (skip)
|
|
14
|
+
* message — user/assistant/toolResult conversation entries
|
|
15
|
+
* custom — extension events (skip)
|
|
16
|
+
* custom_message — extension messages (skip)
|
|
17
|
+
*
|
|
18
|
+
* Directory layout:
|
|
19
|
+
* ~/.pi/agent/sessions/
|
|
20
|
+
* --home-agents-Agents-raider--/
|
|
21
|
+
* 2026-04-10T18-37-44-615Z_<uuid>.jsonl
|
|
22
|
+
* 2026-04-11T07-51-28-282Z_<uuid>.jsonl
|
|
23
|
+
* --home-agents-Development-MAIC--/
|
|
24
|
+
* ...
|
|
25
|
+
*
|
|
26
|
+
* The encoded-cwd uses double-dash separators: /home/agents/Agents/raider
|
|
27
|
+
* becomes --home-agents-Agents-raider--. The session header's `cwd` field
|
|
28
|
+
* is the canonical path; the directory name is a filesystem-safe encoding.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.readPiTranscripts = readPiTranscripts;
|
|
32
|
+
const node_fs_1 = require("node:fs");
|
|
33
|
+
const node_path_1 = require("node:path");
|
|
34
|
+
const node_os_1 = require("node:os");
|
|
35
|
+
const DEFAULT_PI_SESSIONS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent", "sessions");
|
|
36
|
+
/**
|
|
37
|
+
* Read Pi session transcripts modified after `since`.
|
|
38
|
+
*
|
|
39
|
+
* Scans the Pi sessions directory for .jsonl files, filters by mtime,
|
|
40
|
+
* parses each into a TranscriptBatch.
|
|
41
|
+
*
|
|
42
|
+
* @param since Only return sessions with mtime > this date
|
|
43
|
+
* @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
|
|
44
|
+
*/
|
|
45
|
+
function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
|
|
46
|
+
const batches = [];
|
|
47
|
+
let projectDirs;
|
|
48
|
+
try {
|
|
49
|
+
projectDirs = (0, node_fs_1.readdirSync)(sessionsDir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Directory doesn't exist — no Pi sessions. Not an error.
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
for (const projectDir of projectDirs) {
|
|
56
|
+
const projectPath = (0, node_path_1.join)(sessionsDir, projectDir);
|
|
57
|
+
let files;
|
|
58
|
+
try {
|
|
59
|
+
const stat = (0, node_fs_1.statSync)(projectPath);
|
|
60
|
+
if (!stat.isDirectory())
|
|
61
|
+
continue;
|
|
62
|
+
files = (0, node_fs_1.readdirSync)(projectPath).filter((f) => f.endsWith(".jsonl"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const filePath = (0, node_path_1.join)(projectPath, file);
|
|
69
|
+
// Filter by modification time
|
|
70
|
+
try {
|
|
71
|
+
const stat = (0, node_fs_1.statSync)(filePath);
|
|
72
|
+
if (stat.mtime <= since)
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Parse the JSONL file
|
|
79
|
+
try {
|
|
80
|
+
const raw = (0, node_fs_1.readFileSync)(filePath, "utf-8");
|
|
81
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
82
|
+
const entries = [];
|
|
83
|
+
let sessionId = "";
|
|
84
|
+
let sessionCwd = "";
|
|
85
|
+
let sessionDate = "";
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
try {
|
|
88
|
+
const entry = JSON.parse(line);
|
|
89
|
+
entries.push(entry);
|
|
90
|
+
// Extract metadata from the session header
|
|
91
|
+
if (entry.type === "session") {
|
|
92
|
+
sessionId = entry.id ?? "";
|
|
93
|
+
sessionCwd = entry.cwd ?? "";
|
|
94
|
+
// Date from the session timestamp or filename
|
|
95
|
+
sessionDate =
|
|
96
|
+
entry.timestamp?.slice(0, 10) ??
|
|
97
|
+
extractDateFromFilename(file) ??
|
|
98
|
+
"";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Skip malformed lines
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Derive project name from the cwd or directory name
|
|
106
|
+
const projectName = deriveProjectName(sessionCwd, projectDir);
|
|
107
|
+
// Use filename UUID as fallback session ID
|
|
108
|
+
if (!sessionId) {
|
|
109
|
+
sessionId = extractUuidFromFilename(file) ?? file;
|
|
110
|
+
}
|
|
111
|
+
if (!sessionDate) {
|
|
112
|
+
sessionDate = extractDateFromFilename(file) ?? "";
|
|
113
|
+
}
|
|
114
|
+
if (entries.length > 0) {
|
|
115
|
+
batches.push({
|
|
116
|
+
sessionId,
|
|
117
|
+
projectName,
|
|
118
|
+
date: sessionDate,
|
|
119
|
+
entries,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// File read or parse failed — skip
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return batches;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extract the last path segment from a cwd as the project name.
|
|
132
|
+
* /home/agents/Agents/raider → "raider"
|
|
133
|
+
* Falls back to decoding the directory name if cwd is empty.
|
|
134
|
+
*/
|
|
135
|
+
function deriveProjectName(cwd, encodedDir) {
|
|
136
|
+
if (cwd) {
|
|
137
|
+
const segments = cwd.split("/").filter(Boolean);
|
|
138
|
+
return segments[segments.length - 1] ?? "unknown";
|
|
139
|
+
}
|
|
140
|
+
// Decode the Pi directory encoding: --home-agents-Agents-raider-- → raider
|
|
141
|
+
const decoded = encodedDir.replace(/^--/, "").replace(/--$/, "").split("-");
|
|
142
|
+
return decoded[decoded.length - 1] ?? "unknown";
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Extract the date (YYYY-MM-DD) from a Pi session filename.
|
|
146
|
+
* Format: 2026-04-10T18-37-44-615Z_<uuid>.jsonl → "2026-04-10"
|
|
147
|
+
*/
|
|
148
|
+
function extractDateFromFilename(filename) {
|
|
149
|
+
const match = filename.match(/^(\d{4}-\d{2}-\d{2})T/);
|
|
150
|
+
return match ? match[1] : null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Extract the UUID from a Pi session filename.
|
|
154
|
+
* Format: 2026-04-10T18-37-44-615Z_f4227d47-e54f-4977-a50c-4de7f6d1fa21.jsonl
|
|
155
|
+
*/
|
|
156
|
+
function extractUuidFromFilename(filename) {
|
|
157
|
+
const match = filename.match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
|
|
158
|
+
return match ? match[1] : null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pro extension loader.
|
|
3
|
+
*
|
|
4
|
+
* Called from features.ts at boot when a valid paid license is detected.
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* 1. Check ~/.hicortex/pro/installed.json for the currently-installed
|
|
7
|
+
* Pro version (if any).
|
|
8
|
+
* 2. Fetch GET /api/pro/meta from hicortex.gamaze.com to discover the
|
|
9
|
+
* latest available version for the caller's license tier.
|
|
10
|
+
* 3. If the installed version is older (or missing), download the
|
|
11
|
+
* tarball, verify the sha256 sidecar, and extract to ~/.hicortex/pro/.
|
|
12
|
+
* 4. Dynamic-import ~/.hicortex/pro/package/index.js and call activate()
|
|
13
|
+
* on its default export with a ProActivationContext.
|
|
14
|
+
*
|
|
15
|
+
* Failure modes (all soft — OSS host keeps running with defaults):
|
|
16
|
+
* - Network to /api/pro/meta fails → use whatever is already installed
|
|
17
|
+
* - No cached Pro and network fails → Pro not activated, OSS defaults apply
|
|
18
|
+
* - Downloaded tarball fails sha256 → abort download, keep old version
|
|
19
|
+
* - import() of activated module throws → log warning, keep defaults
|
|
20
|
+
*
|
|
21
|
+
* The loader is strictly best-effort. It must NEVER crash the OSS host.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Entry point called from features.ts at boot.
|
|
25
|
+
*
|
|
26
|
+
* @param licenseKey The Pro license key (hctx-...) from config
|
|
27
|
+
* @param stateDir Usually ~/.hicortex/
|
|
28
|
+
* @param hostVersion The version of the OSS host (from package.json) — passed
|
|
29
|
+
* to the Pro activate() for compatibility gating
|
|
30
|
+
* @param serverUrl Override for the Pro meta/download endpoint (defaults
|
|
31
|
+
* to https://hicortex.gamaze.com). Useful for testing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function loadPro(licenseKey: string, stateDir: string, hostVersion: string, serverUrl?: string): Promise<void>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pro extension loader.
|
|
4
|
+
*
|
|
5
|
+
* Called from features.ts at boot when a valid paid license is detected.
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* 1. Check ~/.hicortex/pro/installed.json for the currently-installed
|
|
8
|
+
* Pro version (if any).
|
|
9
|
+
* 2. Fetch GET /api/pro/meta from hicortex.gamaze.com to discover the
|
|
10
|
+
* latest available version for the caller's license tier.
|
|
11
|
+
* 3. If the installed version is older (or missing), download the
|
|
12
|
+
* tarball, verify the sha256 sidecar, and extract to ~/.hicortex/pro/.
|
|
13
|
+
* 4. Dynamic-import ~/.hicortex/pro/package/index.js and call activate()
|
|
14
|
+
* on its default export with a ProActivationContext.
|
|
15
|
+
*
|
|
16
|
+
* Failure modes (all soft — OSS host keeps running with defaults):
|
|
17
|
+
* - Network to /api/pro/meta fails → use whatever is already installed
|
|
18
|
+
* - No cached Pro and network fails → Pro not activated, OSS defaults apply
|
|
19
|
+
* - Downloaded tarball fails sha256 → abort download, keep old version
|
|
20
|
+
* - import() of activated module throws → log warning, keep defaults
|
|
21
|
+
*
|
|
22
|
+
* The loader is strictly best-effort. It must NEVER crash the OSS host.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.loadPro = loadPro;
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
const node_fs_1 = require("node:fs");
|
|
28
|
+
const node_path_1 = require("node:path");
|
|
29
|
+
const node_url_1 = require("node:url");
|
|
30
|
+
const node_child_process_1 = require("node:child_process");
|
|
31
|
+
const extensions_js_1 = require("./extensions.js");
|
|
32
|
+
const VALIDATE_URL = "https://hicortex.gamaze.com";
|
|
33
|
+
/**
|
|
34
|
+
* Entry point called from features.ts at boot.
|
|
35
|
+
*
|
|
36
|
+
* @param licenseKey The Pro license key (hctx-...) from config
|
|
37
|
+
* @param stateDir Usually ~/.hicortex/
|
|
38
|
+
* @param hostVersion The version of the OSS host (from package.json) — passed
|
|
39
|
+
* to the Pro activate() for compatibility gating
|
|
40
|
+
* @param serverUrl Override for the Pro meta/download endpoint (defaults
|
|
41
|
+
* to https://hicortex.gamaze.com). Useful for testing.
|
|
42
|
+
*/
|
|
43
|
+
async function loadPro(licenseKey, stateDir, hostVersion, serverUrl = VALIDATE_URL) {
|
|
44
|
+
const proRoot = (0, node_path_1.join)(stateDir, "pro");
|
|
45
|
+
try {
|
|
46
|
+
(0, node_fs_1.mkdirSync)(proRoot, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// If we can't even create the dir, there's nothing to load
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Step 1: read installed state (if any)
|
|
53
|
+
const installedStatePath = (0, node_path_1.join)(proRoot, "installed.json");
|
|
54
|
+
const installed = readInstalledState(installedStatePath);
|
|
55
|
+
// Step 2: try to fetch the latest meta. Network failure is soft.
|
|
56
|
+
let meta = null;
|
|
57
|
+
try {
|
|
58
|
+
meta = await fetchProMeta(serverUrl, licenseKey);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
console.warn(`[hicortex][pro] Could not fetch Pro metadata (${msg}) — using cached version if present`);
|
|
63
|
+
}
|
|
64
|
+
// Step 3: if meta is newer than installed, download and extract
|
|
65
|
+
if (meta && (!installed || installed.version !== meta.version)) {
|
|
66
|
+
try {
|
|
67
|
+
await downloadAndExtract(serverUrl, licenseKey, meta, proRoot);
|
|
68
|
+
writeInstalledState(installedStatePath, {
|
|
69
|
+
version: meta.version,
|
|
70
|
+
installedAt: new Date().toISOString(),
|
|
71
|
+
sha256: meta.sha256,
|
|
72
|
+
});
|
|
73
|
+
console.log(`[hicortex][pro] Installed Pro v${meta.version}`);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
console.warn(`[hicortex][pro] Pro download failed: ${msg}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Step 4: activate whatever is installed (if anything)
|
|
81
|
+
const indexPath = (0, node_path_1.join)(proRoot, "package", "index.js");
|
|
82
|
+
if (!(0, node_fs_1.existsSync)(indexPath)) {
|
|
83
|
+
// No Pro installed and meta fetch couldn't install one
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const mod = await import((0, node_url_1.pathToFileURL)(indexPath).href);
|
|
88
|
+
const pkg = (mod.default ?? mod);
|
|
89
|
+
if (typeof pkg?.activate !== "function") {
|
|
90
|
+
console.warn(`[hicortex][pro] Pro package has no activate() function`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const ctx = (0, extensions_js_1.createProActivationContext)(hostVersion);
|
|
94
|
+
await pkg.activate(ctx);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
console.warn(`[hicortex][pro] Pro activation failed: ${msg}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Implementation helpers
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
function readInstalledState(path) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = (0, node_fs_1.readFileSync)(path, "utf-8");
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function writeInstalledState(path, state) {
|
|
114
|
+
try {
|
|
115
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
116
|
+
(0, node_fs_1.writeFileSync)(path, JSON.stringify(state, null, 2));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Non-fatal — we'll just re-download next boot
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function fetchProMeta(serverUrl, licenseKey) {
|
|
123
|
+
const url = `${serverUrl.replace(/\/$/, "")}/api/pro/meta`;
|
|
124
|
+
const resp = await fetch(url, {
|
|
125
|
+
headers: { Authorization: `Bearer ${licenseKey}` },
|
|
126
|
+
signal: AbortSignal.timeout(10_000),
|
|
127
|
+
});
|
|
128
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
129
|
+
throw new Error(`Pro access denied (HTTP ${resp.status}) — check license key`);
|
|
130
|
+
}
|
|
131
|
+
if (resp.status === 404) {
|
|
132
|
+
throw new Error("No Pro release available yet");
|
|
133
|
+
}
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
136
|
+
}
|
|
137
|
+
const data = (await resp.json());
|
|
138
|
+
if (!data.version || !data.url) {
|
|
139
|
+
throw new Error("Malformed /api/pro/meta response");
|
|
140
|
+
}
|
|
141
|
+
return data;
|
|
142
|
+
}
|
|
143
|
+
async function downloadAndExtract(serverUrl, licenseKey, meta, proRoot) {
|
|
144
|
+
const downloadUrl = `${serverUrl.replace(/\/$/, "")}${meta.url.startsWith("/") ? "" : "/"}${meta.url}`;
|
|
145
|
+
const resp = await fetch(downloadUrl, {
|
|
146
|
+
headers: { Authorization: `Bearer ${licenseKey}` },
|
|
147
|
+
signal: AbortSignal.timeout(60_000),
|
|
148
|
+
});
|
|
149
|
+
if (!resp.ok) {
|
|
150
|
+
throw new Error(`Download failed with HTTP ${resp.status}`);
|
|
151
|
+
}
|
|
152
|
+
// Stream to a temp file so we can verify the hash before extracting
|
|
153
|
+
const tmpPath = (0, node_path_1.join)(proRoot, `.pro-download-${Date.now()}.tgz`);
|
|
154
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
155
|
+
(0, node_fs_1.writeFileSync)(tmpPath, buf);
|
|
156
|
+
// Verify sha256 if the server provided one
|
|
157
|
+
if (meta.sha256) {
|
|
158
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(buf).digest("hex");
|
|
159
|
+
if (hash !== meta.sha256) {
|
|
160
|
+
try {
|
|
161
|
+
(0, node_fs_1.rmSync)(tmpPath);
|
|
162
|
+
}
|
|
163
|
+
catch { /* non-fatal */ }
|
|
164
|
+
throw new Error(`sha256 mismatch: expected ${meta.sha256}, got ${hash}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Extract to proRoot (overwrites existing package/ directory)
|
|
168
|
+
const existingPackageDir = (0, node_path_1.join)(proRoot, "package");
|
|
169
|
+
if ((0, node_fs_1.existsSync)(existingPackageDir)) {
|
|
170
|
+
try {
|
|
171
|
+
(0, node_fs_1.rmSync)(existingPackageDir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
catch { /* non-fatal */ }
|
|
174
|
+
}
|
|
175
|
+
// Use the system tar to extract (cross-platform, no new dependency)
|
|
176
|
+
const result = (0, node_child_process_1.spawnSync)("tar", ["-xzf", tmpPath, "-C", proRoot], {
|
|
177
|
+
encoding: "utf-8",
|
|
178
|
+
});
|
|
179
|
+
if (result.status !== 0) {
|
|
180
|
+
throw new Error(`tar extraction failed: ${result.stderr || "unknown error"}`);
|
|
181
|
+
}
|
|
182
|
+
// Clean up the temp tarball
|
|
183
|
+
try {
|
|
184
|
+
(0, node_fs_1.rmSync)(tmpPath);
|
|
185
|
+
}
|
|
186
|
+
catch { /* non-fatal */ }
|
|
187
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hicortex",
|
|
3
3
|
"name": "Hicortex — Long-term Memory That Learns",
|
|
4
4
|
"description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"skills": ["./skills/hicortex-memory", "./skills/hicortex-learn", "./skills/hicortex-activate"],
|
|
8
8
|
"configSchema": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gamaze/hicortex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Human-like memory for self-improving AI agents. Automatic capturing, nightly reflection, and cross-agent learning. Works with Claude Code and OpenClaw.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|
|
52
52
|
"url": "https://github.com/gamaze-labs/hicortex.git",
|
|
53
|
-
"directory": "packages/
|
|
53
|
+
"directory": "packages/hicortex"
|
|
54
54
|
},
|
|
55
55
|
"bugs": {
|
|
56
56
|
"url": "https://github.com/gamaze-labs/hicortex/issues"
|
|
57
57
|
},
|
|
58
|
-
"author": "
|
|
58
|
+
"author": "Aironic Ventures Ltd.",
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@huggingface/transformers": "^3.0.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.28.0",
|