Discovered during Memoria Phase 4 first compile run: when compile.py
invokes claude_agent_sdk.query(), the spawned `claude` subprocess
inherits the global ~/.claude/settings.json hook config. Its
SessionEnd hook then fires when the subprocess wraps up, triggering
flush.py against today's daily log — polluting the log with compile
metadata and creating a soft recursion (every compile call also
generates a flush call).
flush.py already had this guard (CLAUDE_INVOKED_BY=memory_flush set
at module top before any SDK import). compile.py / query.py / lint.py
did not.
Add the same guard to the other three SDK call sites with
script-specific sentinel values:
- compile.py → memoria_compile
- query.py → memoria_query
- lint.py → memoria_lint
The sentinel value doesn't matter — both session-end.py and
pre-compact.py check `if os.environ.get("CLAUDE_INVOKED_BY"): exit(0)`,
so any non-empty value short-circuits. Using distinct sentinels makes
diagnostics clearer if a hook trace ever shows it.
Verified: imports clean, all 29 acceptance tests still pass.
160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
"""
|
|
Query the knowledge base using index-guided retrieval (no RAG).
|
|
|
|
The LLM reads the index, picks relevant articles, and synthesizes an answer.
|
|
No vector database, no embeddings, no chunking - just structured markdown
|
|
and an index the LLM can reason over.
|
|
|
|
Usage:
|
|
uv run python query.py "How should I handle auth redirects?"
|
|
uv run python query.py "What patterns do I use for API design?" --file-back
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
# Recursion guard — see compile.py for rationale. Set before any SDK import
|
|
# so the global SessionEnd/PreCompact hooks see CLAUDE_INVOKED_BY in the
|
|
# nested Claude subprocess's env and exit cleanly.
|
|
os.environ["CLAUDE_INVOKED_BY"] = "memoria_query"
|
|
|
|
import argparse
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from config import KNOWLEDGE_DIR, QA_DIR, now_iso
|
|
from utils import load_state, read_wiki_index, save_state
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
# Query model (Sonnet by default — synthesis over the retrieved articles
|
|
# benefits from strong reasoning; override via MEMORIA_QUERY_MODEL).
|
|
QUERY_MODEL = os.environ.get("MEMORIA_QUERY_MODEL", "sonnet")
|
|
|
|
|
|
async def run_query(question: str, file_back: bool = False) -> str:
|
|
"""Query the knowledge base and optionally file the answer back.
|
|
|
|
Unlike upstream, we do NOT inline the entire wiki into the prompt — the
|
|
LLM receives the index only and uses its Read tool to fetch articles
|
|
it decides are relevant. Keeps prompt size bounded regardless of
|
|
knowledge-base size and avoids the whole-wiki-in-prompt cost wall
|
|
documented in upstream issues #3/#5/#9.
|
|
"""
|
|
from claude_agent_sdk import (
|
|
AssistantMessage,
|
|
ClaudeAgentOptions,
|
|
ResultMessage,
|
|
TextBlock,
|
|
query,
|
|
)
|
|
|
|
wiki_index = read_wiki_index()
|
|
|
|
tools = ["Read", "Glob", "Grep"]
|
|
if file_back:
|
|
tools.extend(["Write", "Edit"])
|
|
|
|
file_back_instructions = ""
|
|
if file_back:
|
|
timestamp = now_iso()
|
|
file_back_instructions = f"""
|
|
|
|
## File Back Instructions
|
|
|
|
After answering, do the following:
|
|
1. Create a Q&A article at {QA_DIR}/ with the filename being a slugified version
|
|
of the question (e.g., knowledge/qa/how-to-handle-auth-redirects.md)
|
|
2. Use the Q&A article format from the schema (frontmatter with title, question,
|
|
consulted articles, filed date)
|
|
3. Update {KNOWLEDGE_DIR / 'index.md'} with a new row for this Q&A article
|
|
4. Append to {KNOWLEDGE_DIR / 'log.md'}:
|
|
## [{timestamp}] query (filed) | question summary
|
|
- Question: {question}
|
|
- Consulted: [[list of articles read]]
|
|
- Filed to: [[qa/article-name]]
|
|
"""
|
|
|
|
prompt = f"""You are a knowledge base query engine. Answer the user's question by
|
|
consulting the knowledge base.
|
|
|
|
## How to Answer
|
|
|
|
1. Read the INDEX section first - it lists every article with a one-line summary
|
|
2. Identify 3-10 articles that are relevant to the question
|
|
3. Use the Read tool to fetch those articles (they live at
|
|
{KNOWLEDGE_DIR}/concepts/, {KNOWLEDGE_DIR}/connections/, and
|
|
{KNOWLEDGE_DIR}/qa/). Only read articles you actually need — do not
|
|
read the entire wiki.
|
|
4. Synthesize a clear, thorough answer
|
|
5. Cite your sources using [[wikilinks]] (e.g., [[concepts/supabase-auth]])
|
|
6. If the knowledge base doesn't contain relevant information, say so honestly
|
|
|
|
## Knowledge Base Index
|
|
|
|
{wiki_index}
|
|
|
|
## Question
|
|
|
|
{question}
|
|
{file_back_instructions}"""
|
|
|
|
answer = ""
|
|
cost = 0.0
|
|
|
|
try:
|
|
async for message in query(
|
|
prompt=prompt,
|
|
options=ClaudeAgentOptions(
|
|
cwd=str(ROOT_DIR),
|
|
model=QUERY_MODEL,
|
|
system_prompt={"type": "preset", "preset": "claude_code"},
|
|
allowed_tools=tools,
|
|
permission_mode="acceptEdits",
|
|
max_turns=15,
|
|
),
|
|
):
|
|
if isinstance(message, AssistantMessage):
|
|
for block in message.content:
|
|
if isinstance(block, TextBlock):
|
|
answer += block.text
|
|
elif isinstance(message, ResultMessage):
|
|
cost = message.total_cost_usd or 0.0
|
|
except Exception as e:
|
|
answer = f"Error querying knowledge base: {e}"
|
|
|
|
# Update state
|
|
state = load_state()
|
|
state["query_count"] = state.get("query_count", 0) + 1
|
|
state["total_cost"] = state.get("total_cost", 0.0) + cost
|
|
save_state(state)
|
|
|
|
return answer
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Query the personal knowledge base")
|
|
parser.add_argument("question", help="The question to ask")
|
|
parser.add_argument(
|
|
"--file-back",
|
|
action="store_true",
|
|
help="File the answer back into the knowledge base as a Q&A article",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
print(f"Question: {args.question}")
|
|
print(f"File back: {'yes' if args.file_back else 'no'}")
|
|
print("-" * 60)
|
|
|
|
answer = asyncio.run(run_query(args.question, file_back=args.file_back))
|
|
print(answer)
|
|
|
|
if args.file_back:
|
|
print("\n" + "-" * 60)
|
|
qa_count = len(list(QA_DIR.glob("*.md"))) if QA_DIR.exists() else 0
|
|
print(f"Answer filed to knowledge/qa/ ({qa_count} Q&A articles total)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|