nexo-brain 2.6.15 → 2.6.16

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.15",
3
+ "version": "2.6.16",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -38,7 +38,24 @@ That means NEXO now manages not only the shared runtime and MCP wiring, but also
38
38
  - For Codex specifically, `nexo chat` and Codex headless automation inject the current bootstrap explicitly, so Codex starts as NEXO even when plain global Codex startup is inconsistent about global instructions.
39
39
  - Deep Sleep now reads both Claude Code and Codex transcript stores, so overnight analysis still works even when the user spends the day in Codex.
40
40
 
41
- Version `2.6.14` closes those parity gaps in practice, and `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly.
41
+ Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, and `2.6.16` pushes the system further in three directions:
42
+
43
+ - Codex now gets managed global bootstrap/model sync in `~/.codex/config.toml`, so sessions opened outside `nexo chat` are much less likely to start as plain Codex.
44
+ - Retrieval is smarter by default: HyDE and spreading activation now auto-enable when the query shape benefits, while exact lookups remain conservative.
45
+ - Deep Sleep now blends recent context with older context over a 60-day horizon, and memory decay now tracks per-memory `stability` and `difficulty` instead of relying only on global decay constants.
46
+
47
+ ### Client Capability Matrix
48
+
49
+ | Capability | Claude Code | Codex | Claude Desktop |
50
+ |------------|-------------|-------|----------------|
51
+ | Shared brain / MCP runtime | Yes | Yes | Yes |
52
+ | Managed bootstrap document | `~/.claude/CLAUDE.md` | `~/.codex/AGENTS.md` | Not applicable |
53
+ | Global startup bootstrap sync | Native via hooks + bootstrap | Managed via bootstrap + Codex config `initial_messages` | MCP only |
54
+ | `nexo chat` terminal client | Yes | Yes | No |
55
+ | Background automation backend | Recommended | Supported | No |
56
+ | Raw transcript source for Deep Sleep | Yes | Yes | No |
57
+ | Native hook depth | Deepest | Partial, compensated | None |
58
+ | Recommended today | Yes | Supported | Shared-brain companion |
42
59
 
43
60
  ## The Problem
44
61
 
@@ -100,12 +117,25 @@ NEXO Brain uses **Ebbinghaus forgetting curves** — memories naturally fade ove
100
117
  - A lesson accessed 5 times in 2 weeks gets promoted to long-term memory — because repeated use proves it matters.
101
118
  - A dormant memory can be reactivated if something similar comes up — the "oh wait, I remember this" moment.
102
119
 
120
+ On top of that baseline, NEXO now keeps a lightweight **per-memory profile**:
121
+
122
+ - **stability** slows decay for memories that keep surviving retrieval and reinforcement
123
+ - **difficulty** speeds decay slightly for memories that tend to be weak, noisy, or harder to reuse correctly
124
+
125
+ That keeps the core Ebbinghaus model, but makes decay more individual and less purely global.
126
+
103
127
  ### Semantic Search (Finding by Meaning)
104
128
 
105
129
  NEXO Brain doesn't search by keywords. It searches by **meaning** using vector embeddings (fastembed, 768 dimensions).
106
130
 
107
131
  Example: If you search for "deploy problems", NEXO Brain will find a memory about "SSH connection timeout on production server" — even though they share zero words. This is how human associative memory works.
108
132
 
133
+ Retrieval is now also smarter by default:
134
+
135
+ - **HyDE auto mode** expands conceptual or ambiguous queries when that improves recall
136
+ - **Spreading activation auto mode** adds a shallow associative boost for concept-heavy searches
137
+ - **Exact lookup heuristics** keep both off for literal file paths, IDs, stack traces, and other precision-sensitive queries
138
+
109
139
  ### Metacognition (Thinking About Thinking)
110
140
 
111
141
  Before every code change, NEXO Brain asks itself: **"Have I made a mistake like this before?"**
@@ -156,6 +186,12 @@ Like a human brain, NEXO Brain has automated processes that run while you're not
156
186
 
157
187
  If your Mac was asleep during any scheduled process, NEXO Brain catches up in order when it wakes.
158
188
 
189
+ Deep Sleep now also mixes **recent context with older context across a 60-day horizon**. Instead of only looking at the immediate past, it can surface:
190
+
191
+ - recurring multi-week themes
192
+ - cross-domain links between older learnings and current failures
193
+ - stale followups and topics that keep being mentioned but never formalized
194
+
159
195
  ## Cognitive Cortex
160
196
 
161
197
  The Cortex is a middleware cognitive layer that makes the agent **think before acting**. It implements architectural inhibitory control — the agent cannot bypass reasoning.
@@ -235,21 +271,21 @@ NEXO Brain provides **150+ MCP tools** across 23 categories. These features impl
235
271
  |---------|-------------|
236
272
  | **Pin / Snooze / Archive** | Granular lifecycle states for memories. Pin = never decays (critical knowledge). Snooze = temporarily hidden (revisit later). Archive = cold storage (searchable but inactive). |
237
273
  | **Intelligent Chunking** | Adaptive chunking that respects sentence and paragraph boundaries. Produces semantically coherent chunks instead of arbitrary token splits, reducing retrieval noise. |
238
- | **Adaptive Decay** | Decay rate adapts per memory based on access patterns: frequently-accessed memories decay slower, rarely-accessed ones fade faster. Prevents permanent clutter while keeping active knowledge sharp. |
274
+ | **Adaptive Decay** | Decay rate still follows Ebbinghaus as the base model, but now also adapts per memory using `stability` and `difficulty` profiles. Frequently reinforced memories become stickier; fragile memories fade sooner. |
239
275
  | **Auto-Migration** | Formal schema migration system (schema_migrations table) tracks all database changes. Safe, reversible schema evolution for production systems — upgrades never lose data. |
240
276
  | **Auto-Merge Duplicates** | Batch cosine deduplication during the 03:00 sleep cycle. Respects sibling discrimination — similar memories about different contexts are kept separate. |
241
- | **Memory Dreaming** | Discovers hidden connections between recent memories during the 03:00 sleep cycle. Surfaces non-obvious patterns like "these three bugs all relate to the same root cause." |
277
+ | **Memory Dreaming** | Discovers hidden connections between recent memories during the 03:00 sleep cycle and now feeds a 60-day long-horizon Deep Sleep blend, so older patterns can reappear when they become relevant again. |
242
278
 
243
279
  ### Retrieval
244
280
 
245
281
  | Feature | What It Does |
246
282
  |---------|-------------|
247
- | **HyDE Query Expansion** | Generates hypothetical answer embeddings for richer semantic search. Instead of searching for "deploy error", it imagines what a helpful memory about deploy errors would look like, then searches for that. |
283
+ | **HyDE Query Expansion** | Generates hypothetical answer embeddings for richer semantic search. NEXO now auto-enables HyDE for conceptual or ambiguous queries while keeping literal lookups conservative. |
248
284
  | **Hybrid Search (FTS5+BM25+RRF)** | Combines dense vector search with BM25 keyword search via Reciprocal Rank Fusion. Outperforms pure semantic search on precise terminology and code identifiers. |
249
285
  | **Cross-Encoder Reranking** | After initial vector retrieval, a cross-encoder model rescores candidates for precision. The top-k results are reordered by true semantic relevance before being returned to the agent. |
250
286
  | **Multi-Query Decomposition** | Complex questions are automatically split into sub-queries. Each component is retrieved independently, then fused for a higher-quality answer — improves recall on multi-faceted prompts. |
251
287
  | **Temporal Indexing** | Memories are indexed by time in addition to semantics. Time-sensitive queries ("what did we decide last Tuesday?") use temporal proximity scoring alongside semantic similarity. |
252
- | **Spreading Activation** | Graph-based co-activation network. Memories retrieved together reinforce each other's connections, building an associative web that improves over time. |
288
+ | **Spreading Activation** | Graph-based co-activation network. NEXO now auto-enables a shallow spreading pass for concept-heavy queries, improving contextual recall without turning every exact lookup into a fuzzy search. |
253
289
  | **Recall Explanations** | Transparent score breakdown for every retrieval result. Shows exactly why a memory was returned: semantic similarity, recency, access frequency, and co-activation bonuses. |
254
290
 
255
291
  ### Proactive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.15",
3
+ "version": "2.6.16",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -4,9 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import shlex
7
8
  import shutil
8
9
  import subprocess
9
10
  import tempfile
11
+ import tomllib
10
12
  from pathlib import Path
11
13
 
12
14
  from client_preferences import (
@@ -66,6 +68,10 @@ def _resolve_codex_cli() -> str:
66
68
  return shutil.which("codex") or ""
67
69
 
68
70
 
71
+ def _codex_config_path() -> Path:
72
+ return Path.home() / ".codex" / "config.toml"
73
+
74
+
69
75
  def _headless_env(env: dict | None = None) -> dict:
70
76
  merged = os.environ.copy()
71
77
  if env:
@@ -84,6 +90,21 @@ def _load_client_bootstrap_prompt(client: str) -> str:
84
90
  return load_bootstrap_prompt(client, nexo_home=NEXO_HOME, user_home=Path.home())
85
91
 
86
92
 
93
+ def _codex_managed_initial_messages_enabled() -> bool:
94
+ config_path = _codex_config_path()
95
+ if not config_path.is_file():
96
+ return False
97
+ try:
98
+ payload = tomllib.loads(config_path.read_text())
99
+ except Exception:
100
+ return False
101
+ return bool(
102
+ payload.get("nexo", {})
103
+ .get("codex", {})
104
+ .get("bootstrap_managed")
105
+ )
106
+
107
+
87
108
  def _codex_initial_messages_config(prompt_text: str) -> str:
88
109
  return f'initial_messages=[{{role="system",content={json.dumps(prompt_text, ensure_ascii=False)}}}]'
89
110
 
@@ -121,7 +142,7 @@ def build_interactive_client_command(
121
142
  )
122
143
  cmd = [codex_bin]
123
144
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
124
- if bootstrap_prompt:
145
+ if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
125
146
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
126
147
  if profile["model"]:
127
148
  cmd.extend(["-m", profile["model"]])
@@ -147,6 +168,53 @@ def launch_interactive_client(
147
168
  return subprocess.run(cmd, env=launch_env)
148
169
 
149
170
 
171
+ def build_followup_terminal_shell_command(
172
+ followup_reference: str,
173
+ *,
174
+ client: str | None = None,
175
+ preferences: dict | None = None,
176
+ cwd: str | os.PathLike[str] | None = None,
177
+ ) -> tuple[str, str]:
178
+ prefs = preferences or load_client_preferences()
179
+ selected = resolve_terminal_client(client, preferences=prefs)
180
+ profile = resolve_client_runtime_profile(selected, preferences=prefs)
181
+ prompt = f"NEXO: execute followup from file $(cat {followup_reference})"
182
+
183
+ if selected == CLIENT_CLAUDE_CODE:
184
+ claude_bin = _resolve_claude_cli()
185
+ if not claude_bin:
186
+ raise TerminalClientUnavailableError(
187
+ "Claude Code launcher not found in PATH. Install `claude` first."
188
+ )
189
+ cmd = [claude_bin]
190
+ if profile["model"]:
191
+ cmd.extend(["--model", profile["model"]])
192
+ if profile["reasoning_effort"]:
193
+ cmd.extend(["--effort", profile["reasoning_effort"]])
194
+ cmd.extend(["--dangerously-skip-permissions", prompt])
195
+ return selected, shlex.join(cmd)
196
+
197
+ if selected == CLIENT_CODEX:
198
+ codex_bin = _resolve_codex_cli()
199
+ if not codex_bin:
200
+ raise TerminalClientUnavailableError(
201
+ "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
202
+ )
203
+ target_cwd = str(Path(cwd).expanduser()) if cwd else str(Path.home())
204
+ cmd = [codex_bin]
205
+ bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
206
+ if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
207
+ cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
208
+ if profile["model"]:
209
+ cmd.extend(["-m", profile["model"]])
210
+ if profile["reasoning_effort"]:
211
+ cmd.extend(["-c", f'model_reasoning_effort="{profile["reasoning_effort"]}"'])
212
+ cmd.extend(["-C", target_cwd, prompt])
213
+ return selected, shlex.join(cmd)
214
+
215
+ raise TerminalClientUnavailableError(f"Unsupported terminal client: {selected}")
216
+
217
+
150
218
  def _resolve_runtime_model_and_effort(
151
219
  client: str,
152
220
  *,
@@ -270,7 +338,7 @@ def run_automation_prompt(
270
338
  str(output_path),
271
339
  ]
272
340
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
273
- if bootstrap_prompt:
341
+ if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
274
342
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
275
343
  if resolved_model:
276
344
  cmd.extend(["-m", resolved_model])
@@ -236,6 +236,7 @@ def sync_client_bootstrap(
236
236
  "action": "created",
237
237
  "path": str(target_path),
238
238
  "version": template_version,
239
+ "content": rendered,
239
240
  }
240
241
 
241
242
  existing = target_path.read_text()
@@ -275,6 +276,7 @@ def sync_client_bootstrap(
275
276
  "action": action,
276
277
  "path": str(target_path),
277
278
  "version": template_version,
279
+ "content": updated,
278
280
  }
279
281
 
280
282
 
@@ -8,6 +8,7 @@ import os
8
8
  import shutil
9
9
  import subprocess
10
10
  import sys
11
+ import tomllib
11
12
  from pathlib import Path
12
13
 
13
14
  from bootstrap_docs import sync_client_bootstrap
@@ -19,6 +20,7 @@ try:
19
20
  normalize_backend_key,
20
21
  normalize_client_key,
21
22
  normalize_client_preferences,
23
+ resolve_client_runtime_profile,
22
24
  )
23
25
  except Exception:
24
26
  BACKEND_NONE = "none"
@@ -51,6 +53,13 @@ except Exception:
51
53
  "automation_backend": "claude_code",
52
54
  }
53
55
 
56
+ def resolve_client_runtime_profile(client: str, preferences: dict | None = None) -> dict:
57
+ defaults = {
58
+ "claude_code": {"model": "opus", "reasoning_effort": ""},
59
+ "codex": {"model": "gpt-5.4", "reasoning_effort": "xhigh"},
60
+ }
61
+ return dict(defaults.get(client, {}))
62
+
54
63
 
55
64
 
56
65
  def _user_home() -> Path:
@@ -156,6 +165,118 @@ def _codex_config_path(home: Path | None = None) -> Path:
156
165
  return base / ".codex" / "config.toml"
157
166
 
158
167
 
168
+ def _toml_key(key: str) -> str:
169
+ if key.replace("_", "").replace("-", "").isalnum():
170
+ return key
171
+ escaped = key.replace("\\", "\\\\").replace('"', '\\"')
172
+ return f'"{escaped}"'
173
+
174
+
175
+ def _toml_scalar(value) -> str:
176
+ if isinstance(value, bool):
177
+ return "true" if value else "false"
178
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
179
+ return json.dumps(value)
180
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
181
+ return f'"{escaped}"'
182
+
183
+
184
+ def _toml_inline_table(payload: dict) -> str:
185
+ parts = [f"{_toml_key(str(key))} = {_toml_value(value)}" for key, value in payload.items()]
186
+ return "{ " + ", ".join(parts) + " }"
187
+
188
+
189
+ def _toml_value(value) -> str:
190
+ if isinstance(value, dict):
191
+ return _toml_inline_table(value)
192
+ if isinstance(value, list):
193
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
194
+ return _toml_scalar(value)
195
+
196
+
197
+ def _emit_toml_table(table: dict, prefix: tuple[str, ...] = ()) -> list[str]:
198
+ scalar_lines: list[str] = []
199
+ child_tables: list[tuple[str, dict]] = []
200
+ for key, value in table.items():
201
+ if isinstance(value, dict):
202
+ child_tables.append((str(key), value))
203
+ else:
204
+ scalar_lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
205
+
206
+ lines: list[str] = []
207
+ emit_header = bool(prefix and (scalar_lines or not child_tables))
208
+ if emit_header:
209
+ lines.append("[" + ".".join(_toml_key(part) for part in prefix) + "]")
210
+ lines.extend(scalar_lines)
211
+
212
+ for child_key, child_value in child_tables:
213
+ child_lines = _emit_toml_table(child_value, prefix + (child_key,))
214
+ if child_lines:
215
+ if lines:
216
+ lines.append("")
217
+ lines.extend(child_lines)
218
+ return lines
219
+
220
+
221
+ def _load_toml_object(path: Path) -> dict:
222
+ if not path.is_file():
223
+ return {}
224
+ try:
225
+ data = tomllib.loads(path.read_text())
226
+ except Exception as exc:
227
+ raise ValueError(f"Invalid TOML in {path}: {exc}") from exc
228
+ if not isinstance(data, dict):
229
+ raise ValueError(f"Expected TOML table in {path}")
230
+ return data
231
+
232
+
233
+ def _write_toml_object(path: Path, payload: dict) -> None:
234
+ path.parent.mkdir(parents=True, exist_ok=True)
235
+ lines = _emit_toml_table(payload)
236
+ path.write_text("\n".join(lines).rstrip() + "\n")
237
+
238
+
239
+ def _sync_codex_managed_config(
240
+ path: Path,
241
+ *,
242
+ bootstrap_prompt: str,
243
+ runtime_profile: dict | None,
244
+ ) -> dict:
245
+ payload = _load_toml_object(path)
246
+ action = "updated" if payload else "created"
247
+ runtime_profile = dict(runtime_profile or {})
248
+
249
+ if runtime_profile.get("model"):
250
+ payload["model"] = runtime_profile["model"]
251
+ if "reasoning_effort" in runtime_profile:
252
+ payload["model_reasoning_effort"] = runtime_profile.get("reasoning_effort") or ""
253
+
254
+ payload["initial_messages"] = [
255
+ {
256
+ "role": "system",
257
+ "content": bootstrap_prompt,
258
+ }
259
+ ] if bootstrap_prompt else []
260
+
261
+ nexo_table = payload.setdefault("nexo", {})
262
+ codex_table = nexo_table.setdefault("codex", {})
263
+ codex_table["bootstrap_managed"] = True
264
+ codex_table["bootstrap_bytes"] = len(bootstrap_prompt.encode("utf-8")) if bootstrap_prompt else 0
265
+ if runtime_profile.get("model"):
266
+ codex_table["managed_model"] = runtime_profile["model"]
267
+ codex_table["managed_reasoning_effort"] = runtime_profile.get("reasoning_effort", "") or ""
268
+
269
+ _write_toml_object(path, payload)
270
+ return {
271
+ "ok": True,
272
+ "action": action,
273
+ "path": str(path),
274
+ "bootstrap_managed": True,
275
+ "model": runtime_profile.get("model", ""),
276
+ "reasoning_effort": runtime_profile.get("reasoning_effort", "") or "",
277
+ }
278
+
279
+
159
280
  def _load_json_object(path: Path) -> dict:
160
281
  if not path.is_file():
161
282
  return {}
@@ -197,6 +318,7 @@ def sync_claude_code(
197
318
  python_path: str = "",
198
319
  operator_name: str = "",
199
320
  user_home: str | os.PathLike[str] | None = None,
321
+ preferences: dict | None = None,
200
322
  ) -> dict:
201
323
  server_config = build_server_config(
202
324
  nexo_home=nexo_home,
@@ -229,6 +351,7 @@ def sync_claude_desktop(
229
351
  python_path: str = "",
230
352
  operator_name: str = "",
231
353
  user_home: str | os.PathLike[str] | None = None,
354
+ preferences: dict | None = None,
232
355
  ) -> dict:
233
356
  server_config = build_server_config(
234
357
  nexo_home=nexo_home,
@@ -250,9 +373,12 @@ def sync_codex(
250
373
  python_path: str = "",
251
374
  operator_name: str = "",
252
375
  user_home: str | os.PathLike[str] | None = None,
376
+ preferences: dict | None = None,
253
377
  ) -> dict:
254
378
  nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
255
379
  home_path = Path(user_home).expanduser() if user_home else _user_home()
380
+ active_preferences = normalize_client_preferences(preferences)
381
+ runtime_profile = resolve_client_runtime_profile("codex", preferences=active_preferences)
256
382
  server_config = build_server_config(
257
383
  nexo_home=nexo_home_path,
258
384
  runtime_root=runtime_root,
@@ -276,6 +402,13 @@ def sync_codex(
276
402
  user_home=user_home,
277
403
  )
278
404
  result["bootstrap"] = bootstrap_result
405
+ if bootstrap_result.get("ok"):
406
+ prompt_text = bootstrap_result.get("content") or ""
407
+ result["config"] = _sync_codex_managed_config(
408
+ config_path,
409
+ bootstrap_prompt=prompt_text,
410
+ runtime_profile=runtime_profile,
411
+ )
279
412
  return result
280
413
 
281
414
  cmd = [codex_bin, "mcp", "add", "nexo"]
@@ -324,6 +457,12 @@ def sync_codex(
324
457
  if not bootstrap_result.get("ok"):
325
458
  sync_result["ok"] = False
326
459
  sync_result["error"] = bootstrap_result.get("error", "Codex bootstrap sync failed")
460
+ return sync_result
461
+ sync_result["config"] = _sync_codex_managed_config(
462
+ config_path,
463
+ bootstrap_prompt=bootstrap_result.get("content") or "",
464
+ runtime_profile=runtime_profile,
465
+ )
327
466
  return sync_result
328
467
 
329
468
 
@@ -372,6 +511,7 @@ def sync_all_clients(
372
511
  python_path=python_path,
373
512
  operator_name=operator_name,
374
513
  user_home=user_home,
514
+ preferences=preferences,
375
515
  )
376
516
  except Exception as exc:
377
517
  return {"ok": False, "client": label, "error": str(exc)}
@@ -10,14 +10,18 @@ constants are re-exported here for full backwards compatibility:
10
10
  # Core: DB, embedding, cosine, constants, tables, redaction
11
11
  from cognitive._core import (
12
12
  COGNITIVE_DB, EMBEDDING_DIM, LAMBDA_STM, LAMBDA_LTM,
13
+ DEFAULT_MEMORY_STABILITY, DEFAULT_MEMORY_DIFFICULTY,
13
14
  PE_GATE_REJECT, PE_GATE_REFINE, _gate_stats,
14
15
  DISCRIMINATING_ENTITIES,
15
16
  POSITIVE_SIGNALS, NEGATIVE_SIGNALS, URGENCY_SIGNALS,
16
17
  _get_db, _init_tables, _migrate_lifecycle, _migrate_co_activation,
18
+ _migrate_memory_personalization,
17
19
  _auto_migrate_embeddings,
18
20
  _get_model, _get_reranker, rerank_results,
19
21
  embed, cosine_similarity, _array_to_blob, _blob_to_array,
20
22
  extract_temporal_date, redact_secrets,
23
+ clamp_memory_stability, clamp_memory_difficulty,
24
+ initial_memory_profile, personalize_decay_rate, rehearsal_profile_update,
21
25
  )
22
26
 
23
27
  # Search
@@ -19,6 +19,8 @@ COGNITIVE_DB = os.path.join(_data_dir, "cognitive.db")
19
19
  EMBEDDING_DIM = 768
20
20
  LAMBDA_STM = 0.004126 # half-life = ln(2) / (7 * 24) ≈ 7 days
21
21
  LAMBDA_LTM = 0.000481 # half-life = ln(2) / (60 * 24) ≈ 60 days
22
+ DEFAULT_MEMORY_STABILITY = 1.0
23
+ DEFAULT_MEMORY_DIFFICULTY = 0.5
22
24
 
23
25
  # Prediction Error Gate thresholds
24
26
  PE_GATE_REJECT = 0.85 # similarity > this → reject (not novel enough)
@@ -145,6 +147,7 @@ def _get_db() -> sqlite3.Connection:
145
147
  _init_tables(_conn)
146
148
  _migrate_lifecycle(_conn)
147
149
  _migrate_co_activation(_conn)
150
+ _migrate_memory_personalization(_conn)
148
151
  _auto_migrate_embeddings(_conn)
149
152
  return _conn
150
153
 
@@ -192,6 +195,79 @@ def _migrate_co_activation(conn: sqlite3.Connection):
192
195
  conn.commit()
193
196
 
194
197
 
198
+ def clamp_memory_stability(value: float | int | str | None) -> float:
199
+ try:
200
+ numeric = float(value)
201
+ except (TypeError, ValueError):
202
+ numeric = DEFAULT_MEMORY_STABILITY
203
+ return max(0.6, min(3.0, numeric))
204
+
205
+
206
+ def clamp_memory_difficulty(value: float | int | str | None) -> float:
207
+ try:
208
+ numeric = float(value)
209
+ except (TypeError, ValueError):
210
+ numeric = DEFAULT_MEMORY_DIFFICULTY
211
+ return max(0.2, min(1.2, numeric))
212
+
213
+
214
+ def initial_memory_profile(source_type: str, *, store: str = "stm") -> tuple[float, float]:
215
+ source = str(source_type or "").strip().lower()
216
+ if source in {"learning", "decision", "feedback"}:
217
+ return 1.2 if store == "stm" else 1.4, 0.4
218
+ if source in {"dream_insight", "session_summary"}:
219
+ return 1.1 if store == "stm" else 1.25, 0.55
220
+ if source in {"sensory", "dialog"}:
221
+ return 0.9, 0.6
222
+ return DEFAULT_MEMORY_STABILITY, DEFAULT_MEMORY_DIFFICULTY
223
+
224
+
225
+ def personalize_decay_rate(base_lambda: float, *, stability: float, difficulty: float) -> float:
226
+ stability_factor = clamp_memory_stability(stability)
227
+ difficulty_factor = 0.75 + (clamp_memory_difficulty(difficulty) * 0.5)
228
+ return base_lambda * difficulty_factor / stability_factor
229
+
230
+
231
+ def rehearsal_profile_update(
232
+ stability: float,
233
+ difficulty: float,
234
+ score: float,
235
+ *,
236
+ refinement: bool = False,
237
+ ) -> tuple[float, float]:
238
+ stable = clamp_memory_stability(stability)
239
+ hard = clamp_memory_difficulty(difficulty)
240
+ score = max(0.0, min(1.0, float(score or 0.0)))
241
+
242
+ stability_gain = 0.03 + max(0.0, score - 0.45) * 0.12
243
+ if refinement:
244
+ stability_gain += 0.03
245
+ new_stability = clamp_memory_stability(stable + stability_gain)
246
+
247
+ target_difficulty = clamp_memory_difficulty(1.0 - (score * 0.8))
248
+ if refinement:
249
+ target_difficulty = clamp_memory_difficulty(target_difficulty + 0.05)
250
+ new_difficulty = clamp_memory_difficulty((hard * 0.82) + (target_difficulty * 0.18))
251
+ return new_stability, new_difficulty
252
+
253
+
254
+ def _migrate_memory_personalization(conn: sqlite3.Connection):
255
+ """Add per-memory stability and difficulty columns if they don't exist."""
256
+ for table in ("stm_memories", "ltm_memories"):
257
+ for col, col_type in [
258
+ ("stability", f"REAL DEFAULT {DEFAULT_MEMORY_STABILITY}"),
259
+ ("difficulty", f"REAL DEFAULT {DEFAULT_MEMORY_DIFFICULTY}"),
260
+ ]:
261
+ try:
262
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type}")
263
+ conn.commit()
264
+ except sqlite3.OperationalError as e:
265
+ if "duplicate column" in str(e).lower():
266
+ pass
267
+ else:
268
+ raise
269
+
270
+
195
271
  def _auto_migrate_embeddings(conn: sqlite3.Connection):
196
272
  """Auto-detect old 384-dim embeddings and re-embed to 768-dim. Transparent to user."""
197
273
  try:
@@ -242,6 +318,8 @@ def _init_tables(conn: sqlite3.Connection):
242
318
  last_accessed TEXT DEFAULT (datetime('now')),
243
319
  access_count INTEGER DEFAULT 0,
244
320
  strength REAL DEFAULT 1.0,
321
+ stability REAL DEFAULT 1.0,
322
+ difficulty REAL DEFAULT 0.5,
245
323
  promoted_to_ltm INTEGER DEFAULT 0
246
324
  );
247
325
 
@@ -257,6 +335,8 @@ def _init_tables(conn: sqlite3.Connection):
257
335
  last_accessed TEXT DEFAULT (datetime('now')),
258
336
  access_count INTEGER DEFAULT 0,
259
337
  strength REAL DEFAULT 1.0,
338
+ stability REAL DEFAULT 1.0,
339
+ difficulty REAL DEFAULT 0.5,
260
340
  is_dormant INTEGER DEFAULT 0,
261
341
  original_stm_id INTEGER,
262
342
  tags TEXT DEFAULT ''
@@ -2,7 +2,11 @@
2
2
  import math
3
3
  import numpy as np
4
4
  from datetime import datetime, timedelta
5
- from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob, LAMBDA_STM, LAMBDA_LTM, EMBEDDING_DIM
5
+ from cognitive._core import (
6
+ _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
7
+ LAMBDA_STM, LAMBDA_LTM, EMBEDDING_DIM,
8
+ initial_memory_profile, personalize_decay_rate,
9
+ )
6
10
 
7
11
 
8
12
  def _hnsw_invalidate():
@@ -48,20 +52,32 @@ def apply_decay(adaptive: bool = True):
48
52
  _protected_ltm.add(row["id"])
49
53
 
50
54
  # STM decay (skip pinned)
51
- rows = db.execute("SELECT id, last_accessed, strength FROM stm_memories WHERE promoted_to_ltm = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
55
+ rows = db.execute("SELECT id, last_accessed, strength, stability, difficulty FROM stm_memories WHERE promoted_to_ltm = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
52
56
  for row in rows:
53
57
  last = datetime.fromisoformat(row["last_accessed"])
54
58
  hours = (now - last).total_seconds() / 3600.0
55
- decay_rate = LAMBDA_STM * 0.25 if (adaptive and row["id"] in _protected_stm) else LAMBDA_STM
59
+ decay_rate = personalize_decay_rate(
60
+ LAMBDA_STM,
61
+ stability=row["stability"],
62
+ difficulty=row["difficulty"],
63
+ )
64
+ if adaptive and row["id"] in _protected_stm:
65
+ decay_rate *= 0.25
56
66
  new_strength = row["strength"] * math.exp(-decay_rate * hours)
57
67
  db.execute("UPDATE stm_memories SET strength = ? WHERE id = ?", (new_strength, row["id"]))
58
68
 
59
69
  # LTM decay (skip pinned)
60
- rows = db.execute("SELECT id, last_accessed, strength FROM ltm_memories WHERE is_dormant = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
70
+ rows = db.execute("SELECT id, last_accessed, strength, stability, difficulty FROM ltm_memories WHERE is_dormant = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
61
71
  for row in rows:
62
72
  last = datetime.fromisoformat(row["last_accessed"])
63
73
  hours = (now - last).total_seconds() / 3600.0
64
- decay_rate = LAMBDA_LTM * 0.25 if (adaptive and row["id"] in _protected_ltm) else LAMBDA_LTM
74
+ decay_rate = personalize_decay_rate(
75
+ LAMBDA_LTM,
76
+ stability=row["stability"],
77
+ difficulty=row["difficulty"],
78
+ )
79
+ if adaptive and row["id"] in _protected_ltm:
80
+ decay_rate *= 0.25
65
81
  new_strength = row["strength"] * math.exp(-decay_rate * hours)
66
82
  if new_strength < 0.1:
67
83
  db.execute("UPDATE ltm_memories SET strength = ?, is_dormant = 1 WHERE id = ?", (new_strength, row["id"]))
@@ -101,10 +117,10 @@ def promote_stm_to_ltm():
101
117
  for row in rows:
102
118
  redacted = row["redaction_applied"] if "redaction_applied" in row.keys() else 0
103
119
  db.execute(
104
- """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, original_stm_id, redaction_applied)
105
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
120
+ """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, original_stm_id, redaction_applied, stability, difficulty)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
106
122
  (row["content"], row["embedding"], row["source_type"], row["source_id"],
107
- row["source_title"], row["domain"], row["id"], redacted)
123
+ row["source_title"], row["domain"], row["id"], redacted, row["stability"], row["difficulty"])
108
124
  )
109
125
  db.execute("UPDATE stm_memories SET promoted_to_ltm = 1 WHERE id = ?", (row["id"],))
110
126
  promoted += 1
@@ -322,12 +338,13 @@ def dream_cycle(max_insights: int = 50) -> dict:
322
338
 
323
339
  # Store as LTM with dream_insight tag
324
340
  cur = db.execute(
325
- """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, strength)
326
- VALUES (?, ?, 'dream_insight', ?, ?, ?, 'dream_insight', 0.5)""",
341
+ """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, strength, stability, difficulty)
342
+ VALUES (?, ?, 'dream_insight', ?, ?, ?, 'dream_insight', 0.5, ?, ?)""",
327
343
  (insight_content, blob,
328
344
  f"{mem_a['store']}:{mem_a['id']},{mem_b['store']}:{mem_b['id']}",
329
345
  f"Dream: {title_a[:30]} <-> {title_b[:30]}",
330
- domain_str)
346
+ domain_str,
347
+ *initial_memory_profile("dream_insight", store="ltm"))
331
348
  )
332
349
  insight_id = cur.lastrowid
333
350