nexo-brain 2.6.15 → 2.6.17
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/.claude-plugin/plugin.json +1 -1
- package/README.md +43 -5
- package/package.json +1 -1
- package/src/agent_runner.py +70 -2
- package/src/auto_update.py +10 -1
- package/src/bootstrap_docs.py +5 -1
- package/src/client_preferences.py +68 -2
- package/src/client_sync.py +144 -2
- package/src/cognitive/__init__.py +4 -0
- package/src/cognitive/_core.py +80 -0
- package/src/cognitive/_decay.py +28 -11
- package/src/cognitive/_ingest.py +44 -22
- package/src/cognitive/_memory.py +8 -0
- package/src/cognitive/_search.py +71 -11
- package/src/dashboard/app.py +15 -8
- package/src/db/_schema.py +10 -0
- package/src/db/_sessions.py +13 -6
- package/src/doctor/providers/runtime.py +60 -5
- package/src/hooks/capture-tool-logs.sh +2 -2
- package/src/hooks/inbox-hook.sh +1 -1
- package/src/plugins/cognitive_memory.py +14 -6
- package/src/plugins/update.py +10 -1
- package/src/scripts/deep-sleep/collect.py +181 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +5 -0
- package/src/scripts/deep-sleep/synthesize.py +2 -0
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-reflection.py +7 -4
- package/src/scripts/nexo-update.sh +7 -1
- package/src/server.py +13 -6
- package/src/tools_sessions.py +22 -5
- package/templates/CODEX.AGENTS.md.template +2 -2
package/src/cognitive/_core.py
CHANGED
|
@@ -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 ''
|
package/src/cognitive/_decay.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
|
package/src/cognitive/_ingest.py
CHANGED
|
@@ -7,6 +7,7 @@ from cognitive._core import (
|
|
|
7
7
|
_get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
|
|
8
8
|
redact_secrets, extract_temporal_date, EMBEDDING_DIM,
|
|
9
9
|
PE_GATE_REJECT, PE_GATE_REFINE, _gate_stats,
|
|
10
|
+
initial_memory_profile, rehearsal_profile_update,
|
|
10
11
|
)
|
|
11
12
|
|
|
12
13
|
|
|
@@ -76,6 +77,7 @@ def ingest(
|
|
|
76
77
|
vec = embed(clean_content)
|
|
77
78
|
blob = _array_to_blob(vec)
|
|
78
79
|
temporal = extract_temporal_date(content)
|
|
80
|
+
stability, difficulty = initial_memory_profile(source_type, store="stm")
|
|
79
81
|
|
|
80
82
|
# Auto-pin: corrections and blocking learnings get pinned (zero decay, +0.2 boost)
|
|
81
83
|
# This ensures user's corrections NEVER fade away
|
|
@@ -94,9 +96,9 @@ def ingest(
|
|
|
94
96
|
db.commit()
|
|
95
97
|
# Now actually store in STM
|
|
96
98
|
cur2 = db.execute(
|
|
97
|
-
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state)
|
|
98
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
99
|
-
(clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle)
|
|
99
|
+
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state, stability, difficulty)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
101
|
+
(clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle, stability, difficulty)
|
|
100
102
|
)
|
|
101
103
|
db.commit()
|
|
102
104
|
_hnsw_notify_insert("stm", cur2.lastrowid, vec)
|
|
@@ -105,9 +107,9 @@ def ingest(
|
|
|
105
107
|
# skip_quarantine = direct STM (backward compatibility)
|
|
106
108
|
if skip_quarantine:
|
|
107
109
|
cur = db.execute(
|
|
108
|
-
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state)
|
|
109
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
110
|
-
(clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle)
|
|
110
|
+
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state, stability, difficulty)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
112
|
+
(clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle, stability, difficulty)
|
|
111
113
|
)
|
|
112
114
|
db.commit()
|
|
113
115
|
_hnsw_notify_insert("stm", cur.lastrowid, vec)
|
|
@@ -246,10 +248,11 @@ def ingest_to_ltm(
|
|
|
246
248
|
was_redacted = 1 if clean_content != content else 0
|
|
247
249
|
vec = embed(clean_content)
|
|
248
250
|
blob = _array_to_blob(vec)
|
|
251
|
+
stability, difficulty = initial_memory_profile(source_type, store="ltm")
|
|
249
252
|
cur = db.execute(
|
|
250
|
-
"""INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, redaction_applied)
|
|
251
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
252
|
-
(clean_content, blob, source_type, source_id, source_title, domain, tags, was_redacted)
|
|
253
|
+
"""INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, redaction_applied, stability, difficulty)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
255
|
+
(clean_content, blob, source_type, source_id, source_title, domain, tags, was_redacted, stability, difficulty)
|
|
253
256
|
)
|
|
254
257
|
db.commit()
|
|
255
258
|
return cur.lastrowid
|
|
@@ -267,10 +270,11 @@ def ingest_sensory(
|
|
|
267
270
|
vec = embed(clean_content)
|
|
268
271
|
blob = _array_to_blob(vec)
|
|
269
272
|
ts = created_at or datetime.utcnow().isoformat()
|
|
273
|
+
stability, difficulty = initial_memory_profile("sensory", store="stm")
|
|
270
274
|
cur = db.execute(
|
|
271
|
-
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, domain, created_at, redaction_applied)
|
|
272
|
-
VALUES (?, ?, 'sensory', ?, ?, ?, ?)""",
|
|
273
|
-
(clean_content, blob, source_id, domain, ts, was_redacted)
|
|
275
|
+
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, domain, created_at, redaction_applied, stability, difficulty)
|
|
276
|
+
VALUES (?, ?, 'sensory', ?, ?, ?, ?, ?, ?)""",
|
|
277
|
+
(clean_content, blob, source_id, domain, ts, was_redacted, stability, difficulty)
|
|
274
278
|
)
|
|
275
279
|
db.commit()
|
|
276
280
|
return cur.lastrowid
|
|
@@ -386,6 +390,12 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
|
|
|
386
390
|
db = _get_db()
|
|
387
391
|
table = "stm_memories" if match_info["store"] == "stm" else "ltm_memories"
|
|
388
392
|
memory_id = match_info["id"]
|
|
393
|
+
profile_row = db.execute(
|
|
394
|
+
f"SELECT stability, difficulty FROM {table} WHERE id = ?",
|
|
395
|
+
(memory_id,),
|
|
396
|
+
).fetchone()
|
|
397
|
+
current_stability = profile_row["stability"] if profile_row else 1.0
|
|
398
|
+
current_difficulty = profile_row["difficulty"] if profile_row else 0.5
|
|
389
399
|
|
|
390
400
|
# Check word-level diff to avoid appending near-identical text
|
|
391
401
|
existing_words = set(match_info["content"].lower().split())
|
|
@@ -395,10 +405,16 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
|
|
|
395
405
|
if len(unique_new) < 3:
|
|
396
406
|
# Almost no new words -- just strengthen the existing memory
|
|
397
407
|
now = datetime.utcnow().isoformat()
|
|
408
|
+
new_stability, new_difficulty = rehearsal_profile_update(
|
|
409
|
+
current_stability,
|
|
410
|
+
current_difficulty,
|
|
411
|
+
score=0.7,
|
|
412
|
+
refinement=True,
|
|
413
|
+
)
|
|
398
414
|
db.execute(
|
|
399
415
|
f"UPDATE {table} SET strength = MIN(1.0, strength + 0.1), "
|
|
400
|
-
f"access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
|
401
|
-
(now, memory_id)
|
|
416
|
+
f"access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
|
|
417
|
+
(now, new_stability, new_difficulty, memory_id)
|
|
402
418
|
)
|
|
403
419
|
db.commit()
|
|
404
420
|
return memory_id
|
|
@@ -408,11 +424,17 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
|
|
|
408
424
|
new_vec = embed(merged_content)
|
|
409
425
|
new_blob = _array_to_blob(new_vec)
|
|
410
426
|
now = datetime.utcnow().isoformat()
|
|
427
|
+
new_stability, new_difficulty = rehearsal_profile_update(
|
|
428
|
+
current_stability,
|
|
429
|
+
current_difficulty,
|
|
430
|
+
score=0.82,
|
|
431
|
+
refinement=True,
|
|
432
|
+
)
|
|
411
433
|
|
|
412
434
|
db.execute(
|
|
413
435
|
f"UPDATE {table} SET content = ?, embedding = ?, strength = MIN(1.0, strength + 0.15), "
|
|
414
|
-
f"access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
|
415
|
-
(merged_content, new_blob, now, memory_id)
|
|
436
|
+
f"access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
|
|
437
|
+
(merged_content, new_blob, now, new_stability, new_difficulty, memory_id)
|
|
416
438
|
)
|
|
417
439
|
db.commit()
|
|
418
440
|
return memory_id
|
|
@@ -614,10 +636,10 @@ def process_quarantine() -> dict:
|
|
|
614
636
|
if should_promote:
|
|
615
637
|
# Promote to STM
|
|
616
638
|
cur = db.execute(
|
|
617
|
-
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied)
|
|
618
|
-
VALUES (?, ?, ?, ?, ?, ?, 0)""",
|
|
639
|
+
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, stability, difficulty)
|
|
640
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)""",
|
|
619
641
|
(content, row["embedding"], row["source_type"], row["source_id"],
|
|
620
|
-
row["source_title"], row["domain"])
|
|
642
|
+
row["source_title"], row["domain"], *initial_memory_profile(row["source_type"], store="stm"))
|
|
621
643
|
)
|
|
622
644
|
db.execute(
|
|
623
645
|
"UPDATE quarantine SET status = 'promoted', promoted_at = datetime('now'), confidence = 1.0 WHERE id = ?",
|
|
@@ -689,10 +711,10 @@ def quarantine_promote(quarantine_id: int) -> str:
|
|
|
689
711
|
|
|
690
712
|
# Insert into STM
|
|
691
713
|
db.execute(
|
|
692
|
-
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied)
|
|
693
|
-
VALUES (?, ?, ?, ?, ?, ?, 0)""",
|
|
714
|
+
"""INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, stability, difficulty)
|
|
715
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)""",
|
|
694
716
|
(row["content"], row["embedding"], row["source_type"], row["source_id"],
|
|
695
|
-
row["source_title"], row["domain"])
|
|
717
|
+
row["source_title"], row["domain"], *initial_memory_profile(row["source_type"], store="stm"))
|
|
696
718
|
)
|
|
697
719
|
db.execute(
|
|
698
720
|
"UPDATE quarantine SET status = 'promoted', promoted_at = datetime('now'), confidence = 1.0 WHERE id = ?",
|
package/src/cognitive/_memory.py
CHANGED
|
@@ -406,6 +406,10 @@ def get_stats() -> dict:
|
|
|
406
406
|
|
|
407
407
|
avg_stm = db.execute("SELECT AVG(strength) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
|
|
408
408
|
avg_ltm = db.execute("SELECT AVG(strength) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
|
|
409
|
+
avg_stm_stability = db.execute("SELECT AVG(stability) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
|
|
410
|
+
avg_ltm_stability = db.execute("SELECT AVG(stability) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
|
|
411
|
+
avg_stm_difficulty = db.execute("SELECT AVG(difficulty) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
|
|
412
|
+
avg_ltm_difficulty = db.execute("SELECT AVG(difficulty) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
|
|
409
413
|
|
|
410
414
|
total_retrievals = db.execute("SELECT COUNT(*) FROM retrieval_log").fetchone()[0]
|
|
411
415
|
avg_retrieval_score = db.execute("SELECT AVG(top_score) FROM retrieval_log").fetchone()[0] or 0.0
|
|
@@ -428,6 +432,10 @@ def get_stats() -> dict:
|
|
|
428
432
|
"ltm_dormant": ltm_dormant,
|
|
429
433
|
"avg_stm_strength": round(avg_stm, 3),
|
|
430
434
|
"avg_ltm_strength": round(avg_ltm, 3),
|
|
435
|
+
"avg_stm_stability": round(avg_stm_stability, 3),
|
|
436
|
+
"avg_ltm_stability": round(avg_ltm_stability, 3),
|
|
437
|
+
"avg_stm_difficulty": round(avg_stm_difficulty, 3),
|
|
438
|
+
"avg_ltm_difficulty": round(avg_ltm_difficulty, 3),
|
|
431
439
|
"total_retrievals": total_retrievals,
|
|
432
440
|
"avg_retrieval_score": round(avg_retrieval_score, 3),
|
|
433
441
|
"top_domains_stm": [(r["domain"], r["cnt"]) for r in top_domains_stm],
|
package/src/cognitive/_search.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""NEXO Cognitive — Search, retrieval, ranking."""
|
|
2
2
|
import math
|
|
3
|
+
import re
|
|
3
4
|
import sqlite3
|
|
4
5
|
import numpy as np
|
|
5
6
|
from datetime import datetime
|
|
6
|
-
from cognitive._core import
|
|
7
|
+
from cognitive._core import (
|
|
8
|
+
_get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
|
|
9
|
+
_get_model, _get_reranker, rerank_results, EMBEDDING_DIM,
|
|
10
|
+
rehearsal_profile_update,
|
|
11
|
+
)
|
|
7
12
|
|
|
8
13
|
def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
|
|
9
14
|
source_type_filter: str = "") -> list[dict]:
|
|
@@ -151,6 +156,10 @@ _HISTORICAL_CUES = frozenset({
|
|
|
151
156
|
"cuando", "hace", "meses", "año", "anterior", "antes",
|
|
152
157
|
})
|
|
153
158
|
|
|
159
|
+
_EXACT_LOOKUP_RE = re.compile(
|
|
160
|
+
r"(/|\\|::|\.[A-Za-z0-9]+|#L\d+|line \d+|error[: ]|exception|traceback|0x[0-9a-fA-F]+|[A-Z]{2,}-\d+)"
|
|
161
|
+
)
|
|
162
|
+
|
|
154
163
|
|
|
155
164
|
def _apply_temporal_boost(results: list[dict], query_text: str) -> list[dict]:
|
|
156
165
|
"""Apply bounded temporal boost to retrieval results.
|
|
@@ -209,6 +218,42 @@ def _apply_temporal_boost(results: list[dict], query_text: str) -> list[dict]:
|
|
|
209
218
|
return results
|
|
210
219
|
|
|
211
220
|
|
|
221
|
+
def _looks_like_exact_lookup(query_text: str) -> bool:
|
|
222
|
+
query = str(query_text or "").strip()
|
|
223
|
+
if not query:
|
|
224
|
+
return False
|
|
225
|
+
lowered = query.lower()
|
|
226
|
+
if _EXACT_LOOKUP_RE.search(query):
|
|
227
|
+
return True
|
|
228
|
+
if any(token in lowered for token in ("file ", "path ", "port ", "localhost", "grep ", "rg ", "exact", "literal")):
|
|
229
|
+
return True
|
|
230
|
+
if any(ch in query for ch in ('"', "'", "`", "=", "[", "]")):
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _auto_use_hyde(query_text: str, source_type_filter: str = "") -> bool:
|
|
236
|
+
query = str(query_text or "").strip()
|
|
237
|
+
if not query or source_type_filter:
|
|
238
|
+
return False
|
|
239
|
+
if len(query) < 18 or _looks_like_exact_lookup(query):
|
|
240
|
+
return False
|
|
241
|
+
intent = _classify_query_intent(query)
|
|
242
|
+
return intent in {"howto", "definition", "reasoning"} or (intent == "lookup" and len(query.split()) >= 4)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _auto_spreading_depth(query_text: str, source_type_filter: str = "") -> int:
|
|
246
|
+
query = str(query_text or "").strip().lower()
|
|
247
|
+
if not query or source_type_filter or _looks_like_exact_lookup(query):
|
|
248
|
+
return 0
|
|
249
|
+
intent = _classify_query_intent(query)
|
|
250
|
+
if intent in {"howto", "definition", "reasoning"}:
|
|
251
|
+
return 1
|
|
252
|
+
if any(connector in query for connector in (" and ", " because ", " related ", " connect ", " why ", " how ")):
|
|
253
|
+
return 1
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
212
257
|
# ============================================================================
|
|
213
258
|
# FEATURE 0.5: Knowledge Graph Boost
|
|
214
259
|
# Memories connected to more KG nodes (files, areas, other learnings) are
|
|
@@ -614,9 +659,18 @@ def _rehearse_results(results: list[dict], skip_ids: set = None):
|
|
|
614
659
|
if (r["store"], r["id"]) in skip:
|
|
615
660
|
continue
|
|
616
661
|
table = "stm_memories" if r["store"] == "stm" else "ltm_memories"
|
|
662
|
+
current = db.execute(
|
|
663
|
+
f"SELECT stability, difficulty FROM {table} WHERE id = ?",
|
|
664
|
+
(r["id"],),
|
|
665
|
+
).fetchone()
|
|
666
|
+
new_stability, new_difficulty = rehearsal_profile_update(
|
|
667
|
+
current["stability"] if current else 1.0,
|
|
668
|
+
current["difficulty"] if current else 0.5,
|
|
669
|
+
score=r.get("score", 0.6),
|
|
670
|
+
)
|
|
617
671
|
db.execute(
|
|
618
|
-
f"UPDATE {table} SET strength = MIN(1.0, strength + 0.08), access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
|
619
|
-
(now, r["id"])
|
|
672
|
+
f"UPDATE {table} SET strength = MIN(1.0, strength + 0.08), access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
|
|
673
|
+
(now, new_stability, new_difficulty, r["id"])
|
|
620
674
|
)
|
|
621
675
|
db.commit()
|
|
622
676
|
|
|
@@ -630,18 +684,18 @@ def search(
|
|
|
630
684
|
rehearse: bool = True,
|
|
631
685
|
source_type_filter: str = "",
|
|
632
686
|
include_archived: bool = False,
|
|
633
|
-
use_hyde: bool =
|
|
687
|
+
use_hyde: bool | None = None,
|
|
634
688
|
hybrid: bool = True,
|
|
635
689
|
hybrid_alpha: float = 0.6,
|
|
636
|
-
spreading_depth: int =
|
|
690
|
+
spreading_depth: int | None = None,
|
|
637
691
|
decompose: bool = True,
|
|
638
692
|
exclude_dreams: bool = True,
|
|
639
693
|
) -> list[dict]:
|
|
640
694
|
"""Full vector search across STM and/or LTM with rehearsal and dormant reactivation.
|
|
641
695
|
|
|
642
696
|
Args:
|
|
643
|
-
use_hyde: If True,
|
|
644
|
-
spreading_depth: If >0, fetch co-activated neighbors and boost their scores
|
|
697
|
+
use_hyde: If True, force HyDE on; if False, force it off; if None, auto-enable for conceptual queries.
|
|
698
|
+
spreading_depth: If >0, fetch co-activated neighbors and boost their scores. If None, use a shallow auto-default for multi-hop queries.
|
|
645
699
|
exclude_dreams: If True (default), exclude dream_insight memories from results.
|
|
646
700
|
Dream insights are 21% of LTM and dilute search precision.
|
|
647
701
|
Set to False only when explicitly looking for cross-domain patterns.
|
|
@@ -677,6 +731,8 @@ def search(
|
|
|
677
731
|
return merged
|
|
678
732
|
|
|
679
733
|
db = _get_db()
|
|
734
|
+
resolved_use_hyde = _auto_use_hyde(query_text, source_type_filter) if use_hyde is None else bool(use_hyde)
|
|
735
|
+
resolved_spreading_depth = _auto_spreading_depth(query_text, source_type_filter) if spreading_depth is None else max(0, int(spreading_depth))
|
|
680
736
|
|
|
681
737
|
# Detect temporal queries — boost results with temporal_date
|
|
682
738
|
_temporal_keywords = {"when", "date", "time", "first", "last", "before", "after",
|
|
@@ -684,7 +740,7 @@ def search(
|
|
|
684
740
|
query_lower = query_text.lower().split()
|
|
685
741
|
is_temporal_query = bool(_temporal_keywords & set(query_lower))
|
|
686
742
|
|
|
687
|
-
if
|
|
743
|
+
if resolved_use_hyde:
|
|
688
744
|
query_vec = hyde_expand_query(query_text)
|
|
689
745
|
else:
|
|
690
746
|
query_vec = embed(query_text)
|
|
@@ -855,9 +911,9 @@ def search(
|
|
|
855
911
|
|
|
856
912
|
# Spreading activation: boost co-activated neighbors (Feature 2)
|
|
857
913
|
co_activation_applied = False
|
|
858
|
-
if
|
|
914
|
+
if resolved_spreading_depth > 0 and results:
|
|
859
915
|
memory_ids = [(r["store"], r["id"]) for r in results]
|
|
860
|
-
neighbor_boosts = _get_co_activated_neighbors(memory_ids, depth=
|
|
916
|
+
neighbor_boosts = _get_co_activated_neighbors(memory_ids, depth=resolved_spreading_depth)
|
|
861
917
|
|
|
862
918
|
if neighbor_boosts:
|
|
863
919
|
co_activation_applied = True
|
|
@@ -911,7 +967,7 @@ def search(
|
|
|
911
967
|
reactivated = r.get("reactivated", False)
|
|
912
968
|
|
|
913
969
|
ranking_desc = "semantic_similarity"
|
|
914
|
-
if
|
|
970
|
+
if resolved_use_hyde:
|
|
915
971
|
ranking_desc = "hyde_centroid_similarity"
|
|
916
972
|
parts = [f"Ranked #{rank}: {ranking_desc}={score:.3f}"]
|
|
917
973
|
parts.append(f"store={store}, strength={strength:.2f}, accesses={access_count}")
|
|
@@ -919,6 +975,10 @@ def search(
|
|
|
919
975
|
parts.append(f"kg_boost=+{r['kg_boost']:.3f} ({r.get('kg_connections', 0)} edges)")
|
|
920
976
|
if r.get("co_activation_boost"):
|
|
921
977
|
parts.append(f"co_activation_boost=+{r['co_activation_boost']:.3f}")
|
|
978
|
+
if use_hyde is None and resolved_use_hyde:
|
|
979
|
+
parts.append("hyde=auto")
|
|
980
|
+
if spreading_depth is None and resolved_spreading_depth > 0:
|
|
981
|
+
parts.append(f"spreading=auto:{resolved_spreading_depth}")
|
|
922
982
|
if created:
|
|
923
983
|
parts.append(f"created={created[:10]}")
|
|
924
984
|
if tags:
|
package/src/dashboard/app.py
CHANGED
|
@@ -28,6 +28,8 @@ _PARENT = str(Path(__file__).resolve().parent.parent)
|
|
|
28
28
|
if _PARENT not in sys.path:
|
|
29
29
|
sys.path.insert(0, _PARENT)
|
|
30
30
|
|
|
31
|
+
from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
|
|
32
|
+
|
|
31
33
|
app = FastAPI(title="NEXO Brain Dashboard", version="3.0.0")
|
|
32
34
|
|
|
33
35
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
@@ -137,7 +139,7 @@ class ChatMessage(BaseModel):
|
|
|
137
139
|
|
|
138
140
|
def _cognitive_db():
|
|
139
141
|
"""Direct connection to cognitive.db."""
|
|
140
|
-
nexo_home = os.environ.get("NEXO_HOME", str(Path.home() / "
|
|
142
|
+
nexo_home = os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))
|
|
141
143
|
db_path = Path(nexo_home) / "data" / "cognitive.db"
|
|
142
144
|
conn = sqlite3.connect(str(db_path))
|
|
143
145
|
conn.row_factory = sqlite3.Row
|
|
@@ -145,7 +147,8 @@ def _cognitive_db():
|
|
|
145
147
|
|
|
146
148
|
def _email_db():
|
|
147
149
|
"""Direct connection to nexo-email.db."""
|
|
148
|
-
|
|
150
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
151
|
+
db_path = nexo_home / "nexo-email" / "nexo-email.db"
|
|
149
152
|
if not db_path.exists():
|
|
150
153
|
return None
|
|
151
154
|
conn = sqlite3.connect(str(db_path))
|
|
@@ -420,7 +423,7 @@ async def api_sessions(limit: int = Query(10, ge=1, le=50)):
|
|
|
420
423
|
conn = db.get_db()
|
|
421
424
|
# Active sessions (from sessions table, not diaries)
|
|
422
425
|
active_rows = conn.execute(
|
|
423
|
-
"SELECT sid as session_id, task, last_update_epoch, claude_session_id "
|
|
426
|
+
"SELECT sid as session_id, task, last_update_epoch, claude_session_id, external_session_id, session_client "
|
|
424
427
|
"FROM sessions WHERE last_update_epoch > (strftime('%s','now') - 900) "
|
|
425
428
|
"ORDER BY last_update_epoch DESC"
|
|
426
429
|
).fetchall()
|
|
@@ -716,7 +719,7 @@ async def api_ops_move(body: MoveRequest):
|
|
|
716
719
|
|
|
717
720
|
@app.post("/api/ops/execute/{fid}")
|
|
718
721
|
async def api_ops_execute(fid: str):
|
|
719
|
-
"""Execute a followup by opening Terminal with
|
|
722
|
+
"""Execute a followup by opening Terminal with the configured NEXO client."""
|
|
720
723
|
db = _db()
|
|
721
724
|
conn = db.get_db()
|
|
722
725
|
row = conn.execute("SELECT * FROM followups WHERE id = ?", (fid,)).fetchone()
|
|
@@ -734,9 +737,13 @@ async def api_ops_execute(fid: str):
|
|
|
734
737
|
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", prefix="nexo-followup-", delete=False)
|
|
735
738
|
tmp.write(fid)
|
|
736
739
|
tmp.close()
|
|
737
|
-
# The
|
|
738
|
-
|
|
739
|
-
|
|
740
|
+
# The selected terminal client reads the followup ID from the temp file — no shell interpolation of description
|
|
741
|
+
try:
|
|
742
|
+
_, shell_cmd = build_followup_terminal_shell_command(tmp.name)
|
|
743
|
+
except AgentRunnerError as exc:
|
|
744
|
+
return JSONResponse({"error": str(exc)}, status_code=503)
|
|
745
|
+
escaped = shell_cmd.replace("\\", "\\\\").replace('"', '\\"')
|
|
746
|
+
script = f'tell application "Terminal" to do script "{escaped}"'
|
|
740
747
|
subprocess.Popen(["osascript", "-e", script])
|
|
741
748
|
return {"success": True, "followup_id": fid}
|
|
742
749
|
|
|
@@ -1388,7 +1395,7 @@ async def api_credentials():
|
|
|
1388
1395
|
|
|
1389
1396
|
@app.get("/api/backups")
|
|
1390
1397
|
async def api_backups():
|
|
1391
|
-
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / "
|
|
1398
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
1392
1399
|
backup_dir = nexo_home / "backups"
|
|
1393
1400
|
data_dir = nexo_home / "data"
|
|
1394
1401
|
backups = []
|
package/src/db/_schema.py
CHANGED
|
@@ -256,6 +256,15 @@ def _m13_claude_session_id(conn):
|
|
|
256
256
|
conn.commit()
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def _m21_external_session_fields(conn):
|
|
260
|
+
"""Generalize session linkage beyond Claude-specific naming."""
|
|
261
|
+
_migrate_add_column(conn, "sessions", "external_session_id", "TEXT DEFAULT ''")
|
|
262
|
+
_migrate_add_column(conn, "sessions", "session_client", "TEXT DEFAULT ''")
|
|
263
|
+
_migrate_add_index(conn, "idx_sessions_external_sid", "sessions", "external_session_id")
|
|
264
|
+
_migrate_add_index(conn, "idx_sessions_client", "sessions", "session_client")
|
|
265
|
+
conn.commit()
|
|
266
|
+
|
|
267
|
+
|
|
259
268
|
def _m14_learnings_priority_weight(conn):
|
|
260
269
|
"""Add priority, weight, and guard usage tracking to learnings + followup priority."""
|
|
261
270
|
_migrate_add_column(conn, "learnings", "priority", "TEXT DEFAULT 'medium'")
|
|
@@ -445,6 +454,7 @@ MIGRATIONS = [
|
|
|
445
454
|
(18, "skills_steps_column", _m18_skills_steps),
|
|
446
455
|
(19, "skills_v2", _m19_skills_v2),
|
|
447
456
|
(20, "personal_scripts_registry", _m20_personal_scripts_registry),
|
|
457
|
+
(21, "external_session_fields", _m21_external_session_fields),
|
|
448
458
|
]
|
|
449
459
|
|
|
450
460
|
|
package/src/db/_sessions.py
CHANGED
|
@@ -34,18 +34,26 @@ def _validate_sid(sid: str) -> str:
|
|
|
34
34
|
raise ValueError(f"Invalid SID format: {sid[:80]}")
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def register_session(
|
|
37
|
+
def register_session(
|
|
38
|
+
sid: str,
|
|
39
|
+
task: str,
|
|
40
|
+
claude_session_id: str = "",
|
|
41
|
+
*,
|
|
42
|
+
external_session_id: str = "",
|
|
43
|
+
session_client: str = "",
|
|
44
|
+
) -> dict:
|
|
38
45
|
"""Register or re-register a session."""
|
|
39
46
|
sid = _validate_sid(sid)
|
|
40
47
|
conn = get_db()
|
|
41
48
|
now = now_epoch()
|
|
49
|
+
linked_session_id = (external_session_id or claude_session_id or "").strip()
|
|
42
50
|
conn.execute(
|
|
43
|
-
"INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id) "
|
|
44
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
45
|
-
(sid, task, now, now, local_time_str(),
|
|
51
|
+
"INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id, external_session_id, session_client) "
|
|
52
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
53
|
+
(sid, task, now, now, local_time_str(), linked_session_id, linked_session_id, (session_client or "").strip())
|
|
46
54
|
)
|
|
47
55
|
conn.commit()
|
|
48
|
-
return {"sid": sid, "task": task}
|
|
56
|
+
return {"sid": sid, "task": task, "external_session_id": linked_session_id, "session_client": (session_client or "").strip()}
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
def update_session(sid: str, task: str | None) -> dict:
|
|
@@ -320,4 +328,3 @@ def _expire_old_questions(conn: sqlite3.Connection):
|
|
|
320
328
|
(cutoff,)
|
|
321
329
|
)
|
|
322
330
|
|
|
323
|
-
|