brainpalace-cli 26.7.1__tar.gz → 26.7.2__tar.gz

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.
Files changed (115) hide show
  1. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/PKG-INFO +1 -1
  2. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/ai_guidance.py +3 -1
  3. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/client/api_client.py +59 -0
  4. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/hook.py +16 -2
  5. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/query.py +71 -2
  6. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/sync_docs.py +13 -1
  7. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/data/ai_guidance.md +50 -12
  8. brainpalace_cli-26.7.2/brainpalace_cli/doc_sync/checkers/mode_parity_checker.py +35 -0
  9. brainpalace_cli-26.7.2/brainpalace_cli/doc_sync/checkers/modes.py +108 -0
  10. brainpalace_cli-26.7.2/brainpalace_cli/doc_sync/contract_parity.py +88 -0
  11. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/generator.py +29 -0
  12. brainpalace_cli-26.7.2/brainpalace_cli/doc_sync/mode_meta.py +93 -0
  13. brainpalace_cli-26.7.2/brainpalace_cli/doc_sync/mode_parity.py +70 -0
  14. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/serializer.py +38 -3
  15. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/mcp_server/schemas.py +23 -2
  16. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/mcp_server/server.py +2 -2
  17. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/pyproject.toml +1 -1
  18. brainpalace_cli-26.7.1/brainpalace_cli/doc_sync/checkers/modes.py +0 -63
  19. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/README.md +0 -0
  20. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/__init__.py +0 -0
  21. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/__main__.py +0 -0
  22. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/cli.py +0 -0
  23. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/client/__init__.py +0 -0
  24. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/client/errors.py +0 -0
  25. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/__init__.py +0 -0
  26. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/_dashboard_url.py +0 -0
  27. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/ai_guide.py +0 -0
  28. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/backfill.py +0 -0
  29. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/bm25_project.py +0 -0
  30. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/cache.py +0 -0
  31. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/config.py +0 -0
  32. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/context.py +0 -0
  33. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/dashboard.py +0 -0
  34. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/doctor.py +0 -0
  35. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/estimate_util.py +0 -0
  36. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/extraction.py +0 -0
  37. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/extraction_drain.py +0 -0
  38. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/folders.py +0 -0
  39. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/graph.py +0 -0
  40. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/index.py +0 -0
  41. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/init.py +0 -0
  42. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/init_plan.py +0 -0
  43. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/inject.py +0 -0
  44. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/install_agent.py +0 -0
  45. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/jobs.py +0 -0
  46. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/list_cmd.py +0 -0
  47. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/lsp.py +0 -0
  48. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/mcp.py +0 -0
  49. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/memories.py +0 -0
  50. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/plugin_detect.py +0 -0
  51. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/read_only.py +0 -0
  52. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/reap.py +0 -0
  53. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/recall.py +0 -0
  54. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/records.py +0 -0
  55. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/remember.py +0 -0
  56. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/reset.py +0 -0
  57. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/session_hooks.py +0 -0
  58. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/sessions.py +0 -0
  59. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/start.py +0 -0
  60. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/status.py +0 -0
  61. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/stop.py +0 -0
  62. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/types.py +0 -0
  63. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/uninstall.py +0 -0
  64. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/update.py +0 -0
  65. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/verify_docs.py +0 -0
  66. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/commands/whoami.py +0 -0
  67. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config.py +0 -0
  68. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_fields.py +0 -0
  69. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_migrate.py +0 -0
  70. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_parity.py +0 -0
  71. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_resolve.py +0 -0
  72. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_review.py +0 -0
  73. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/config_schema.py +0 -0
  74. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/data/__init__.py +0 -0
  75. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/data/hooks/__init__.py +0 -0
  76. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/data/hooks/sessionstart-hook.sh +0 -0
  77. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/diagnostics.py +0 -0
  78. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/discovery.py +0 -0
  79. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/__init__.py +0 -0
  80. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/allowlist.py +0 -0
  81. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/__init__.py +0 -0
  82. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/base.py +0 -0
  83. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/cli_commands.py +0 -0
  84. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/config.py +0 -0
  85. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/endpoints.py +0 -0
  86. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/mcp.py +0 -0
  87. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/plugin_docs.py +0 -0
  88. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/provider_tables.py +0 -0
  89. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/checkers/skills.py +0 -0
  90. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/facts.py +0 -0
  91. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/introspect.py +0 -0
  92. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/markers.py +0 -0
  93. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/orchestrator.py +0 -0
  94. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/referential.py +0 -0
  95. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/doc_sync/triggers.py +0 -0
  96. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/known_projects.py +0 -0
  97. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/lsp_install.py +0 -0
  98. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/mcp_server/__init__.py +0 -0
  99. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/mcp_server/lifecycle.py +0 -0
  100. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/mcp_server/tools.py +0 -0
  101. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/migration.py +0 -0
  102. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/optional_deps.py +0 -0
  103. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/prompt_render.py +0 -0
  104. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/providers.py +0 -0
  105. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/__init__.py +0 -0
  106. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/claude_converter.py +0 -0
  107. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/codex_converter.py +0 -0
  108. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/converter_base.py +0 -0
  109. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/gemini_converter.py +0 -0
  110. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/opencode_converter.py +0 -0
  111. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/parser.py +0 -0
  112. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/skill_runtime_converter.py +0 -0
  113. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/tool_maps.py +0 -0
  114. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/runtime/types.py +0 -0
  115. {brainpalace_cli-26.7.1 → brainpalace_cli-26.7.2}/brainpalace_cli/xdg_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainpalace-cli
