nexo-brain 7.32.0 → 7.34.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 +1 -1
- package/package.json +1 -1
- package/src/consolidation_prep.py +380 -0
- package/src/db/__init__.py +5 -1
- package/src/db/_episodic.py +32 -0
- package/src/db/_memory_v2.py +276 -0
- package/src/db/_protocol.py +35 -0
- package/src/db/_schema.py +207 -0
- package/src/hooks/auto_capture.py +60 -24
- package/src/learning_resolver.py +42 -0
- package/src/local_context/api.py +237 -33
- package/src/local_context/db.py +3 -2
- package/src/local_context/usage_events.py +2 -0
- package/src/memory_retrieval.py +96 -7
- package/src/message_batch_preview.py +290 -0
- package/src/plugins/protocol.py +218 -27
- package/src/ppr.py +473 -0
- package/src/pre_answer_router.py +316 -3
- package/src/pre_answer_runtime.py +156 -1
- package/src/resolution_cache.py +1119 -0
- package/src/scripts/deep-sleep/apply_findings.py +86 -9
- package/src/scripts/deep-sleep/rewrite.py +625 -0
- package/src/scripts/nexo-deep-sleep.sh +10 -0
- package/src/scripts/nexo-followup-runner.py +110 -8
- package/src/scripts/nexo-morning-agent.py +43 -2
- package/src/scripts/nexo-postmortem-consolidator.py +44 -1
- package/src/self_error_detector.py +414 -0
- package/src/semantic_layers.py +30 -3
- package/templates/core-prompts/morning-agent.md +3 -0
- package/templates/core-prompts/postmortem-consolidator.md +29 -2
package/src/pre_answer_router.py
CHANGED
|
@@ -565,6 +565,8 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
565
565
|
SourceStep("workflows", timeout_ms=260),
|
|
566
566
|
SourceStep("change_log", timeout_ms=260),
|
|
567
567
|
SourceStep("causal_graph", timeout_ms=120, max_chars=900),
|
|
568
|
+
SourceStep("kg_neighbors", timeout_ms=120, max_chars=900),
|
|
569
|
+
SourceStep("associative_graph", timeout_ms=120, max_chars=900),
|
|
568
570
|
SourceStep("diary", timeout_ms=260),
|
|
569
571
|
),
|
|
570
572
|
fallback=(
|
|
@@ -592,6 +594,8 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
592
594
|
SourceStep("guard_context", timeout_ms=160),
|
|
593
595
|
SourceStep("change_log", timeout_ms=300),
|
|
594
596
|
SourceStep("workflows", timeout_ms=260),
|
|
597
|
+
SourceStep("kg_neighbors", timeout_ms=120, max_chars=900),
|
|
598
|
+
SourceStep("associative_graph", timeout_ms=120, max_chars=900),
|
|
595
599
|
),
|
|
596
600
|
fallback=(
|
|
597
601
|
SourceStep("transcripts", phase="fallback", timeout_ms=650),
|
|
@@ -625,6 +629,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
625
629
|
SourceStep("diary", timeout_ms=280),
|
|
626
630
|
SourceStep("change_log", timeout_ms=300),
|
|
627
631
|
SourceStep("transcripts", timeout_ms=700),
|
|
632
|
+
SourceStep("kg_neighbors", timeout_ms=120, max_chars=900),
|
|
628
633
|
),
|
|
629
634
|
fallback=(SourceStep("continuity", phase="fallback", timeout_ms=400),),
|
|
630
635
|
),
|
|
@@ -654,6 +659,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
654
659
|
SourceStep("project_atlas", timeout_ms=160),
|
|
655
660
|
SourceStep("system_catalog", timeout_ms=420),
|
|
656
661
|
SourceStep("diary", timeout_ms=280),
|
|
662
|
+
SourceStep("kg_neighbors", timeout_ms=120, max_chars=900),
|
|
657
663
|
),
|
|
658
664
|
fallback=(
|
|
659
665
|
SourceStep("transcripts", phase="fallback", timeout_ms=700),
|
|
@@ -667,6 +673,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
667
673
|
SourceStep("system_catalog", timeout_ms=420),
|
|
668
674
|
SourceStep("project_atlas", timeout_ms=160),
|
|
669
675
|
SourceStep("runtime_docs", timeout_ms=300),
|
|
676
|
+
SourceStep("kg_neighbors", timeout_ms=120, max_chars=900),
|
|
670
677
|
),
|
|
671
678
|
fallback=(
|
|
672
679
|
SourceStep("source_grep", phase="fallback", timeout_ms=600),
|
|
@@ -1218,6 +1225,8 @@ def default_source_adapters() -> dict[str, SourceAdapter]:
|
|
|
1218
1225
|
"workflows": _source_workflows,
|
|
1219
1226
|
"change_log": _source_change_log,
|
|
1220
1227
|
"causal_graph": _source_causal_graph,
|
|
1228
|
+
"kg_neighbors": _source_kg_neighbors,
|
|
1229
|
+
"associative_graph": _source_associative_graph,
|
|
1221
1230
|
"diary": _source_diary,
|
|
1222
1231
|
"transcripts": _source_transcripts,
|
|
1223
1232
|
"memory": _source_memory,
|
|
@@ -1710,6 +1719,244 @@ def _source_causal_graph(request: SourceRequest) -> SourceResult:
|
|
|
1710
1719
|
)
|
|
1711
1720
|
|
|
1712
1721
|
|
|
1722
|
+
def _source_kg_neighbors(request: SourceRequest) -> SourceResult:
|
|
1723
|
+
"""KG neighbors + verified causal/ops edges for entities/files in the query.
|
|
1724
|
+
|
|
1725
|
+
task_close (7.32.0) writes causal/provenance edges but nothing READ the KG at
|
|
1726
|
+
answer time, so the richer non-causal structure (touched/applies_to/belongs_to/
|
|
1727
|
+
mentions/...) never reached an answer. This bounded, fail-open, 1-hop source
|
|
1728
|
+
reads it. Hard-limited (<=3 refs, <=6 neighbors), index-backed, respects the
|
|
1729
|
+
per-source timeout — it can never block the answer.
|
|
1730
|
+
"""
|
|
1731
|
+
try:
|
|
1732
|
+
import knowledge_graph as kg
|
|
1733
|
+
import causal_graph
|
|
1734
|
+
except Exception as exc:
|
|
1735
|
+
return SourceResult(source="kg_neighbors", ok=False, skipped=True, aborted_reason="source_error", error=str(exc))
|
|
1736
|
+
|
|
1737
|
+
refs: list[str] = []
|
|
1738
|
+
for raw in (request.files or "").split(","):
|
|
1739
|
+
clean = raw.strip()
|
|
1740
|
+
if clean:
|
|
1741
|
+
refs.append(clean)
|
|
1742
|
+
if not refs:
|
|
1743
|
+
for match in _PATHISH_RE.findall(request.query or ""):
|
|
1744
|
+
refs.append(match)
|
|
1745
|
+
for match in re.findall(r"\b[\w.-]+(?:/[\w.@+-]+)+\b", request.query or ""):
|
|
1746
|
+
refs.append(match)
|
|
1747
|
+
refs = list(dict.fromkeys(refs))
|
|
1748
|
+
if not refs:
|
|
1749
|
+
return SourceResult(source="kg_neighbors")
|
|
1750
|
+
|
|
1751
|
+
rendered_parts: list[str] = []
|
|
1752
|
+
evidence_refs: list[str] = []
|
|
1753
|
+
result_count = 0
|
|
1754
|
+
for ref in refs[:3]:
|
|
1755
|
+
try:
|
|
1756
|
+
node = None
|
|
1757
|
+
for ntype, nref in (("file", ref), ("file", f"file:{ref}"), ("entity", ref), ("entity", f"entity:{ref}")):
|
|
1758
|
+
node = kg.get_node(ntype, nref)
|
|
1759
|
+
if node:
|
|
1760
|
+
break
|
|
1761
|
+
if node:
|
|
1762
|
+
for nb in kg.get_neighbors(int(node["id"]), active_only=True)[:6]:
|
|
1763
|
+
relation = str(nb.get("relation") or "")
|
|
1764
|
+
if relation.startswith("causal:") or relation.startswith("ops:"):
|
|
1765
|
+
continue # surfaced via query_edges below (avoid duplicate)
|
|
1766
|
+
line = f"- {relation} ({nb.get('direction')}) {nb.get('node_type')}:{nb.get('node_ref')}"
|
|
1767
|
+
if nb.get("label"):
|
|
1768
|
+
line += f" ({nb.get('label')})"
|
|
1769
|
+
rendered_parts.append(line)
|
|
1770
|
+
evidence_refs.append(f"kg:node:{node['id']}:{nb.get('id')}")
|
|
1771
|
+
result_count += 1
|
|
1772
|
+
cg = causal_graph.query_edges(
|
|
1773
|
+
ref_type="file", ref=ref, project_key=request.area, include_historical=False, limit=4,
|
|
1774
|
+
)
|
|
1775
|
+
if cg.get("has_evidence"):
|
|
1776
|
+
rendered_parts.append(causal_graph.render_query_result(cg, max_chars=request.max_chars))
|
|
1777
|
+
result_count += len(cg.get("edges") or [])
|
|
1778
|
+
for edge in cg.get("edges") or []:
|
|
1779
|
+
props = edge.get("properties_dict") or {}
|
|
1780
|
+
evidence_refs.extend(str(i) for i in props.get("evidence_refs") or [] if str(i).strip())
|
|
1781
|
+
except Exception:
|
|
1782
|
+
continue
|
|
1783
|
+
if not rendered_parts:
|
|
1784
|
+
return SourceResult(source="kg_neighbors")
|
|
1785
|
+
return SourceResult(
|
|
1786
|
+
source="kg_neighbors",
|
|
1787
|
+
rendered=_clip("\n".join(rendered_parts), request.max_chars),
|
|
1788
|
+
evidence_refs=list(dict.fromkeys(evidence_refs)),
|
|
1789
|
+
result_count=result_count,
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _associative_graph_basename_match(kg, ref: str, *, limit: int = 3) -> list[int]:
|
|
1794
|
+
"""Resolve a bare basename to KG file node ids via one bounded indexed LIKE.
|
|
1795
|
+
|
|
1796
|
+
Only fires for path-ish refs (contains a '.' extension or '/'), so a generic
|
|
1797
|
+
word never triggers a table-wide LIKE. Returns at most ``limit`` node ids.
|
|
1798
|
+
"""
|
|
1799
|
+
clean = str(ref or "").strip()
|
|
1800
|
+
if not clean or ("." not in clean and "/" not in clean):
|
|
1801
|
+
return []
|
|
1802
|
+
base = clean.rsplit("/", 1)[-1]
|
|
1803
|
+
if len(base) < 4:
|
|
1804
|
+
return []
|
|
1805
|
+
try:
|
|
1806
|
+
rows = kg._get_db().execute(
|
|
1807
|
+
"SELECT id FROM kg_nodes WHERE node_type='file' AND node_ref LIKE ? LIMIT ?",
|
|
1808
|
+
(f"%{base}", int(limit)),
|
|
1809
|
+
).fetchall()
|
|
1810
|
+
return [int(r["id"]) for r in rows]
|
|
1811
|
+
except Exception:
|
|
1812
|
+
return []
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _associative_graph_seeds(request: SourceRequest, kg, *, max_seeds: int = 8) -> dict[int, float]:
|
|
1816
|
+
"""Resolve the personalization vector for the associative-graph PPR.
|
|
1817
|
+
|
|
1818
|
+
Two sources, union'd and capped (plan section 2.1):
|
|
1819
|
+
(i) entities — entity_live_profile.resolve_entity(limit=8) — only when the
|
|
1820
|
+
query looks entity/path-worthy (reuses the local_context gate so we do
|
|
1821
|
+
NOT scan the entities table on generic queries).
|
|
1822
|
+
(ii) paths/files — request.files or the _PATHISH_RE / slash-token regex,
|
|
1823
|
+
same extraction as kg_neighbors.
|
|
1824
|
+
|
|
1825
|
+
Returns {kg_node_id: weight}. Weight = entity score (i) or 1.0 (ii).
|
|
1826
|
+
"""
|
|
1827
|
+
seeds: dict[int, float] = {}
|
|
1828
|
+
|
|
1829
|
+
# (i) Entities — gated by the same worthiness check used for local_context so
|
|
1830
|
+
# generic queries never pay the full-table scan in resolve_entity.
|
|
1831
|
+
if _local_context_query_worthwhile(request):
|
|
1832
|
+
try:
|
|
1833
|
+
import entity_live_profile
|
|
1834
|
+
|
|
1835
|
+
resolved = entity_live_profile.resolve_entity(request.query or "", limit=max_seeds)
|
|
1836
|
+
for cand in (resolved.get("candidates") or [])[:max_seeds]:
|
|
1837
|
+
ent_id = cand.get("entity_id")
|
|
1838
|
+
if not ent_id:
|
|
1839
|
+
continue
|
|
1840
|
+
node = kg.get_node("entity", f"entity:{ent_id}") or kg.get_node("entity", str(ent_id))
|
|
1841
|
+
if node:
|
|
1842
|
+
nid = int(node["id"])
|
|
1843
|
+
score = float(cand.get("score") or 0.0) or 1.0
|
|
1844
|
+
seeds[nid] = max(seeds.get(nid, 0.0), score)
|
|
1845
|
+
except Exception:
|
|
1846
|
+
pass
|
|
1847
|
+
|
|
1848
|
+
# (ii) Paths / files — identical extraction to kg_neighbors.
|
|
1849
|
+
refs: list[str] = []
|
|
1850
|
+
for raw in (request.files or "").split(","):
|
|
1851
|
+
clean = raw.strip()
|
|
1852
|
+
if clean:
|
|
1853
|
+
refs.append(clean)
|
|
1854
|
+
if not refs:
|
|
1855
|
+
for match in _PATHISH_RE.findall(request.query or ""):
|
|
1856
|
+
refs.append(match)
|
|
1857
|
+
for match in re.findall(r"\b[\w.-]+(?:/[\w.@+-]+)+\b", request.query or ""):
|
|
1858
|
+
refs.append(match)
|
|
1859
|
+
for ref in list(dict.fromkeys(refs)):
|
|
1860
|
+
try:
|
|
1861
|
+
node = None
|
|
1862
|
+
for ntype, nref in (("file", ref), ("file", f"file:{ref}"), ("entity", ref), ("entity", f"entity:{ref}")):
|
|
1863
|
+
node = kg.get_node(ntype, nref)
|
|
1864
|
+
if node:
|
|
1865
|
+
break
|
|
1866
|
+
if node:
|
|
1867
|
+
seeds.setdefault(int(node["id"]), 1.0)
|
|
1868
|
+
else:
|
|
1869
|
+
# Basename suffix-match: the KG stores files under full paths
|
|
1870
|
+
# (file:/Users/.../foo.py) while a query usually carries the bare
|
|
1871
|
+
# basename (foo.py). One bounded indexed LIKE recovers those — a
|
|
1872
|
+
# capability kg_neighbors (exact-match only) lacks.
|
|
1873
|
+
for nid in _associative_graph_basename_match(kg, ref, limit=3):
|
|
1874
|
+
seeds.setdefault(nid, 1.0)
|
|
1875
|
+
if len(seeds) >= max_seeds:
|
|
1876
|
+
break
|
|
1877
|
+
except Exception:
|
|
1878
|
+
continue
|
|
1879
|
+
if len(seeds) >= max_seeds:
|
|
1880
|
+
break
|
|
1881
|
+
|
|
1882
|
+
# Cap to <=max_seeds (entities first by insertion order, then paths).
|
|
1883
|
+
if len(seeds) > max_seeds:
|
|
1884
|
+
seeds = dict(list(seeds.items())[:max_seeds])
|
|
1885
|
+
return seeds
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _source_associative_graph(request: SourceRequest) -> SourceResult:
|
|
1889
|
+
"""Multi-hop associative recall via Personalized PageRank over the KG (Ola 2).
|
|
1890
|
+
|
|
1891
|
+
Generalises ``kg_neighbors`` (bounded 1-hop fan-out) to a ranked multi-hop
|
|
1892
|
+
spreading-activation (HippoRAG2-style "connect the dots at answer time").
|
|
1893
|
+
Seeds from query entities + paths, runs a pure-Python forward-push PPR over
|
|
1894
|
+
the active ``kg_edges`` (column-stochastic -> hub-safe), and surfaces the
|
|
1895
|
+
top-ranked related nodes that a 1-hop fan-out would miss.
|
|
1896
|
+
|
|
1897
|
+
Fail-open absolute: any error / missing module / 0 seeds returns a bare
|
|
1898
|
+
SourceResult (no evidence). Bounded by ``max_push`` and the per-source
|
|
1899
|
+
timeout — it can never block the answer. Refs are ``kg:node:<id>`` (cacheable
|
|
1900
|
+
via the resolution_cache global watermark, identical to kg_neighbors).
|
|
1901
|
+
"""
|
|
1902
|
+
try:
|
|
1903
|
+
import knowledge_graph as kg
|
|
1904
|
+
import ppr
|
|
1905
|
+
except Exception as exc:
|
|
1906
|
+
return SourceResult(
|
|
1907
|
+
source="associative_graph", ok=False, skipped=True,
|
|
1908
|
+
aborted_reason="source_error", error=str(exc),
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
try:
|
|
1912
|
+
seeds = _associative_graph_seeds(request, kg, max_seeds=ppr.DEFAULT_MAX_SEEDS)
|
|
1913
|
+
if not seeds:
|
|
1914
|
+
return SourceResult(source="associative_graph")
|
|
1915
|
+
|
|
1916
|
+
# Cold-start contract: the FULL graph build (~13k edges) plus a cold
|
|
1917
|
+
# process's imports/first-DB-touch overruns the 120ms step timeout, which
|
|
1918
|
+
# would make the dispatcher abort the step and the feature contribute
|
|
1919
|
+
# nothing on query-1. So we never build inline. If the per-process cache
|
|
1920
|
+
# is already warm (query-2+, or a process whose pre-warm finished), we run
|
|
1921
|
+
# the multi-hop PPR straight off the cached graph (~5-7ms). If it is cold,
|
|
1922
|
+
# we kick off a non-blocking background pre-warm and degrade THIS query to
|
|
1923
|
+
# the bounded 1-hop fan-out (parity with kg_neighbors) — fast, never times
|
|
1924
|
+
# out — so the next query gets multi-hop.
|
|
1925
|
+
if ppr.cache_is_warm():
|
|
1926
|
+
ranked = ppr.rank_related(seeds, top_n=ppr.DEFAULT_TOP_N)
|
|
1927
|
+
if not ranked:
|
|
1928
|
+
# Warm but PPR returned nothing (e.g. seeds isolated) -> 1-hop.
|
|
1929
|
+
ranked = ppr.fallback_neighbors(list(seeds), limit=6)
|
|
1930
|
+
else:
|
|
1931
|
+
ppr.prewarm_async()
|
|
1932
|
+
ranked = ppr.fallback_neighbors(list(seeds), limit=6)
|
|
1933
|
+
if not ranked:
|
|
1934
|
+
return SourceResult(source="associative_graph")
|
|
1935
|
+
|
|
1936
|
+
rendered_parts: list[str] = []
|
|
1937
|
+
evidence_refs: list[str] = []
|
|
1938
|
+
for node in ranked:
|
|
1939
|
+
line = f"- {node.node_type}:{node.node_ref}"
|
|
1940
|
+
if node.label:
|
|
1941
|
+
line += f" ({node.label})"
|
|
1942
|
+
if node.score:
|
|
1943
|
+
line += f" [ppr={node.score:.4f}]"
|
|
1944
|
+
rendered_parts.append(line)
|
|
1945
|
+
evidence_refs.append(f"kg:node:{node.node_id}")
|
|
1946
|
+
|
|
1947
|
+
return SourceResult(
|
|
1948
|
+
source="associative_graph",
|
|
1949
|
+
rendered=_clip("\n".join(rendered_parts), request.max_chars),
|
|
1950
|
+
evidence_refs=list(dict.fromkeys(evidence_refs)),
|
|
1951
|
+
result_count=len(ranked),
|
|
1952
|
+
)
|
|
1953
|
+
except Exception as exc:
|
|
1954
|
+
return SourceResult(
|
|
1955
|
+
source="associative_graph", ok=False, skipped=True,
|
|
1956
|
+
aborted_reason="source_error", error=str(exc),
|
|
1957
|
+
)
|
|
1958
|
+
|
|
1959
|
+
|
|
1713
1960
|
def _source_diary(request: SourceRequest) -> SourceResult:
|
|
1714
1961
|
from db import read_session_diary
|
|
1715
1962
|
|
|
@@ -1784,7 +2031,28 @@ def _source_memory(request: SourceRequest) -> SourceResult:
|
|
|
1784
2031
|
from db import recall
|
|
1785
2032
|
|
|
1786
2033
|
rows = recall(request.query, days=45)[:5]
|
|
1787
|
-
|
|
2034
|
+
# ``recall`` returns heterogeneous FTS rows keyed by (source, source_id) in
|
|
2035
|
+
# the unified_search index, NOT a single ``id`` column. The default
|
|
2036
|
+
# ``_rows_result`` ref would collapse to a POSITIONAL ``memory:<idx>`` that
|
|
2037
|
+
# identifies no row, so the resolution cache could not version it (and would
|
|
2038
|
+
# refuse to cache, or worse, serve stale). Emit a RESOLVABLE ref
|
|
2039
|
+
# ``memory:<source>:<source_id>`` that the cache versions via
|
|
2040
|
+
# unified_search(source, source_id).updated_at, so an edited memory row
|
|
2041
|
+
# invalidates the cached answer. Falls back to the positional ref only when
|
|
2042
|
+
# a row carries no (source, source_id) pair (which the cache then treats as
|
|
2043
|
+
# untrackable and refuses to cache — conservative, never stale).
|
|
2044
|
+
if not rows:
|
|
2045
|
+
return SourceResult(source="memory")
|
|
2046
|
+
result = _rows_result("memory", rows, ("source", "title", "snippet", "category"), request.max_chars)
|
|
2047
|
+
resolvable_refs: list[str] = []
|
|
2048
|
+
for row in rows[:5]:
|
|
2049
|
+
src = str(row.get("source") or "").strip()
|
|
2050
|
+
sid = str(row.get("source_id") or "").strip()
|
|
2051
|
+
if src and sid:
|
|
2052
|
+
resolvable_refs.append(f"memory:{src}:{sid}")
|
|
2053
|
+
if resolvable_refs:
|
|
2054
|
+
result.evidence_refs = resolvable_refs
|
|
2055
|
+
return result
|
|
1788
2056
|
|
|
1789
2057
|
|
|
1790
2058
|
def _source_project_atlas(request: SourceRequest) -> SourceResult:
|
|
@@ -2261,9 +2529,46 @@ def _filter_rows_by_query(rows: Iterable[dict[str, Any]], query: str, fields: tu
|
|
|
2261
2529
|
return matched
|
|
2262
2530
|
|
|
2263
2531
|
|
|
2264
|
-
|
|
2532
|
+
# The resolution-cache versioner (``resolution_cache._SOURCE_VERSIONERS``) looks
|
|
2533
|
+
# up each cached ref by an EXACT id-column. If ``_rows_result`` builds the ref
|
|
2534
|
+
# from a DIFFERENT column than the versioner reads, ``ref_version`` resolves to
|
|
2535
|
+
# the wrong row (or, on a value collision between a free-text id column of one
|
|
2536
|
+
# row and the numeric id of another, to a REAL but WRONG row) → editing the row
|
|
2537
|
+
# the ref encodes does not move the snapshot → STALE HIT. The generic
|
|
2538
|
+
# ``id -> evidence_id -> task_id -> run_id -> session_id -> idx`` chain is exactly
|
|
2539
|
+
# such a mismatch source: e.g. a ``session_diary`` row carries BOTH ``id`` (the
|
|
2540
|
+
# versioner column) and ``session_id`` (a free-text column the OLD chain never
|
|
2541
|
+
# reached because ``id`` won), so the emitted ref and the versioner agreed only
|
|
2542
|
+
# by luck — and ``lifecycle_events`` has no ``id`` column at all, so the chain
|
|
2543
|
+
# fell through to ``session_id``/positional ``idx`` while the versioner read
|
|
2544
|
+
# ``event_id``.
|
|
2545
|
+
#
|
|
2546
|
+
# ``_ROUTER_REF_ID_FIELD`` pins, per source, the SINGLE column whose value builds
|
|
2547
|
+
# the ref. It MUST equal the ``id_column`` the resolution-cache versioner uses
|
|
2548
|
+
# for that source so ``ref_version('{source}:{id}')`` resolves to the exact row
|
|
2549
|
+
# the ref encodes. When the pinned column is absent/empty on a row, we emit a
|
|
2550
|
+
# deliberately positional ``{source}:__row<idx>`` ref: positional refs do not
|
|
2551
|
+
# match any id-column, so the write gate refuses to cache them (untrackable) —
|
|
2552
|
+
# never a silent fallback to a colliding column. Sources NOT listed here keep the
|
|
2553
|
+
# legacy chain (their adapters emit a composite/canonical ref, e.g.
|
|
2554
|
+
# ``evidence_ledger`` → ``evidence_id``, or are watermark/untrackable anyway).
|
|
2555
|
+
_ROUTER_REF_ID_FIELD: dict[str, str] = {
|
|
2556
|
+
"diary": "id", # versioner: session_diary.id (NOT session_id — collides)
|
|
2557
|
+
"runtime_db": "event_id", # versioner: lifecycle_events.event_id (no ``id`` column)
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _rows_result(
|
|
2562
|
+
source: str,
|
|
2563
|
+
rows: list[dict[str, Any]],
|
|
2564
|
+
fields: tuple[str, ...],
|
|
2565
|
+
max_chars: int,
|
|
2566
|
+
*,
|
|
2567
|
+
id_field: str | None = None,
|
|
2568
|
+
) -> SourceResult:
|
|
2265
2569
|
if not rows:
|
|
2266
2570
|
return SourceResult(source=source)
|
|
2571
|
+
pinned = id_field or _ROUTER_REF_ID_FIELD.get(source)
|
|
2267
2572
|
lines: list[str] = []
|
|
2268
2573
|
refs: list[str] = []
|
|
2269
2574
|
for idx, row in enumerate(rows[:5], start=1):
|
|
@@ -2273,7 +2578,15 @@ def _rows_result(source: str, rows: list[dict[str, Any]], fields: tuple[str, ...
|
|
|
2273
2578
|
if value not in (None, ""):
|
|
2274
2579
|
parts.append(f"{field_name}={_clip(str(value), 180)}")
|
|
2275
2580
|
lines.append(f"- " + " | ".join(parts))
|
|
2276
|
-
|
|
2581
|
+
if pinned is not None:
|
|
2582
|
+
# Pinned source: the ref MUST use the same column the versioner reads.
|
|
2583
|
+
value = row.get(pinned)
|
|
2584
|
+
# A positional fallback ref deliberately matches no id-column so the
|
|
2585
|
+
# resolution-cache write gate refuses to cache it (untrackable) rather
|
|
2586
|
+
# than silently emit a ref under a colliding column.
|
|
2587
|
+
ref_id = value if value not in (None, "") else f"__row{idx}"
|
|
2588
|
+
else:
|
|
2589
|
+
ref_id = row.get("id") or row.get("evidence_id") or row.get("task_id") or row.get("run_id") or row.get("session_id") or idx
|
|
2277
2590
|
refs.append(f"{source}:{ref_id}")
|
|
2278
2591
|
return SourceResult(
|
|
2279
2592
|
source=source,
|
|
@@ -12,6 +12,41 @@ from pre_answer_router import DEFAULT_BUDGET_MS, DEFAULT_TOKEN_BUDGET, classify_
|
|
|
12
12
|
|
|
13
13
|
RUNTIME_BUDGET_POLICY_VERSION = "runtime_budget_v1"
|
|
14
14
|
|
|
15
|
+
# Working-memory TTL for "I just resolved this" intents (Francisco's brief:
|
|
16
|
+
# don't re-search project X / "what do you know about María" from zero).
|
|
17
|
+
# Protected by the 3 invalidations (TTL + per-row snapshot + change_watermark),
|
|
18
|
+
# so a real change still forces a fresh resolution regardless of TTL.
|
|
19
|
+
#
|
|
20
|
+
# DEFENSE IN DEPTH: the TTL is now 15 MINUTES (900s), down from 6h. The per-row
|
|
21
|
+
# snapshot is the primary anti-stale guard, but it can only catch a change it can
|
|
22
|
+
# RESOLVE; a short TTL bounds the maximum obsolescence to minutes for anything
|
|
23
|
+
# that ever slips past resolution (a newly-introduced untracked store, a row-
|
|
24
|
+
# correctness regression), without losing the "lo acabo de mirar" repeat-question
|
|
25
|
+
# win — 15 minutes still covers the same-conversation re-ask. Configurable via
|
|
26
|
+
# NEXO_RESOLUTION_CACHE_PRIOR_WORK_TTL (seconds); set to 0 to disable the override
|
|
27
|
+
# and fall back to the tier's short TTL. Instant stays 0 (volatile) and critical
|
|
28
|
+
# keeps its short tier TTL (excluded below).
|
|
29
|
+
WORKING_MEMORY_INTENTS = {
|
|
30
|
+
"prior_work",
|
|
31
|
+
"identity_authorship",
|
|
32
|
+
"live_state_claim",
|
|
33
|
+
"memory_question",
|
|
34
|
+
}
|
|
35
|
+
_DEFAULT_WORKING_MEMORY_TTL = 15 * 60 # 900s — defense-in-depth obsolescence cap
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _working_memory_ttl_override() -> int:
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
raw = os.environ.get("NEXO_RESOLUTION_CACHE_PRIOR_WORK_TTL")
|
|
42
|
+
if raw is None or str(raw).strip() == "":
|
|
43
|
+
return _DEFAULT_WORKING_MEMORY_TTL
|
|
44
|
+
try:
|
|
45
|
+
value = int(raw)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return _DEFAULT_WORKING_MEMORY_TTL
|
|
48
|
+
return max(0, value)
|
|
49
|
+
|
|
15
50
|
HEAVY_SOURCES = {"memory", "cognitive", "local_context", "transcripts"}
|
|
16
51
|
CANONICAL_ROUTER_SOURCES = {
|
|
17
52
|
"semantic_layers",
|
|
@@ -22,6 +57,8 @@ CANONICAL_ROUTER_SOURCES = {
|
|
|
22
57
|
"workflows",
|
|
23
58
|
"change_log",
|
|
24
59
|
"causal_graph",
|
|
60
|
+
"kg_neighbors",
|
|
61
|
+
"associative_graph",
|
|
25
62
|
"diary",
|
|
26
63
|
"transcripts",
|
|
27
64
|
"memory",
|
|
@@ -89,6 +126,14 @@ def _as_optional_positive_int(value: Any) -> int | None:
|
|
|
89
126
|
return number if number > 0 else None
|
|
90
127
|
|
|
91
128
|
|
|
129
|
+
def _truthy(value: Any) -> bool:
|
|
130
|
+
if isinstance(value, bool):
|
|
131
|
+
return value
|
|
132
|
+
if value is None:
|
|
133
|
+
return False
|
|
134
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
135
|
+
|
|
136
|
+
|
|
92
137
|
def _clean(value: Any) -> str:
|
|
93
138
|
return " ".join(str(value or "").strip().lower().split())
|
|
94
139
|
|
|
@@ -327,6 +372,22 @@ def select_budget_policy(
|
|
|
327
372
|
required_checks.append("server_verification")
|
|
328
373
|
if clean_area in {"billing", "legal", "external_publication"}:
|
|
329
374
|
required_checks.append("permissions")
|
|
375
|
+
cache_ttl_seconds = int(spec.get("cache_ttl_seconds") or 0)
|
|
376
|
+
# Working-memory TTL: extend caching for "I just resolved this" intents so a
|
|
377
|
+
# repeat question in the same window is served from memory instead of a cold
|
|
378
|
+
# re-search. Never applied to instant (ttl=0, volatile) or critical
|
|
379
|
+
# (release/server/billing/legal must keep their short ttl). Safe because the
|
|
380
|
+
# source_fingerprint + change_watermark invalidations still fire.
|
|
381
|
+
if (
|
|
382
|
+
cache_ttl_seconds > 0
|
|
383
|
+
and tier != "critical"
|
|
384
|
+
and intent in WORKING_MEMORY_INTENTS
|
|
385
|
+
):
|
|
386
|
+
override = _working_memory_ttl_override()
|
|
387
|
+
if override > cache_ttl_seconds:
|
|
388
|
+
cache_ttl_seconds = override
|
|
389
|
+
reasons.append("working_memory_ttl_extended")
|
|
390
|
+
|
|
330
391
|
deadline_ms = int(spec["deadline_ms"])
|
|
331
392
|
token_budget = int(spec["token_budget"])
|
|
332
393
|
if budget_ms_override is not None:
|
|
@@ -381,7 +442,7 @@ def select_budget_policy(
|
|
|
381
442
|
can_delay_first_response=bool(spec.get("can_delay_first_response", False)),
|
|
382
443
|
must_disclose_gap=bool(spec.get("must_disclose_gap", False)),
|
|
383
444
|
delay_message_threshold_ms=int(spec.get("delay_message_threshold_ms") or 0),
|
|
384
|
-
cache_ttl_seconds=
|
|
445
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
385
446
|
route_cache_key=route_key,
|
|
386
447
|
privacy_level=str(spec.get("privacy_level") or "normal"),
|
|
387
448
|
reason_codes=tuple(dict.fromkeys(reasons)),
|
|
@@ -485,6 +546,69 @@ def run_pre_answer_route(
|
|
|
485
546
|
budget_ms_override=_as_optional_positive_int(payload.get("budget_ms")),
|
|
486
547
|
token_budget_override=_as_optional_positive_int(payload.get("token_budget")),
|
|
487
548
|
)
|
|
549
|
+
|
|
550
|
+
# ── FAST-PATH: working-memory / resolution cache ──────────────────────
|
|
551
|
+
# The cache key (route_cache_key) and TTL (cache_ttl_seconds) were already
|
|
552
|
+
# computed by select_budget_policy; here we finally READ them. A valid hit
|
|
553
|
+
# serves the prior FINAL result without re-running route_pre_answer or the
|
|
554
|
+
# escalation pass. 'instant' tier (ttl=0) never caches. The anti-stale
|
|
555
|
+
# contract (TTL + status + source_fingerprint + change_watermark) lives in
|
|
556
|
+
# resolution_cache.is_valid — any failure is a MISS and falls through.
|
|
557
|
+
sid = str(payload.get("sid") or payload.get("session_id") or "")
|
|
558
|
+
cache_enabled = (
|
|
559
|
+
policy.cache_ttl_seconds > 0
|
|
560
|
+
and bool(policy.route_cache_key)
|
|
561
|
+
and not _truthy(payload.get("no_cache"))
|
|
562
|
+
and not _truthy(payload.get("bypass_cache"))
|
|
563
|
+
)
|
|
564
|
+
if cache_enabled:
|
|
565
|
+
try:
|
|
566
|
+
import resolution_cache
|
|
567
|
+
|
|
568
|
+
expected_sid = sid if policy.intent in resolution_cache.SESSION_SCOPED_INTENTS else ""
|
|
569
|
+
hit = resolution_cache.get(policy.route_cache_key, expected_sid=expected_sid)
|
|
570
|
+
except Exception:
|
|
571
|
+
hit = None
|
|
572
|
+
if hit and isinstance(hit.get("result"), dict) and hit["result"]:
|
|
573
|
+
# Serving from cache skips route_pre_answer (and thus the cognitive
|
|
574
|
+
# source's process_queue=True). Don't lose memory-observation
|
|
575
|
+
# ingestion just because we hit the cache: process the queue
|
|
576
|
+
# directly on the hit turn. Bounded, best-effort, never blocks the
|
|
577
|
+
# answer — failures are swallowed.
|
|
578
|
+
try:
|
|
579
|
+
from memory_retrieval import process_memory_observation_queue
|
|
580
|
+
|
|
581
|
+
process_memory_observation_queue(limit=50)
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
cached_result = dict(hit["result"])
|
|
585
|
+
cached_result["cache_hit"] = True
|
|
586
|
+
cached_result["resolution_cache"] = {
|
|
587
|
+
"cache_key": policy.route_cache_key,
|
|
588
|
+
"hit": True,
|
|
589
|
+
"resolved_at": hit.get("resolved_at"),
|
|
590
|
+
"expires_at": hit.get("expires_at"),
|
|
591
|
+
"hit_count": hit.get("hit_count"),
|
|
592
|
+
}
|
|
593
|
+
try:
|
|
594
|
+
from local_context.usage_events import record_router_usage
|
|
595
|
+
|
|
596
|
+
cached_result["usage_event"] = record_router_usage(
|
|
597
|
+
query,
|
|
598
|
+
cached_result,
|
|
599
|
+
client=str(payload.get("source") or payload.get("client") or "unknown"),
|
|
600
|
+
tool="pre_answer_router",
|
|
601
|
+
route_stage="pre_answer",
|
|
602
|
+
intent=str(cached_result.get("intent") or policy.intent or "auto"),
|
|
603
|
+
elapsed_ms=0,
|
|
604
|
+
deadline_ms=int(cached_result.get("deadline_ms") or policy.deadline_ms),
|
|
605
|
+
used_before_response=True,
|
|
606
|
+
cache_hit=True,
|
|
607
|
+
)
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
return cached_result
|
|
611
|
+
|
|
488
612
|
result = _run_with_policy(
|
|
489
613
|
query=query,
|
|
490
614
|
payload=payload,
|
|
@@ -524,6 +648,36 @@ def run_pre_answer_route(
|
|
|
524
648
|
result["escalated_from"] = policy.budget_tier
|
|
525
649
|
result["escalated_to"] = escalated_policy.budget_tier
|
|
526
650
|
|
|
651
|
+
# ── SET: cache the FINAL result (post-escalation), never mid-way ──────
|
|
652
|
+
# Only cache real, source-backed answers: an empty/no-evidence pass should
|
|
653
|
+
# not poison the cache (it would mask a future genuine resolution). The
|
|
654
|
+
# 'instant' tier and missing key are rejected inside resolution_cache.set.
|
|
655
|
+
result["cache_hit"] = False
|
|
656
|
+
if cache_enabled and (result.get("should_inject") or result.get("evidence_refs")):
|
|
657
|
+
try:
|
|
658
|
+
import resolution_cache
|
|
659
|
+
|
|
660
|
+
set_outcome = resolution_cache.set(
|
|
661
|
+
policy.route_cache_key,
|
|
662
|
+
result,
|
|
663
|
+
ttl_seconds=int(policy.cache_ttl_seconds),
|
|
664
|
+
kind="route",
|
|
665
|
+
intent=str(policy.intent or ""),
|
|
666
|
+
area=str(payload.get("area") or ""),
|
|
667
|
+
sid=sid if policy.intent in resolution_cache.SESSION_SCOPED_INTENTS else "",
|
|
668
|
+
source_refs=result.get("evidence_refs"),
|
|
669
|
+
policy_version=str(policy.policy_version or ""),
|
|
670
|
+
)
|
|
671
|
+
result["resolution_cache"] = {
|
|
672
|
+
"cache_key": policy.route_cache_key,
|
|
673
|
+
"hit": False,
|
|
674
|
+
"stored": bool(set_outcome.get("ok")),
|
|
675
|
+
"store_reason": set_outcome.get("reason", ""),
|
|
676
|
+
"expires_at": set_outcome.get("expires_at"),
|
|
677
|
+
}
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
|
|
527
681
|
try:
|
|
528
682
|
from local_context.usage_events import record_router_usage
|
|
529
683
|
|
|
@@ -537,6 +691,7 @@ def run_pre_answer_route(
|
|
|
537
691
|
elapsed_ms=int(float(result.get("elapsed_ms") or 0)),
|
|
538
692
|
deadline_ms=int(result.get("deadline_ms") or policy.deadline_ms),
|
|
539
693
|
used_before_response=True,
|
|
694
|
+
cache_hit=False,
|
|
540
695
|
)
|
|
541
696
|
except Exception as exc:
|
|
542
697
|
result["usage_event"] = {
|