nexo-brain 7.27.3 → 7.28.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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/bin/windows-wsl-bridge.js +9 -0
- package/package.json +1 -1
- package/src/causal_graph.py +763 -0
- package/src/classifier_local.py +44 -0
- package/src/cognitive/_core.py +3 -0
- package/src/cognitive_control_observatory.py +2 -0
- package/src/db/__init__.py +8 -0
- package/src/db/_commitments.py +344 -0
- package/src/db/_entities.py +98 -11
- package/src/db/_memory_v2.py +130 -2
- package/src/db/_schema.py +565 -0
- package/src/desktop_bridge.py +1 -1
- package/src/doctor/providers/runtime.py +9 -3
- package/src/enforcement_engine.py +128 -2
- package/src/entity_live_profile.py +1073 -0
- package/src/failure_prevention.py +1052 -0
- package/src/hook_guardrails.py +104 -0
- package/src/knowledge_graph.py +46 -9
- package/src/local_context/api.py +54 -22
- package/src/local_context/usage_events.py +273 -8
- package/src/memory_executive.py +620 -0
- package/src/memory_utility.py +952 -0
- package/src/plugin_loader.py +9 -5
- package/src/plugins/entities.py +84 -7
- package/src/plugins/entity_live_profile.py +101 -0
- package/src/plugins/failure_prevention.py +162 -0
- package/src/plugins/memory_export.py +55 -18
- package/src/plugins/protocol.py +133 -0
- package/src/plugins/semantic_layers.py +138 -0
- package/src/pre_answer_router.py +622 -28
- package/src/pre_answer_runtime.py +463 -18
- package/src/r14_correction_learning.py +3 -3
- package/src/requirements.txt +5 -1
- package/src/runtime_versioning.py +11 -1
- package/src/saved_not_used_audit.py +44 -3
- package/src/scripts/nexo-followup-runner.py +194 -0
- package/src/semantic_layers.py +1153 -0
- package/src/semantic_reasoner.py +2 -2
- package/src/semantic_router.py +58 -11
- package/src/server.py +41 -3
- package/src/tools_sessions.py +88 -31
- package/src/tools_transcripts.py +38 -22
- package/src/user_state_model.py +971 -0
- package/tool-enforcement-map.json +230 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Operational causal graph facade over the existing Knowledge Graph.
|
|
2
|
+
|
|
3
|
+
Verified causal edges live in ``kg_edges``. Unverified suggestions live in
|
|
4
|
+
``causal_edge_candidates`` until a caller explicitly promotes them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
20
|
+
VALID_RELATIONS = {
|
|
21
|
+
"causal:motivated_by",
|
|
22
|
+
"causal:resolved_by",
|
|
23
|
+
"causal:prevented",
|
|
24
|
+
"causal:verified_by",
|
|
25
|
+
"causal:depends_on",
|
|
26
|
+
"causal:blocked_by",
|
|
27
|
+
"causal:superseded_by",
|
|
28
|
+
"causal:regressed_by",
|
|
29
|
+
"causal:reverted_by",
|
|
30
|
+
"ops:contains",
|
|
31
|
+
"ops:produced",
|
|
32
|
+
"ops:reviewed_by",
|
|
33
|
+
"ops:approved_by",
|
|
34
|
+
}
|
|
35
|
+
ACTIVE_EDGE_STATUSES = {"active", "verified", "stale", "contradicted", "superseded", "retracted"}
|
|
36
|
+
CANDIDATE_STATUSES = {"proposed", "review", "approved", "promoted", "rejected", "expired", "superseded"}
|
|
37
|
+
PRIVACY_LEVELS = {"public", "normal", "private", "sensitive", "secret"}
|
|
38
|
+
PRIVACY_ALIASES = {"internal": "normal", "confidential": "sensitive"}
|
|
39
|
+
SECRET_PATTERNS = (
|
|
40
|
+
re.compile(
|
|
41
|
+
r"\b(?:(?:sk|pk|rk)(?:[-_](?:live|test|proj))?[-_][A-Za-z0-9_=-]{10,}|"
|
|
42
|
+
r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|"
|
|
43
|
+
r"(?:xoxb|xoxp)-[A-Za-z0-9_=-]{10,})\b"
|
|
44
|
+
),
|
|
45
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b", re.IGNORECASE),
|
|
46
|
+
re.compile(
|
|
47
|
+
r"\b(api[_-]?key|token|secret|password|passwd|pwd|authorization)\s*[:=]\s*['\"]?[^'\"\s,;]+",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _db():
|
|
54
|
+
import db
|
|
55
|
+
|
|
56
|
+
return db.get_db()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _kg():
|
|
60
|
+
import knowledge_graph
|
|
61
|
+
|
|
62
|
+
return knowledge_graph
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _kg_db():
|
|
66
|
+
import cognitive
|
|
67
|
+
|
|
68
|
+
return cognitive._get_db()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
return conn.execute(
|
|
74
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
|
75
|
+
(table_name,),
|
|
76
|
+
).fetchone() is not None
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_json(value: str, default: Any) -> Any:
|
|
82
|
+
try:
|
|
83
|
+
parsed = json.loads(value or "")
|
|
84
|
+
return parsed if parsed is not None else default
|
|
85
|
+
except Exception:
|
|
86
|
+
return default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _json(value: Any) -> str:
|
|
90
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize(value: Any) -> str:
|
|
94
|
+
return " ".join(str(value or "").strip().lower().split())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _privacy(value: str) -> str:
|
|
98
|
+
clean = _normalize(value)
|
|
99
|
+
clean = PRIVACY_ALIASES.get(clean, clean)
|
|
100
|
+
return clean if clean in PRIVACY_LEVELS else "private"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def redact_reason(reason: str, *, privacy_level: str = "normal", max_chars: int = 240) -> tuple[str, bool]:
|
|
104
|
+
privacy = _privacy(privacy_level)
|
|
105
|
+
if privacy == "secret":
|
|
106
|
+
return "", bool(reason)
|
|
107
|
+
text = str(reason or "").strip()
|
|
108
|
+
redacted = text
|
|
109
|
+
for pattern in SECRET_PATTERNS:
|
|
110
|
+
redacted = pattern.sub("[REDACTED_SECRET]", redacted)
|
|
111
|
+
if len(redacted) > max_chars:
|
|
112
|
+
redacted = redacted[: max(0, max_chars - 3)].rstrip() + "..."
|
|
113
|
+
return redacted, redacted != text
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def edge_uid_for(
|
|
117
|
+
*,
|
|
118
|
+
source_type: str,
|
|
119
|
+
source_ref: str,
|
|
120
|
+
relation: str,
|
|
121
|
+
target_type: str,
|
|
122
|
+
target_ref: str,
|
|
123
|
+
evidence_refs: list[str] | tuple[str, ...] | None,
|
|
124
|
+
) -> str:
|
|
125
|
+
seed = "|".join(
|
|
126
|
+
[
|
|
127
|
+
_normalize(source_type),
|
|
128
|
+
_normalize(source_ref),
|
|
129
|
+
relation.strip(),
|
|
130
|
+
_normalize(target_type),
|
|
131
|
+
_normalize(target_ref),
|
|
132
|
+
",".join(sorted(str(ref).strip() for ref in (evidence_refs or []) if str(ref).strip())),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
return hashlib.sha256(seed.encode("utf-8", errors="ignore")).hexdigest()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def candidate_uid_for(
|
|
139
|
+
*,
|
|
140
|
+
project_key: str = "",
|
|
141
|
+
source_type: str,
|
|
142
|
+
source_ref: str,
|
|
143
|
+
relation: str,
|
|
144
|
+
target_type: str,
|
|
145
|
+
target_ref: str,
|
|
146
|
+
producer: str,
|
|
147
|
+
source_event_uid: str = "",
|
|
148
|
+
evidence_refs: list[str] | tuple[str, ...] | None,
|
|
149
|
+
) -> str:
|
|
150
|
+
seed = "|".join(
|
|
151
|
+
[
|
|
152
|
+
_normalize(project_key),
|
|
153
|
+
_normalize(source_type),
|
|
154
|
+
_normalize(source_ref),
|
|
155
|
+
relation.strip(),
|
|
156
|
+
_normalize(target_type),
|
|
157
|
+
_normalize(target_ref),
|
|
158
|
+
_normalize(producer),
|
|
159
|
+
_normalize(source_event_uid),
|
|
160
|
+
",".join(sorted(str(ref).strip() for ref in (evidence_refs or []) if str(ref).strip())),
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
return hashlib.sha256(seed.encode("utf-8", errors="ignore")).hexdigest()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def ensure_kg_indexes() -> None:
|
|
167
|
+
conn = _kg_db()
|
|
168
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_source_relation_active ON kg_edges(source_id, relation, valid_until)")
|
|
169
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_target_relation_active ON kg_edges(target_id, relation, valid_until)")
|
|
170
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_relation_active ON kg_edges(relation, valid_until)")
|
|
171
|
+
conn.commit()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _query_exists(conn, table: str, column: str, value: str) -> bool:
|
|
175
|
+
if not _table_exists(conn, table):
|
|
176
|
+
return False
|
|
177
|
+
row = conn.execute(f"SELECT 1 FROM {table} WHERE {column}=? LIMIT 1", (value,)).fetchone()
|
|
178
|
+
return row is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _path_exists(ref: str, *, repo_root: Path) -> bool:
|
|
182
|
+
raw = str(ref or "").strip()
|
|
183
|
+
if not raw:
|
|
184
|
+
return False
|
|
185
|
+
path = Path(raw).expanduser()
|
|
186
|
+
if not path.is_absolute():
|
|
187
|
+
path = repo_root / raw
|
|
188
|
+
try:
|
|
189
|
+
return path.exists()
|
|
190
|
+
except Exception:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _git_commit_exists(ref: str, *, repo_root: Path) -> bool:
|
|
195
|
+
clean = str(ref or "").strip()
|
|
196
|
+
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", clean):
|
|
197
|
+
return False
|
|
198
|
+
if not (repo_root / ".git").exists():
|
|
199
|
+
return True
|
|
200
|
+
result = subprocess.run(
|
|
201
|
+
["git", "cat-file", "-e", f"{clean}^{{commit}}"],
|
|
202
|
+
cwd=repo_root,
|
|
203
|
+
stdout=subprocess.DEVNULL,
|
|
204
|
+
stderr=subprocess.DEVNULL,
|
|
205
|
+
check=False,
|
|
206
|
+
)
|
|
207
|
+
return result.returncode == 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_ref(
|
|
211
|
+
ref_type: str,
|
|
212
|
+
ref: str,
|
|
213
|
+
*,
|
|
214
|
+
evidence_refs: list[str] | None = None,
|
|
215
|
+
repo_root: Path = ROOT,
|
|
216
|
+
) -> tuple[bool, str]:
|
|
217
|
+
clean_type = _normalize(ref_type)
|
|
218
|
+
clean_ref = str(ref or "").strip()
|
|
219
|
+
if not clean_type or not clean_ref:
|
|
220
|
+
return False, "missing_ref"
|
|
221
|
+
|
|
222
|
+
conn = _db()
|
|
223
|
+
if clean_type == "protocol_task":
|
|
224
|
+
return (_query_exists(conn, "protocol_tasks", "task_id", clean_ref), "missing_ref")
|
|
225
|
+
if clean_type in {"workflow", "workflow_run"}:
|
|
226
|
+
return (_query_exists(conn, "workflow_runs", "run_id", clean_ref), "missing_ref")
|
|
227
|
+
if clean_type == "workflow_checkpoint":
|
|
228
|
+
return (_query_exists(conn, "workflow_checkpoints", "id", clean_ref), "missing_ref")
|
|
229
|
+
if clean_type == "commitment":
|
|
230
|
+
return (_query_exists(conn, "commitments", "id", clean_ref), "missing_ref")
|
|
231
|
+
if clean_type in {"change_log", "change"}:
|
|
232
|
+
return (_query_exists(conn, "change_log", "id", clean_ref), "missing_ref")
|
|
233
|
+
if clean_type == "memory_event":
|
|
234
|
+
return (_query_exists(conn, "memory_events", "event_uid", clean_ref), "missing_ref")
|
|
235
|
+
if clean_type in {"file", "artifact", "spec", "audit", "test"}:
|
|
236
|
+
if _path_exists(clean_ref, repo_root=repo_root):
|
|
237
|
+
return True, ""
|
|
238
|
+
return (bool(evidence_refs), "missing_ref")
|
|
239
|
+
if clean_type == "release":
|
|
240
|
+
return (bool(re.fullmatch(r"v?\d+\.\d+(?:\.\d+)?(?:[-+][a-zA-Z0-9_.-]+)?", clean_ref)), "missing_ref")
|
|
241
|
+
if clean_type == "commit":
|
|
242
|
+
return (_git_commit_exists(clean_ref, repo_root=repo_root), "missing_ref")
|
|
243
|
+
if clean_type == "risk":
|
|
244
|
+
return (clean_ref.startswith("risk:") and bool(evidence_refs), "missing_ref")
|
|
245
|
+
if clean_type == "finding":
|
|
246
|
+
return (clean_ref.startswith("finding:") and bool(evidence_refs), "missing_ref")
|
|
247
|
+
return False, "unsupported_ref_type"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _candidate_row(row: sqlite3.Row | None) -> dict[str, Any]:
|
|
251
|
+
if not row:
|
|
252
|
+
return {}
|
|
253
|
+
item = dict(row)
|
|
254
|
+
item["evidence_refs"] = _parse_json(item.pop("evidence_refs_json", "[]"), [])
|
|
255
|
+
item["metadata"] = _parse_json(item.pop("metadata_json", "{}"), {})
|
|
256
|
+
return item
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def propose_candidate(
|
|
260
|
+
*,
|
|
261
|
+
source_type: str,
|
|
262
|
+
source_ref: str,
|
|
263
|
+
relation: str,
|
|
264
|
+
target_type: str,
|
|
265
|
+
target_ref: str,
|
|
266
|
+
reason_public: str = "",
|
|
267
|
+
evidence_refs: list[str] | None = None,
|
|
268
|
+
source_event_uid: str = "",
|
|
269
|
+
producer: str = "manual",
|
|
270
|
+
project_key: str = "",
|
|
271
|
+
privacy_level: str = "normal",
|
|
272
|
+
confidence: float = 0.5,
|
|
273
|
+
status: str = "proposed",
|
|
274
|
+
metadata: dict[str, Any] | None = None,
|
|
275
|
+
now: float | None = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
conn = _db()
|
|
278
|
+
stamp = float(now if now is not None else time.time())
|
|
279
|
+
refs = [str(ref).strip() for ref in (evidence_refs or []) if str(ref).strip()]
|
|
280
|
+
privacy = _privacy(privacy_level)
|
|
281
|
+
clean_reason, redacted = redact_reason(reason_public, privacy_level=privacy)
|
|
282
|
+
clean_status = status if status in CANDIDATE_STATUSES else "review"
|
|
283
|
+
review_reason = ""
|
|
284
|
+
if relation not in VALID_RELATIONS:
|
|
285
|
+
clean_status = "review"
|
|
286
|
+
review_reason = "unknown_relation"
|
|
287
|
+
elif privacy == "secret":
|
|
288
|
+
clean_status = "review"
|
|
289
|
+
review_reason = "secret_reference_only"
|
|
290
|
+
elif not refs:
|
|
291
|
+
clean_status = "review"
|
|
292
|
+
review_reason = "missing_evidence"
|
|
293
|
+
candidate_uid = candidate_uid_for(
|
|
294
|
+
project_key=project_key,
|
|
295
|
+
source_type=source_type,
|
|
296
|
+
source_ref=source_ref,
|
|
297
|
+
relation=relation,
|
|
298
|
+
target_type=target_type,
|
|
299
|
+
target_ref=target_ref,
|
|
300
|
+
producer=producer,
|
|
301
|
+
source_event_uid=source_event_uid,
|
|
302
|
+
evidence_refs=refs,
|
|
303
|
+
)
|
|
304
|
+
meta = dict(metadata or {})
|
|
305
|
+
if redacted:
|
|
306
|
+
meta["redaction_applied"] = True
|
|
307
|
+
conn.execute(
|
|
308
|
+
"""
|
|
309
|
+
INSERT INTO causal_edge_candidates (
|
|
310
|
+
candidate_uid, created_at, updated_at, source_type, source_ref,
|
|
311
|
+
relation, target_type, target_ref, reason_public, evidence_refs_json,
|
|
312
|
+
source_event_uid, producer, project_key, privacy_level, confidence,
|
|
313
|
+
status, review_reason, promoted_edge_uid, metadata_json
|
|
314
|
+
)
|
|
315
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)
|
|
316
|
+
ON CONFLICT(candidate_uid) DO UPDATE SET
|
|
317
|
+
updated_at = excluded.updated_at,
|
|
318
|
+
reason_public = excluded.reason_public,
|
|
319
|
+
confidence = MAX(causal_edge_candidates.confidence, excluded.confidence),
|
|
320
|
+
status = CASE
|
|
321
|
+
WHEN causal_edge_candidates.status = 'promoted' THEN 'promoted'
|
|
322
|
+
ELSE excluded.status
|
|
323
|
+
END,
|
|
324
|
+
review_reason = excluded.review_reason,
|
|
325
|
+
metadata_json = excluded.metadata_json
|
|
326
|
+
""",
|
|
327
|
+
(
|
|
328
|
+
candidate_uid,
|
|
329
|
+
stamp,
|
|
330
|
+
stamp,
|
|
331
|
+
_normalize(source_type),
|
|
332
|
+
str(source_ref).strip(),
|
|
333
|
+
relation,
|
|
334
|
+
_normalize(target_type),
|
|
335
|
+
str(target_ref).strip(),
|
|
336
|
+
clean_reason,
|
|
337
|
+
_json(refs),
|
|
338
|
+
str(source_event_uid or "").strip(),
|
|
339
|
+
_normalize(producer) or "manual",
|
|
340
|
+
str(project_key or "").strip(),
|
|
341
|
+
privacy,
|
|
342
|
+
max(0.0, min(1.0, float(confidence or 0.0))),
|
|
343
|
+
clean_status,
|
|
344
|
+
review_reason,
|
|
345
|
+
_json(meta),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
conn.commit()
|
|
349
|
+
row = conn.execute("SELECT * FROM causal_edge_candidates WHERE candidate_uid=?", (candidate_uid,)).fetchone()
|
|
350
|
+
item = _candidate_row(row)
|
|
351
|
+
item["ok"] = True
|
|
352
|
+
return item
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def list_candidates(*, status: str = "", limit: int = 20) -> list[dict[str, Any]]:
|
|
356
|
+
conn = _db()
|
|
357
|
+
if status:
|
|
358
|
+
rows = conn.execute(
|
|
359
|
+
"SELECT * FROM causal_edge_candidates WHERE status=? ORDER BY updated_at DESC LIMIT ?",
|
|
360
|
+
(status, max(1, int(limit or 20))),
|
|
361
|
+
).fetchall()
|
|
362
|
+
else:
|
|
363
|
+
rows = conn.execute(
|
|
364
|
+
"SELECT * FROM causal_edge_candidates ORDER BY updated_at DESC LIMIT ?",
|
|
365
|
+
(max(1, int(limit or 20)),),
|
|
366
|
+
).fetchall()
|
|
367
|
+
return [_candidate_row(row) for row in rows]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _active_edge_by_uid(edge_uid: str) -> dict[str, Any]:
|
|
371
|
+
conn = _kg_db()
|
|
372
|
+
rows = conn.execute(
|
|
373
|
+
"SELECT * FROM kg_edges WHERE valid_until IS NULL AND properties LIKE ?",
|
|
374
|
+
(f'%"{edge_uid}"%',),
|
|
375
|
+
).fetchall()
|
|
376
|
+
for row in rows:
|
|
377
|
+
item = dict(row)
|
|
378
|
+
props = _parse_json(item.get("properties") or "{}", {})
|
|
379
|
+
if props.get("edge_uid") == edge_uid:
|
|
380
|
+
item["properties_dict"] = props
|
|
381
|
+
return item
|
|
382
|
+
return {}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def upsert_active_edge(
|
|
386
|
+
*,
|
|
387
|
+
source_type: str,
|
|
388
|
+
source_ref: str,
|
|
389
|
+
relation: str,
|
|
390
|
+
target_type: str,
|
|
391
|
+
target_ref: str,
|
|
392
|
+
reason_public: str,
|
|
393
|
+
evidence_refs: list[str],
|
|
394
|
+
source_event_uid: str = "",
|
|
395
|
+
producer: str = "manual",
|
|
396
|
+
project_key: str = "",
|
|
397
|
+
privacy_level: str = "normal",
|
|
398
|
+
confidence: float = 0.8,
|
|
399
|
+
status: str = "",
|
|
400
|
+
repo_root: Path = ROOT,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
refs = [str(ref).strip() for ref in (evidence_refs or []) if str(ref).strip()]
|
|
403
|
+
if relation not in VALID_RELATIONS:
|
|
404
|
+
return {"ok": False, "status": "rejected", "review_reason": "unknown_relation"}
|
|
405
|
+
if not refs:
|
|
406
|
+
return {"ok": False, "status": "review", "review_reason": "missing_evidence"}
|
|
407
|
+
privacy = _privacy(privacy_level)
|
|
408
|
+
if privacy == "secret":
|
|
409
|
+
return {"ok": False, "status": "review", "review_reason": "secret_reference_only"}
|
|
410
|
+
source_ok, source_reason = validate_ref(source_type, source_ref, evidence_refs=refs, repo_root=repo_root)
|
|
411
|
+
if not source_ok:
|
|
412
|
+
return {"ok": False, "status": "review", "review_reason": f"source_{source_reason}"}
|
|
413
|
+
target_ok, target_reason = validate_ref(target_type, target_ref, evidence_refs=refs, repo_root=repo_root)
|
|
414
|
+
if not target_ok:
|
|
415
|
+
return {"ok": False, "status": "review", "review_reason": f"target_{target_reason}"}
|
|
416
|
+
|
|
417
|
+
edge_uid = edge_uid_for(
|
|
418
|
+
source_type=source_type,
|
|
419
|
+
source_ref=source_ref,
|
|
420
|
+
relation=relation,
|
|
421
|
+
target_type=target_type,
|
|
422
|
+
target_ref=target_ref,
|
|
423
|
+
evidence_refs=refs,
|
|
424
|
+
)
|
|
425
|
+
existing = _active_edge_by_uid(edge_uid)
|
|
426
|
+
if existing:
|
|
427
|
+
return {"ok": True, "action": "NOOP", "edge_id": existing["id"], "edge_uid": edge_uid}
|
|
428
|
+
|
|
429
|
+
clean_reason, redacted = redact_reason(reason_public, privacy_level=privacy)
|
|
430
|
+
edge_status = status if status in ACTIVE_EDGE_STATUSES else ("verified" if confidence >= 0.9 else "active")
|
|
431
|
+
props = {
|
|
432
|
+
"schema_version": 1,
|
|
433
|
+
"edge_uid": edge_uid,
|
|
434
|
+
"status": edge_status,
|
|
435
|
+
"project_key": str(project_key or "").strip(),
|
|
436
|
+
"reason_public": clean_reason,
|
|
437
|
+
"evidence_refs": refs,
|
|
438
|
+
"source_event_uid": str(source_event_uid or "").strip(),
|
|
439
|
+
"producer": _normalize(producer) or "manual",
|
|
440
|
+
"privacy_level": privacy,
|
|
441
|
+
"redaction_applied": bool(redacted),
|
|
442
|
+
"created_by": "causal_graph",
|
|
443
|
+
}
|
|
444
|
+
ensure_kg_indexes()
|
|
445
|
+
result = _kg().upsert_edge(
|
|
446
|
+
source_type=_normalize(source_type),
|
|
447
|
+
source_ref=str(source_ref).strip(),
|
|
448
|
+
relation=relation,
|
|
449
|
+
target_type=_normalize(target_type),
|
|
450
|
+
target_ref=str(target_ref).strip(),
|
|
451
|
+
weight=1.0,
|
|
452
|
+
confidence=max(0.0, min(1.0, float(confidence or 0.0))),
|
|
453
|
+
source_memory_id=str(source_event_uid or "").strip(),
|
|
454
|
+
properties=props,
|
|
455
|
+
)
|
|
456
|
+
result.update({"ok": True, "edge_uid": edge_uid, "properties": props})
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def promote_candidate(candidate_uid: str) -> dict[str, Any]:
|
|
461
|
+
conn = _db()
|
|
462
|
+
row = conn.execute("SELECT * FROM causal_edge_candidates WHERE candidate_uid=?", (candidate_uid,)).fetchone()
|
|
463
|
+
if not row:
|
|
464
|
+
return {"ok": False, "error": "candidate_not_found"}
|
|
465
|
+
candidate = _candidate_row(row)
|
|
466
|
+
if candidate.get("status") not in {"approved", "proposed"}:
|
|
467
|
+
return {"ok": False, "error": f"candidate_status_not_promotable:{candidate.get('status')}"}
|
|
468
|
+
result = upsert_active_edge(
|
|
469
|
+
source_type=candidate["source_type"],
|
|
470
|
+
source_ref=candidate["source_ref"],
|
|
471
|
+
relation=candidate["relation"],
|
|
472
|
+
target_type=candidate["target_type"],
|
|
473
|
+
target_ref=candidate["target_ref"],
|
|
474
|
+
reason_public=candidate.get("reason_public") or "",
|
|
475
|
+
evidence_refs=candidate.get("evidence_refs") or [],
|
|
476
|
+
source_event_uid=candidate.get("source_event_uid") or "",
|
|
477
|
+
producer=candidate.get("producer") or "candidate",
|
|
478
|
+
project_key=candidate.get("project_key") or "",
|
|
479
|
+
privacy_level=candidate.get("privacy_level") or "normal",
|
|
480
|
+
confidence=float(candidate.get("confidence") or 0.0),
|
|
481
|
+
)
|
|
482
|
+
if not result.get("ok"):
|
|
483
|
+
conn.execute(
|
|
484
|
+
"UPDATE causal_edge_candidates SET status='review', review_reason=?, updated_at=? WHERE candidate_uid=?",
|
|
485
|
+
(result.get("review_reason") or result.get("error") or "promotion_failed", time.time(), candidate_uid),
|
|
486
|
+
)
|
|
487
|
+
conn.commit()
|
|
488
|
+
return result
|
|
489
|
+
conn.execute(
|
|
490
|
+
"UPDATE causal_edge_candidates SET status='promoted', promoted_edge_uid=?, updated_at=? WHERE candidate_uid=?",
|
|
491
|
+
(result.get("edge_uid") or "", time.time(), candidate_uid),
|
|
492
|
+
)
|
|
493
|
+
conn.commit()
|
|
494
|
+
result["candidate_uid"] = candidate_uid
|
|
495
|
+
return result
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def record_task_close_edges(
|
|
499
|
+
*,
|
|
500
|
+
task_id: str,
|
|
501
|
+
change_log_id: str | int = "",
|
|
502
|
+
test_refs: list[str] | None = None,
|
|
503
|
+
risk_ref: str = "",
|
|
504
|
+
evidence_refs: list[str] | None = None,
|
|
505
|
+
project_key: str = "",
|
|
506
|
+
reason_public: str = "",
|
|
507
|
+
) -> list[dict[str, Any]]:
|
|
508
|
+
refs = [str(ref).strip() for ref in (evidence_refs or []) if str(ref).strip()]
|
|
509
|
+
if not refs:
|
|
510
|
+
refs = [f"protocol_task:{task_id}"]
|
|
511
|
+
results: list[dict[str, Any]] = []
|
|
512
|
+
if change_log_id not in ("", None):
|
|
513
|
+
change_ref = str(change_log_id)
|
|
514
|
+
results.append(
|
|
515
|
+
upsert_active_edge(
|
|
516
|
+
source_type="protocol_task",
|
|
517
|
+
source_ref=task_id,
|
|
518
|
+
relation="ops:produced",
|
|
519
|
+
target_type="change_log",
|
|
520
|
+
target_ref=change_ref,
|
|
521
|
+
reason_public=reason_public or "Task produced a change log entry.",
|
|
522
|
+
evidence_refs=refs,
|
|
523
|
+
producer="task_close",
|
|
524
|
+
project_key=project_key,
|
|
525
|
+
confidence=0.9,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
results.append(
|
|
529
|
+
upsert_active_edge(
|
|
530
|
+
source_type="change_log",
|
|
531
|
+
source_ref=change_ref,
|
|
532
|
+
relation="causal:motivated_by",
|
|
533
|
+
target_type="protocol_task",
|
|
534
|
+
target_ref=task_id,
|
|
535
|
+
reason_public=reason_public or "Change was motivated by the closed task.",
|
|
536
|
+
evidence_refs=refs,
|
|
537
|
+
producer="task_close",
|
|
538
|
+
project_key=project_key,
|
|
539
|
+
confidence=0.85,
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
for test_ref in test_refs or []:
|
|
543
|
+
results.append(
|
|
544
|
+
upsert_active_edge(
|
|
545
|
+
source_type="protocol_task",
|
|
546
|
+
source_ref=task_id,
|
|
547
|
+
relation="causal:verified_by",
|
|
548
|
+
target_type="test",
|
|
549
|
+
target_ref=test_ref,
|
|
550
|
+
reason_public=reason_public or "Task was verified by test evidence.",
|
|
551
|
+
evidence_refs=[*refs, f"test:{test_ref}"],
|
|
552
|
+
producer="task_close",
|
|
553
|
+
project_key=project_key,
|
|
554
|
+
confidence=0.92,
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
if risk_ref:
|
|
558
|
+
results.append(
|
|
559
|
+
upsert_active_edge(
|
|
560
|
+
source_type="change_log",
|
|
561
|
+
source_ref=str(change_log_id),
|
|
562
|
+
relation="causal:prevented",
|
|
563
|
+
target_type="risk",
|
|
564
|
+
target_ref=risk_ref,
|
|
565
|
+
reason_public=reason_public or "Change prevented a documented risk.",
|
|
566
|
+
evidence_refs=refs,
|
|
567
|
+
producer="task_close",
|
|
568
|
+
project_key=project_key,
|
|
569
|
+
confidence=0.8,
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
return results
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def record_commitment_resolution_edges(commitment_id: str) -> list[dict[str, Any]]:
|
|
576
|
+
conn = _db()
|
|
577
|
+
if not _table_exists(conn, "commitments"):
|
|
578
|
+
return []
|
|
579
|
+
row = conn.execute("SELECT * FROM commitments WHERE id=?", (commitment_id,)).fetchone()
|
|
580
|
+
if not row:
|
|
581
|
+
return []
|
|
582
|
+
item = dict(row)
|
|
583
|
+
if item.get("status") not in {"fulfilled"}:
|
|
584
|
+
return []
|
|
585
|
+
action_ref_type = str(item.get("action_ref_type") or "").strip()
|
|
586
|
+
action_ref_id = str(item.get("action_ref_id") or "").strip()
|
|
587
|
+
evidence_ref = str(item.get("evidence_ref") or "").strip()
|
|
588
|
+
refs = [ref for ref in [evidence_ref, f"commitment:{commitment_id}"] if ref]
|
|
589
|
+
results: list[dict[str, Any]] = []
|
|
590
|
+
if action_ref_type and action_ref_id:
|
|
591
|
+
results.append(
|
|
592
|
+
upsert_active_edge(
|
|
593
|
+
source_type="commitment",
|
|
594
|
+
source_ref=commitment_id,
|
|
595
|
+
relation="causal:resolved_by",
|
|
596
|
+
target_type=action_ref_type,
|
|
597
|
+
target_ref=action_ref_id,
|
|
598
|
+
reason_public="Commitment was fulfilled by the linked action.",
|
|
599
|
+
evidence_refs=refs,
|
|
600
|
+
producer="commitment",
|
|
601
|
+
project_key=str(item.get("project_key") or ""),
|
|
602
|
+
confidence=float(item.get("confidence") or 0.8),
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
if evidence_ref:
|
|
606
|
+
results.append(
|
|
607
|
+
upsert_active_edge(
|
|
608
|
+
source_type="commitment",
|
|
609
|
+
source_ref=commitment_id,
|
|
610
|
+
relation="causal:verified_by",
|
|
611
|
+
target_type="artifact",
|
|
612
|
+
target_ref=evidence_ref,
|
|
613
|
+
reason_public="Commitment resolution has explicit evidence.",
|
|
614
|
+
evidence_refs=refs,
|
|
615
|
+
producer="commitment",
|
|
616
|
+
project_key=str(item.get("project_key") or ""),
|
|
617
|
+
confidence=float(item.get("confidence") or 0.8),
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
return results
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def propose_from_memory_executive(event: dict[str, Any], decision: dict[str, Any]) -> dict[str, Any]:
|
|
624
|
+
if str(decision.get("decision_kind") or "") != "proposed_causal_edge":
|
|
625
|
+
return {"ok": False, "error": "decision_not_causal"}
|
|
626
|
+
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
|
|
627
|
+
edge = metadata.get("causal_edge") if isinstance(metadata.get("causal_edge"), dict) else {}
|
|
628
|
+
if not edge:
|
|
629
|
+
return {"ok": False, "error": "missing_causal_edge_payload"}
|
|
630
|
+
return propose_candidate(
|
|
631
|
+
source_type=str(edge.get("source_type") or ""),
|
|
632
|
+
source_ref=str(edge.get("source_ref") or ""),
|
|
633
|
+
relation=str(edge.get("relation") or ""),
|
|
634
|
+
target_type=str(edge.get("target_type") or ""),
|
|
635
|
+
target_ref=str(edge.get("target_ref") or ""),
|
|
636
|
+
reason_public=str(edge.get("reason_public") or decision.get("reason") or ""),
|
|
637
|
+
evidence_refs=[str(ref) for ref in edge.get("evidence_refs") or event.get("evidence_refs") or []],
|
|
638
|
+
source_event_uid=str(event.get("event_uid") or ""),
|
|
639
|
+
producer="memory_executive",
|
|
640
|
+
project_key=str(event.get("project_key") or ""),
|
|
641
|
+
privacy_level=str(edge.get("privacy_level") or event.get("privacy_level") or "normal"),
|
|
642
|
+
confidence=float(edge.get("confidence") or decision.get("confidence") or 0.5),
|
|
643
|
+
status="proposed",
|
|
644
|
+
metadata={"memory_decision": decision.get("dedupe_key") or ""},
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def approve_candidate(candidate_uid: str) -> dict[str, Any]:
|
|
649
|
+
conn = _db()
|
|
650
|
+
row = conn.execute("SELECT * FROM causal_edge_candidates WHERE candidate_uid=?", (candidate_uid,)).fetchone()
|
|
651
|
+
if not row:
|
|
652
|
+
return {"ok": False, "error": "candidate_not_found"}
|
|
653
|
+
conn.execute(
|
|
654
|
+
"UPDATE causal_edge_candidates SET status='approved', review_reason='', updated_at=? WHERE candidate_uid=?",
|
|
655
|
+
(time.time(), candidate_uid),
|
|
656
|
+
)
|
|
657
|
+
conn.commit()
|
|
658
|
+
return {"ok": True, "candidate_uid": candidate_uid, "status": "approved"}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _node_id(ref_type: str, ref: str) -> int | None:
|
|
662
|
+
node = _kg().get_node(_normalize(ref_type), str(ref).strip())
|
|
663
|
+
if not node:
|
|
664
|
+
return None
|
|
665
|
+
return int(node["id"])
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def query_edges(
|
|
669
|
+
*,
|
|
670
|
+
ref_type: str,
|
|
671
|
+
ref: str,
|
|
672
|
+
project_key: str = "",
|
|
673
|
+
include_historical: bool = False,
|
|
674
|
+
limit: int = 8,
|
|
675
|
+
) -> dict[str, Any]:
|
|
676
|
+
node_id = _node_id(ref_type, ref)
|
|
677
|
+
if node_id is None:
|
|
678
|
+
return {"ok": True, "has_evidence": False, "edges": [], "message": "no tengo evidencia suficiente"}
|
|
679
|
+
conn = _kg_db()
|
|
680
|
+
conditions = ["(e.source_id=? OR e.target_id=?)"]
|
|
681
|
+
params: list[Any] = [node_id, node_id]
|
|
682
|
+
if not include_historical:
|
|
683
|
+
conditions.append("e.valid_until IS NULL")
|
|
684
|
+
rows = conn.execute(
|
|
685
|
+
f"""
|
|
686
|
+
SELECT e.*, src.node_type AS source_type, src.node_ref AS source_ref,
|
|
687
|
+
tgt.node_type AS target_type, tgt.node_ref AS target_ref
|
|
688
|
+
FROM kg_edges e
|
|
689
|
+
JOIN kg_nodes src ON src.id=e.source_id
|
|
690
|
+
JOIN kg_nodes tgt ON tgt.id=e.target_id
|
|
691
|
+
WHERE {' AND '.join(conditions)}
|
|
692
|
+
ORDER BY e.confidence DESC, e.id DESC
|
|
693
|
+
LIMIT ?
|
|
694
|
+
""",
|
|
695
|
+
[*params, max(1, min(int(limit or 8), 50))],
|
|
696
|
+
).fetchall()
|
|
697
|
+
edges: list[dict[str, Any]] = []
|
|
698
|
+
for row in rows:
|
|
699
|
+
item = dict(row)
|
|
700
|
+
relation = str(item.get("relation") or "")
|
|
701
|
+
if not (relation.startswith("causal:") or relation.startswith("ops:")):
|
|
702
|
+
continue
|
|
703
|
+
props = _parse_json(item.get("properties") or "{}", {})
|
|
704
|
+
status = str(props.get("status") or "active")
|
|
705
|
+
privacy = _privacy(props.get("privacy_level") or "normal")
|
|
706
|
+
if not include_historical and status not in {"active", "verified"}:
|
|
707
|
+
continue
|
|
708
|
+
if project_key and props.get("project_key") and props.get("project_key") != project_key:
|
|
709
|
+
continue
|
|
710
|
+
if privacy == "secret":
|
|
711
|
+
continue
|
|
712
|
+
if privacy == "sensitive":
|
|
713
|
+
item["renderable_reason"] = "Tengo una relacion causal con evidencia privada; puedo revisarla si me das permiso para usar ese contexto."
|
|
714
|
+
else:
|
|
715
|
+
item["renderable_reason"] = props.get("reason_public") or ""
|
|
716
|
+
item["properties_dict"] = props
|
|
717
|
+
edges.append(item)
|
|
718
|
+
return {
|
|
719
|
+
"ok": True,
|
|
720
|
+
"has_evidence": bool(edges),
|
|
721
|
+
"edges": edges[: max(1, min(int(limit or 8), 50))],
|
|
722
|
+
"message": "" if edges else "no tengo evidencia suficiente",
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def render_query_result(result: dict[str, Any], *, max_chars: int = 1200) -> str:
|
|
727
|
+
edges = result.get("edges") or []
|
|
728
|
+
if not edges:
|
|
729
|
+
return "No tengo evidencia suficiente."
|
|
730
|
+
lines = ["Causal evidence:"]
|
|
731
|
+
for edge in edges:
|
|
732
|
+
props = edge.get("properties_dict") or {}
|
|
733
|
+
refs = props.get("evidence_refs") or []
|
|
734
|
+
reason = edge.get("renderable_reason") or props.get("reason_public") or ""
|
|
735
|
+
lines.append(
|
|
736
|
+
f"- {edge.get('source_type')}:{edge.get('source_ref')} {edge.get('relation')} "
|
|
737
|
+
f"{edge.get('target_type')}:{edge.get('target_ref')} - {reason} "
|
|
738
|
+
f"(evidence: {', '.join(refs) or 'none'})"
|
|
739
|
+
)
|
|
740
|
+
text = "\n".join(lines)
|
|
741
|
+
return text if len(text) <= max_chars else text[: max(0, max_chars - 3)].rstrip() + "..."
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
__all__ = [
|
|
745
|
+
"ACTIVE_EDGE_STATUSES",
|
|
746
|
+
"CANDIDATE_STATUSES",
|
|
747
|
+
"VALID_RELATIONS",
|
|
748
|
+
"approve_candidate",
|
|
749
|
+
"candidate_uid_for",
|
|
750
|
+
"edge_uid_for",
|
|
751
|
+
"ensure_kg_indexes",
|
|
752
|
+
"list_candidates",
|
|
753
|
+
"promote_candidate",
|
|
754
|
+
"propose_from_memory_executive",
|
|
755
|
+
"propose_candidate",
|
|
756
|
+
"query_edges",
|
|
757
|
+
"record_commitment_resolution_edges",
|
|
758
|
+
"record_task_close_edges",
|
|
759
|
+
"redact_reason",
|
|
760
|
+
"render_query_result",
|
|
761
|
+
"upsert_active_edge",
|
|
762
|
+
"validate_ref",
|
|
763
|
+
]
|