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.
@@ -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
- return _rows_result("memory", rows, ("source", "title", "snippet", "category"), request.max_chars)
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
- def _rows_result(source: str, rows: list[dict[str, Any]], fields: tuple[str, ...], max_chars: int) -> SourceResult:
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
- 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
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=int(spec.get("cache_ttl_seconds") or 0),
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"] = {