@gamaze/hicortex 0.4.0 → 0.4.2

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 CHANGED
@@ -71,7 +71,7 @@ npx @gamaze/hicortex uninstall # Remove CC integration (keeps DB
71
71
  ## Architecture
72
72
 
73
73
  ```
74
- Client (MBP) Server (bedrock) Client (MBA)
74
+ Client A Server Client B
75
75
  ┌──────────┐ ┌──────────────┐ ┌──────────┐
76
76
  │CC sessions│ │ Shared DB │ │CC sessions│
77
77
  │ ↓ │ POST │ │ POST │ ↓ │
@@ -99,6 +99,7 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
99
99
  | `serverUrl` | Remote server URL (client mode) |
100
100
  | `llmModel` | Model for importance scoring |
101
101
  | `distillModel` | Model for session distillation (9b+ recommended) |
102
+ | `distillBaseUrl` | Separate Ollama instance for distillation |
102
103
  | `reflectModel` | Model for nightly reflection (largest available) |
103
104
  | `reflectBaseUrl` | Separate Ollama instance for reflection |
104
105
  | `authToken` | Bearer token for endpoint auth |
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * CLAUDE.md lesson injection — manages the Hicortex Learnings block.
3
3
  *
4
- * Writes a delimited block into ~/.claude/CLAUDE.md containing:
5
- * - Agent guidance for proactive tool use
6
- * - Active lessons from the DB
4
+ * Injects a dynamic, nightly-updated block into ~/.claude/CLAUDE.md:
5
+ * - Top lessons (from reflection, high-confidence)
6
+ * - Memory index (projects + counts, primes the agent to search)
7
+ * - Current project context (recent decisions for this project)
7
8
  *
8
- * Idempotent: calling twice with the same lessons produces the same file.
9
+ * Idempotent: calling twice with the same data produces the same file.
9
10
  */
10
11
  import type Database from "better-sqlite3";
11
12
  /**
12
- * Inject lessons from the DB into CLAUDE.md.
13
+ * Inject lessons, memory index, and project context into CLAUDE.md.
13
14
  * Creates the file if it doesn't exist.
14
15
  * Replaces existing block if present, appends if not.
15
16
  */
package/dist/claude-md.js CHANGED
@@ -2,11 +2,12 @@
2
2
  /**
3
3
  * CLAUDE.md lesson injection — manages the Hicortex Learnings block.
4
4
  *
5
- * Writes a delimited block into ~/.claude/CLAUDE.md containing:
6
- * - Agent guidance for proactive tool use
7
- * - Active lessons from the DB
5
+ * Injects a dynamic, nightly-updated block into ~/.claude/CLAUDE.md:
6
+ * - Top lessons (from reflection, high-confidence)
7
+ * - Memory index (projects + counts, primes the agent to search)
8
+ * - Current project context (recent decisions for this project)
8
9
  *
9
- * Idempotent: calling twice with the same lessons produces the same file.
10
+ * Idempotent: calling twice with the same data produces the same file.
10
11
  */
11
12
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
13
  if (k2 === undefined) k2 = k;
@@ -46,42 +47,67 @@ exports.injectLessons = injectLessons;
46
47
  exports.removeLessonsBlock = removeLessonsBlock;
47
48
  const node_fs_1 = require("node:fs");
48
49
  const node_path_1 = require("node:path");
49
- const node_path_2 = require("node:path");
50
50
  const node_os_1 = require("node:os");
51
51
  const storage = __importStar(require("./storage.js"));
52
52
  const license_js_1 = require("./license.js");
53
53
  const START_MARKER = "<!-- HICORTEX-LEARNINGS:START -->";
54
54
  const END_MARKER = "<!-- HICORTEX-LEARNINGS:END -->";
55
- const DEFAULT_CLAUDE_MD = (0, node_path_2.join)((0, node_os_1.homedir)(), ".claude", "CLAUDE.md");
56
- const AGENT_GUIDANCE = `You have access to long-term memory via Hicortex MCP tools. Use \`hicortex_search\` when you need context from past sessions, decisions, or prior work. Use \`hicortex_context\` at session start to recall recent project state. Use \`hicortex_ingest\` to save important decisions or learnings. Sessions are auto-captured nightly.`;
55
+ const DEFAULT_CLAUDE_MD = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "CLAUDE.md");
57
56
  /**
58
- * Inject lessons from the DB into CLAUDE.md.
57
+ * Inject lessons, memory index, and project context into CLAUDE.md.
59
58
  * Creates the file if it doesn't exist.
60
59
  * Replaces existing block if present, appends if not.
61
60
  */
