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.
- 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/package.json +1 -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,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())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|