@gamaze/hicortex 0.13.2 → 0.14.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/dist/retrieval.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * score = similarity * 0.4 + effective_strength * 0.3 + connection_score * 0.2 + recency * 0.1
8
8
  *
9
9
  * Decay model (B+E+D):
10
- * base_decay = 0.0005 (~60-day half-life at importance 0.5)
10
+ * base_decay = derived from decayHalfLifeDays (config; default 365 ~1-year
11
+ * half-life at importance 0.5, importance-scaled either way)
11
12
  * decay_rate = 1 - base_decay * (1 - importance)
12
13
  * decay_rate = 1 - (1 - decay_rate) * 0.7^access_count
13
14
  * decay_rate = 1 - (1 - decay_rate) * 0.7^link_count
@@ -48,13 +49,67 @@ var __importStar = (this && this.__importStar) || (function () {
48
49
  };
49
50
  })();
50
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
53
+ exports.decayConstantForHalfLife = decayConstantForHalfLife;
54
+ exports.configureDecay = configureDecay;
55
+ exports.configureRecall = configureRecall;
51
56
  exports.l2ToCosine = l2ToCosine;
52
57
  exports.effectiveStrength = effectiveStrength;
53
58
  exports.computeScore = computeScore;
54
59
  exports.retrieve = retrieve;
55
60
  exports.searchRecent = searchRecent;
56
61
  const storage = __importStar(require("./storage.js"));
57
- const BASE_DECAY = 0.0005;
62
+ /** Default decay half-life (days) at importance 0.5. #192: was 0.0005/h
63
+ * (~115-day half-life at base 0.5) — aggressive enough to bury the long tail
64
+ * in ranking. Long-term remembering is the product; time preference stays,
65
+ * but mild. */
66
+ exports.DEFAULT_DECAY_HALF_LIFE_DAYS = 365;
67
+ /**
68
+ * Derive the per-hour base decay constant from a half-life target: for the
69
+ * decayable portion, retention^hours = 0.5 at `days`, evaluated at the
70
+ * reference importance 0.5 (the model scales the rate by (1 − importance)).
71
+ * decay_rate = 1 − λ(1 − imp) ⇒ half-life ≈ ln2 / (λ·(1 − imp)), so
72
+ * λ = ln2 / (24·days·0.5).
73
+ */
74
+ function decayConstantForHalfLife(days) {
75
+ return Math.LN2 / (24 * days * 0.5);
76
+ }
77
+ let BASE_DECAY = decayConstantForHalfLife(exports.DEFAULT_DECAY_HALF_LIFE_DAYS);
78
+ /**
79
+ * Configure the decay speed from config (`decayHalfLifeDays`). Called at boot
80
+ * by the server and the nightly so both processes score with the same clock.
81
+ * Invalid/absent values keep the default. Exported value for tests.
82
+ */
83
+ function configureDecay(options) {
84
+ const days = Number(options?.halfLifeDays);
85
+ BASE_DECAY = decayConstantForHalfLife(Number.isFinite(days) && days > 0 ? days : exports.DEFAULT_DECAY_HALF_LIFE_DAYS);
86
+ return BASE_DECAY;
87
+ }
88
+ const RECALL_DEFAULTS = {
89
+ searchLimit: 8,
90
+ recentLimit: 12,
91
+ recentWindowDays: 180,
92
+ coldExposureSlots: 2,
93
+ };
94
+ let recallDefaults = { ...RECALL_DEFAULTS };
95
+ /**
96
+ * Configure recall breadth from config. Called at boot next to
97
+ * configureDecay(); invalid/absent values keep the shipped defaults.
98
+ * Returns the resolved values (for logging + tests).
99
+ */
100
+ function configureRecall(config) {
101
+ const pick = (key) => {
102
+ const v = Number(config?.[key]);
103
+ return Number.isFinite(v) && v >= 0 ? Math.floor(v) : RECALL_DEFAULTS[key];
104
+ };
105
+ recallDefaults = {
106
+ searchLimit: Math.max(1, pick("searchLimit")),
107
+ recentLimit: Math.max(1, pick("recentLimit")),
108
+ recentWindowDays: Math.max(1, pick("recentWindowDays")),
109
+ coldExposureSlots: pick("coldExposureSlots"),
110
+ };
111
+ return { ...recallDefaults };
112
+ }
58
113
  /**
59
114
  * Placeholder L2 distance for candidates that have no measured vector
60
115
  * distance (FTS-only hits and graph-discovered neighbors). Chosen so that
@@ -177,7 +232,7 @@ function collectLinks(db, seedIds, maxHops = 2) {
177
232
  // ---------------------------------------------------------------------------
178
233
  // Formatting
179
234
  // ---------------------------------------------------------------------------
180
- function formatResult(memory, score, effStr, connections) {
235
+ function formatResult(memory, score, effStr, connections, provenance) {
181
236
  return {
182
237
  id: memory.id,
183
238
  content: memory.content ?? "",
@@ -188,6 +243,8 @@ function formatResult(memory, score, effStr, connections) {
188
243
  project: memory.project ?? null,
189
244
  created_at: memory.created_at ?? "",
190
245
  connections,
246
+ similarity: provenance ? provenance.similarity : undefined,
247
+ source: provenance ? provenance.source : undefined,
191
248
  };
192
249
  }
193
250
  // ---------------------------------------------------------------------------
@@ -224,19 +281,24 @@ function reciprocalRankFusion(rankedLists, k = RRF_K) {
224
281
  * and composite scoring. Strengthens accessed memories.
225
282
  */
