arkaos 4.13.2 → 4.14.1
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/VERSION +1 -1
- package/arka/SKILL.md +16 -0
- package/bin/arka-doctor +20 -1
- package/config/cognition/schedules.yaml +16 -0
- package/config/hooks/_lib/fastpath/engine.cjs +337 -0
- package/config/hooks/gate-manifest.json +741 -0
- package/config/hooks/post-tool-use.cjs +113 -0
- package/config/hooks/pre-tool-use.cjs +109 -0
- package/config/hooks/session-end.ps1 +40 -0
- package/config/hooks/session-end.sh +55 -0
- package/config/hooks/session-start.sh +25 -189
- package/config/hooks/subagent-stop.ps1 +40 -0
- package/config/hooks/subagent-stop.sh +56 -0
- package/config/mcp-policy.yaml +8 -0
- package/config/skills-curated.yaml +39 -0
- package/config/statusline.sh +57 -1
- package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
- package/core/cognition/capture/store.py +7 -2
- package/core/evals/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/evals/__pycache__/sanitizer.cpython-314.pyc +0 -0
- package/core/evals/__pycache__/schema.cpython-314.pyc +0 -0
- package/core/evals/__pycache__/verdict_labels.cpython-314.pyc +0 -0
- package/core/forge/__pycache__/complexity.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/judge.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/leak_scanner.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/qg_verdict.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/routing_feedback.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/routing_feedback.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/routing_feedback_cli.cpython-313.pyc +0 -0
- package/core/governance/evidence_checks.py +42 -7
- package/core/governance/routing_feedback.py +229 -0
- package/core/governance/routing_feedback_cli.py +49 -0
- package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/gate_manifest.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/subagent_stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/subagent_stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-314.pyc +0 -0
- package/core/hooks/gate_manifest.py +366 -0
- package/core/hooks/post_tool_use.py +5 -2
- package/core/hooks/session_end.py +127 -0
- package/core/hooks/session_start.py +322 -0
- package/core/hooks/stop.py +69 -0
- package/core/hooks/subagent_stop.py +186 -0
- package/core/hooks/user_prompt_submit.py +10 -6
- package/core/knowledge/__pycache__/embedding_backends.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/embedding_backends.cpython-314.pyc +0 -0
- package/core/knowledge/__pycache__/pattern_cards.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/pattern_cards.cpython-314.pyc +0 -0
- package/core/knowledge/__pycache__/recipes.cpython-314.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-314.pyc +0 -0
- package/core/knowledge/embedding_backends.py +278 -0
- package/core/knowledge/pattern_cards.py +49 -1
- package/core/knowledge/vector_store.py +14 -2
- package/core/memory/__pycache__/semantic_store.cpython-312.pyc +0 -0
- package/core/memory/__pycache__/semantic_store.cpython-313.pyc +0 -0
- package/core/memory/__pycache__/semantic_store.cpython-314.pyc +0 -0
- package/core/memory/__pycache__/turn_capture.cpython-313.pyc +0 -0
- package/core/memory/__pycache__/turn_capture.cpython-314.pyc +0 -0
- package/core/memory/semantic_store.py +301 -0
- package/core/memory/turn_capture.py +277 -0
- package/core/registry/__pycache__/generator.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/mcp_telemetry.cpython-312.pyc +0 -0
- package/core/shared/__pycache__/decay.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/decay.cpython-314.pyc +0 -0
- package/core/shared/__pycache__/sqlite_recovery.cpython-312.pyc +0 -0
- package/core/shared/__pycache__/sqlite_recovery.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/sqlite_recovery.cpython-314.pyc +0 -0
- package/core/shared/decay.py +94 -0
- package/core/shared/sqlite_recovery.py +266 -0
- package/core/synapse/__pycache__/agent_experiences_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/agent_experiences_layer.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/pattern_library_layer.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/recipe_layer.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/routing_feedback_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/routing_feedback_layer.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/session_memory_layer.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/session_memory_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/session_memory_layer.cpython-314.pyc +0 -0
- package/core/synapse/agent_experiences_layer.py +42 -3
- package/core/synapse/engine.py +13 -0
- package/core/synapse/recipe_layer.py +27 -4
- package/core/synapse/routing_feedback_layer.py +132 -0
- package/core/synapse/session_memory_layer.py +152 -0
- package/core/workflow/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/design_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-314.pyc +0 -0
- package/installer/adapters/claude-code.js +37 -0
- package/installer/claude-plugins.js +6 -0
- package/installer/cli.js +23 -2
- package/installer/config-seed.js +25 -16
- package/installer/doctor.js +116 -1
- package/installer/hook-lib.js +28 -0
- package/installer/index.js +48 -153
- package/installer/mcp-runner.js +108 -0
- package/installer/skill-deploy.js +189 -0
- package/installer/skills-mode.js +64 -0
- package/installer/update.js +55 -79
- package/knowledge/commands-registry.json.bak +171 -4
- package/knowledge/skills-manifest.json +2337 -0
- package/mcps/arka-prompts/commands.py +451 -0
- package/mcps/arka-prompts/pyproject.toml +15 -0
- package/mcps/arka-prompts/server.py +156 -0
- package/mcps/arka-tools/__pycache__/server.cpython-313.pyc +0 -0
- package/mcps/arka-tools/pyproject.toml +10 -0
- package/mcps/arka-tools/server.py +319 -0
- package/mcps/profiles/base.json +19 -0
- package/mcps/profiles/brand.json +19 -0
- package/mcps/profiles/comms.json +13 -0
- package/mcps/profiles/content.json +20 -0
- package/mcps/profiles/ecommerce.json +13 -0
- package/mcps/profiles/full-stack.json +15 -0
- package/mcps/profiles/laravel.json +12 -0
- package/mcps/profiles/nextjs.json +13 -0
- package/mcps/profiles/nuxt.json +13 -0
- package/mcps/profiles/react.json +12 -0
- package/mcps/profiles/vue.json +12 -0
- package/mcps/registry.json +423 -0
- package/mcps/scripts/apply-mcps.sh +282 -0
- package/mcps/stacks/laravel-packages.json +49 -0
- package/mcps/stacks/react-packages.json +58 -0
- package/package.json +2 -1
- package/pyproject.toml +9 -1
- package/scripts/__pycache__/marketplace_gen.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-314.pyc +0 -0
- package/scripts/marketplace_gen.py +310 -0
- package/scripts/tools/__pycache__/docs_stats.cpython-313.pyc +0 -0
- package/scripts/tools/__pycache__/skill_budget.cpython-313.pyc +0 -0
- package/scripts/tools/docs_stats.py +6 -4
- 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())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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)
|
package/core/synapse/engine.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|