diff --git a/arborist/cli.py b/arborist/cli.py
index 6e169e0..7cc302c 100644
--- a/arborist/cli.py
+++ b/arborist/cli.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import json
import os
+import re
import sys
from pathlib import Path
@@ -7517,6 +7518,35 @@ def _ignore(fn, *a, **kw):
pass
+_POINTER_LINE_RE = re.compile(
+ r'^\s*\[E\d+(?:\s*\|[^\]]*?)?\]\s*$',
+ flags=re.MULTILINE,
+)
+_INLINE_POINTER_RE = re.compile(r'\[E\d+(?:\s*\|[^\]]*?)?\]')
+
+
+def _session_strip_pointers(answer_text: str) -> str:
+ """Strip claim-lattice pointer markers from a prior turn's answer
+ before threading it through as LLM conversation history. The
+ spotlight-rendered ``[E# |
| : "..."]``
+ blocks (which sit on their own line per claim) get removed; bare
+ ``[E#]`` mid-prose markers also drop. Leaves the prose claims
+ intact so the model sees: "Spider-Man is a superhero. Created by
+ Stan Lee." without stale pointer ids that don't match the new
+ turn's evidence_map.
+ """
+ if not answer_text:
+ return ""
+ # Drop standalone pointer lines first (they're the bulk of the
+ # answer_text size for claim_lattice mode).
+ s = _POINTER_LINE_RE.sub("", answer_text)
+ # Then drop any remaining inline [E#] / [E# | …] markers.
+ s = _INLINE_POINTER_RE.sub("", s)
+ # Collapse multi-blank-lines.
+ s = re.sub(r"\n{3,}", "\n\n", s).strip()
+ return s
+
+
def _cmd_session(args: argparse.Namespace) -> int:
"""Merkle-rooted multi-turn Q&A REPL with forks.
@@ -7829,16 +7859,40 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
print(f"unknown command: {line} (try /help)")
continue
- # Non-command line → new question. Read cited titles
- # directly from ancestor node rows (already materialized at
- # insert time) and pass as retrieval_keywords. Verifier-
- # blind; same discipline as the legacy --retrieval-keywords
- # flag (#000001).
+ # Non-command line → new question. Two pieces of session
+ # context flow into the call:
+ #
+ # 1. retrieval_keywords: ancestor cited titles → widen
+ # FTS5 retrieval. Verifier-blind; same discipline as
+ # the legacy --retrieval-keywords flag (#000001).
+ # 2. conversation_history: ancestor (user/assistant)
+ # pairs → give the LLM antecedent context for pronoun
+ # questions ("who created it?" after "what is
+ # permaculture?"). Folds into conversation_hash so each
+ # distinct chat path gets its own cache_key.
seen_titles: list[str] = []
- for n in sess.path_to_root(sess.current_bates)[1:]:
+ history: list[dict] = []
+ # current_bates is the PARENT of the soon-to-be-minted
+ # node, so its path-to-root IS the conversation history.
+ # Walk oldest-first so the LLM reads in chronological
+ # order; skip the synthetic root (seq=0).
+ ancestors = sess.path_to_root(sess.current_bates)
+ for n in reversed(ancestors):
+ if n.seq == 0:
+ continue # skip synthetic root
for t in n.cited_titles:
if t and t not in seen_titles:
seen_titles.append(t)
+ history.append({"role": "user", "content": n.question})
+ # Strip claim-lattice pointer-line excerpts before
+ # passing prior answers to the model — the [E#] bindings
+ # are stale (each turn has a fresh evidence_map) and the
+ # spotlight excerpts inflate the prompt without adding
+ # antecedent value. The bare claim prose remains.
+ history.append({
+ "role": "assistant",
+ "content": _session_strip_pointers(n.answer_text),
+ })
policy_local = dict(policy)
if seen_titles:
policy_local["retrieval_keywords"] = " ".join(seen_titles[:8])
@@ -7851,6 +7905,7 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
model_id=model,
burn_existing=bool(args.burn),
top_k=args.top_k,
+ conversation_history=history or None,
)
except Exception as e:
print(f"query failed: {e}")
diff --git a/arborist/qa/corpus_query.py b/arborist/qa/corpus_query.py
index 3df2a04..3f64185 100644
--- a/arborist/qa/corpus_query.py
+++ b/arborist/qa/corpus_query.py
@@ -135,6 +135,7 @@ def run_query(
warrant_check_enabled: bool = False,
policy: dict | None = None,
precomputed_hits: list | None = None,
+ conversation_history: list[dict] | None = None,
) -> dict:
"""End-to-end claim-lattice query over any Corpus.
@@ -385,11 +386,17 @@ def run_query(
f"QUESTION: {question}\n\n"
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
)
- messages = [
- {"role": "system", "content": sys_prompt},
- {"role": "user", "content": user_prompt},
- ]
- prompt_chars = len(sys_prompt) + len(user_prompt)
+ # Optional conversation_history is inserted between system and the
+ # current user turn — gives the model antecedent context for
+ # pronoun questions ("who created it?" after "what is permaculture?").
+ # The history's per-turn user/assistant content stays grounded in
+ # the CURRENT turn's evidence_map: the model is told which pointer
+ # ids resolve via the new EVIDENCE block, not the prior turns'.
+ messages = [{"role": "system", "content": sys_prompt}]
+ if conversation_history:
+ messages.extend(conversation_history)
+ messages.append({"role": "user", "content": user_prompt})
+ prompt_chars = sum(len(m["content"]) for m in messages)
# 4. LLM call.
ts = _time.time()
@@ -469,6 +476,7 @@ def run_query(
"document_root": ev.source_root,
"document_uri": ev.document_uri,
"title": ev.title,
+ "chunk_idx": ev.chunk_idx,
"source_role": ev.source_role,
"used": ev.source_root in used_doc_roots,
"used_pointer_ids": sorted(set(
diff --git a/arborist/qa/providence_query.py b/arborist/qa/providence_query.py
index 2384673..49aed6d 100644
--- a/arborist/qa/providence_query.py
+++ b/arborist/qa/providence_query.py
@@ -62,23 +62,35 @@ _DEFAULT_CHUNKING_VERSION = "tok-512-v1"
def _build_canonical_messages_for_hash(
- question: str, evidence_text: str
+ question: str, evidence_text: str,
+ conversation_history: list[dict] | None = None,
) -> list[dict]:
"""Mirror the message shape run_query passes to chat_completion.
Must stay in sync with arborist/qa/corpus_query.py:run_query —
same system prompt, same user-payload template. The byte-identity
test (tests/test_run_query_byte_identity.py) catches drift.
+
+ ``conversation_history``: optional list of preceding turn
+ messages (user / assistant pairs). When set, they're inserted
+ between the system prompt and the current EVIDENCE+QUESTION user
+ turn so the model has antecedent context for pronouns ("who
+ created it?" after "what is permaculture?"). Folds into
+ ``conversation_hash`` so each unique chat history gets its own
+ cache_key.
"""
user = (
f"EVIDENCE:\n\n{evidence_text}\n\n"
f"QUESTION: {question}\n\n"
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
)
- return [
+ msgs: list[dict] = [
{"role": "system", "content": CLAIM_LATTICE_SYSTEM_PROMPT},
- {"role": "user", "content": user},
]
+ if conversation_history:
+ msgs.extend(conversation_history)
+ msgs.append({"role": "user", "content": user})
+ return msgs
def _compute_cache_key(
@@ -282,6 +294,7 @@ def providence_query(
temperature: float = 0.1,
max_tokens: int = 512,
progress: Any = None,
+ conversation_history: list[dict] | None = None,
) -> dict[str, Any]:
"""Cache-aware claim-lattice query.
@@ -459,7 +472,10 @@ def providence_query(
qhash = question_hash(question, mode="equivalence_class")
mhash = model_profile_hash(model_id)
chash = conversation_hash(
- _build_canonical_messages_for_hash(question, evidence_repr)
+ _build_canonical_messages_for_hash(
+ question, evidence_repr,
+ conversation_history=conversation_history,
+ )
)
ghash = governance_policy_hash(policy)
schema_version = policy.get("schema_version", _DEFAULT_SCHEMA_VERSION)
@@ -556,6 +572,7 @@ def providence_query(
temperature=temperature, max_tokens=max_tokens,
policy=policy,
precomputed_hits=hits,
+ conversation_history=conversation_history,
)
progress.emit(
"llm.done",