arkaos 4.13.2 → 4.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.
Files changed (130) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +16 -0
  3. package/bin/arka-doctor +20 -1
  4. package/config/cognition/schedules.yaml +16 -0
  5. package/config/hooks/_lib/fastpath/engine.cjs +337 -0
  6. package/config/hooks/gate-manifest.json +741 -0
  7. package/config/hooks/post-tool-use.cjs +113 -0
  8. package/config/hooks/pre-tool-use.cjs +109 -0
  9. package/config/hooks/session-end.ps1 +40 -0
  10. package/config/hooks/session-end.sh +55 -0
  11. package/config/hooks/session-start.sh +25 -189
  12. package/config/hooks/subagent-stop.ps1 +40 -0
  13. package/config/hooks/subagent-stop.sh +56 -0
  14. package/config/mcp-policy.yaml +8 -0
  15. package/config/skills-curated.yaml +39 -0
  16. package/config/statusline.sh +57 -1
  17. package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
  18. package/core/cognition/capture/store.py +7 -2
  19. package/core/evals/__pycache__/__init__.cpython-314.pyc +0 -0
  20. package/core/evals/__pycache__/sanitizer.cpython-314.pyc +0 -0
  21. package/core/evals/__pycache__/schema.cpython-314.pyc +0 -0
  22. package/core/evals/__pycache__/verdict_labels.cpython-314.pyc +0 -0
  23. package/core/forge/__pycache__/complexity.cpython-314.pyc +0 -0
  24. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/judge.cpython-314.pyc +0 -0
  26. package/core/governance/__pycache__/leak_scanner.cpython-314.pyc +0 -0
  27. package/core/governance/__pycache__/qg_verdict.cpython-314.pyc +0 -0
  28. package/core/governance/__pycache__/routing_feedback.cpython-313.pyc +0 -0
  29. package/core/governance/__pycache__/routing_feedback.cpython-314.pyc +0 -0
  30. package/core/governance/__pycache__/routing_feedback_cli.cpython-313.pyc +0 -0
  31. package/core/governance/evidence_checks.py +42 -7
  32. package/core/governance/routing_feedback.py +229 -0
  33. package/core/governance/routing_feedback_cli.py +49 -0
  34. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  35. package/core/hooks/__pycache__/gate_manifest.cpython-313.pyc +0 -0
  36. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  37. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  38. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  39. package/core/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  40. package/core/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  41. package/core/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  42. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  43. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  44. package/core/hooks/__pycache__/subagent_stop.cpython-313.pyc +0 -0
  45. package/core/hooks/__pycache__/subagent_stop.cpython-314.pyc +0 -0
  46. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  47. package/core/hooks/__pycache__/user_prompt_submit.cpython-314.pyc +0 -0
  48. package/core/hooks/gate_manifest.py +366 -0
  49. package/core/hooks/post_tool_use.py +5 -2
  50. package/core/hooks/session_end.py +127 -0
  51. package/core/hooks/session_start.py +322 -0
  52. package/core/hooks/stop.py +69 -0
  53. package/core/hooks/subagent_stop.py +186 -0
  54. package/core/hooks/user_prompt_submit.py +10 -6
  55. package/core/knowledge/__pycache__/embedding_backends.cpython-313.pyc +0 -0
  56. package/core/knowledge/__pycache__/embedding_backends.cpython-314.pyc +0 -0
  57. package/core/knowledge/__pycache__/pattern_cards.cpython-313.pyc +0 -0
  58. package/core/knowledge/__pycache__/pattern_cards.cpython-314.pyc +0 -0
  59. package/core/knowledge/__pycache__/recipes.cpython-314.pyc +0 -0
  60. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  61. package/core/knowledge/__pycache__/vector_store.cpython-314.pyc +0 -0
  62. package/core/knowledge/embedding_backends.py +278 -0
  63. package/core/knowledge/pattern_cards.py +49 -1
  64. package/core/knowledge/vector_store.py +14 -2
  65. package/core/memory/__pycache__/semantic_store.cpython-312.pyc +0 -0
  66. package/core/memory/__pycache__/semantic_store.cpython-313.pyc +0 -0
  67. package/core/memory/__pycache__/semantic_store.cpython-314.pyc +0 -0
  68. package/core/memory/__pycache__/turn_capture.cpython-313.pyc +0 -0
  69. package/core/memory/__pycache__/turn_capture.cpython-314.pyc +0 -0
  70. package/core/memory/semantic_store.py +301 -0
  71. package/core/memory/turn_capture.py +277 -0
  72. package/core/registry/__pycache__/generator.cpython-312.pyc +0 -0
  73. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  74. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  75. package/core/runtime/__pycache__/cost_governor.cpython-314.pyc +0 -0
  76. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  77. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/mcp_telemetry.cpython-312.pyc +0 -0
  79. package/core/shared/__pycache__/decay.cpython-313.pyc +0 -0
  80. package/core/shared/__pycache__/decay.cpython-314.pyc +0 -0
  81. package/core/shared/__pycache__/sqlite_recovery.cpython-312.pyc +0 -0
  82. package/core/shared/__pycache__/sqlite_recovery.cpython-313.pyc +0 -0
  83. package/core/shared/__pycache__/sqlite_recovery.cpython-314.pyc +0 -0
  84. package/core/shared/decay.py +94 -0
  85. package/core/shared/sqlite_recovery.py +266 -0
  86. package/core/synapse/__pycache__/agent_experiences_layer.cpython-313.pyc +0 -0
  87. package/core/synapse/__pycache__/agent_experiences_layer.cpython-314.pyc +0 -0
  88. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  89. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  90. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  91. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  92. package/core/synapse/__pycache__/pattern_library_layer.cpython-314.pyc +0 -0
  93. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  94. package/core/synapse/__pycache__/recipe_layer.cpython-314.pyc +0 -0
  95. package/core/synapse/__pycache__/routing_feedback_layer.cpython-313.pyc +0 -0
  96. package/core/synapse/__pycache__/routing_feedback_layer.cpython-314.pyc +0 -0
  97. package/core/synapse/__pycache__/session_memory_layer.cpython-312.pyc +0 -0
  98. package/core/synapse/__pycache__/session_memory_layer.cpython-313.pyc +0 -0
  99. package/core/synapse/__pycache__/session_memory_layer.cpython-314.pyc +0 -0
  100. package/core/synapse/agent_experiences_layer.py +42 -3
  101. package/core/synapse/engine.py +13 -0
  102. package/core/synapse/recipe_layer.py +27 -4
  103. package/core/synapse/routing_feedback_layer.py +132 -0
  104. package/core/synapse/session_memory_layer.py +152 -0
  105. package/core/workflow/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/core/workflow/__pycache__/design_authorization.cpython-314.pyc +0 -0
  107. package/core/workflow/__pycache__/frontend_gate.cpython-314.pyc +0 -0
  108. package/core/workflow/__pycache__/specialist_enforcer.cpython-314.pyc +0 -0
  109. package/installer/adapters/claude-code.js +37 -0
  110. package/installer/claude-plugins.js +6 -0
  111. package/installer/cli.js +23 -2
  112. package/installer/config-seed.js +25 -16
  113. package/installer/doctor.js +116 -1
  114. package/installer/hook-lib.js +28 -0
  115. package/installer/index.js +48 -153
  116. package/installer/mcp-runner.js +108 -0
  117. package/installer/skill-deploy.js +189 -0
  118. package/installer/skills-mode.js +64 -0
  119. package/installer/update.js +55 -79
  120. package/knowledge/commands-registry.json.bak +171 -4
  121. package/knowledge/skills-manifest.json +2337 -0
  122. package/package.json +1 -1
  123. package/pyproject.toml +9 -1
  124. package/scripts/__pycache__/marketplace_gen.cpython-313.pyc +0 -0
  125. package/scripts/__pycache__/synapse-bridge.cpython-314.pyc +0 -0
  126. package/scripts/marketplace_gen.py +310 -0
  127. package/scripts/tools/__pycache__/docs_stats.cpython-313.pyc +0 -0
  128. package/scripts/tools/__pycache__/skill_budget.cpython-313.pyc +0 -0
  129. package/scripts/tools/docs_stats.py +6 -4
  130. package/scripts/tools/skill_budget.py +209 -0