3
- Version: 26.7.1
3
+ Version: 26.7.2
4
4
  Summary: BrainPalace CLI - Command-line interface for managing AI agent memory and knowledge retrieval
5
5
  License: MIT
6
6
  Keywords: brainpalace,rag,cli,ai-memory,llm-memory,semantic-search,ai-agent,claude-code,agent-memory
@@ -43,9 +43,11 @@ _SKILL_FRONTMATTER = """\
43
43
  name: using-brainpalace
44
44
  description: |
45
45
  Expert BrainPalace skill for document search with BM25 keyword, semantic
46
- vector, hybrid, graph, and multi retrieval modes.
46
+ vector, hybrid, graph, multi, compute, scan, absence, and timeline
47
+ retrieval modes.
47
48
  Use when asked to "search documentation", "query domain", "find in docs",
48
49
  "bm25 search", "hybrid search", "semantic search", "graph search", "multi search",
50
+ "compute query", "scan sessions", "absence query", "timeline query",
49
51
  "find dependencies", "code relationships", "searching knowledge base",
50
52
  "querying indexed documents", "finding code references", "exploring codebase",
51
53
  "what calls this function", "find imports", "trace dependencies",
@@ -100,6 +100,31 @@ class ScanRow:
100
100
  score: float = 0.0
101
101
 
102
102
 
103
+ @dataclass
104
+ class AbsenceRow:
105
+ """One absence-mode row (subject present under one partition, absent under
106
+ another)."""
107
+
108
+ label: str
109
+ present_in: str
110
+ absent_from: str
111
+ partition: str
112
+ score: float = 0.0
113
+
114
+
115
+ @dataclass
116
+ class TimelineRow:
117
+ """One timeline-mode row (an entity's edge at a point in its history)."""
118
+
119
+ subject: str
120
+ predicate: str
121
+ object: str
122
+ valid_from: str | None = None
123
+ valid_until: str | None = None
124
+ valid: bool = True
125
+ score: float = 0.0
126
+
127
+
103
128
  @dataclass
104
129
  class QueryResponse:
105
130
  """Query response with results."""
@@ -109,6 +134,8 @@ class QueryResponse:
109
134
  total_results: int
110
135
  compute: list[ComputeRow] | None = None
111
136
  scan: list[ScanRow] | None = None
137
+ absence: list[AbsenceRow] | None = None
138
+ timeline: list[TimelineRow] | None = None
112
139
  index_blocked: dict[str, Any] | None = None
113
140
 
114
141
 
@@ -360,12 +387,44 @@ class DocServeClient:
360
387
  for r in raw_scan
361
388
  ]
362
389
 