62
61
  function injectLessons(db, options = {}) {
63
62
  const claudeMdPath = options.claudeMdPath ?? DEFAULT_CLAUDE_MD;
64
- const stateDir = options.stateDir ?? (0, node_path_2.join)((0, node_os_1.homedir)(), ".hicortex");
65
- // Determine lesson limit based on license
63
+ const stateDir = options.stateDir ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
64
+ // Determine limits based on license
66
65
  const features = (0, license_js_1.getFeatures)(stateDir);
67
- const maxLessons = features.maxMemories === -1 ? 15 : 7;
68
- // Load lessons
66
+ const maxLessons = features.maxMemories === -1 ? 10 : 5;
67
+ // --- Lessons ---
69
68
  const lessons = storage.getLessons(db, 30, options.project);
70
69
  const selected = lessons.slice(0, maxLessons);
71
- // Format lesson lines
72
70
  const lessonLines = selected.map((l) => {
73
- const match = l.content.match(/## Lesson: (.+)/);
74
- return match ? `- ${match[1]}` : `- ${l.content.slice(0, 200)}`;
71
+ const titleMatch = l.content.match(/## Lesson: (.+)/);
72
+ const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
73
+ const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
74
+ const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
75
+ const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
76
+ return `- ${title}${meta ? ` (${meta})` : ""}`;
75
77
  });
76
- // Build the block
77
- const blockParts = [START_MARKER, "## Hicortex Learnings", "", AGENT_GUIDANCE];
78
+ // --- Memory Index ---
79
+ const projectIndex = buildProjectIndex(db);
80
+ const totalCount = storage.countMemories(db);
81
+ const lessonCount = lessons.length;
82
+ const sourceCount = countSources(db);
83
+ // --- Current Project Context ---
84
+ const currentProject = detectCurrentProject(claudeMdPath);
85
+ const projectContext = currentProject
86
+ ? buildProjectContext(db, currentProject)
87
+ : [];
88
+ // --- Build Block ---
89
+ const blockParts = [START_MARKER, "## Hicortex Memory"];
90
+ // Mandatory instruction
91
+ blockParts.push("", "You have access to shared long-term memory across all agents and sessions.", "BEFORE making decisions, search memory: `hicortex_search` for prior decisions on the same topic.", "Use `hicortex_context` at session start for recent project state.");
92
+ // Lessons
78
93
  if (lessonLines.length > 0) {
79
- blockParts.push("", "### Active Lessons");
94
+ blockParts.push("", "### Lessons (updated nightly)");
80
95
  blockParts.push(...lessonLines);
81
96
  }
97
+ // Project context
98
+ if (projectContext.length > 0) {
99
+ blockParts.push("", `### This Project (${currentProject})`);
100
+ blockParts.push(...projectContext);
101
+ }
102
+ // Memory index
103
+ if (projectIndex.length > 0) {
104
+ blockParts.push("", "### Memory Index");
105
+ blockParts.push(projectIndex.join(" | "));
106
+ blockParts.push(`${totalCount} memories, ${lessonCount} lessons, ${sourceCount} agents. Search with \`hicortex_search\`.`);
107
+ }
82
108
  blockParts.push(END_MARKER);
83
109
  const block = blockParts.join("\n");
84
- // Read existing file or start empty
110
+ // --- Write ---
85
111
  let content = "";
86
112
  try {
87
113
  content = (0, node_fs_1.readFileSync)(claudeMdPath, "utf-8");
@@ -89,31 +115,94 @@ function injectLessons(db, options = {}) {
89
115
  catch {
90
116
  // File doesn't exist — will create it
91
117
  }
92
- // Replace or append
93
118
  const startIdx = content.indexOf(START_MARKER);
94
119
  const endIdx = content.indexOf(END_MARKER);
95
120
  if (startIdx !== -1 && endIdx !== -1) {
96
- // Replace existing block
97
121
  content =
98
122
  content.slice(0, startIdx) +
99
123
  block +
100
124
  content.slice(endIdx + END_MARKER.length);
101
125
  }
102
126
  else {
103
- // Append with blank line separator
104
- if (content.length > 0 && !content.endsWith("\n")) {
127
+ if (content.length > 0 && !content.endsWith("\n"))
105
128
  content += "\n";
106
- }
107
- if (content.length > 0) {
129
+ if (content.length > 0)
108
130
  content += "\n";
109
- }
110
131
  content += block + "\n";
111
132
  }
112
- // Write
113
133
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(claudeMdPath), { recursive: true });
114
134
  (0, node_fs_1.writeFileSync)(claudeMdPath, content);
115
135
  return { lessonsCount: selected.length, path: claudeMdPath };
116
136
  }
137
+ /**
138
+ * Build compact project index: "hicortex: 18 | boat: 24 | health: 45"
139
+ */
140
+ function buildProjectIndex(db) {
141
+ try {
142
+ const rows = db
143
+ .prepare(`SELECT project, COUNT(*) as cnt FROM memories
144
+ WHERE project IS NOT NULL
145
+ GROUP BY project ORDER BY cnt DESC LIMIT 10`)
146
+ .all();
147
+ return rows.map((r) => `${r.project}: ${r.cnt}`);
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ }
153
+ /**
154
+ * Count distinct source agents.
155
+ */
156
+ function countSources(db) {
157
+ try {
158
+ return db
159
+ .prepare("SELECT COUNT(DISTINCT source_agent) as cnt FROM memories")
160
+ .get().cnt;
161
+ }
162
+ catch {
163
+ return 0;
164
+ }
165
+ }
166
+ /**
167
+ * Detect current project from the CLAUDE.md path.
168
+ * CC puts project-specific CLAUDE.md files in ~/.claude/projects/<encoded-path>/
169
+ * The global ~/.claude/CLAUDE.md has no project context.
170
+ */
171
+ function detectCurrentProject(claudeMdPath) {
172
+ // Global CLAUDE.md — no project
173
+ if (claudeMdPath === DEFAULT_CLAUDE_MD)
174
+ return null;
175
+ // Project CLAUDE.md: ~/.claude/projects/-Users-foo-myproject/CLAUDE.md
176
+ // Extract the last segment of the encoded path
177
+ const match = claudeMdPath.match(/projects\/[^/]*-([^/]+)\//);
178
+ if (match)
179
+ return match[1];
180
+ return null;
181
+ }
182
+ /**
183
+ * Build recent decisions/facts for a specific project.
184
+ * Returns formatted lines like: "- Shipped v0.4.1 with multi-client (2026-03-28)"
185
+ */
186
+ function buildProjectContext(db, project) {
187
+ try {
188
+ const rows = db
189
+ .prepare(`SELECT content, created_at FROM memories
190
+ WHERE project = ? AND memory_type IN ('decision', 'episode')
191
+ ORDER BY created_at DESC LIMIT 5`)
192
+ .all(project);
193
+ return rows.map((r) => {
194
+ const date = r.created_at?.slice(0, 10) ?? "";
195
+ // Extract first meaningful line from content
196
+ const lines = r.content.split("\n").filter((l) => l.trim().length > 10);
197
+ const summary = lines.find((l) => l.startsWith("- ") || l.startsWith("### "))?.replace(/^[-#\s]+/, "").slice(0, 120) ??
198
+ r.content.slice(0, 120);
199
+ return `- ${summary} (${date})`;
200
+ });
201
+ }
202
+ catch {
203
+ return [];
204
+ }
205
+ }
117
206
  /**
118
207
  * Remove the Hicortex Learnings block from CLAUDE.md.
119
208
  * Used by the uninstall command.
@@ -124,16 +213,14 @@ function removeLessonsBlock(claudeMdPath = DEFAULT_CLAUDE_MD) {
124
213
  content = (0, node_fs_1.readFileSync)(claudeMdPath, "utf-8");
125
214
  }
126
215
  catch {
127
- return false; // File doesn't exist
216
+ return false;
128
217
  }
129
218
  const startIdx = content.indexOf(START_MARKER);
130
219
  const endIdx = content.indexOf(END_MARKER);
131
220
  if (startIdx === -1 || endIdx === -1)
132
221
  return false;
133
- // Remove block and any trailing blank line
134
222
  let newContent = content.slice(0, startIdx) +
135
223
  content.slice(endIdx + END_MARKER.length);
136
- // Clean up double blank lines left by removal
137
224
  newContent = newContent.replace(/\n{3,}/g, "\n\n").trim();
138
225
  if (newContent.length > 0)
139
226
  newContent += "\n";
@@ -4,12 +4,20 @@
4
4
  * not from filesystem scanning.
5
5
  */
6
6
  import type { LlmClient } from "./llm.js";
7
+ /**
8
+ * Estimate a safe chunk size in chars based on the LLM provider and model.
9
+ * - API providers (Anthropic, OpenAI, claude-cli): no chunking needed (large context windows)
10
+ * - Ollama: query /api/show for context_length, use ~60% for chunks (leaving room for prompt + generation)
11
+ * - Fallback: 20K chars (~5K tokens) — safe for 4B models with 32K context
12
+ */
13
+ export declare function detectChunkSize(provider: string, model: string, baseUrl?: string): Promise<number>;
7
14
  /**
8
15
  * Convert OpenClaw hook messages to a filtered transcript string.
9
16
  */
10
17
  export declare function extractConversationText(messages: unknown[]): string;
11
18
  /**
12
19
  * Send filtered conversation to LLM for knowledge extraction.
20
+ * For large transcripts, chunks into segments to avoid overwhelming small models.
13
21
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
14
22
  */
15
- export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string): Promise<string[]>;
23
+ export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number): Promise<string[]>;
package/dist/distiller.js CHANGED
@@ -5,11 +5,53 @@
5
5
  * not from filesystem scanning.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.detectChunkSize = detectChunkSize;
8
9
  exports.extractConversationText = extractConversationText;
9
10
  exports.distillSession = distillSession;
10
11
  const prompts_js_1 = require("./prompts.js");
11
12
  const MAX_TRANSCRIPT_CHARS = 80_000;
12
13
  const MIN_CONVERSATION_CHARS = 200;
14
+ /**
15
+ * Estimate a safe chunk size in chars based on the LLM provider and model.
16
+ * - API providers (Anthropic, OpenAI, claude-cli): no chunking needed (large context windows)
17
+ * - Ollama: query /api/show for context_length, use ~60% for chunks (leaving room for prompt + generation)
18
+ * - Fallback: 20K chars (~5K tokens) — safe for 4B models with 32K context
19
+ */
20
+ async function detectChunkSize(provider, model, baseUrl) {
21
+ // API-based providers handle large contexts natively — no chunking needed
22
+ if (provider !== "ollama") {
23
+ return MAX_TRANSCRIPT_CHARS;
24
+ }
25
+ // Query Ollama for model metadata
26
+ if (baseUrl) {
27
+ try {
28
+ const resp = await fetch(`${baseUrl}/api/show`, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({ name: model }),
32
+ signal: AbortSignal.timeout(5000),
33
+ });
34
+ if (resp.ok) {
35
+ const data = await resp.json();
36
+ // Try to extract context length from model_info
37
+ const info = data.model_info ?? {};
38
+ const ctxKey = Object.keys(info).find((k) => k.endsWith("context_length") || k.endsWith("context_window"));
39
+ if (ctxKey && typeof info[ctxKey] === "number") {
40
+ const contextTokens = info[ctxKey];
41
+ // Use 60% of context for chunk input (~4 chars/token)
42
+ const chunkChars = Math.floor(contextTokens * 0.6 * 4);
43
+ console.log(`[hicortex] Model context: ${contextTokens} tokens, chunk size: ${chunkChars} chars`);
44
+ return Math.min(chunkChars, MAX_TRANSCRIPT_CHARS);
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ // Failed to query — use fallback
50
+ }
51
+ }
52
+ // Fallback: 20K chars (~5K tokens) — safe for 4B models with 32K context
53
+ return 20_000;
54
+ }
13
55
  // Entry types to skip entirely (from the Python distiller)
14
56
  const SKIP_ENTRY_TYPES = new Set([
15
57
  "progress",
@@ -128,17 +170,54 @@ function extractConversationText(messages) {
128
170
  }
129
171
  /**
130
172
  * Send filtered conversation to LLM for knowledge extraction.
173
+ * For large transcripts, chunks into segments to avoid overwhelming small models.
131
174
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
132
175
  */
133
- async function distillSession(llm, conversation, projectName, date) {
176
+ async function distillSession(llm, conversation, projectName, date, chunkSizeChars) {
134
177
  if (conversation.length < MIN_CONVERSATION_CHARS) {
135
178
  return [];
136
179
  }
137
- // Truncate if too long
180
+ // Cap total input at MAX_TRANSCRIPT_CHARS
138
181
  let transcript = conversation;
139
182
  if (transcript.length > MAX_TRANSCRIPT_CHARS) {
140
183
  transcript = transcript.slice(0, MAX_TRANSCRIPT_CHARS) + "\n\n[...truncated...]";
141
184
  }
185
+ // Use provided chunk size or default to no chunking
186
+ const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
187
+ // If transcript fits in one chunk, distill directly
188
+ if (transcript.length <= chunkSize) {
189
+ return distillChunk(llm, transcript, projectName, date);
190
+ }
191
+ // Chunk large transcripts and distill each segment
192
+ const chunks = splitIntoChunks(transcript, chunkSize);
193
+ console.log(`[hicortex] Chunking ${transcript.length} chars into ${chunks.length} segments`);
194
+ const allEntries = [];
195
+ const seen = new Set();
196
+ for (let i = 0; i < chunks.length; i++) {
197
+ console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
198
+ try {
199
+ const entries = await distillChunk(llm, chunks[i], projectName, date);
200
+ for (const entry of entries) {
201
+ // Deduplicate by normalized content
202
+ const key = entry.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
203
+ if (!seen.has(key)) {
204
+ seen.add(key);
205
+ allEntries.push(entry);
206
+ }
207
+ }
208
+ }
209
+ catch (err) {
210
+ const msg = err instanceof Error ? err.message : String(err);
211
+ console.error(`[hicortex] Chunk ${i + 1} failed: ${msg}`);
212
+ // Continue with remaining chunks — partial extraction is better than none
213
+ }
214
+ }
215
+ return allEntries;
216
+ }
217
+ /**
218
+ * Distill a single chunk of conversation text.
219
+ */
220
+ async function distillChunk(llm, transcript, projectName, date) {
142
221
  const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
143
222
  try {
144
223
  const result = await llm.completeDistill(prompt);
@@ -147,7 +226,6 @@ async function distillSession(llm, conversation, projectName, date) {
147
226
  if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
148
227
  return [];
149
228
  }
150
- // Split distilled markdown into individual memory entries
151
229
  return parseDistilledEntries(result);
152
230
  }
153
231
  catch (err) {
@@ -156,6 +234,35 @@ async function distillSession(llm, conversation, projectName, date) {
156
234
  return [];
157
235
  }
158
236
  }
237
+ /**
238
+ * Split transcript text into chunks at natural boundaries (double newlines).
239
+ * Each chunk is at most maxChars, split at the last paragraph boundary.
240
+ */
241
+ function splitIntoChunks(text, maxChars) {
242
+ if (text.length <= maxChars)
243
+ return [text];
244
+ const chunks = [];
245
+ let remaining = text;
246
+ while (remaining.length > 0) {
247
+ if (remaining.length <= maxChars) {
248
+ chunks.push(remaining);
249
+ break;
250
+ }
251
+ // Find the last paragraph break within maxChars
252
+ let splitAt = remaining.lastIndexOf("\n\n", maxChars);
253
+ if (splitAt < maxChars * 0.5) {
254
+ // No good paragraph break — fall back to last newline
255
+ splitAt = remaining.lastIndexOf("\n", maxChars);
256
+ }
257
+ if (splitAt < maxChars * 0.3) {
258
+ // No good break at all — hard split
259
+ splitAt = maxChars;
260
+ }
261
+ chunks.push(remaining.slice(0, splitAt).trim());
262
+ remaining = remaining.slice(splitAt).trim();
263
+ }
264
+ return chunks.filter((c) => c.length >= MIN_CONVERSATION_CHARS);
265
+ }
159
266
  /**
160
267
  * Parse distilled markdown into individual memory entry strings.
161
268
  * Each section item becomes a separate memory.
package/dist/init.js CHANGED
@@ -717,7 +717,7 @@ async function runInit(options = {}) {
717
717
  catch { /* new file */ }
718
718
  const block = [
719
719
  "<!-- HICORTEX-LEARNINGS:START -->",
720
- "## Hicortex Learnings",
720
+ "## Hicortex Memory",
721
721
  "",
722
722
  "You have access to long-term memory via Hicortex MCP tools. Use `hicortex_search` when you need context from past sessions, decisions, or prior work. Use `hicortex_context` at session start to recall recent project state. Use `hicortex_ingest` to save important decisions or learnings. Sessions are auto-captured nightly.",
723
723
  "<!-- HICORTEX-LEARNINGS:END -->",
@@ -858,7 +858,7 @@ async function runClientInit(serverUrl) {
858
858
  catch { }
859
859
  const block = [
860
860
  "<!-- HICORTEX-LEARNINGS:START -->",
861
- "## Hicortex Learnings",
861
+ "## Hicortex Memory",
862
862
  "",
863
863
  "You have access to long-term memory via Hicortex MCP tools. Use `hicortex_search` when you need context from past sessions, decisions, or prior work. Use `hicortex_context` at session start to recall recent project state. Use `hicortex_ingest` to save important decisions or learnings. Sessions are auto-captured nightly.",
864
864
  "<!-- HICORTEX-LEARNINGS:END -->",
package/dist/llm.d.ts CHANGED
@@ -25,6 +25,10 @@ export interface LlmConfig {
25
25
  provider: string;
26
26
  /** Optional separate model for distillation (defaults to model if unset). */
27
27
  distillModel?: string;
28
+ /** Optional separate endpoint for distillation (e.g. remote Ollama with larger/faster model). */
29
+ distillBaseUrl?: string;
30
+ distillApiKey?: string;
31
+ distillProvider?: string;
28
32
  /** Optional separate endpoint for reflect-tier LLM (e.g. remote Ollama with larger model). */
29
33
  reflectBaseUrl?: string;
30
34
  reflectApiKey?: string;
@@ -59,6 +63,17 @@ export declare function findClaudeBinary(): string | null;
59
63
  * baseUrl field stores the path to the claude binary.
60
64
  */
61
65
  export declare function claudeCliConfig(claudePath: string): LlmConfig;
66
+ /**
67
+ * Check if a local Ollama instance is reachable and has models loaded.
68
+ * Returns the model name if available, null otherwise.
69
+ */
70
+ export declare function probeOllama(baseUrl?: string): Promise<string | null>;
71
+ /**
72
+ * For batch operations (nightly pipeline), prefer Ollama when available.
73
+ * Claude CLI has strict rate limits that kill batch distillation.
74
+ * Falls back to the provided config if Ollama is unreachable.
75
+ */
76
+ export declare function preferOllamaForBatch(resolved: LlmConfig, ollamaBaseUrl?: string): Promise<LlmConfig>;
62
77
  export declare class RateLimitError extends Error {
63
78
  retryAfterMs: number;
64
79
  constructor(retryAfterMs: number);
@@ -81,6 +96,7 @@ export declare class LlmClient {
81
96
  completeReflect(prompt: string, maxTokens?: number): Promise<string>;
82
97
  /**
83
98
  * Distillation-tier completion (session knowledge extraction).
99
+ * Routes to distillBaseUrl/distillProvider if configured (e.g. remote Ollama with faster model).
84
100
  */
85
101
  completeDistill(prompt: string, maxTokens?: number): Promise<string>;
86
102
  /**
package/dist/llm.js CHANGED
@@ -24,6 +24,8 @@ exports.resolveLlmConfig = resolveLlmConfig;
24
24
  exports.resolveLlmConfigForCC = resolveLlmConfigForCC;
25
25
  exports.findClaudeBinary = findClaudeBinary;
26
26
  exports.claudeCliConfig = claudeCliConfig;
27
+ exports.probeOllama = probeOllama;
28
+ exports.preferOllamaForBatch = preferOllamaForBatch;
27
29
  const node_fs_1 = require("node:fs");
28
30
  const node_path_1 = require("node:path");
29
31
  const node_os_1 = require("node:os");
@@ -348,6 +350,55 @@ function claudeCliConfig(claudePath) {
348
350
  provider: "claude-cli",
349
351
  };
350
352
  }
353
+ /**
354
+ * Check if a local Ollama instance is reachable and has models loaded.
355
+ * Returns the model name if available, null otherwise.
356
+ */
357
+ async function probeOllama(baseUrl = "http://localhost:11434") {
358
+ try {
359
+ const resp = await fetch(`${baseUrl}/api/ps`, {
360
+ signal: AbortSignal.timeout(3000),
361
+ });
362
+ if (!resp.ok)
363
+ return null;
364
+ const data = (await resp.json());
365
+ if (data.models && data.models.length > 0) {
366
+ return data.models[0].name;
367
+ }
368
+ // No model loaded — check if any are available
369
+ const tagsResp = await fetch(`${baseUrl}/api/tags`, {
370
+ signal: AbortSignal.timeout(3000),
371
+ });
372
+ if (!tagsResp.ok)
373
+ return null;
374
+ const tags = (await tagsResp.json());
375
+ return tags.models?.[0]?.name ?? null;
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ /**
382
+ * For batch operations (nightly pipeline), prefer Ollama when available.
383
+ * Claude CLI has strict rate limits that kill batch distillation.
384
+ * Falls back to the provided config if Ollama is unreachable.
385
+ */
386
+ async function preferOllamaForBatch(resolved, ollamaBaseUrl = "http://localhost:11434") {
387
+ // Only override claude-cli (which has rate limits)
388
+ if (resolved.provider !== "claude-cli")
389
+ return resolved;
390
+ const model = await probeOllama(ollamaBaseUrl);
391
+ if (!model)
392
+ return resolved;
393
+ return {
394
+ ...resolved,
395
+ baseUrl: ollamaBaseUrl,
396
+ apiKey: "",
397
+ model,
398
+ reflectModel: resolved.reflectModel ?? model,
399
+ provider: "ollama",
400
+ };
401
+ }
351
402
  // ---------------------------------------------------------------------------
352
403
  // LLM Client class
353
404
  // ---------------------------------------------------------------------------
@@ -401,8 +452,12 @@ class LlmClient {
401
452
  }
402
453
  /**
403
454
  * Distillation-tier completion (session knowledge extraction).
455
+ * Routes to distillBaseUrl/distillProvider if configured (e.g. remote Ollama with faster model).
404
456
  */
405
457
  async completeDistill(prompt, maxTokens = 2048) {
458
+ if (this.config.distillBaseUrl) {
459
+ return this.completeWithOverride(this.config.distillBaseUrl, this.config.distillApiKey ?? this.config.apiKey, this.config.distillProvider ?? this.config.provider, this.config.distillModel ?? this.config.model, prompt, maxTokens, 900_000);
460
+ }
406
461
  return this.complete(this.config.distillModel ?? this.config.model, prompt, maxTokens, 900_000);
407
462
  }
408
463
  /**
@@ -231,7 +231,7 @@ function createMcpServer() {
231
231
  // ---------------------------------------------------------------------------
232
232
  async function startServer(options = {}) {
233
233
  const port = options.port ?? 8787;
234
- const host = options.host ?? "127.0.0.1";
234
+ const host = options.host ?? "0.0.0.0";
235
235
  // Initialize core
236
236
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
237
237
  console.log(`[hicortex] Initializing database at ${dbPath}`);
@@ -273,10 +273,15 @@ async function startServer(options = {}) {
273
273
  reflectModel: savedConfig?.reflectModel,
274
274
  });
275
275
  }
276
- // Apply optional distillModel (e.g. larger local model for session extraction)
276
+ // Apply optional distill endpoint (e.g. remote Ollama with faster model)
277
277
  if (savedConfig?.distillModel) {
278
278
  llmConfig.distillModel = savedConfig.distillModel;
279
279
  }
280
+ if (savedConfig?.distillBaseUrl) {
281
+ llmConfig.distillBaseUrl = savedConfig.distillBaseUrl;
282
+ llmConfig.distillApiKey = savedConfig.distillApiKey ?? llmConfig.apiKey;
283
+ llmConfig.distillProvider = savedConfig.distillProvider ?? llmConfig.provider;
284
+ }
280
285
  // Apply separate reflect endpoint if configured (e.g. remote Ollama with larger model)
281
286
  if (savedConfig?.reflectBaseUrl) {
282
287
  llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
@@ -284,11 +289,13 @@ async function startServer(options = {}) {
284
289
  llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
285
290
  }
286
291
  llm = new llm_js_1.LlmClient(llmConfig);
287
- const distillInfo = llmConfig.distillModel ? `, distill: ${llmConfig.distillModel}` : "";
292
+ const distillInfo = llmConfig.distillBaseUrl
293
+ ? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
294
+ : llmConfig.distillModel ? llmConfig.distillModel : "";
288
295
  const reflectInfo = llmConfig.reflectBaseUrl
289
296
  ? `${llmConfig.reflectProvider}/${llmConfig.reflectModel}@${llmConfig.reflectBaseUrl}`
290
297
  : llmConfig.reflectModel;
291
- console.log(`[hicortex] LLM fast: ${llmConfig.provider}/${llmConfig.model}${distillInfo}, reflect: ${reflectInfo}`);
298
+ console.log(`[hicortex] LLM fast: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}, reflect: ${reflectInfo}`);
292
299
  // License: read from options, config file, or env var
293
300
  const licenseKey = options.licenseKey
294
301
  ?? savedConfig?.licenseKey
package/dist/nightly.js CHANGED
@@ -147,18 +147,30 @@ async function runNightly(options = {}) {
147
147
  reflectModel: savedConfig?.reflectModel,
148
148
  });
149
149
  }
150
- // Apply distillModel and reflect overrides from config
150
+ // Apply distill and reflect overrides from config
151
151
  if (savedConfig?.distillModel) {
152
152
  llmConfig.distillModel = savedConfig.distillModel;
153
153
  }
154
+ if (savedConfig?.distillBaseUrl) {
155
+ llmConfig.distillBaseUrl = savedConfig.distillBaseUrl;
156
+ llmConfig.distillApiKey = savedConfig.distillApiKey ?? llmConfig.apiKey;
157
+ llmConfig.distillProvider = savedConfig.distillProvider ?? llmConfig.provider;
158
+ }
154
159
  if (savedConfig?.reflectBaseUrl) {
155
160
  llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
156
161
  llmConfig.reflectApiKey = savedConfig.reflectApiKey ?? llmConfig.apiKey;
157
162
  llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
158
163
  }
164
+ // Auto-detect Ollama for batch distillation (claude-cli has rate limits)
165
+ llmConfig = await (0, llm_js_1.preferOllamaForBatch)(llmConfig);
166
+ if (llmConfig.provider === "ollama") {
167
+ console.log(`[hicortex] Auto-detected local Ollama (${llmConfig.model}) — using for batch distillation`);
168
+ }
159
169
  const llm = new llm_js_1.LlmClient(llmConfig);
160
- const distillInfo = llmConfig.distillModel ? `, distill: ${llmConfig.distillModel}` : "";
161
- console.log(`[hicortex] LLM: ${llmConfig.provider}/${llmConfig.model}${distillInfo}`);
170
+ const distillInfo = llmConfig.distillBaseUrl
171
+ ? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
172
+ : llmConfig.distillModel ?? "";
173
+ console.log(`[hicortex] LLM: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}`);
162
174
  // Step 1: Read new CC transcripts
163
175
  const since = readLastRun();
164
176
  console.log(`[hicortex] Reading CC transcripts since ${since.toISOString()}`);
@@ -171,6 +183,8 @@ async function runNightly(options = {}) {
171
183
  // Step 2: Distill each session
172
184
  let memoriesIngested = 0;
173
185
  const features = (0, license_js_1.getFeatures)(stateDir);
186
+ // Detect safe chunk size based on model context window
187
+ const chunkSize = await (0, distiller_js_1.detectChunkSize)(llmConfig.provider, llmConfig.distillModel ?? llmConfig.model, llmConfig.baseUrl);
174
188
  for (const batch of batches) {
175
189
  const transcript = (0, distiller_js_1.extractConversationText)(batch.entries);
176
190
  if (transcript.length < 200) {
@@ -189,7 +203,7 @@ async function runNightly(options = {}) {
189
203
  break;
190
204
  }
191
205
  try {
192
- const entries = await (0, distiller_js_1.distillSession)(llm, transcript, batch.projectName, batch.date);
206
+ const entries = await (0, distiller_js_1.distillSession)(llm, transcript, batch.projectName, batch.date, chunkSize);
193
207
  for (const entry of entries) {
194
208
  try {
195
209
  const embedding = await (0, embedder_js_1.embed)(entry);
@@ -288,8 +302,23 @@ async function runClientNightly(config, dryRun) {
288
302
  if (config.distillModel) {
289
303
  llmConfig.distillModel = config.distillModel;
290
304
  }
305
+ if (config.distillBaseUrl) {
306
+ llmConfig.distillBaseUrl = config.distillBaseUrl;
307
+ llmConfig.distillApiKey = config.distillApiKey ?? llmConfig.apiKey;
308
+ llmConfig.distillProvider = config.distillProvider ?? llmConfig.provider;
309
+ }
310
+ // Auto-detect Ollama for batch distillation when claude-cli was resolved (fallback)
311
+ llmConfig = await (0, llm_js_1.preferOllamaForBatch)(llmConfig);
312
+ if (llmConfig.provider === "ollama" && !config.distillBaseUrl) {
313
+ console.log(`[hicortex] Auto-detected local Ollama (${llmConfig.model}) — using for batch distillation`);
314
+ }
291
315
  const llm = new llm_js_1.LlmClient(llmConfig);
292
- console.log(`[hicortex] LLM: ${llmConfig.provider}/${llmConfig.distillModel ?? llmConfig.model}`);
316
+ const distillInfo = llmConfig.distillBaseUrl
317
+ ? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
318
+ : llmConfig.distillModel ?? "";
319
+ console.log(`[hicortex] LLM: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}`);
320
+ // Detect safe chunk size based on model context window
321
+ const chunkSize = await (0, distiller_js_1.detectChunkSize)(llmConfig.provider, llmConfig.distillModel ?? llmConfig.model, llmConfig.baseUrl);
293
322
  // Read new CC transcripts
294
323
  const since = readLastRun();
295
324
  console.log(`[hicortex] Reading CC transcripts since ${since.toISOString()}`);
@@ -316,7 +345,7 @@ async function runClientNightly(config, dryRun) {
316
345
  continue;
317
346
  }
318
347
  try {
319
- const entries = await (0, distiller_js_1.distillSession)(llm, transcript, batch.projectName, batch.date);
348
+ const entries = await (0, distiller_js_1.distillSession)(llm, transcript, batch.projectName, batch.date, chunkSize);
320
349
  if (entries.length === 0) {
321
350
  console.log(`[hicortex] → No memories extracted`);
322
351
  continue;
package/dist/prompts.js CHANGED
@@ -134,6 +134,8 @@ RULES:
134
134
  tool configurations, API discoveries, project milestones
135
135
  - PRIORITIZE Corrections & Rejections — these are high-value signals for learning
136
136
  what the user does NOT want. Even a single "no" or style correction is worth extracting.
137
+ - Strong language or profanity from the user is a high-intensity signal — it indicates
138
+ the correction matters deeply. Note the intensity in the extraction.
137
139
  - PRIVACY CLASSIFICATION (one of):
138
140
  - PUBLIC: general tech knowledge, open-source patterns, publicly available info
139
141
  - WORK: project-specific decisions, architecture choices, client/business context
package/dist/status.js CHANGED
@@ -112,9 +112,43 @@ async function runStatus() {
112
112
  const lastRunPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly-last-run.txt");
113
113
  try {
114
114
  const ts = (0, node_fs_1.readFileSync)(lastRunPath, "utf-8").trim();
115
- console.log(` Last run: ${ts}`);
115
+ const lastRun = new Date(ts);
116
+ if (isNaN(lastRun.getTime())) {
117
+ console.log(` Last run: ${ts} (invalid timestamp)`);
118
+ }
119
+ else {
120
+ const STALE_THRESHOLD_HOURS = 30;
121
+ const ageHours = Math.round((Date.now() - lastRun.getTime()) / (60 * 60 * 1000));
122
+ const ageStr = ageHours < 1 ? "just now" : ageHours < 24 ? `${ageHours}h ago` : `${Math.round(ageHours / 24)}d ago`;
123
+ console.log(` Last run: ${ts} (${ageStr})`);
124
+ // Show staleness warning if missed a night
125
+ if (ageHours > STALE_THRESHOLD_HOURS) {
126
+ console.log(` ⚠ Nightly pipeline hasn't run in ${ageHours}h. Check: hicortex nightly --dry-run`);
127
+ }
128
+ }
116
129
  }
117
130
  catch {
118
- console.log(" Last run: never");
131
+ console.log(" Last run: never (run: hicortex nightly)");
132
+ }
133
+ // Distillation stats (if DB exists)
134
+ if (dbExists) {
135
+ const TOP_SOURCES_LIMIT = 5;
136
+ try {
137
+ const { initDb } = await import("./db.js");
138
+ const db2 = initDb(dbPath);
139
+ // Count memories by source
140
+ const rows = db2.prepare(`SELECT source_agent, COUNT(*) as cnt FROM memories GROUP BY source_agent ORDER BY cnt DESC LIMIT ${TOP_SOURCES_LIMIT}`).all();
141
+ if (rows.length > 0) {
142
+ console.log(" Sources:");
143
+ for (const r of rows) {
144
+ const agent = r.source_agent || "unknown";
145
+ console.log(` ${agent}: ${r.cnt}`);
146
+ }
147
+ }
148
+ db2.close();
149
+ }
150
+ catch (err) {
151
+ console.log(` Sources: (error: ${err instanceof Error ? err.message : String(err)})`);
152
+ }
119
153
  }
120
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": {
@@ -11,6 +11,17 @@
11
11
  "./dist/index.js"
12
12
  ]
13
13
  },
14
+ "keywords": [
15
+ "mcp-server",
16
+ "memory",
17
+ "ai-agents",
18
+ "agent-memory",
19
+ "self-improving",
20
+ "claude-code",
21
+ "openclaw",
22
+ "vector-search",
23
+ "nightly-distillation"
24
+ ],
14
25
  "types": "dist/index.d.ts",
15
26
  "files": [
16
27
  "dist/",