@@ -0,0 +1,94 @@
1
+ """Knowledge decay — "the graph that forgets" (F1-C1, ruflo teardown).
2
+
3
+ Read-time exponential decay for knowledge stores (pattern cards as of
4
+ F1-C1; recipes and agent experiences wire in F1-C2): records are NEVER
5
+ deleted by decay — they fade out of context injection unless
6
+ reinforcement re-touches them.
7
+ Verified semantics from the claude-flow v3 teardown: rows carry a
8
+ last-reinforced timestamp; weight halves every ``half_life_days``;
9
+ consumers drop records below a floor from injection only.
10
+
11
+ Config (``~/.arkaos/config.json``):
12
+ knowledge.decay.enabled default True
13
+ knowledge.decay.halfLifeDays default 60
14
+ Env kill-switch: ``ARKA_KNOWLEDGE_DECAY=0``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ DEFAULT_HALF_LIFE_DAYS = 60.0
26
+ INJECTION_FLOOR = 0.15 # below this weight a record fades from injection
27
+ _CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
28
+
29
+
30
+ def _decay_config() -> dict:
31
+ try:
32
+ data = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
33
+ except (OSError, json.JSONDecodeError):
34
+ return {}
35
+ if not isinstance(data, dict):
36
+ return {}
37
+ knowledge = data.get("knowledge")
38
+ if not isinstance(knowledge, dict):
39
+ return {}
40
+ section = knowledge.get("decay")
41
+ return section if isinstance(section, dict) else {}
42
+
43
+
44
+ def decay_enabled() -> bool:
45
+ if os.environ.get("ARKA_KNOWLEDGE_DECAY", "").strip() == "0":
46
+ return False
47
+ return bool(_decay_config().get("enabled", True))
48
+
49
+
50
+ def half_life_days() -> float:
51
+ value = _decay_config().get("halfLifeDays", DEFAULT_HALF_LIFE_DAYS)
52
+ try:
53
+ parsed = float(value)
54
+ except (TypeError, ValueError):
55
+ return DEFAULT_HALF_LIFE_DAYS
56
+ return parsed if parsed > 0 else DEFAULT_HALF_LIFE_DAYS
57
+
58
+
59
+ def _parse_ts(ts: str) -> datetime | None:
60
+ try:
61
+ parsed = datetime.fromisoformat(ts)
62
+ except (ValueError, TypeError):
63
+ return None
64
+ if parsed.tzinfo is None:
65
+ parsed = parsed.replace(tzinfo=UTC)
66
+ return parsed
67
+
68
+
69
+ def decayed_weight(
70
+ last_reinforced_iso: str,
71
+ half_life: float | None = None,
72
+ now: datetime | None = None,
73
+ enabled: bool | None = None,
74
+ ) -> float:
75
+ """Weight in (0, 1]: 1.0 fresh, 0.5 after one half-life, and so on.
76
+
77
+ An unparseable/empty timestamp returns the floor (not 0.0): a record
78
+ with unknown age is dimmed, never silently erased from ranking.
79
+ When decay is disabled every record weighs 1.0.
80
+
81
+ Hot-path callers MUST hoist the config reads: resolve
82
+ ``decay_enabled()`` and ``half_life_days()`` ONCE per query and pass
83
+ them via ``enabled``/``half_life`` — per-record config parsing on an
84
+ injection path is the exact laziness this param surface exists to
85
+ prevent.
86
+ """
87
+ if not (decay_enabled() if enabled is None else enabled):
88
+ return 1.0
89
+ parsed = _parse_ts(last_reinforced_iso or "")
90
+ if parsed is None:
91
+ return INJECTION_FLOOR
92
+ current = now or datetime.now(UTC)
93
+ age_days = max(0.0, (current - parsed).total_seconds() / 86400.0)
94
+ return math.pow(0.5, age_days / (half_life or half_life_days()))
@@ -0,0 +1,266 @@
1
+ """SQLite self-heal (F1-D1 — memory/learning reform).
2
+
3
+ Python port of a verified claude-flow v3 pattern (recoverMemoryDatabase):
4
+ 1. confirm corruption first (``PRAGMA quick_check``) — never rewrite
5
+ a healthy DB
6
+ 2. exclusive-writer guard (``BEGIN IMMEDIATE``) — never race a live
7
+ writer
8
+ 3. salvage rows in rowid-range chunks into a sibling file — rows on
9
+ unreadable pages are lost, rows on healthy pages survive; a table
10
+ whose schema can't be recreated is skipped whole
11
+ 4. verify the rebuild BEFORE touching the original:
12
+ ``integrity_check`` plus a non-circular row floor — a rebuild
13
+ that lost every row of a store that demonstrably had rows, or
14
+ less than half of any fully-countable table, is refused
15
+ 5. atomic ``os.replace`` swap, corrupt original kept as ``.bak``
16
+
17
+ Never destructive: every failure path leaves the original untouched,
18
+ and a "successful" heal that would silently empty the live store is
19
+ treated as a failure (``verify-failed``), not a success.
20
+
21
+ CLI: ``python3 -m core.shared.sqlite_recovery <db_path>``
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import shutil
29
+ import sqlite3
30
+ import sys
31
+ import time
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from pydantic import BaseModel
37
+
38
+ _SALVAGE_CHUNK = 200 # rows fetched per rowid-range step
39
+ _MAX_SKIPS = 4096 # single-row steps past unreadable rows — bounded walk
40
+ _FLOOR_FRACTION = 0.5 # countable tables must keep at least this share
41
+
42
+
43
+ class RecoveryResult(BaseModel):
44
+ """Outcome of a recovery attempt — reasons are honest, never blank."""
45
+
46
+ recovered: bool = False
47
+ reason: str = ""
48
+ backup_path: str = ""
49
+ rows_recovered: int = 0
50
+
51
+
52
+ @dataclass
53
+ class SalvageOutcome:
54
+ copied: int = 0 # rows written to the rebuild
55
+ countable_est: int = 0 # source rows across fully-countable tables
56
+ countable_copied: int = 0 # rows copied from those same tables
57
+ hit_errors: bool = False # at least one unreadable range/table
58
+
59
+
60
+ def _quote(name: str) -> str:
61
+ """Quote an SQL identifier from a possibly-hostile sqlite_master."""
62
+ return '"' + name.replace('"', '""') + '"'
63
+
64
+
65
+ def _quick_check_ok(conn: sqlite3.Connection) -> bool:
66
+ try:
67
+ row = conn.execute("PRAGMA quick_check(1)").fetchone()
68
+ return bool(row) and str(row[0]).lower() == "ok"
69
+ except sqlite3.DatabaseError:
70
+ return False # so corrupt the check itself fails — proceed to salvage
71
+
72
+
73
+ def _count_rows(src: sqlite3.Connection, name: str) -> Optional[int]:
74
+ """Best-effort source count; None when the table can't be scanned."""
75
+ count_sql = f"SELECT COUNT(*) FROM {_quote(name)}" # identifier quoted above
76
+ try:
77
+ return int(src.execute(count_sql).fetchone()[0])
78
+ except sqlite3.Error:
79
+ return None
80
+
81
+
82
+ def _copy_whole(src: sqlite3.Connection, dst: sqlite3.Connection, name: str) -> tuple[int, bool]:
83
+ """WITHOUT ROWID fallback: all-or-nothing copy of one table."""
84
+ select_sql = f"SELECT * FROM {_quote(name)}" # identifier quoted above
85
+ try:
86
+ rows = src.execute(select_sql).fetchall()
87
+ except sqlite3.Error:
88
+ return 0, True
89
+ if rows:
90
+ marks = ",".join("?" * len(rows[0]))
91
+ dst.executemany(f"INSERT OR IGNORE INTO {_quote(name)} VALUES ({marks})", rows)
92
+ return len(rows), False
93
+
94
+
95
+ def _narrow_or_step(chunk: int, last: int, skips: int) -> tuple[int, int, int]:
96
+ """Halve a failing range; once isolated, step past ONLY the bad row."""
97
+ if chunk > 1:
98
+ return chunk // 2, last, skips
99
+ return 1, last + 1, skips + 1
100
+
101
+
102
+ def _walk_table(src: sqlite3.Connection, dst: sqlite3.Connection, name: str) -> tuple[int, bool]:
103
+ """Adaptive rowid walk — one bad page loses only its own rows,
104
+ never healthy neighbours."""
105
+ q = _quote(name)
106
+ table_info_sql = f"PRAGMA table_info({q})" # identifier quoted above
107
+ cols = [r[1] for r in dst.execute(table_info_sql).fetchall()]
108
+ if not cols:
109
+ return 0, True
110
+ col_list = ",".join(["rowid"] + [_quote(c) for c in cols])
111
+ marks = ",".join("?" * (len(cols) + 1))
112
+ insert = f"INSERT OR IGNORE INTO {q} ({col_list}) VALUES ({marks})"
113
+ select_sql = f"SELECT rowid,* FROM {q} WHERE rowid > ? ORDER BY rowid LIMIT ?"
114
+ copied, errors, skips, last, chunk = 0, False, 0, 0, _SALVAGE_CHUNK
115
+ while skips < _MAX_SKIPS:
116
+ try:
117
+ rows = src.execute(select_sql, (last, chunk)).fetchall()
118
+ except sqlite3.Error as exc:
119
+ if isinstance(exc, sqlite3.OperationalError) and "rowid" in str(exc).lower():
120
+ return _copy_whole(src, dst, name) # WITHOUT ROWID table
121
+ errors = True
122
+ chunk, last, skips = _narrow_or_step(chunk, last, skips)
123
+ continue
124
+ if not rows:
125
+ break
126
+ dst.executemany(insert, rows)
127
+ copied, last, chunk = copied + len(rows), rows[-1][0], _SALVAGE_CHUNK
128
+ return copied, errors
129
+
130
+
131
+ def _salvage_into(src: sqlite3.Connection, tmp_path: Path) -> SalvageOutcome:
132
+ """Copy schema + readable rows into ``tmp_path`` (table-level stats)."""
133
+ objects = src.execute(
134
+ "SELECT type, name, sql FROM sqlite_master "
135
+ "WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'"
136
+ ).fetchall()
137
+ out = SalvageOutcome()
138
+ dst = sqlite3.connect(tmp_path)
139
+ try:
140
+ for kind, name, sql in objects:
141
+ if kind != "table":
142
+ continue
143
+ try:
144
+ dst.execute(sql)
145
+ except sqlite3.Error:
146
+ out.hit_errors = True
147
+ continue # untranslatable table def — skip whole table
148
+ copied, errors = _walk_table(src, dst, name)
149
+ out.copied += copied
150
+ out.hit_errors = out.hit_errors or errors
151
+ est = _count_rows(src, name)
152
+ if est is not None:
153
+ out.countable_est += est
154
+ out.countable_copied += copied
155
+ for kind, _name, sql in objects:
156
+ if kind == "table":
157
+ continue
158
+ try:
159
+ dst.execute(sql)
160
+ except sqlite3.Error:
161
+ pass # an index over corrupt data — non-fatal
162
+ dst.commit()
163
+ return out
164
+ finally:
165
+ dst.close()
166
+
167
+
168
+ def _floor_ok(out: SalvageOutcome) -> bool:
169
+ """Refuse a rebuild that silently emptied or gutted the store."""
170
+ had_rows = out.countable_est > 0 or out.hit_errors
171
+ if out.copied == 0 and had_rows:
172
+ return False
173
+ if out.countable_est > 0:
174
+ return out.countable_copied >= int(out.countable_est * _FLOOR_FRACTION)
175
+ return True
176
+
177
+
178
+ def _rebuilt_is_healthy(tmp_path: Path, out: SalvageOutcome) -> bool:
179
+ """Verify BEFORE touching the original: integrity ok + row floor."""
180
+ if not _floor_ok(out):
181
+ return False
182
+ try:
183
+ check = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True)
184
+ try:
185
+ row = check.execute("PRAGMA integrity_check").fetchone()
186
+ return bool(row) and str(row[0]).lower() == "ok"
187
+ finally:
188
+ check.close()
189
+ except sqlite3.Error:
190
+ return False
191
+
192
+
193
+ def _swap_in(db: Path, tmp: Path, bak: Path, copied: int) -> RecoveryResult:
194
+ """Back up the corrupt original, then atomically install the rebuild."""
195
+ shutil.copyfile(db, bak)
196
+ os.replace(tmp, db)
197
+ for suffix in ("-wal", "-shm"):
198
+ Path(f"{db}{suffix}").unlink(missing_ok=True)
199
+ return RecoveryResult(recovered=True, backup_path=str(bak), rows_recovered=copied)
200
+
201
+
202
+ def recover(db_path: str | Path) -> RecoveryResult:
203
+ """Attempt in-place recovery of a corrupt SQLite file (see module doc)."""
204
+ db = Path(db_path)
205
+ if not db.is_file():
206
+ return RecoveryResult(reason="no-db")
207
+ ts = int(time.time() * 1000)
208
+ tmp = db.with_name(f"{db.name}.recovering-{ts}")
209
+ bak = db.with_name(f"{db.name}.corrupt-{ts}.bak")
210
+ src = sqlite3.connect(db, timeout=1.5, isolation_level=None)
211
+ try:
212
+ if _quick_check_ok(src):
213
+ return RecoveryResult(reason="not-corrupt")
214
+ try:
215
+ src.execute("BEGIN IMMEDIATE")
216
+ except sqlite3.OperationalError:
217
+ return RecoveryResult(reason="writer-active")
218
+ except sqlite3.DatabaseError:
219
+ # Not even lockable (e.g. "file is not a database") — nothing
220
+ # sqlite3 can salvage; leave the original byte-for-byte intact.
221
+ return RecoveryResult(reason="unreadable")
222
+ try:
223
+ outcome = _salvage_into(src, tmp)
224
+ except sqlite3.Error:
225
+ tmp.unlink(missing_ok=True)
226
+ return RecoveryResult(reason="unreadable")
227
+ finally:
228
+ try:
229
+ src.execute("ROLLBACK")
230
+ except sqlite3.Error:
231
+ pass
232
+ finally:
233
+ src.close()
234
+ if not _rebuilt_is_healthy(tmp, outcome):
235
+ tmp.unlink(missing_ok=True)
236
+ return RecoveryResult(reason="verify-failed")
237
+ return _swap_in(db, tmp, bak, outcome.copied)
238
+
239
+
240
+ def open_with_recovery(db_path: str | Path, opener):
241
+ """Run ``opener()``; on DatabaseError recover ONCE and retry.
242
+
243
+ ``opener`` is a zero-arg callable that opens/initialises the store.
244
+ A second failure propagates — callers keep their own degraded paths.
245
+ """
246
+ try:
247
+ return opener()
248
+ except sqlite3.DatabaseError:
249
+ result = recover(db_path)
250
+ if not result.recovered:
251
+ raise
252
+ return opener()
253
+
254
+
255
+ def main(argv: list[str] | None = None) -> int:
256
+ args = argv if argv is not None else sys.argv[1:]
257
+ if len(args) != 1:
258
+ print("usage: python3 -m core.shared.sqlite_recovery <db_path>")
259
+ return 2
260
+ result = recover(args[0])
261
+ print(json.dumps(result.model_dump()))
262
+ return 0 if result.recovered or result.reason == "not-corrupt" else 1
263
+
264
+
265
+ if __name__ == "__main__": # pragma: no cover
266
+ raise SystemExit(main())
@@ -68,11 +68,19 @@ class AgentExperiencesLayer(Layer):
68
68
  if not experiences:
69
69
  return self._empty_result(start, tag=f"[agent-experiences:{target} none]")
70
70
 
71
- content = format_experiences(target, experiences)
71
+ # F1-C2 decay: stale lessons collapse to a count line instead of
72
+ # full injection — history is never lost (JSONL untouched), it
73
+ # just stops paying context rent.
74
+ fresh, faded_count = _split_by_decay(experiences)
75
+ if not fresh:
76
+ return self._empty_result(
77
+ start, tag=f"[agent-experiences:{target} faded:{faded_count}]"
78
+ )
79
+ content = format_experiences(target, fresh, faded_count=faded_count)
72
80
  ms = int((time.time() - start) * 1000)
73
81
  return LayerResult(
74
82
  layer_id=self.id,
75
- tag=f"[agent-experiences:{target} count:{len(experiences)}]",
83
+ tag=f"[agent-experiences:{target} count:{len(fresh)}]",
76
84
  content=content,
77
85
  tokens_est=max(1, len(content) // 4),
78
86
  compute_ms=ms,
@@ -100,7 +108,33 @@ def _extract_dispatch_target(user_input: str) -> str | None:
100
108
  return matches[-1].group(1).lower()
101
109
 
102
110
 
103
- def format_experiences(target: str, experiences: list[Experience]) -> str:
111
+ def _split_by_decay(experiences: list[Experience]) -> tuple[list[Experience], int]:
112
+ """Partition lessons into fresh (rendered) and faded (counted only).
113
+
114
+ Read-time only: decay for an append-only experience JSONL needs no
115
+ reinforcement field — a lesson's age IS its ``ts``. Config resolved
116
+ ONCE per call (hot-path hoisting mandated by core/shared/decay.py).
117
+ """
118
+ from core.shared.decay import (
119
+ INJECTION_FLOOR,
120
+ decay_enabled,
121
+ decayed_weight,
122
+ half_life_days,
123
+ )
124
+
125
+ if not decay_enabled():
126
+ return experiences, 0
127
+ hl = half_life_days()
128
+ fresh = [
129
+ exp for exp in experiences
130
+ if decayed_weight(exp.ts, half_life=hl, enabled=True) >= INJECTION_FLOOR
131
+ ]
132
+ return fresh, len(experiences) - len(fresh)
133
+
134
+
135
+ def format_experiences(
136
+ target: str, experiences: list[Experience], faded_count: int = 0
137
+ ) -> str:
104
138
  """Render a compact, model-readable summary of past lessons."""
105
139
  lines = [f"Past lessons for {target} (most recent first):"]
106
140
  for i, exp in enumerate(experiences, start=1):
@@ -117,5 +151,10 @@ def format_experiences(target: str, experiences: list[Experience]) -> str:
117
151
  if exp.references:
118
152
  refs = ", ".join(exp.references[:2])
119
153
  lines.append(f" refs: {refs}")
154
+ if faded_count > 0:
155
+ lines.append(
156
+ f" +{faded_count} older lesson(s) faded from injection "
157
+ f"(history kept on disk)"
158
+ )
120
159
  lines.append("Apply these lessons proactively. Do not repeat the rejected patterns.")
121
160
  return "\n".join(lines)
@@ -205,6 +205,8 @@ def create_default_engine(
205
205
  from core.synapse.graph_context_layer import GraphContextLayer
206
206
  from core.synapse.pattern_library_layer import PatternLibraryLayer
207
207
  from core.synapse.recipe_layer import RecipeLayer
208
+ from core.synapse.routing_feedback_layer import RoutingFeedbackLayer
209
+ from core.synapse.session_memory_layer import SessionMemoryLayer
208
210
 
209
211
  engine = SynapseEngine()
210
212
 
@@ -236,6 +238,12 @@ def create_default_engine(
236
238
  engine.register_layer(BranchLayer())
237
239
  engine.register_layer(CommandHintsLayer(commands=commands))
238
240
  engine.register_layer(QualityGateLayer())
241
+ # L5.5 (F1-B2) — routing feedback. When the detected department has a
242
+ # poor recent QG record (routing-scores.json, F1-B1), injects
243
+ # [arka:redo-risk] with citable counts. Silent below 5 samples or at
244
+ # healthy approval — warnings only, never noise. Closes the second
245
+ # learning loop of the memory reform.
246
+ engine.register_layer(RoutingFeedbackLayer())
239
247
  # L7 TimeLayer removed (prompt-surface P0 2026-07-08): the time-of-day tag
240
248
  # had no consumer rule and invalidated the prompt cache at every
241
249
  # 5h/12h/18h boundary — same rationale as the session-start hook's
@@ -252,5 +260,10 @@ def create_default_engine(
252
260
  # work. Inert when no recipes are captured.
253
261
  engine.register_layer(RecipeLayer())
254
262
  engine.register_layer(SessionContextLayer())
263
+ # L9.5 (F1-A3) — cross-session semantic turn memory. Reads the cache
264
+ # the detached turn-capture worker precomputed (labeled ranked@HH:MMZ
265
+ # / pre-ranked) plus a live keyword LIKE; NEVER embeds on the prompt
266
+ # path. Registered unconditionally: inert when the store is empty.
267
+ engine.register_layer(SessionMemoryLayer())
255
268
 
256
269
  return engine
@@ -79,17 +79,40 @@ class RecipeLayer(Layer):
79
79
 
80
80
 
81
81
  def _match_recipes(keywords: list[str], limit: int) -> list[Recipe]:
82
- """Rank recipes by keyword overlap with feature_keywords + stack."""
82
+ """Rank recipes by keyword overlap, faded by age (F1-C2 decay).
83
+
84
+ Overlap is multiplied by the decayed weight of the recipe's
85
+ ``provenance.captured_at`` — a stale recipe fades from injection
86
+ (never from disk) unless re-captured. Config resolved ONCE per call
87
+ (hot-path hoisting mandated by core/shared/decay.py).
88
+ """
89
+ from core.shared.decay import (
90
+ INJECTION_FLOOR,
91
+ decay_enabled,
92
+ decayed_weight,
93
+ half_life_days,
94
+ )
95
+
83
96
  kw = set(keywords)
84
- scored: list[tuple[int, Recipe]] = []
97
+ decay_on = decay_enabled()
98
+ hl = half_life_days() if decay_on else 0.0
99
+ scored: list[tuple[float, Recipe]] = []
85
100
  for recipe in list_recipes():
86
101
  haystack = {
87
102
  w.lower()
88
103
  for w in (recipe.feature_keywords + recipe.stack)
89
104
  }
90
105
  overlap = len(kw & haystack)
91
- if overlap:
92
- scored.append((overlap, recipe))
106
+ if not overlap:
107
+ continue
108
+ weight = 1.0
109
+ if decay_on:
110
+ weight = decayed_weight(
111
+ recipe.provenance.captured_at, half_life=hl, enabled=True
112
+ )
113
+ if weight < INJECTION_FLOOR:
114
+ continue # fades from injection only — recipe.json untouched
115
+ scored.append((overlap * weight, recipe))
93
116
  scored.sort(key=lambda pair: (-pair[0], pair[1].slug))
94
117
  return [recipe for _, recipe in scored[:limit]]
95
118
 
@@ -0,0 +1,132 @@
1
+ """Synapse L5.5 — routing feedback injection (F1-B2).
2
+
3
+ CLOSES the second learning loop: the F1-B1 aggregator turned QG/judge
4
+ verdicts into ``~/.arkaos/routing-scores.json``; this layer reads it at
5
+ prompt time and warns when the detected department has a poor recent
6
+ approval record, citing exact counts (evidence-flow: numbers the
7
+ orchestrator can verify, never vibes):
8
+
9
+ [arka:redo-risk] frontend: 3/9 approved (90d, smoothed 0.36).
10
+ Top blockers: function-length, missing-tests. Dispatch the
11
+ specialist early; require evidence before the QG.
12
+
13
+ Silence rules (honesty over noise):
14
+ - fewer than ``_MIN_SAMPLES`` QG verdicts → SILENT (no warnings
15
+ from statistical noise)
16
+ - smoothed approval >= ``_RISK_THRESHOLD`` → silent (no praise
17
+ spam; the layer only warns)
18
+ - no department detected in the prompt → silent
19
+
20
+ Flag: ``synapse.l55RoutingFeedback`` (default ON) + env ``ARKA_BYPASS_L55``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import time
28
+ from pathlib import Path
29
+
30
+ from core.synapse.layers import Layer, LayerResult, PromptContext
31
+
32
+ _MIN_SAMPLES = 5
33
+ _RISK_THRESHOLD = 0.5
34
+ _CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
35
+
36
+
37
+ def _l55_feature_flag_on() -> bool:
38
+ if os.environ.get("ARKA_BYPASS_L55", "").strip() == "1":
39
+ return False
40
+ try:
41
+ data = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
42
+ except (OSError, json.JSONDecodeError):
43
+ return True
44
+ if not isinstance(data, dict):
45
+ return True # malformed-but-valid JSON (list/str) => documented default ON
46
+ synapse_cfg = data.get("synapse") or {}
47
+ return bool(synapse_cfg.get("l55RoutingFeedback", True))
48
+
49
+
50
+ def _detect_department(ctx: PromptContext) -> str:
51
+ """Reuse L1's keyword detection — one source of routing truth."""
52
+ try:
53
+ from core.synapse.layers import DepartmentLayer
54
+
55
+ result = DepartmentLayer().compute(ctx)
56
+ # L1 tags as [dept:<slug>] — extract the slug.
57
+ tag = result.tag or ""
58
+ if tag.startswith("[dept:") and tag.endswith("]"):
59
+ return tag[len("[dept:"):-1]
60
+ except Exception: # detection is best-effort
61
+ pass
62
+ return ""
63
+
64
+
65
+ class RoutingFeedbackLayer(Layer):
66
+ """L5.5: cite the department's recent QG record when it warns."""
67
+
68
+ @property
69
+ def id(self) -> str:
70
+ return "L5.5"
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ return "RoutingFeedback"
75
+
76
+ @property
77
+ def cache_ttl(self) -> int:
78
+ return 300 # scores rebuild at most hourly — 5 min cache is safe
79
+
80
+ @property
81
+ def input_sensitive(self) -> bool:
82
+ return True # department detection depends on the prompt
83
+
84
+ @property
85
+ def priority(self) -> int:
86
+ return 55 # after L5 CommandHints (50), before L6 QualityGate (60)
87
+
88
+ def compute(self, ctx: PromptContext) -> LayerResult:
89
+ start = time.time()
90
+ content = ""
91
+ try:
92
+ if ctx.user_input and _l55_feature_flag_on():
93
+ content = self._warn_if_risky(ctx)
94
+ except Exception: # layer must never break the prompt
95
+ content = ""
96
+ elapsed = int((time.time() - start) * 1000)
97
+ if not content:
98
+ return LayerResult(layer_id=self.id, tag="", content="",
99
+ tokens_est=0, compute_ms=elapsed, cached=False)
100
+ return LayerResult(
101
+ layer_id=self.id,
102
+ tag=content,
103
+ content=content,
104
+ tokens_est=len(content.split()),
105
+ compute_ms=elapsed,
106
+ cached=False,
107
+ )
108
+
109
+ def _warn_if_risky(self, ctx: PromptContext) -> str:
110
+ department = _detect_department(ctx)
111
+ if not department:
112
+ return ""
113
+ from core.governance.routing_feedback import load_scores
114
+
115
+ scores = load_scores()
116
+ if scores is None:
117
+ return ""
118
+ score = next(
119
+ (s for s in scores.scores if s.department == department), None
120
+ )
121
+ if score is None or score.samples < _MIN_SAMPLES:
122
+ return "" # silence below the sample floor — noise is not signal
123
+ if score.smoothed_approval >= _RISK_THRESHOLD:
124
+ return ""
125
+ blockers = ", ".join(score.top_blocker_patterns) or "n/a"
126
+ return (
127
+ f"[arka:redo-risk] {department}: {score.approvals}/{score.samples}"
128
+ f" approved ({scores.window_days}d,"
129
+ f" smoothed {score.smoothed_approval:.2f})."
130
+ f" Top blockers: {blockers}."
131
+ f" Dispatch the specialist early; require evidence before the QG."
132
+ )