390
+ raw_absence = data.get("absence")
391
+ absence_rows: list[AbsenceRow] | None = None
392
+ if raw_absence is not None:
393
+ absence_rows = [
394
+ AbsenceRow(
395
+ label=str(r.get("label", "")),
396
+ present_in=str(r.get("present_in", "")),
397
+ absent_from=str(r.get("absent_from", "")),
398
+ partition=str(r.get("partition", "")),
399
+ score=float(r.get("score", 0.0)),
400
+ )
401
+ for r in raw_absence
402
+ ]
403
+
404
+ raw_timeline = data.get("timeline")
405
+ timeline_rows: list[TimelineRow] | None = None
406
+ if raw_timeline is not None:
407
+ timeline_rows = [
408
+ TimelineRow(
409
+ subject=str(r.get("subject", "")),
410
+ predicate=str(r.get("predicate", "")),
411
+ object=str(r.get("object", "")),
412
+ valid_from=r.get("valid_from"),
413
+ valid_until=r.get("valid_until"),
414
+ valid=bool(r.get("valid", r.get("valid_until") is None)),
415
+ score=float(r.get("score", 0.0)),
416
+ )
417
+ for r in raw_timeline
418
+ ]
419
+
363
420
  return QueryResponse(
364
421
  results=results,
365
422
  query_time_ms=data.get("query_time_ms", 0.0),
366
423
  total_results=data.get("total_results", len(results)),
367
424
  compute=compute_rows,
368
425
  scan=scan_rows,
426
+ absence=absence_rows,
427
+ timeline=timeline_rows,
369
428
  index_blocked=data.get("index_blocked"),
370
429
  )
371
430
 
@@ -171,7 +171,21 @@ def hook_userpromptsubmit() -> None:
171
171
  # Query modes a guarded subagent prompt must reference to prove it will search
172
172
  # via BrainPalace. Mirrors the modes accepted by `brainpalace query --mode` and
173
173
  # the MCP/skill `query` tool's `mode` argument.
174
- _GUARD_QUERY_MODES = ("hybrid", "bm25", "vector", "graph", "multi")
174
+ # The 9 query modes. Kept a CLI-local literal (NOT imported from the server enum)
175
+ # because hook.py is a fast, server-import-free shim loaded on every CLI call; the
176
+ # contract_parity gate (tests/doc_sync/test_mode_parity.py + lint:doc-sync) holds
177
+ # this tuple equal to brainpalace_server.models.query.QueryMode.
178
+ _GUARD_QUERY_MODES = (
179
+ "hybrid",
180
+ "bm25",
181
+ "vector",
182
+ "graph",
183
+ "multi",
184
+ "compute",
185
+ "scan",
186
+ "absence",
187
+ "timeline",
188
+ )
175
189
  _GUARD_MODES_ALT = "|".join(_GUARD_QUERY_MODES)
176
190
  # A prompt proves BrainPalace search two equivalent ways:
177
191
  # CLI form — `brainpalace query ... --mode <mode>`
@@ -217,7 +231,7 @@ _GUARD_SEARCH_INTENT_RE = re.compile(
217
231
  )
218
232
  _GUARD_DENY_REASON = (
219
233
  "Subagent prompt must instruct BrainPalace search: include "
220
- "`brainpalace query ... --mode <hybrid|bm25|vector|graph|multi>`. "
234
+ f"`brainpalace query ... --mode <{_GUARD_MODES_ALT}>`. "
221
235
  "If genuinely exempt, open the prompt with a line "
222
236
  "`# BRAINPALACE_EXEMPT: <reason of 20+ chars>`."
223
237
  )
@@ -86,7 +86,17 @@ def _maybe_print_ai_hint() -> None:
86
86
  "--mode",
87
87
  default="hybrid",
88
88
  type=click.Choice(
89
- ["vector", "bm25", "hybrid", "graph", "multi", "compute", "scan"],
89
+ [
90
+ "vector",
91
+ "bm25",
92
+ "hybrid",
93
+ "graph",
94
+ "multi",
95
+ "compute",
96
+ "scan",
97
+ "absence",
98
+ "timeline",
99
+ ],
90
100
  case_sensitive=False,
91
101
  ),
92
102
  help=(
@@ -98,7 +108,14 @@ def _maybe_print_ai_hint() -> None:
98
108
  "unless record extraction has populated them), "
99
109
  "'scan' (deterministic term counts over the archived session "
100
110
  "transcripts; 'which week did I mention X most'; empty when the "
101
- "session archive is off). Default: hybrid."
111
+ "session archive is off), "
112
+ "'absence' (anti-join over typed records: subjects present under one "
113
+ "partition value but absent under another, e.g. 'distance but not "
114
+ "duration'; empty when no two stored values resolve), "
115
+ "'timeline' (walk an entity's edge-validity/supersession history: "
116
+ "how a belief/fact evolved, e.g. 'how did the auth decision evolve'; "
117
+ "empty when the named entity resolves to no graph node), "
118
+ "Default: hybrid."
102
119
  ),
103
120
  )