226
283
  async function retrieve(db, embedFn, query, options) {
227
- const limit = options?.limit ?? 5;
284
+ const limit = options?.limit ?? recallDefaults.searchLimit;
228
285
  const project = options?.project;
229
286
  const privacy = options?.privacy;
230
287
  const sourceAgent = options?.sourceAgent;
231
288
  const now = new Date();
232
289
  // 1. Embed
233
290
  const queryEmbedding = await embedFn(query);
234
- // 2. Dual retrieval — vector + BM25
235
- const fetchLimit = limit * 3;
291
+ // 2. Dual retrieval — vector + BM25.
292
+ // #192: sqlite-vec can't push filters into the KNN, so filtered queries must
293
+ // over-fetch — the old flat limit*3 intersected a global top-15 with (for the
294
+ // median project) ~1% of the corpus, starving every filtered query.
295
+ const filtered = Boolean(project || privacy || sourceAgent);
296
+ const fetchLimit = filtered ? Math.min(limit * 20, 200) : limit * 3;
236
297
  let vecCandidates = storage.vectorSearch(db, queryEmbedding, fetchLimit, []);
237
298
  let ftsCandidates = [];
238
299
  try {
239
- ftsCandidates = storage.searchFts(db, query, fetchLimit, privacy, sourceAgent);
300
+ // privacy/sourceAgent/project are all pushed into the FTS SQL.
301
+ ftsCandidates = storage.searchFts(db, query, fetchLimit, privacy, sourceAgent, project ?? undefined);
240
302
  }
241
303
  catch {
242
304
  // FTS5 search can fail on special characters; fall back to vector-only
@@ -247,7 +309,6 @@ async function retrieve(db, embedFn, query, options) {
247
309
  // Post-filter vector candidates (sqlite-vec can't filter)
248
310
  if (project) {
249
311
  vecCandidates = vecCandidates.filter((c) => c.project === project);
250
- ftsCandidates = ftsCandidates.filter((c) => c.project === project);
251
312
  }
252
313
  if (privacy) {
253
314
  vecCandidates = vecCandidates.filter((c) => privacy.includes(c.privacy));
@@ -259,14 +320,18 @@ async function retrieve(db, embedFn, query, options) {
259
320
  const vecRanked = vecCandidates.map((c) => c.id);
260
321
  const ftsRanked = ftsCandidates.map((c) => c.id);
261
322
  const rrfScores = reciprocalRankFusion([vecRanked, ftsRanked]);
262
- // Build unified candidate map
323
+ // Build unified candidate map (with retrieval-channel provenance, #192)
263
324
  const candidateMap = new Map();
264
325
  for (const c of vecCandidates) {
265
- candidateMap.set(c.id, { mem: c, distance: c.distance });
326
+ candidateMap.set(c.id, { mem: c, distance: c.distance, source: "vector" });
266
327
  }
267
328
  for (const c of ftsCandidates) {
268
- if (!candidateMap.has(c.id)) {
269
- candidateMap.set(c.id, { mem: c, distance: DEFAULT_GRAPH_DISTANCE });
329
+ const existing = candidateMap.get(c.id);
330
+ if (existing) {
331
+ existing.source = "both";
332
+ }
333
+ else {
334
+ candidateMap.set(c.id, { mem: c, distance: DEFAULT_GRAPH_DISTANCE, source: "fts" });
270
335
  }
271
336
  }
272
337
  // 4. Graph traversal
@@ -284,7 +349,7 @@ async function retrieve(db, embedFn, query, options) {
284
349
  continue;
285
350
  if (sourceAgent && mem.source_agent !== sourceAgent)
286
351
  continue;
287
- candidateMap.set(gid, { mem, distance: DEFAULT_GRAPH_DISTANCE });
352
+ candidateMap.set(gid, { mem, distance: DEFAULT_GRAPH_DISTANCE, source: "graph" });
288
353
  }
289
354
  // 5. Compute composite scores
290
355
  const maxConnections = Math.max(...([...connectionCounts.values()].length > 0
@@ -292,7 +357,7 @@ async function retrieve(db, embedFn, query, options) {
292
357
  : [0]));
293
358
  const scored = [];
294
359
  const maxRrf = Math.max(...([...rrfScores.values()].length > 0 ? [...rrfScores.values()] : [1]));
295
- for (const [mid, { mem, distance }] of candidateMap) {
360
+ for (const [mid, { mem, distance, source }] of candidateMap) {
296
361
  const connCount = connectionCounts.get(mid) ?? 0;
297
362
  const composite = computeScore(mem, distance, connCount, maxConnections, now);
298
363
  const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
@@ -302,25 +367,60 @@ async function retrieve(db, embedFn, query, options) {
302
367
  const rrf = rrfScores.get(mid) ?? 0;
303
368
  const normalizedRrf = maxRrf > 0 ? rrf / maxRrf : 0;
304
369
  const finalScore = composite * 0.8 + normalizedRrf * 0.2;
305
- scored.push({ mem, finalScore, effStr, connCount });
370
+ // Measured cosine only for vector-matched candidates; FTS/graph hits carry
371
+ // the neutral placeholder distance, which is not a real similarity.
372
+ const similarity = source === "vector" || source === "both"
373
+ ? Math.round(l2ToCosine(distance) * 1e6) / 1e6
374
+ : null;
375
+ scored.push({ mem, finalScore, effStr, connCount, similarity, source });
306
376
  }
307
- // 6. Sort and take top N
377
+ // 6. Sort and take top N — with cold-exposure slots (#192).
378
+ // Access hardening + effective strength make past winners self-reinforcing:
379
+ // 88% of the production corpus had never been returned by any query. Reserve
380
+ // up to 2 of k for the best-scoring never-accessed candidates so the long
381
+ // tail gets nonzero exposure whenever it is semantically in range. Slots are
382
+ // only "reserved" when cold candidates exist; otherwise the top-k is the
383
+ // plain score order.
308
384
  scored.sort((a, b) => b.finalScore - a.finalScore);
309
- const top = scored.slice(0, limit);
310
- const results = top.map((t) => formatResult(t.mem, t.finalScore, t.effStr, t.connCount));
311
- // 7. Strengthen
312
- strengthen(db, top.map((t) => t.mem), now);
385
+ const coldSlots = limit >= 4 ? recallDefaults.coldExposureSlots : 0;
386
+ let top = scored.slice(0, limit);
387
+ if (coldSlots > 0 && scored.length > limit) {
388
+ const coldInTop = top.filter((t) => (t.mem.access_count ?? 0) === 0).length;
389
+ const wanted = coldSlots - coldInTop;
390
+ if (wanted > 0) {
391
+ const coldExtras = scored
392
+ .slice(limit)
393
+ .filter((t) => (t.mem.access_count ?? 0) === 0)
394
+ .slice(0, wanted);
395
+ if (coldExtras.length > 0) {
396
+ top = [...top.slice(0, limit - coldExtras.length), ...coldExtras];
397
+ }
398
+ }
399
+ }
400
+ const results = top.map((t) => formatResult(t.mem, t.finalScore, t.effStr, t.connCount, {
401
+ similarity: t.similarity,
402
+ source: t.source,
403
+ }));
404
+ // 7. Strengthen — skipped for pushed recall (#192): appearing in a pushed
405
+ // index is exposure, not use; the /recall-index path records shown_count +
406
+ // last_accessed via storage.touchMemoriesShown instead.
407
+ if (!options?.noStrengthen) {
408
+ strengthen(db, top.map((t) => t.mem), now);
409
+ }
313
410
  return results;
314
411
  }
315
412
  /**
316
413
  * Get recent context, optionally filtered by project and privacy.
317
414
  */
318
415
  function searchRecent(db, options) {
319
- const limit = options?.limit ?? 10;
416
+ const limit = options?.limit ?? recallDefaults.recentLimit;
320
417
  const project = options?.project;
321
418
  const privacy = options?.privacy;
322
419
  const now = new Date();
323
- let candidates = storage.getRecentMemories(db, 30, limit * 3);
420
+ // #192 breadth: 30 180-day default window (config recentWindowDays).
421
+ // "Recent" for a long-lived corpus is a season, not a month; the narrow
422
+ // window kept queryless recall re-serving the same few weeks.
423
+ let candidates = storage.getRecentMemories(db, recallDefaults.recentWindowDays, limit * 3);
324
424
  if (project) {
325
425
  candidates = candidates.filter((c) => c.project === project);
326
426
  }
package/dist/storage.d.ts CHANGED
@@ -30,6 +30,13 @@ export declare function updateMemory(db: Database.Database, memoryId: string, fi
30
30
  * Atomically increment access_count and reset last_accessed.
31
31
  */
32
32
  export declare function strengthenMemory(db: Database.Database, memoryId: string, nowIsoStr: string): void;
33
+ /**
34
+ * Record that memories appeared in a pushed recall index (#192): bump
35
+ * shown_count and refresh last_accessed (a mild strengthen — the decay clock
36
+ * resets so topically-live memories stop sinking) WITHOUT touching
37
+ * access_count, which stays reserved for real use (hardening + prune shield).
38
+ */
39
+ export declare function touchMemoriesShown(db: Database.Database, memoryIds: string[], nowIsoStr: string): void;
33
40
  /**
34
41
  * Delete a memory, its vector, its tags, and all its links.
35
42
  */
@@ -87,7 +94,7 @@ export declare function vectorSearch(db: Database.Database, queryEmbedding: Floa
87
94
  * Full-text search using FTS5 BM25 ranking.
88
95
  * Returns memories with a rank field (lower is better).
89
96
  */
90
- export declare function searchFts(db: Database.Database, query: string, limit?: number, privacy?: string[], sourceAgent?: string): Array<Memory & {
97
+ export declare function searchFts(db: Database.Database, query: string, limit?: number, privacy?: string[], sourceAgent?: string, project?: string): Array<Memory & {
91
98
  rank: number;
92
99
  }>;
93
100
  /**
package/dist/storage.js CHANGED
@@ -9,6 +9,7 @@ exports.insertMemory = insertMemory;
9
9
  exports.getMemory = getMemory;
10
10
  exports.updateMemory = updateMemory;
11
11
  exports.strengthenMemory = strengthenMemory;
12
+ exports.touchMemoriesShown = touchMemoriesShown;
12
13
  exports.deleteMemory = deleteMemory;
13
14
  exports.setMemoryTags = setMemoryTags;
14
15
  exports.getMemoryTags = getMemoryTags;
@@ -130,6 +131,24 @@ function strengthenMemory(db, memoryId, nowIsoStr) {
130
131
  SET access_count = access_count + 1, last_accessed = ?
131
132
  WHERE id = ?`).run(nowIsoStr, memoryId);
132
133
  }
134
+ /**
135
+ * Record that memories appeared in a pushed recall index (#192): bump
136
+ * shown_count and refresh last_accessed (a mild strengthen — the decay clock
137
+ * resets so topically-live memories stop sinking) WITHOUT touching
138
+ * access_count, which stays reserved for real use (hardening + prune shield).
139
+ */
140
+ function touchMemoriesShown(db, memoryIds, nowIsoStr) {
141
+ if (memoryIds.length === 0)
142
+ return;
143
+ const stmt = db.prepare(`UPDATE memories
144
+ SET shown_count = COALESCE(shown_count, 0) + 1, last_accessed = ?
145
+ WHERE id = ?`);
146
+ const run = db.transaction((ids) => {
147
+ for (const id of ids)
148
+ stmt.run(nowIsoStr, id);
149
+ });
150
+ run(memoryIds);
151
+ }
133
152
  /**
134
153
  * Delete a memory, its vector, its tags, and all its links.
135
154
  */
@@ -234,7 +253,7 @@ function vectorSearch(db, queryEmbedding, limit = 10, excludeIds = []) {
234
253
  * Full-text search using FTS5 BM25 ranking.
235
254
  * Returns memories with a rank field (lower is better).
236
255
  */
237
- function searchFts(db, query, limit = 10, privacy, sourceAgent) {
256
+ function searchFts(db, query, limit = 10, privacy, sourceAgent, project) {
238
257
  const conditions = ["memories_fts MATCH ?"];
239
258
  const params = [query];
240
259
  if (privacy && privacy.length > 0) {
@@ -246,6 +265,13 @@ function searchFts(db, query, limit = 10, privacy, sourceAgent) {
246
265
  conditions.push("m.source_agent = ?");
247
266
  params.push(sourceAgent);
248
267
  }
268
+ // #192: project is pushed into SQL (like privacy/sourceAgent) instead of
269
+ // being post-filtered by the caller — post-filtering a global top-N against
270
+ // a small project starves the result set regardless of corpus content.
271
+ if (project) {
272
+ conditions.push("m.project = ?");
273
+ params.push(project);
274
+ }
249
275
  const where = conditions.join(" AND ");
250
276
  params.push(limit);
251
277
  const rows = db
@@ -15,7 +15,7 @@ export interface TranscriptBatch {
15
15
  date: string;
16
16
  entries: unknown[];
17
17
  /**
18
- * Optional source-agent label (e.g. "hermes/lenny"). When set, the nightly
18
+ * Optional source-agent label (e.g. "hermes/alice"). When set, the nightly
19
19
  * pipeline uses it verbatim for provenance instead of the default
20
20
  * `claude-code/<project>`. Lets per-harness readers stamp their own origin.
21
21
  */
@@ -159,12 +159,12 @@ function parseTranscriptFile(filePath, projectName, cursorKey, startCursor, gene
159
159
  }
160
160
  /**
161
161
  * Decode CC project directory name to a human-readable project name.
162
- * CC uses path-based hashing: "-Users-mattias-Development-Tools-hicortex"
162
+ * CC uses path-based hashing: "-Users-alice-Development-Tools-hicortex"
163
163
  * becomes "hicortex" (last path component).
164
164
  */
165
165
  function decodeProjectDirName(dirName) {
166
166
  // CC encodes paths by replacing / with -
167
- // e.g. "-Users-mattias-Development-Tools-hicortex"
167
+ // e.g. "-Users-alice-Development-Tools-hicortex"
168
168
  const parts = dirName.split("-").filter(Boolean);
169
169
  if (parts.length === 0)
170
170
  return dirName;
package/dist/types.d.ts CHANGED
@@ -42,6 +42,12 @@ export interface MemorySearchResult {
42
42
  project: string | null;
43
43
  created_at: string;
44
44
  connections: number;
45
+ /** True cosine similarity to the query for vector-matched candidates; null
46
+ * for FTS-only and graph-discovered hits (no measured distance). */
47
+ similarity?: number | null;
48
+ /** Which retrieval channel produced the candidate (vector KNN, BM25 FTS,
49
+ * both, or graph traversal). Used by the /recall-index relevance gate. */
50
+ source?: "vector" | "fts" | "both" | "graph";
45
51
  }
46
52
  /** Report returned by the consolidation pipeline. */
47
53
  export interface ConsolidationReport {
@@ -9,11 +9,26 @@
9
9
  "Backfill an existing corpus with: hicortex classify-domains"
10
10
  ],
11
11
  "domains": [
12
- { "name": "Work", "description": "Your job and professional life — employer, clients, workstreams" },
13
- { "name": "Personal", "description": "Private life — home, hobbies, everyday matters" },
14
- { "name": "People", "description": "Relationshipsfamily, friends, social life, network" },
15
- { "name": "Health", "description": "Fitness, wellbeing, medical" },
16
- { "name": "Finance", "description": "Money — budgeting, spending, investing" }
12
+ {
13
+ "name": "Work",
14
+ "description": "Your job and professional life employer, clients, workstreams"
15
+ },
16
+ {
17
+ "name": "Personal",
18
+ "description": "Private life — home, hobbies, everyday matters"
19
+ },
20
+ {
21
+ "name": "People",
22
+ "description": "Relationships — family, friends, social life, network"
23
+ },
24
+ {
25
+ "name": "Health",
26
+ "description": "Fitness, wellbeing, medical"
27
+ },
28
+ {
29
+ "name": "Finance",
30
+ "description": "Money — budgeting, spending, investing"
31
+ }
17
32
  ],
18
33
  "_powerUserExample": {
19
34
  "_readme": [
@@ -22,14 +37,39 @@
22
37
  "`weakPrimaryFloor` (default 0.45) is the minimum embedding similarity for a no-fit memory to earn a weak primary; tune it from your corpus."
23
38
  ],
24
39
  "domains": [
25
- { "name": "Work", "description": "Employer, day job, client projects, workstreams", "compartment": true },
26
- { "name": "Personal", "description": "Private life — home, hobbies, everyday matters" },
27
- { "name": "People", "description": "Relationships — family, friends, social life, network" },
28
- { "name": "Health", "description": "Fitness, wellbeing, medical" },
29
- { "name": "Finance", "description": "Money — budgeting, spending, investing" },
30
- { "name": "Boating", "description": "Boats — maintenance, gear, trips, harbour life" },
31
- { "name": "Property", "description": "House and land — renovation, upkeep, garden" },
32
- { "name": "Vehicles", "description": "Cars and other vehicles service, repairs, purchases" }
40
+ {
41
+ "name": "Work",
42
+ "description": "Employer, day job, client projects, workstreams",
43
+ "compartment": true
44
+ },
45
+ {
46
+ "name": "Personal",
47
+ "description": "Private lifehome, hobbies, everyday matters"
48
+ },
49
+ {
50
+ "name": "People",
51
+ "description": "Relationships — family, friends, social life, network"
52
+ },
53
+ {
54
+ "name": "Health",
55
+ "description": "Fitness, wellbeing, medical"
56
+ },
57
+ {
58
+ "name": "Finance",
59
+ "description": "Money — budgeting, spending, investing"
60
+ },
61
+ {
62
+ "name": "Photography",
63
+ "description": "Camera gear, shoots, editing workflow, photo projects"
64
+ },
65
+ {
66
+ "name": "Home",
67
+ "description": "House, renovation projects, maintenance, garden"
68
+ },
69
+ {
70
+ "name": "Travel",
71
+ "description": "Trips, destinations, bookings, travel plans"
72
+ }
33
73
  ],
34
74
  "weakPrimaryFloor": 0.5
35
75
  }
@@ -33,8 +33,8 @@ _INJECT_CONTENT_CAP = 500
33
33
 
34
34
  # Agent ids are joined into a filesystem path server-side, so they share the
35
35
  # section-name allowlist. \Z (NOT $) anchors the END OF STRING: Python's $ also
36
- # matches just before a trailing "\n", so "nano\n" would pass and go out as
37
- # agent=nano%0A → a 400 the fail-soft path silently swallows.
36
+ # matches just before a trailing "\n", so "alice\n" would pass and go out as
37
+ # agent=alice%0A → a 400 the fail-soft path silently swallows.
38
38
  _AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*\Z")
39
39
 
40
40
 
@@ -48,7 +48,7 @@ def _sanitize_agent_id(raw: Optional[str]) -> Optional[str]:
48
48
  EXACTLY so a profile resolves to the SAME id on both harnesses (a mismatch
49
49
  would make one honor the persona firewall and the other leak global context
50
50
  into an ``off``/``override`` persona): lowercase → collapse invalid runs to
51
- "-" → strip leading -/_ → truncate 64 → validate. "Lenny" → "lenny";
51
+ "-" → strip leading -/_ → truncate 64 → validate. "Alice" → "alice";
52
52
  "MacBook-Pro.local" → "macbook-pro-local"; all-symbols → None."""
53
53
  if not isinstance(raw, str):
54
54
  return None
@@ -73,7 +73,7 @@ def _resolve_agent_name(cfg: Dict[str, Any]) -> Optional[str]:
73
73
  2. ``HERMES_PROFILE`` env;
74
74
  3. parse ``HERMES_HOME`` when it ends ``profiles/<name>``;
75
75
  4. None → bare fetch → the global set.
76
- Each source is stripped then SANITIZED (not rejected) so "Lenny" → "lenny"
76
+ Each source is stripped then SANITIZED (not rejected) so "Alice" → "alice"
77
77
  matches the TS contract; a source that sanitizes to None yields None (bare
78
78
  fetch), never a fall-through to another identity."""
79
79
  configured = (cfg.get("agent_name") or "").strip()
@@ -11,7 +11,7 @@
11
11
  "serverUrl": {
12
12
  "type": "string",
13
13
  "default": "http://127.0.0.1:8787",
14
- "description": "Hicortex server URL. Defaults to localhost (co-located server). For multi-machine setups, point this at the remote server (e.g. http://bedrock:8787 or a Tailscale HTTPS URL)."
14
+ "description": "Hicortex server URL. Defaults to localhost (co-located server). For multi-machine setups, point this at the remote server (e.g. http://your-server:8787 or an HTTPS URL)."
15
15
  },
16
16
  "authToken": {
17
17
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Self-learning memory for AI agents \u2014 experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {