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,301 @@
1
+ """Session semantic memory store (F1-A2 — memory/learning reform).
2
+
3
+ Per-turn transcript memory at ``~/.arkaos/session-memory.db``: every
4
+ turn is summarised, sanitized, optionally embedded (multi-backend, see
5
+ core/knowledge/embedding_backends.py) and stored. Retrieval happens in
6
+ two honest modes, labeled exactly like core/knowledge/vector_store.py:
7
+
8
+ - ``semantic`` — cosine over stored embeddings, computed OFF
9
+ the hot path (the detached turn_capture worker
10
+ precomputes neighbours into a session cache)
11
+ - ``keyword-degraded`` — live LIKE query, ``score`` is None
12
+
13
+ No vec0 dependency by design: the read path NEVER embeds, and the
14
+ worker's brute-force cosine over the bounded scan window (most recent
15
+ rows with embeddings) runs off-turn, so an extension adds failure modes
16
+ without buying latency where it matters. Embeddings are only compared
17
+ within the same backend+model+dims — cosine across vector spaces is a
18
+ meaningless number dressed as similarity, so mismatched rows are skipped.
19
+
20
+ The store is born self-healing (core/shared/sqlite_recovery.py).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import math
27
+ import os
28
+ import re
29
+ import sqlite3
30
+ import uuid
31
+ from contextlib import closing
32
+ from datetime import UTC, datetime, timedelta
33
+ from pathlib import Path
34
+
35
+ from pydantic import BaseModel, Field
36
+
37
+ DB_ENV = "ARKA_SESSION_MEMORY_DB"
38
+ _SEMANTIC_SCAN_CAP = 2000 # most-recent embedded rows the worker scans
39
+ _DEFAULT_RETENTION_DAYS = 90
40
+ _DEFAULT_MAX_ROWS = 20_000
41
+
42
+
43
+ class TurnRecord(BaseModel):
44
+ """One captured turn — embeddings always declare their provenance."""
45
+
46
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
47
+ ts: str = ""
48
+ session_id: str = ""
49
+ project_name: str = ""
50
+ cwd: str = ""
51
+ summary: str = ""
52
+ tools_used: list[str] = Field(default_factory=list)
53
+ file_paths: list[str] = Field(default_factory=list)
54
+ importance: float = 0.5
55
+ embedding: list[float] | None = None
56
+ embedding_backend: str = "none"
57
+ embedding_model: str = ""
58
+ dims: int = 0
59
+
60
+
61
+ def default_db_path() -> Path:
62
+ env = os.environ.get(DB_ENV, "").strip()
63
+ if env:
64
+ return Path(env)
65
+ return Path.home() / ".arkaos" / "session-memory.db"
66
+
67
+
68
+ _TAG_TOKEN_RE = re.compile(r"\[([^\]]*)\]")
69
+
70
+
71
+ def neutralize_summary(text: str) -> str:
72
+ """Read-side neutralization before any context injection (OWASP LLM01):
73
+ collapse whitespace so a stored summary cannot forge a new line, and
74
+ defuse ``[tag]`` tokens into ``(tag)`` so it cannot impersonate
75
+ ``[SESSION-MEMORY]``/``[arka:*]`` markers. Write-time sanitization
76
+ only redacts client identifiers — it does not neutralize payloads."""
77
+ collapsed = " ".join(str(text).split())
78
+ return _TAG_TOKEN_RE.sub(r"(\1)", collapsed)
79
+
80
+
81
+ def _cosine(a: list[float], b: list[float]) -> float:
82
+ # Callers guarantee equal dims (backend+model+dims guard upstream).
83
+ dot = sum(x * y for x, y in zip(a, b, strict=True))
84
+ norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
85
+ return dot / norm if norm else 0.0
86
+
87
+
88
+ class SessionMemoryStore:
89
+ """SQLite-backed turn memory; connections opened per operation."""
90
+
91
+ def __init__(self, db_path: str | Path | None = None) -> None:
92
+ self._db_path = Path(db_path) if db_path else default_db_path()
93
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
94
+ from core.shared.sqlite_recovery import open_with_recovery
95
+ open_with_recovery(self._db_path, self._init_db)
96
+
97
+ def _conn(self) -> sqlite3.Connection:
98
+ conn = sqlite3.connect(self._db_path)
99
+ conn.row_factory = sqlite3.Row
100
+ conn.execute("PRAGMA journal_mode=WAL")
101
+ return conn
102
+
103
+ def _init_db(self) -> None:
104
+ # closing() closes the connection; the inner `conn` context
105
+ # commits the transaction — sqlite3's context manager alone
106
+ # commits but never closes (leaks in long-lived consumers).
107
+ with closing(self._conn()) as conn, conn:
108
+ conn.execute("""
109
+ CREATE TABLE IF NOT EXISTS turns (
110
+ id TEXT PRIMARY KEY,
111
+ ts TEXT NOT NULL,
112
+ session_id TEXT NOT NULL,
113
+ project_name TEXT NOT NULL DEFAULT '',
114
+ cwd TEXT NOT NULL DEFAULT '',
115
+ summary TEXT NOT NULL DEFAULT '',
116
+ tools_used TEXT NOT NULL DEFAULT '[]',
117
+ file_paths TEXT NOT NULL DEFAULT '[]',
118
+ importance REAL NOT NULL DEFAULT 0.5,
119
+ embedding TEXT,
120
+ embedding_backend TEXT NOT NULL DEFAULT 'none',
121
+ embedding_model TEXT NOT NULL DEFAULT '',
122
+ dims INTEGER NOT NULL DEFAULT 0
123
+ )
124
+ """)
125
+ conn.execute(
126
+ "CREATE INDEX IF NOT EXISTS idx_turns_project_ts "
127
+ "ON turns (project_name, ts DESC)"
128
+ )
129
+ conn.execute(
130
+ "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns (session_id)"
131
+ )
132
+
133
+ def save(self, record: TurnRecord) -> None:
134
+ row = record.model_dump()
135
+ row["tools_used"] = json.dumps(record.tools_used)
136
+ row["file_paths"] = json.dumps(record.file_paths)
137
+ row["embedding"] = (
138
+ json.dumps(record.embedding) if record.embedding is not None else None
139
+ )
140
+ with closing(self._conn()) as conn, conn:
141
+ conn.execute(
142
+ "INSERT OR REPLACE INTO turns (id, ts, session_id, project_name,"
143
+ " cwd, summary, tools_used, file_paths, importance, embedding,"
144
+ " embedding_backend, embedding_model, dims)"
145
+ " VALUES (:id, :ts, :session_id, :project_name, :cwd, :summary,"
146
+ " :tools_used, :file_paths, :importance, :embedding,"
147
+ " :embedding_backend, :embedding_model, :dims)",
148
+ row,
149
+ )
150
+
151
+ def _row_to_record(self, row: sqlite3.Row) -> TurnRecord:
152
+ data = dict(row)
153
+ data["tools_used"] = json.loads(data.get("tools_used") or "[]")
154
+ data["file_paths"] = json.loads(data.get("file_paths") or "[]")
155
+ raw_emb = data.get("embedding")
156
+ data["embedding"] = json.loads(raw_emb) if raw_emb else None
157
+ return TurnRecord(**data)
158
+
159
+ def recent(
160
+ self,
161
+ project_name: str | None = None,
162
+ limit: int = 20,
163
+ exclude_session: str | None = None,
164
+ ) -> list[TurnRecord]:
165
+ """Most recent turns, importance-weighted recency order."""
166
+ where, params = self._scope(project_name, exclude_session)
167
+ with closing(self._conn()) as conn:
168
+ rows = conn.execute(
169
+ f"SELECT * FROM turns {where} ORDER BY ts DESC LIMIT ?",
170
+ [*params, limit],
171
+ ).fetchall()
172
+ records = [self._row_to_record(r) for r in rows]
173
+ return sorted(records, key=lambda r: (r.importance, r.ts), reverse=True)
174
+
175
+ def _scope(
176
+ self, project_name: str | None, exclude_session: str | None
177
+ ) -> tuple[str, list]:
178
+ clauses, params = [], []
179
+ if project_name:
180
+ clauses.append("project_name = ?")
181
+ params.append(project_name)
182
+ if exclude_session:
183
+ clauses.append("session_id != ?")
184
+ params.append(exclude_session)
185
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
186
+ return where, params
187
+
188
+ def keyword_search(
189
+ self, query: str, project_name: str | None = None, top_k: int = 3
190
+ ) -> list[dict]:
191
+ """Live substring match — labeled, never presented as semantic."""
192
+ words = [w for w in query.lower().split() if len(w) > 2][:5]
193
+ if not words:
194
+ return []
195
+ where, params = self._scope(project_name, None)
196
+ like = " OR ".join(["lower(summary) LIKE ?"] * len(words))
197
+ where = f"{where} AND ({like})" if where else f"WHERE {like}"
198
+ with closing(self._conn()) as conn:
199
+ rows = conn.execute(
200
+ f"SELECT * FROM turns {where} ORDER BY ts DESC LIMIT ?",
201
+ params + [f"%{w}%" for w in words] + [top_k],
202
+ ).fetchall()
203
+ return [
204
+ {**self._row_to_record(r).model_dump(exclude={"embedding"}),
205
+ "score": None, "retrieval": "keyword-degraded"}
206
+ for r in rows
207
+ ]
208
+
209
+ def semantic_neighbors(
210
+ self,
211
+ vector: list[float],
212
+ project_name: str | None = None,
213
+ top_k: int = 3,
214
+ exclude_session: str | None = None,
215
+ backend: str = "",
216
+ model: str = "",
217
+ ) -> list[dict]:
218
+ """Brute-force cosine over the bounded scan window (off-turn only).
219
+
220
+ Rows are SKIPPED unless backend, model AND dims all match the
221
+ query vector's provenance — cosine across different embedding
222
+ spaces yields a meaningless number dressed as similarity, the
223
+ exact silent lie the honesty labels exist to prevent.
224
+ """
225
+ where, params = self._scope(project_name, exclude_session)
226
+ prefix = f"{where} AND" if where else "WHERE"
227
+ with closing(self._conn()) as conn:
228
+ rows = conn.execute(
229
+ f"SELECT * FROM turns {prefix} embedding IS NOT NULL"
230
+ " ORDER BY ts DESC LIMIT ?",
231
+ [*params, _SEMANTIC_SCAN_CAP],
232
+ ).fetchall()
233
+ scored = []
234
+ for row in rows:
235
+ record = self._row_to_record(row)
236
+ if not record.embedding or record.dims != len(vector):
237
+ continue
238
+ if record.embedding_backend != backend or record.embedding_model != model:
239
+ continue
240
+ score = _cosine(vector, record.embedding)
241
+ scored.append(
242
+ {**record.model_dump(exclude={"embedding"}),
243
+ "score": score, "retrieval": "semantic"}
244
+ )
245
+ scored.sort(key=lambda d: d["score"], reverse=True)
246
+ return scored[:top_k]
247
+
248
+ def backfill_candidates(self, limit: int = 10) -> list[TurnRecord]:
249
+ with closing(self._conn()) as conn:
250
+ rows = conn.execute(
251
+ "SELECT * FROM turns WHERE embedding IS NULL AND summary != ''"
252
+ " ORDER BY ts DESC LIMIT ?",
253
+ (limit,),
254
+ ).fetchall()
255
+ return [self._row_to_record(r) for r in rows]
256
+
257
+ def update_embedding(
258
+ self, turn_id: str, vector: list[float], backend: str, model: str
259
+ ) -> None:
260
+ with closing(self._conn()) as conn, conn:
261
+ conn.execute(
262
+ "UPDATE turns SET embedding = ?, embedding_backend = ?,"
263
+ " embedding_model = ?, dims = ? WHERE id = ?",
264
+ (json.dumps(vector), backend, model, len(vector), turn_id),
265
+ )
266
+
267
+ def prune(
268
+ self,
269
+ retention_days: int = _DEFAULT_RETENTION_DAYS,
270
+ max_rows: int = _DEFAULT_MAX_ROWS,
271
+ ) -> int:
272
+ cutoff = (
273
+ datetime.now(UTC) - timedelta(days=retention_days)
274
+ ).isoformat()
275
+ with closing(self._conn()) as conn, conn:
276
+ removed = conn.execute(
277
+ "DELETE FROM turns WHERE ts < ?", (cutoff,)
278
+ ).rowcount
279
+ removed += conn.execute(
280
+ "DELETE FROM turns WHERE id NOT IN"
281
+ " (SELECT id FROM turns ORDER BY ts DESC LIMIT ?)",
282
+ (max_rows,),
283
+ ).rowcount
284
+ return removed
285
+
286
+ def vacuum(self) -> None:
287
+ """Nightly-maintenance compaction (never on a hook hot path)."""
288
+ with closing(self._conn()) as conn:
289
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
290
+ conn.execute("VACUUM")
291
+
292
+ def stats(self) -> dict:
293
+ with closing(self._conn()) as conn:
294
+ total = conn.execute("SELECT COUNT(*) AS c FROM turns").fetchone()["c"]
295
+ backends = dict(
296
+ conn.execute(
297
+ "SELECT embedding_backend, COUNT(*) FROM turns"
298
+ " GROUP BY embedding_backend"
299
+ ).fetchall()
300
+ )
301
+ return {"total_turns": total, "by_embedding_backend": backends}
@@ -0,0 +1,277 @@
1
+ """Detached turn-capture worker (F1-A2 — memory/learning reform).
2
+
3
+ Enqueued by the Stop hook via ``Popen(start_new_session=True)`` — ALL
4
+ embedding/summarisation cost is paid here, off-turn, never on a hook's
5
+ hot path. Pipeline per turn:
6
+
7
+ transcript tail → summary → sanitize (MANDATORY — recipes precedent:
8
+ no sanitizer config ⇒ text is refused, metadata-only record) →
9
+ importance → embed (multi-backend, provenance declared) → store →
10
+ precompute cross-session semantic neighbours into the session cache
11
+ PRODUCED FOR the F1-A3 retrieval layer (no reader exists in this PR)
12
+ → amortised maintenance (embedding backfill + retention prune).
13
+
14
+ CLI: ``python3 -m core.memory.turn_capture (<session_id> <transcript_path> [cwd] | maintenance)``
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import tempfile
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+
27
+ from core.knowledge.embedding_backends import EmbeddingResult, embed
28
+ from core.memory.semantic_store import SessionMemoryStore, TurnRecord
29
+
30
+ _TAIL_BYTES = 262_144 # bounded transcript read
31
+ _SUMMARY_CHARS = 600
32
+ _CACHE_DIRNAME = "context-cache"
33
+ _NEIGHBOUR_K = 3
34
+ _BACKFILL_BATCH = 10
35
+
36
+
37
+ def _config_enabled() -> bool:
38
+ """``memory.sessionMemory`` (default True) + env kill-switch."""
39
+ if os.environ.get("ARKA_SESSION_MEMORY", "").strip() == "0":
40
+ return False
41
+ config_path = Path.home() / ".arkaos" / "config.json"
42
+ try:
43
+ data = json.loads(config_path.read_text(encoding="utf-8"))
44
+ except (OSError, json.JSONDecodeError):
45
+ return True
46
+ memory_cfg = data.get("memory") or {}
47
+ return bool(memory_cfg.get("sessionMemory", True))
48
+
49
+
50
+ def _read_tail(transcript_path: str) -> str:
51
+ path = Path(transcript_path)
52
+ if not path.is_file():
53
+ return ""
54
+ size = path.stat().st_size
55
+ with path.open("rb") as fh:
56
+ fh.seek(max(0, size - _TAIL_BYTES))
57
+ return fh.read().decode("utf-8", "replace")
58
+
59
+
60
+ def _last_assistant_text(transcript_path: str, raw: str) -> str:
61
+ try:
62
+ from core.workflow.flow_enforcer import _load_last_assistant_messages
63
+ msgs = _load_last_assistant_messages(transcript_path, 1, raw_text=raw)
64
+ return msgs[-1] if msgs else ""
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ def _parse_tool_uses(raw: str) -> tuple[list[str], list[str]]:
70
+ """Collect tool names + touched file paths from the transcript tail."""
71
+ tools: list[str] = []
72
+ paths: list[str] = []
73
+ for line in raw.splitlines()[-400:]:
74
+ try:
75
+ entry = json.loads(line)
76
+ except (json.JSONDecodeError, ValueError):
77
+ continue
78
+ content = (entry.get("message") or {}).get("content") or []
79
+ for block in content if isinstance(content, list) else []:
80
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
81
+ continue
82
+ name = str(block.get("name") or "")
83
+ if name and name not in tools:
84
+ tools.append(name)
85
+ file_path = (block.get("input") or {}).get("file_path")
86
+ if isinstance(file_path, str) and file_path not in paths:
87
+ paths.append(file_path)
88
+ return tools[:20], paths[:20]
89
+
90
+
91
+ def _sanitized_summary(text: str) -> tuple[str, bool]:
92
+ """Sanitize or refuse — unsanitized text is never persisted."""
93
+ if not text:
94
+ return "", True
95
+ try:
96
+ from core.evals.sanitizer import SanitizerConfigMissing, sanitize_text
97
+ except Exception:
98
+ return "", False
99
+ try:
100
+ clean, _counts = sanitize_text(text[:_SUMMARY_CHARS])
101
+ return clean, True
102
+ except SanitizerConfigMissing:
103
+ return "", False
104
+
105
+
106
+ _MCP_TOOL_RE = re.compile(r"^mcp__[^_]((?!__).)*__")
107
+
108
+
109
+ def _safe_tool_names(tools: list[str], sanitized_ok: bool) -> list[str]:
110
+ """Tool names are metadata but not neutral: ``mcp__<server>__<tool>``
111
+ carries an operator-chosen server segment that can name a client.
112
+ Sanitizer available → redact through it; unavailable → strip the
113
+ server segment entirely (fail closed, v2.18.0 precedent)."""
114
+ if sanitized_ok:
115
+ try:
116
+ from core.evals.sanitizer import sanitize_text
117
+ return [sanitize_text(name)[0] for name in tools]
118
+ except Exception:
119
+ pass
120
+ return [_MCP_TOOL_RE.sub("mcp__", name) for name in tools]
121
+
122
+
123
+ def _importance(text: str, tools: list[str]) -> float:
124
+ score = 0.5
125
+ lowered = text.lower()
126
+ if re.search(r"\berror\b|\bfailed\b|\bexception\b|traceback", lowered):
127
+ score += 0.2
128
+ if re.search(r"\[arka:qg:|verdict|approved|rejected", lowered):
129
+ score += 0.15
130
+ if any(t in ("Write", "Edit", "NotebookEdit") for t in tools):
131
+ score += 0.1
132
+ return min(score, 1.0)
133
+
134
+
135
+ def _cache_path(session_id: str) -> Path:
136
+ cache_dir = Path.home() / ".arkaos" / _CACHE_DIRNAME
137
+ cache_dir.mkdir(parents=True, exist_ok=True)
138
+ return cache_dir / f"session-memory-{session_id}.json"
139
+
140
+
141
+ def _precompute_cache(
142
+ store: SessionMemoryStore, record: TurnRecord, session_id: str
143
+ ) -> None:
144
+ """Rank cross-session neighbours NOW so the next prompt just reads."""
145
+ if record.embedding is not None:
146
+ items = store.semantic_neighbors(
147
+ record.embedding, record.project_name or None,
148
+ top_k=_NEIGHBOUR_K, exclude_session=session_id,
149
+ backend=record.embedding_backend, model=record.embedding_model,
150
+ )
151
+ retrieval = "semantic"
152
+ else:
153
+ items, retrieval = [], "keyword-degraded"
154
+ payload = {
155
+ "version": 1,
156
+ "session_id": session_id,
157
+ "computed_at": datetime.now(UTC).isoformat(),
158
+ "retrieval": retrieval,
159
+ "embedding_backend": record.embedding_backend,
160
+ "embedding_model": record.embedding_model,
161
+ "dims": record.dims,
162
+ "items": [
163
+ {"summary": i["summary"][:200], "project_name": i["project_name"],
164
+ "ts": i["ts"], "score": i["score"], "retrieval": i["retrieval"]}
165
+ for i in items
166
+ ],
167
+ }
168
+ target = _cache_path(session_id)
169
+ # Unique tmp per writer: overlapping workers for the same session must
170
+ # not share a tmp path, or write+replace stops being atomic.
171
+ fd, tmp_name = tempfile.mkstemp(
172
+ dir=target.parent, prefix=f"{target.name}.", suffix=".tmp"
173
+ )
174
+ try:
175
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
176
+ fh.write(json.dumps(payload, indent=2))
177
+ os.replace(tmp_name, target)
178
+ except OSError:
179
+ Path(tmp_name).unlink(missing_ok=True)
180
+
181
+
182
+ def _maintenance(store: SessionMemoryStore) -> None:
183
+ """Amortised per run: small embedding backfill + retention prune."""
184
+ for candidate in store.backfill_candidates(_BACKFILL_BATCH):
185
+ result: EmbeddingResult = embed(candidate.summary)
186
+ if result.vector is not None:
187
+ store.update_embedding(
188
+ candidate.id, result.vector, result.backend, result.model
189
+ )
190
+ store.prune()
191
+
192
+
193
+ def capture_turn(session_id: str, transcript_path: str, cwd: str = "") -> int:
194
+ if not _config_enabled() or not session_id:
195
+ return 0
196
+ raw = _read_tail(transcript_path)
197
+ if not raw:
198
+ return 0
199
+ text = _last_assistant_text(transcript_path, raw)
200
+ tools, paths = _parse_tool_uses(raw)
201
+ summary, sanitized_ok = _sanitized_summary(text)
202
+ tools = _safe_tool_names(tools, sanitized_ok)
203
+ if not sanitized_ok:
204
+ paths = [] # refuse anything textual when the sanitizer is absent
205
+ result: EmbeddingResult = embed(summary) if summary else EmbeddingResult()
206
+ record = TurnRecord(
207
+ ts=datetime.now(UTC).isoformat(),
208
+ session_id=session_id,
209
+ project_name=Path(cwd).name if cwd else "",
210
+ cwd=cwd,
211
+ summary=summary,
212
+ tools_used=tools,
213
+ file_paths=paths,
214
+ importance=_importance(text, tools),
215
+ embedding=result.vector,
216
+ embedding_backend=result.backend,
217
+ embedding_model=result.model,
218
+ dims=result.dims,
219
+ )
220
+ store = SessionMemoryStore()
221
+ store.save(record)
222
+ _precompute_cache(store, record, session_id)
223
+ _maintenance(store)
224
+ return 0
225
+
226
+
227
+ _MAINTENANCE_MAX_BATCHES = 50
228
+
229
+
230
+ def run_maintenance() -> int:
231
+ """Nightly job (F1-A4, scheduler python_module): full embedding
232
+ backfill in batches, retention prune, vacuum. Off-turn by design —
233
+ the per-turn worker only amortises a small slice of this."""
234
+ if not _config_enabled():
235
+ return 0
236
+ from core.memory.semantic_store import default_db_path
237
+
238
+ if not default_db_path().is_file():
239
+ return 0
240
+ store = SessionMemoryStore()
241
+ for _ in range(_MAINTENANCE_MAX_BATCHES):
242
+ candidates = store.backfill_candidates(_BACKFILL_BATCH)
243
+ if not candidates:
244
+ break
245
+ for candidate in candidates:
246
+ result = embed(candidate.summary)
247
+ if result.vector is None:
248
+ return 0 # backend degraded — retry next night, never spin
249
+ store.update_embedding(
250
+ candidate.id, result.vector, result.backend, result.model
251
+ )
252
+ store.prune()
253
+ store.vacuum()
254
+ return 0
255
+
256
+
257
+ def main(argv: list[str] | None = None) -> int:
258
+ args = argv if argv is not None else sys.argv[1:]
259
+ if args and args[0] == "maintenance":
260
+ try:
261
+ return run_maintenance()
262
+ except Exception: # detached worker must die quietly
263
+ return 0
264
+ if len(args) < 2:
265
+ print(
266
+ "usage: python3 -m core.memory.turn_capture"
267
+ " (<session_id> <transcript_path> [cwd] | maintenance)"
268
+ )
269
+ return 2
270
+ try:
271
+ return capture_turn(args[0], args[1], args[2] if len(args) > 2 else "")
272
+ except Exception: # detached worker must die quietly
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__": # pragma: no cover
277
+ raise SystemExit(main())