104
121
  @click.option(
@@ -237,6 +254,18 @@ def query_command(
237
254
  import dataclasses
238
255
 
239
256
  output["scan"] = [dataclasses.asdict(s) for s in response.scan]
257
+ if response.absence is not None:
258
+ import dataclasses
259
+
260
+ output["absence"] = [
261
+ dataclasses.asdict(a) for a in response.absence
262
+ ]
263
+ if response.timeline is not None:
264
+ import dataclasses
265
+
266
+ output["timeline"] = [
267
+ dataclasses.asdict(t) for t in response.timeline
268
+ ]
240
269
  blocked = getattr(response, "index_blocked", None)
241
270
  if blocked is not None:
242
271
  output["index_blocked"] = blocked
@@ -290,6 +319,46 @@ def query_command(
290
319
  _maybe_print_ai_hint()
291
320
  return
292
321
 
322
+ # Absence mode: print "in X, not Y" lines
323
+ if response.absence is not None:
324
+ console.print(
325
+ f"\n[bold]Query:[/] {query_text}\n"
326
+ f"[dim]Absence results in {response.query_time_ms:.1f}ms[/]\n"
327
+ )
328
+ if not response.absence:
329
+ console.print(
330
+ "[yellow]No gaps found (no two stored values resolved, "
331
+ "or nothing is absent).[/]"
332
+ )
333
+ else:
334
+ for absence_row in response.absence:
335
+ console.print(
336
+ f"{absence_row.label} (in {absence_row.present_in}, "
337
+ f"not {absence_row.absent_from})"
338
+ )
339
+ _maybe_print_ai_hint()
340
+ return
341
+
342
+ # Timeline mode: print entity edge-validity history lines
343
+ if response.timeline is not None:
344
+ if not response.timeline:
345
+ # M3: empty has two causes — no entity parsed from the query,
346
+ # or the entity resolved to no node / no edges. Cover both.
347
+ console.print(
348
+ "[yellow]No history found — the query named no "
349
+ "recognizable entity, or the entity resolved to no graph "
350
+ "node / has no edges. Try 'history of <name>'.[/]"
351
+ )
352
+ else:
353
+ for tl_row in response.timeline:
354
+ mark = "[green]✓[/]" if tl_row.valid else "[dim]✗[/]"
355
+ until = tl_row.valid_until or "now"
356
+ console.print(
357
+ f"{mark} {tl_row.subject} —{tl_row.predicate}→ "
358
+ f"{tl_row.object} [{tl_row.valid_from or '?'} … {until}]"
359
+ )
360
+ return
361
+
293
362
  # Display header
294
363
  console.print(
295
364
  f"\n[bold]Query:[/] {query_text}\n"
@@ -19,6 +19,7 @@ from brainpalace_cli.doc_sync.checkers.provider_tables import ProviderTablesChec
19
19
  from brainpalace_cli.doc_sync.checkers.skills import SkillsChecker
20
20
  from brainpalace_cli.doc_sync.generator import (
21
21
  regenerate_mcp_tools,
22
+ regenerate_modes_block,
22
23
  regenerate_provider_tables,
23
24
  regenerate_query_modes,
24
25
  )
@@ -43,6 +44,14 @@ _PLUGIN = _REPO_ROOT / "brainpalace-plugin"
43
44
  # Roots scanned for provider/install GENERATED blocks (whole plugin tree + docs +
44
45
  # both READMEs). Recursive — a new doc that adds a block is covered automatically.
45
46
  _PROVIDER_DOC_ROOTS = [_PLUGIN, _DOCS, _REPO_ROOT / "README.md"]
47
+ # Doc -> mode-table style, for the additional (non-canonical) GENERATED:modes
48
+ # blocks: each doc keeps its own richer column shape, all rendered from the same
49
+ # MODE_META single source (see doc_sync/mode_meta.py).
50
+ _MODES_DOC_STYLES: dict[Path, str] = {
51
+ _REPO_ROOT / "README.md": "grid",
52
+ _DOCS / "API_REFERENCE.md": "table",
53
+ _DOCS / "USER_GUIDE.md": "commands",
54
+ }
46
55
 
47
56
 
48
57
  def _require_repo() -> None:
@@ -77,7 +86,7 @@ def _provider_doc_files() -> list[Path]:
77
86
  def _checkers() -> list[Any]:
78
87
  return [
79
88
  CliCommandsChecker(docs_dir=_DOCS_DIR),
80
- ModesChecker(docs_dir=_DOCS_DIR),
89
+ ModesChecker(docs_dir=_DOCS_DIR, targets=_MODES_DOC_STYLES),
81
90
  SkillsChecker(
82
91
  skills_dir=_SKILLS_DIR,
83
92
  docs_dir=_DOCS_DIR,
@@ -131,6 +140,9 @@ def sync_docs_command(mode_check: bool, mode_fix: bool) -> None:
131
140
  regenerate_mcp_tools(_DOCS_DIR / MCP_CANONICAL, snap.mcp_tools)
132
141
  for doc in _provider_doc_files():
133
142
  regenerate_provider_tables(doc, snap)
143
+ for doc, style in _MODES_DOC_STYLES.items():
144
+ if doc.exists():
145
+ regenerate_modes_block(doc, snap.modes, style)
134
146
  prose_needed = run_fix(checkers, snap)
135
147
  for item in prose_needed:
136
148
  click.echo(
@@ -53,6 +53,8 @@ Pick `--mode` for any inline search. Default to `hybrid` when unsure.
53
53
  | Maximum recall (ALL usages / references) | `--mode multi` |
54
54
  | Aggregation ("how many", "total", "which week had most") | `--mode compute` |
55
55
  | Own-utterance history ("which week did I mention X") | `--mode scan` |
56
+ | Anti-join over typed records ("X but not Y") | `--mode absence` |
57
+ | Belief/fact evolution ("how did X evolve", "history of X") | `--mode timeline` |
56
58
 
57
59
  Common flags: `--top-k N` (default 5), `--threshold F` (default 0.3),
58
60
  `--alpha F` (hybrid balance, 0=BM25…1=Vector, default 0.5), `--language CODE`
@@ -97,18 +99,7 @@ Per-result keys are `text`, `source`, `score`, `chunk_id` — there is NO
97
99
  `{"error": ...}` (with `detail`/`hint`) and a non-zero exit, with **no**
98
100
  `results` key. Never append `2>/dev/null` — diagnostics go to stderr. A
99
101
  top-level `index_blocked` object means the index is STALE (paused over
100
- budget) — never auto-approve.
101
-
102
- ```bash
103
- brainpalace query "..." --mode hybrid --top-k 8 --json | python3 -c "
104
- import json, sys
105
- d = json.load(sys.stdin)
106
- if 'error' in d: sys.exit('brainpalace error: %s' % d['error'])
107
- for r in d['results']:
108
- print(r['source'], r['score'])
109
- print(r['text'][:500])
110
- "
111
- ```
102
+ budget) — never auto-approve. (Runnable parsing snippet: `--tier full`.)
112
103
 
113
104
  **Allowed Glob/Grep cases (NOT codebase search):** searching inside a single file
114
105
  BrainPalace already returned; non-indexed paths (`~/.claude/`, `~/.config/`,
@@ -134,6 +125,21 @@ call the `ai_guide` tool).
134
125
 
135
126
  ---
136
127
 
128
+ ## Parsing `--json` Output — runnable example
129
+
130
+ ```bash
131
+ brainpalace query "..." --mode hybrid --top-k 8 --json | python3 -c "
132
+ import json, sys
133
+ d = json.load(sys.stdin)
134
+ if 'error' in d: sys.exit('brainpalace error: %s' % d['error'])
135
+ for r in d['results']:
136
+ print(r['source'], r['score'])
137
+ print(r['text'][:500])
138
+ "
139
+ ```
140
+
141
+ ---
142
+
137
143
  ## Paused (Budget-Blocked) Indexing
138
144
 
139
145
  Query output (CLI `--json` and the MCP `query` tool) may carry a top-level
@@ -225,6 +231,38 @@ is off or no term parses. Tie-break with compute: typed record metric wins.
225
231
 
226
232
  ---
227
233
 
234
+ ## Absence Mode — Anti-Join over Typed Records
235
+
236
+ `--mode absence` — anti-join over typed records: subjects present under one
237
+ partition value (`metric`/`source`/`domain`) but absent under another
238
+ ("distance but not duration"). Deterministic, no LLM. Empty when no two
239
+ stored values resolve, or nothing qualifies (e.g. no records exist).
240
+ Auto-routed after compute and scan — a query carrying a compute or scan tell
241
+ alongside an absence tell routes there instead (e.g. "did I discuss gmail but
242
+ not session" is scan, not absence, because it carries "did i discuss").
243
+
244
+ **`--json` contract for absence:** `results` is always `[]`; rows live under
245
+ `absence`. Each row: `label` (the missing subject), `present_in`,
246
+ `absent_from`, `partition` (`metric`/`source`/`domain`), `score` (reserved,
247
+ always `0.0`).
248
+
249
+ ---
250
+
251
+ ## Timeline Mode — Edge-Validity / Supersession History
252
+
253
+ `timeline` — walk an entity's edge-validity/supersession history in the graph:
254
+ how a belief/fact evolved ("how did the auth decision evolve", "history of
255
+ auth.py"). Deterministic, no LLM. Requires the graph index (`ENABLE_GRAPH_INDEX`);
256
+ empty when the named entity resolves to no graph node or has no edges.
257
+ Auto-routed after compute, scan, and absence — a query carrying one of those
258
+ tells alongside a timeline tell routes there instead.
259
+
260
+ **`--json` contract for timeline:** `results` is always `[]`; rows live under
261
+ `timeline`. Each row: `subject`, `predicate`, `object`, `valid_from`,
262
+ `valid_until` (`null` = still valid), `valid`, `score`.
263
+
264
+ ---
265
+
228
266
  ## Session Memory — Optional, Separately Gated
229
267
 
230
268
  Recall of prior AI-coding sessions (past-session transcript chunks and distilled
@@ -0,0 +1,35 @@
1
+ """Adapt the query_modes contract-parity result into doc-sync DriftRecords so the
2
+ lint:doc-sync gate fails on any cross-surface mode drift (both directions)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from brainpalace_cli.doc_sync.facts import DriftKind, DriftRecord, InterfaceSnapshot
7
+ from brainpalace_cli.doc_sync.mode_parity import query_modes_mismatches
8
+
9
+ _SURFACE_LOCATOR = {
10
+ "cli_choice": "brainpalace_cli/commands/query.py (--mode Choice)",
11
+ "mcp_literal": "brainpalace_cli/mcp_server/schemas.py (QueryMode)",
12
+ "hook_guard": "brainpalace_cli/commands/hook.py (_GUARD_QUERY_MODES)",
13
+ "mode_meta": "brainpalace_cli/doc_sync/mode_meta.py (MODE_META)",
14
+ "<sot>": "brainpalace_server/models/query.py (QueryMode)",
15
+ }
16
+
17
+
18
+ class ModeParityChecker:
19
+ surface = "mode-parity"
20
+
21
+ def check(self, snap: InterfaceSnapshot) -> list[DriftRecord]:
22
+ records: list[DriftRecord] = []
23
+ for m in query_modes_mismatches():
24
+ path = _SURFACE_LOCATOR.get(m.surface, m.surface)
25
+ if m.error is not None:
26
+ msg = f"{m.surface}: extractor error: {m.error}"
27
+ else:
28
+ msg = (
29
+ f"{m.surface} out of sync with server QueryMode enum: "
30
+ f"missing={sorted(m.missing)} extra={sorted(m.extra)}"
31
+ )
32
+ records.append(
33
+ DriftRecord(self.surface, m.surface, path, DriftKind.MISMATCH, msg)
34
+ )
35
+ return records
@@ -0,0 +1,108 @@
1
+ """Modes surface: canonical GENERATED:modes block in brainpalace-query.md (add/remove
2
+ gate) + a scoped referential check for `--mode <token>` invocations elsewhere +
3
+ MODE_META coverage + any additional per-doc `targets` (README/docs mode tables in
4
+ their own richer shape, e.g. README's grid or USER_GUIDE's commands table)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from brainpalace_cli.doc_sync.facts import DriftKind, DriftRecord, InterfaceSnapshot
12
+ from brainpalace_cli.doc_sync.generator import MODES_RENDERERS
13
+ from brainpalace_cli.doc_sync.markers import MarkerError, find_block
14
+ from brainpalace_cli.doc_sync.mode_meta import MODE_META
15
+ from brainpalace_cli.doc_sync.referential import dangling_tokens
16
+ from brainpalace_cli.doc_sync.serializer import render_modes_table
17
+
18
+ SURFACE = "modes"
19
+ CANONICAL = "brainpalace-query.md"
20
+ # Scoped to real invocations: `--mode <tok>` or `--mode=<tok>` (resolution H).
21
+ _MODE_RE = re.compile(r"--mode[= ]([a-z][a-z0-9-]*)")
22
+
23
+
24
+ class ModesChecker:
25
+ surface = SURFACE
26
+
27
+ def __init__(self, docs_dir: Path, targets: dict[Path, str] | None = None) -> None:
28
+ self.docs_dir = Path(docs_dir)
29
+ # path -> style ("table"/"grid"/"commands"): additional docs whose own
30
+ # GENERATED:modes block (if present) is checked against that doc's style.
31
+ self.targets = {Path(p): style for p, style in (targets or {}).items()}
32
+
33
+ def check(self, snap: InterfaceSnapshot) -> list[DriftRecord]:
34
+ records: list[DriftRecord] = []
35
+ missing_meta = [m for m in snap.modes if m not in MODE_META]
36
+ if missing_meta:
37
+ records.append(
38
+ DriftRecord(
39
+ SURFACE,
40
+ "mode-meta",
41
+ "brainpalace_cli/doc_sync/mode_meta.py",
42
+ DriftKind.MISSING,
43
+ f"MODE_META has no entry for live mode(s): {missing_meta!r}",
44
+ )
45
+ )
46
+ # A mode lacking MODE_META already yields a MISSING record above; every
47
+ # render_modes_* call raises ValueError for it, so skip the render-based
48
+ # comparisons below rather than crash the whole checker on that gap.
49
+ canon = self.docs_dir / CANONICAL
50
+ if canon.exists():
51
+ text = canon.read_text(encoding="utf-8")
52
+ try:
53
+ inner = find_block(text, "modes")
54
+ if (
55
+ not missing_meta
56
+ and inner.strip() != render_modes_table(snap.modes).strip()
57
+ ):
58
+ records.append(
59
+ DriftRecord(
60
+ SURFACE,
61
+ "query",
62
+ str(canon),
63
+ DriftKind.MISMATCH,
64
+ "canonical modes block out of sync with --mode Choice",
65
+ )
66
+ )
67
+ except MarkerError:
68
+ records.append(
69
+ DriftRecord(
70
+ SURFACE,
71
+ "query",
72
+ str(canon),
73
+ DriftKind.MISSING,
74
+ "canonical GENERATED:modes block absent",
75
+ )
76
+ )
77
+ if not missing_meta:
78
+ for path, style in self.targets.items():
79
+ if not path.exists():
80
+ continue
81
+ text = path.read_text(encoding="utf-8")
82
+ try:
83
+ inner = find_block(text, "modes")
84
+ except MarkerError:
85
+ continue # block not present in this doc — nothing to gate
86
+ render = MODES_RENDERERS[style]
87
+ if inner.strip() != render(snap.modes).strip():
88
+ records.append(
89
+ DriftRecord(
90
+ SURFACE,
91
+ path.stem,
92
+ str(path),
93
+ DriftKind.MISMATCH,
94
+ f"GENERATED:modes block ({style}) out of sync with "
95
+ "--mode Choice",
96
+ )
97
+ )
98
+ all_docs = sorted(self.docs_dir.glob("brainpalace-*.md"))
99
+ records.extend(
100
+ dangling_tokens(
101
+ all_docs,
102
+ _MODE_RE,
103
+ set(snap.modes),
104
+ SURFACE,
105
+ "`--mode {tok}` references a non-existent mode",
106
+ )
107
+ )
108
+ return records
@@ -0,0 +1,88 @@
1
+ """Generic cross-surface parity gate.
2
+
3
+ A *contract* is one canonical token set (the source of truth) plus a set of named
4
+ *surfaces* that each independently re-declare that set. The check asserts every
5
+ surface equals the SoT, in BOTH directions (a surface missing a token, or carrying
6
+ an extra one), and fails loud per surface (an extractor that raises is reported by
7
+ name, never crashes the run). Register a contract once; `check_all` loops them all.
8
+ This kills the "same list hardcoded in N places drifts apart" bug class for any
9
+ future contract, not just query modes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Contract:
20
+ name: str
21
+ sot: Callable[[], set[str]]
22
+ surfaces: dict[str, Callable[[], set[str]]] = field(default_factory=dict)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ParityMismatch:
27
+ contract: str
28
+ surface: str
29
+ missing: frozenset[str] = frozenset() # in SoT, absent from the surface
30
+ extra: frozenset[str] = frozenset() # in the surface, absent from the SoT
31
+ error: str | None = None # the extractor (or SoT) raised
32
+
33
+
34
+ _CONTRACTS: dict[str, Contract] = {}
35
+
36
+
37
+ def register_contract(
38
+ name: str,
39
+ sot: Callable[[], set[str]],
40
+ surfaces: dict[str, Callable[[], set[str]]],
41
+ ) -> None:
42
+ _CONTRACTS[name] = Contract(name, sot, dict(surfaces))
43
+
44
+
45
+ def clear_contracts() -> None:
46
+ _CONTRACTS.clear()
47
+
48
+
49
+ def check_contract(name: str) -> list[ParityMismatch]:
50
+ c = _CONTRACTS[name]
51
+ try:
52
+ truth = c.sot()
53
+ except Exception as exc: # noqa: BLE001 - report, don't crash the gate
54
+ return [ParityMismatch(c.name, "<sot>", error=repr(exc))]
55
+ out: list[ParityMismatch] = []
56
+ for surface, extract in c.surfaces.items():
57
+ try:
58
+ got = extract()
59
+ except Exception as exc: # noqa: BLE001
60
+ out.append(ParityMismatch(c.name, surface, error=repr(exc)))
61
+ continue
62
+ missing = frozenset(truth - got)
63
+ extra = frozenset(got - truth)
64
+ if missing or extra:
65
+ out.append(ParityMismatch(c.name, surface, missing=missing, extra=extra))
66
+ return out
67
+
68
+
69
+ def check_all() -> list[ParityMismatch]:
70
+ out: list[ParityMismatch] = []
71
+ for name in _CONTRACTS:
72
+ out.extend(check_contract(name))
73
+ return out
74
+
75
+
76
+ def format_mismatches(mismatches: list[ParityMismatch]) -> str:
77
+ if not mismatches:
78
+ return "contract parity OK"
79
+ lines = ["contract parity FAILED:"]
80
+ for m in mismatches:
81
+ if m.error is not None:
82
+ lines.append(f" [{m.contract}] {m.surface}: extractor error: {m.error}")
83
+ else:
84
+ lines.append(
85
+ f" [{m.contract}] {m.surface}: "
86
+ f"missing={sorted(m.missing)} extra={sorted(m.extra)}"
87
+ )
88
+ return "\n".join(lines)
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import re
8
8
  import subprocess
9
9
  from pathlib import Path
10
+ from typing import Callable
10
11
 
11
12
  from brainpalace_cli.doc_sync.facts import CommandFact, InterfaceSnapshot
12
13
  from brainpalace_cli.doc_sync.markers import (
@@ -20,10 +21,20 @@ from brainpalace_cli.doc_sync.serializer import (
20
21
  render_flags_section,
21
22
  render_flags_table,
22
23
  render_mcp_tools_table,
24
+ render_modes_commands,
25
+ render_modes_grid,
23
26
  render_modes_table,
24
27
  render_params_yaml,
25
28
  )
26
29
 
30
+ #: style name -> renderer(modes) -> markdown body. Each target doc registers the
31
+ #: style matching its own richer column shape; all pull from the same MODE_META.
32
+ MODES_RENDERERS: dict[str, Callable[[list[str]], str]] = {
33
+ "table": render_modes_table,
34
+ "grid": render_modes_grid,
35
+ "commands": render_modes_commands,
36
+ }
37
+
27
38
  _PARAMS_RE = re.compile(r"^parameters:.*?(?=^\S|\Z)", re.MULTILINE | re.DOTALL)
28
39
 
29
40
 
@@ -117,6 +128,24 @@ def regenerate_query_modes(path: Path, modes: list[str]) -> None:
117
128
  path.write_text(text, encoding="utf-8")
118
129
 
119
130
 
131
+ def regenerate_modes_block(path: Path, modes: list[str], style: str) -> bool:
132
+ """Refresh the GENERATED:modes block in ``path`` IF PRESENT, using the renderer
133
+ for ``style`` (mirrors regenerate_provider_tables: these tables are placed by a
134
+ human where they belong, never auto-appended). Returns True if the file changed,
135
+ False when the file has no modes block (skipped, untouched)."""
136
+ render = MODES_RENDERERS[style]
137
+ text = path.read_text(encoding="utf-8")
138
+ try:
139
+ find_block(text, "modes")
140
+ except MarkerError:
141
+ return False
142
+ new_text = replace_block(text, "modes", render(modes))
143
+ if new_text != text:
144
+ path.write_text(new_text, encoding="utf-8")
145
+ return True
146
+ return False
147
+
148
+
120
149
  def regenerate_provider_tables(path: Path, snap: InterfaceSnapshot) -> bool:
121
150
  """Refresh every provider/install GENERATED block PRESENT in ``path`` from the
122
151
  live registry. Only existing blocks are rewritten (these tables are placed by a