cctally 1.52.1 → 1.53.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/CHANGELOG.md +26 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +91 -26
- package/bin/_cctally_db.py +347 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +225 -9
- package/bin/_lib_conversation_query.py +1020 -316
- package/bin/_lib_doctor.py +85 -0
- package/dashboard/static/assets/index-Bli-0vNb.css +1 -0
- package/dashboard/static/assets/index-Dwu_Neot.js +71 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CqBt0IM7.js +0 -68
- package/dashboard/static/assets/index-klTHJA52.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.53.0] - 2026-06-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Dashboard conversation outline — per-subagent cost, a resizable column, a nested-subagent tree, and a compaction landmark.** Each subagent thread in the outline now shows its cost beside its label (a display-only, summed-cost-once figure that covers every thread, including older ones with no captured spawn metadata). A thin resize divider between the reading column and the outline lets you drag the outline wider or narrower — keyboard-resizable too (`←`/`→` step, `Home`/`End` to the extremes, with a labeled `separator` accessibility surface) — and the width persists across reloads. When a subagent spawns its own sub-subagents, the outline nests those children indented beneath their parent thread (a session with no such nesting looks exactly as before). And a conversation compaction (the auto-summarize point) is now a navigable outline landmark with its own "Jump to" chip and `m`/`M` reader keys. The outline's "Jump to" chips also change: a primary click now jumps to the most-recent occurrence of that landmark family, while shift-click steps to the previous one.
|
|
12
|
+
- **Dashboard conversation reader — opens at the bottom with two-way scrolling, remembers where you were reading, and jumps straight to the last prompt or error.** A long conversation now opens on its newest turns (single-request tail load, with live-tail already following) instead of at the top; a short conversation that fits one page still opens at the top so it reads from the start. The reader window pages in both directions — scrolling down loads newer turns and scrolling up prepends older ones, anchored so the turn you're reading stays put rather than jumping — and a backward (scroll-up) page never breaks the "follow the live session" state. Switching away from a conversation and returning later restores you to the turn you were last reading (anchored to the turn, so it survives a resized window or a grown transcript; a deep-link or jump target still wins, and a vanished turn falls back to the bottom). Two new reader keys jump directly to the most-recent occurrence: `a` to the last prompt and `L` to the last error. `Latest ↓` / `End` now reset to the newest page in a single request (instant even on a very long session) rather than paging all the way forward.
|
|
13
|
+
- **`cctally doctor` — new conversation-rollup consistency check.** `doctor` (and the dashboard's doctor panel) now run a `data.conversation_sessions_rollup` check that compares the materialized `conversation_sessions` browse-rail rollup against the distinct session count in the conversation cache, warning only when they disagree in a quiescent cache — an in-flight sync, reingest, or backfill is detected (via a non-blocking cache-lock probe and the pending-flag set) and never warns — so a stale or half-built rollup that would skew the conversation list's filters and counts is surfaced instead of passing silently.
|
|
14
|
+
- **Dashboard conversation reader — open-at-bottom and backward pagination on the reader API.** `GET /api/conversation/<id>` now pages with `before=<id>` (the page of turns immediately *before* a cursor) and `tail=1` (the last page in a single request, so a client can land on the newest turns without paging forward through the whole session) alongside the existing forward `after=<id>`; the three cursors are mutually exclusive (more than one is a `400`) and `limit` is clamped `1`–`1000` (default `500`). The `page` object gains additive `prev_before`/`has_prev` (older turns remain to page back to) beside `next_after`/`has_more`, each computed from the page's actual position so a short backward page at the head still reports more forward; a stale cursor yields an empty page rather than re-serving the head or tail. Existing no-cursor and `after` responses are byte-stable except for the two additive `page` keys (backend contract only — the frontend lights this up in a later session).
|
|
15
|
+
- **Dashboard conversation search — `kind=title` title search and server-side filtered search.** `GET /api/conversation/search` adds a `kind=title` facet that searches the AI-generated session titles and returns one session-level hit per matching session (anchored to the session's first turn, with `match_kinds:["title"]` and the matched title as the snippet); its `total` counts only sessions with surviving turns, so the count never lies, and it falls back to a `LIKE` scan when the host lacks FTS5. The search route also now accepts the same browse filters as the conversation list (`date_from`/`date_to`/`projects`/`cost_min`/`cost_max`/`rebuild_min`), applied as a session-scope restriction across every kind, with the same brief project/cost/rebuild degradation (a `filter_degraded` flag, date-axis only) while the per-session rollup is still indexing; a no-filter request is byte-stable with the prior output. The cross-session-only `title` facet is rejected with a `400` (never a `500`) on the in-conversation `find` route. Backend contract only — surfaced in a later frontend session.
|
|
16
|
+
- **Dashboard conversation search — `kind=files` file-path search.** `GET /api/conversation/search` adds a `kind=files` facet that searches the write-class file paths each session edited or created (files touched by an `Edit`/`MultiEdit`/`Write`/`NotebookEdit` tool call — read-only `Read` and shell `Bash` are excluded so the axis answers "which sessions modified this file"), returning one hit per distinct `(session, file path)` anchored to that path's most-recent touch (`match_kinds:["file"]`, snippet = the file path) and a `total` that is the distinct `(session, file path)` count; a query with no leading separator is treated as a case-insensitive path prefix that is genuinely index-assisted — it rides the `COLLATE NOCASE` path index as a key-range search (the index is `NOCASE` precisely because the default `LIKE` is case-insensitive and a binary-collated index can't serve it) — while a leading-separator query is a full substring scan over the modest touch table (a leading wildcard can never use an index), and the facet composes with the same browse filters as the other kinds and is rejected with a `400` on the in-conversation `find` route. The axis is backed by a re-derivable `conversation_file_touches` table (cache migration `019`) backfilled once on the first sync after upgrade. Backend contract only — surfaced in a later frontend session.
|
|
17
|
+
- **Dashboard conversation reader — long Bash output collapses by default, and a key to search the conversation list from inside a reader.** A Bash tool card whose output runs long (more than 20 rendered lines) now opens collapsed with a `show N lines` hint so it no longer buries the next turn — short output stays open, and the `[`/`]` collapse-all and a per-card click still override either way. The new `f` key focuses the conversation-list search input even while a conversation is open, so you can look up another session without pressing `Esc` to leave the reader first (`/` stays reader-aware and opens the in-conversation find bar).
|
|
18
|
+
- **Dashboard conversation find bar — `⌘F` / `Ctrl+F`, regex + case-sensitive search, a focus trap, and live results.** The in-conversation find bar now opens with `⌘F` / `Ctrl+F` as well as `/` (the browser's native find bar is suppressed only inside the Conversations workspace, and only when no modal or text input is up — it still works on the dashboard panels), and two toggles beside the input — `.*` (regular expression) and `Aa` (case-sensitive), both remembered across reloads — drive a real server-side match over the full transcript corpus (prose, tool commands/results/stderr, and thinking, the same content the search facets cover); an invalid regex shows an announced "invalid regex" hint rather than failing silently. The prose underline now honors the case toggle (and is suppressed in regex mode, where the match count and jump still work fully). The bar traps keyboard focus while open (`Tab`/`Shift+Tab` cycle its controls, `Esc` exits to the transcript), and its results now live-refresh as the open conversation grows — keeping your place by jumping back to the same match across the refresh, and resetting to the first match only when the one you were on disappeared.
|
|
19
|
+
- **Dashboard conversation rail — sort the browse list, persist your filters + sort, and apply filters to search.** A new **Sort** control in the rail header orders the conversation list by Recent (default), Oldest, Cost (high→low), Messages (high→low), or Project (A→Z, with un-labeled sessions last); the chosen sort and the browse filters now persist across reloads instead of resetting every session. The Browse filters also apply to full-text **search** now — the same filter set is shared, so the Filters button stays enabled with a search needle active, the active-filter chips stay visible, and the chosen date/project/cost/rebuild axes narrow the search results (file-path and AI-title results included) rather than only the browse list. While a brand-new or `--no-sync` dashboard is still building its per-session rollup, the Cost/Project sorts briefly fall back to Recent order (and the rollup-only filter axes to date-only), each with a muted "while indexing" note, self-correcting after the first full sync. The `GET /api/conversations` route gains a `sort` param (`recent`/`oldest`/`cost`/`messages`/`project`, lenient on unknown values) and an additive `page.sort_degraded` flag.
|
|
20
|
+
- **Dashboard conversation search — search across sessions by AI-title or file path, surfaced in the rail.** The cross-session search chip row gains two structural facets after a subtle separator — **Title** (searches the AI-generated session titles) and **Files** (searches the write-class file paths each session edited or created) — joining the existing All / Prompts / Assistant / Tools / Thinking content kinds; both stay available even while the search index is still backfilling (neither rides the split index). A Title hit shows the matched title and opens that session at its first turn; a Files hit leads with the file path (with the session title secondary) and opens the session at that path's most-recent touch, so you can jump from "which session modified `bin/cctally`" straight into the conversation. The new hits carry `title` / `file` match badges, and both facets narrow to your active browse filters like every other kind. (This lights up the `kind=title` / `kind=files` backend facets shipped earlier in the conversation viewer's search route.)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **Dashboard conversation viewer — backend performance and tech-debt hardening (no visible change).** The per-sync browse-rail rollup no longer runs a full transcript assembly for every touched session — the cache-rebuild count now comes from a dedicated lightweight query that shares the exact cache-failure rule with the reader — so the rollup maintenance that runs under the cache lock on every sync no longer does work proportional to sessions × full-assembly; the in-conversation find skips assembling a transcript entirely when a query matches nothing; cross-session search batches its badge and snippet lookups into a couple of bounded queries instead of issuing up to ~200 per-result round-trips; and a dead, always-empty `search_aux` column is removed via cache migration `016`. The rendered conversation output is byte-for-byte unchanged.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Dashboard conversation reader — a cluster of small reading and accessibility refinements.** The header `Errors` focus-mode badge now counts error *turns* (matching the "Jump to" error chip and exactly what clicking the filter steps between) rather than the raw total of error events, so it can no longer read higher than the number of places the filter lands. A WebSearch results-count chip no longer shows green when the search errored (it takes a neutral/error style, with the `· error` marker alongside). The browse list's "Load more" button disables and shows a loading label while a page is fetching, matching the search results list. The Find control uses an inline icon (matching the other reader controls), the `· N hidden ·` focus-mode marker carries a screen-reader label naming the hidden-turn count, the load-full affordance disables when there's no addressable session, the diff result disclosure names the snippet's line count instead of a fixed string, and the ExitPlanMode outcome badge no longer mis-reads a free-text user response that merely mentions "approve"/"reject" as an approval or rejection.
|
|
27
|
+
- **Dashboard conversation viewer — a subagent whose result exceeds 16 KB no longer drops its nested subagent into a detached, unlinked card.** When a subagent returned more than ~16 KB of text, the `agentId:` trailer that links a nested (grandchild) subagent back to the thread that spawned it could fall past the point where the result is clipped for storage, so the grandchild rendered as a flat, top-level card disconnected from its parent. The link is now captured at ingest time from the full, pre-clip result (with the read-time parse kept as a fallback), so deeply-nested subagents render correctly regardless of result size; existing transcripts are re-derived once by a flag-only cache migration (`017_arm_nested_agent_reingest`), and because the cache is fully re-derivable there is nothing to do on upgrade.
|
|
28
|
+
- **Dashboard conversation viewer — full-payload reads now bound and flag truncation honestly.** A tool input large enough to exceed the payload cap is now actually clipped to that cap rather than served over-cap with only a truncation flag set (the bound is measured in the same serialization the response uses, so it holds for non-ASCII text too), and a tool result whose `stderr` stream is itself truncated now carries an explicit `stderr_truncated` flag — so a consumer of the read-full-payload route can tell a clipped stream from a complete one. No change to the rendered transcript.
|
|
29
|
+
- **Dashboard — the Recent Sessions table no longer spills past its panel card at narrow two-column widths (≈1100–1600px).** Its columns (Started · Dur · Model · Project · Cost) sum wider than the card at those widths, so the Cost column used to bleed past the card and the viewport edge; the table now scrolls horizontally inside the card instead, keeping every column full-width (no truncation) with the scrollbar shown only when the table can't fit. Wider layouts (≥~1300px, including the centered ≥1600px cap) are unchanged — the table fits with no scrollbar (#208).
|
|
30
|
+
- **Dashboard — the Daily heatmap no longer clips the digits of larger costs at narrow widths.** A full three-digit cost such as `$212.83` overflowed the day cell's clip box and lost its leading/trailing glyphs (rendering as "212.8"); each cell now sizes its cost text to the cell's width given the value's length, so the whole amount (cents included) always fits — wide cells keep the original 16px size and only cells that would otherwise overflow shrink, fluidly, at any width (#208).
|
|
31
|
+
- **`record-credit` — a week with an already-applied credit is now refused before the confirm prompt, not after it.** Re-running `record-credit` on a week that already has a fully-recorded credit is refused by default (exit 2, naming the recorded pre-credit value and pointing at `--force`). Previously, on an interactive terminal, the command printed the full preview and asked `Proceed? [y/N]` first, then refused only after you answered `y` — so you were prompted to confirm an action that was never going to apply. The refusal now fires before the prompt, so the "already recorded — pass `--force`" message appears immediately on both interactive and non-interactive reruns (#212).
|
|
32
|
+
- **Dashboard conversation find bar — `Esc` from a find-bar button no longer closes the whole reader, and a Files search result no longer prints its path twice.** Pressing `Esc` while focus was on one of the find bar's buttons (Close, the `.*`/`Aa` toggles, or the prev/next arrows) closed the find bar *and* tore the entire reader down back to the conversation list — only `Esc` from the text input behaved correctly. `Esc` is now handled at the bar level, so it closes only the find bar from any of its controls and restores keyboard focus to the transcript (instead of dropping it to the page body). Separately, a `Files` search hit in the conversation rail showed the file path both as its prominent primary line and again, redundantly, in the snippet row beneath; the duplicate snippet row is now suppressed for file hits (every other result kind keeps its snippet).
|
|
33
|
+
|
|
8
34
|
## [1.52.1] - 2026-06-20
|
|
9
35
|
|
|
10
36
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -185,8 +185,8 @@ _CONV_INSERT_SQL = (
|
|
|
185
185
|
" timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
|
|
186
186
|
" req_id,cwd,git_branch,is_sidechain,source_tool_use_id,"
|
|
187
187
|
" stop_reason,attribution_skill,attribution_plugin,"
|
|
188
|
-
" search_tool,search_thinking
|
|
189
|
-
" VALUES(
|
|
188
|
+
" search_tool,search_thinking)"
|
|
189
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
# #193: last non-null write wins (ai-title carries no timestamp; see spec S1). NO
|
|
@@ -206,19 +206,19 @@ def _conv_row_tuple(m, path_str):
|
|
|
206
206
|
"""Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
|
|
207
207
|
|
|
208
208
|
The #177 enrichment fields (stop_reason / attribution_skill /
|
|
209
|
-
attribution_plugin / search_tool / search_thinking
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
columns carry the non-prose index."""
|
|
209
|
+
attribution_plugin / search_tool / search_thinking) are TAIL-APPENDED after
|
|
210
|
+
source_tool_use_id — same order as the SQL column list — so both ingest paths
|
|
211
|
+
(fused per-file walk + backfill_conversation_messages) carry them through this
|
|
212
|
+
one tuple. #217 S1 / U7a: the documented-dead ``search_aux`` column is gone
|
|
213
|
+
from the live schema (dropped by migration 016); the split
|
|
214
|
+
``search_tool``/``search_thinking`` columns carry the non-prose index."""
|
|
215
215
|
return (
|
|
216
216
|
m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
|
|
217
217
|
m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
|
|
218
218
|
m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
|
|
219
219
|
m.source_tool_use_id,
|
|
220
220
|
m.stop_reason, m.attribution_skill, m.attribution_plugin,
|
|
221
|
-
m.search_tool, m.search_thinking,
|
|
221
|
+
m.search_tool, m.search_thinking,
|
|
222
222
|
)
|
|
223
223
|
|
|
224
224
|
|
|
@@ -595,6 +595,9 @@ _TARGETED_DECLINE_FLAGS = (
|
|
|
595
595
|
"conversation_promote_command_args_pending",
|
|
596
596
|
"conversation_sessions_backfill_pending",
|
|
597
597
|
"conversation_queued_prompt_reingest_pending", # migration 014
|
|
598
|
+
"conversation_reingest_nested_agent_pending", # migration 017
|
|
599
|
+
"conversation_title_fts_backfill_pending", # migration 018 (P1-2: HERE ONLY)
|
|
600
|
+
"conversation_reingest_file_touches_pending", # migration 019 (P1-2: HERE ONLY)
|
|
598
601
|
)
|
|
599
602
|
|
|
600
603
|
|
|
@@ -731,7 +734,20 @@ def sync_cache(
|
|
|
731
734
|
# while-busy user prompts persisted as queued_command attachments); the
|
|
732
735
|
# same offset-0 walk re-derives them through the current parser, so drop
|
|
733
736
|
# that flag here too — MISSING this site re-arms the flag on every
|
|
734
|
-
# cache-sync --rebuild.
|
|
737
|
+
# cache-sync --rebuild. #217 S1 migration 017 sets the DISTINCT
|
|
738
|
+
# conversation_reingest_nested_agent_pending flag (to land the
|
|
739
|
+
# ingest-time structured agent_id stamp on >16 KB nested-subagent
|
|
740
|
+
# grandchildren); the same offset-0 walk re-derives it through the
|
|
741
|
+
# current parser, so drop that flag here as well. #217 S2 migrations
|
|
742
|
+
# 018/019 set conversation_title_fts_backfill_pending (title FTS) and
|
|
743
|
+
# conversation_reingest_file_touches_pending (+ its
|
|
744
|
+
# conversation_file_touches_cursor); the offset-0 walk re-derives both
|
|
745
|
+
# the title FTS and the file-touch axis, so drop them too (#219 S2.3).
|
|
746
|
+
# NOTE: unlike the flags above, dropping the 018/019 keys here is
|
|
747
|
+
# COSMETIC — their consumers run on the just-wiped (empty) tables and
|
|
748
|
+
# self-clear before the offset-0 walk repopulates, so leaving them
|
|
749
|
+
# caused no redundant expensive pass and no re-arming. We add them for
|
|
750
|
+
# consistency with the documented convention only.
|
|
735
751
|
conn.execute(
|
|
736
752
|
"DELETE FROM cache_meta WHERE key IN "
|
|
737
753
|
"('conversation_reingest_pending',"
|
|
@@ -739,6 +755,10 @@ def sync_cache(
|
|
|
739
755
|
" 'conversation_reingest_enrichment_pending',"
|
|
740
756
|
" 'conversation_media_reingest_pending',"
|
|
741
757
|
" 'conversation_queued_prompt_reingest_pending',"
|
|
758
|
+
" 'conversation_reingest_nested_agent_pending',"
|
|
759
|
+
" 'conversation_title_fts_backfill_pending',"
|
|
760
|
+
" 'conversation_reingest_file_touches_pending',"
|
|
761
|
+
" 'conversation_file_touches_cursor',"
|
|
742
762
|
" 'conversation_reingest_cursor',"
|
|
743
763
|
" 'conversation_reingest_cursor_gen')")
|
|
744
764
|
# #177 S6: a rebuild repopulates search_tool/search_thinking via the
|
|
@@ -885,9 +905,14 @@ def sync_cache(
|
|
|
885
905
|
# for a distinct flag. Migration 014 uses ANOTHER distinct flag
|
|
886
906
|
# ``conversation_queued_prompt_reingest_pending`` to land queued-while-
|
|
887
907
|
# busy user prompts (queued_command attachments the parser now promotes
|
|
888
|
-
# to HUMAN); same offset-0 re-parse, same distinct-flag reason.
|
|
889
|
-
#
|
|
890
|
-
#
|
|
908
|
+
# to HUMAN); same offset-0 re-parse, same distinct-flag reason. #217 S1
|
|
909
|
+
# migration 017 uses ANOTHER distinct flag
|
|
910
|
+
# ``conversation_reingest_nested_agent_pending`` to land the ingest-time
|
|
911
|
+
# structured agent_id stamp on >16 KB nested-subagent grandchildren
|
|
912
|
+
# (whose agentId: trailer was clipped past the 16 KB cap); same offset-0
|
|
913
|
+
# re-parse, same distinct-flag reason. We trigger the SAME clear +
|
|
914
|
+
# offset-0 backfill on ANY of these flags and clear them ALL atomically
|
|
915
|
+
# here under the held flock.
|
|
891
916
|
try:
|
|
892
917
|
_reingest = conn.execute(
|
|
893
918
|
"SELECT 1 FROM cache_meta WHERE key IN "
|
|
@@ -895,7 +920,8 @@ def sync_cache(
|
|
|
895
920
|
" 'conversation_source_tool_use_reingest_pending',"
|
|
896
921
|
" 'conversation_reingest_enrichment_pending',"
|
|
897
922
|
" 'conversation_media_reingest_pending',"
|
|
898
|
-
" 'conversation_queued_prompt_reingest_pending'
|
|
923
|
+
" 'conversation_queued_prompt_reingest_pending',"
|
|
924
|
+
" 'conversation_reingest_nested_agent_pending')"
|
|
899
925
|
).fetchone() is not None
|
|
900
926
|
except sqlite3.OperationalError:
|
|
901
927
|
_reingest = False
|
|
@@ -931,6 +957,21 @@ def sync_cache(
|
|
|
931
957
|
# HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
|
|
932
958
|
_consume_promote_command_args(conn)
|
|
933
959
|
|
|
960
|
+
# #217 S2 / E7: consume the migration-018 title-FTS backfill under the
|
|
961
|
+
# SAME held flock. An FTS5 'rebuild' re-derives the external-content
|
|
962
|
+
# title index from conversation_ai_titles (P1-7) — idempotent under
|
|
963
|
+
# the 012-then-018 both-pending ordering, and a cheap clear-only
|
|
964
|
+
# no-op on a no-FTS5 build (P1-6). Touches ONLY the title index, never
|
|
965
|
+
# conversation_messages (P1-2).
|
|
966
|
+
_consume_title_fts(conn)
|
|
967
|
+
|
|
968
|
+
# #217 S2 / I-3: consume the migration-019 file-touches backfill under
|
|
969
|
+
# the SAME held flock. Derives conversation_file_touches from existing
|
|
970
|
+
# blocks_json history (cursor-resumable; idempotent via INSERT OR
|
|
971
|
+
# IGNORE). Touches ONLY conversation_file_touches, never
|
|
972
|
+
# conversation_messages (P1-2).
|
|
973
|
+
_consume_file_touches(conn)
|
|
974
|
+
|
|
934
975
|
if targeted:
|
|
935
976
|
paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
|
|
936
977
|
else:
|
|
@@ -1293,6 +1334,16 @@ def sync_cache(
|
|
|
1293
1334
|
# (parallel to the cost path's lifecycle).
|
|
1294
1335
|
if conv_rows:
|
|
1295
1336
|
conn.executemany(_CONV_INSERT_SQL, conv_rows)
|
|
1337
|
+
# #217 S2 / I-3: derive this tick's file touches, scoped to the
|
|
1338
|
+
# just-ingested rows' PHYSICAL keys (cr[3]=source_path,
|
|
1339
|
+
# cr[4]=byte_offset per _conv_row_tuple). Cheap (proportional to
|
|
1340
|
+
# new bytes); decoupled from the INSERT OR IGNORE rowcount —
|
|
1341
|
+
# _fill_file_touches reads conversation_messages by physical key,
|
|
1342
|
+
# so an already-present (rowcount-0) row still gets its touches.
|
|
1343
|
+
# Lands in the SAME per-file write transaction as the message
|
|
1344
|
+
# rows, so they commit atomically.
|
|
1345
|
+
_fill_file_touches(
|
|
1346
|
+
conn, scope=[(cr[3], cr[4]) for cr in conv_rows])
|
|
1296
1347
|
# #193: ai-title upserts for this file, in file order (last wins).
|
|
1297
1348
|
# Committed atomically with the session_files cursor below.
|
|
1298
1349
|
if ai_rows:
|
|
@@ -1486,6 +1537,7 @@ _REINGEST_FLAG_KEYS = (
|
|
|
1486
1537
|
"conversation_reingest_enrichment_pending",
|
|
1487
1538
|
"conversation_media_reingest_pending", # #177 S4 (migration 009)
|
|
1488
1539
|
"conversation_queued_prompt_reingest_pending", # migration 014
|
|
1540
|
+
"conversation_reingest_nested_agent_pending", # #217 S1 (migration 017)
|
|
1489
1541
|
)
|
|
1490
1542
|
|
|
1491
1543
|
|
|
@@ -1550,10 +1602,25 @@ def _resumable_reingest_conversation_messages(conn):
|
|
|
1550
1602
|
conn.commit()
|
|
1551
1603
|
continue
|
|
1552
1604
|
try:
|
|
1605
|
+
# #217 S2 / I-3 (P1-4): conversation_file_touches is derived state
|
|
1606
|
+
# keyed by conversation_messages.id, and this per-source reingest
|
|
1607
|
+
# DELETEs + re-inserts the file's message rows (bumping autoincrement
|
|
1608
|
+
# ids). Delete the file's touches BEFORE the message delete (resolving
|
|
1609
|
+
# the ids while they still exist), then refill from the reinserted rows
|
|
1610
|
+
# AFTER — all in this one atomic transaction, so a crash leaves no stale
|
|
1611
|
+
# or duplicate anchors.
|
|
1612
|
+
conn.execute(
|
|
1613
|
+
"DELETE FROM conversation_file_touches WHERE message_id IN "
|
|
1614
|
+
"(SELECT id FROM conversation_messages WHERE source_path=?)",
|
|
1615
|
+
(path_str,))
|
|
1553
1616
|
conn.execute("DELETE FROM conversation_messages WHERE source_path=?",
|
|
1554
1617
|
(path_str,))
|
|
1555
1618
|
if rows:
|
|
1556
1619
|
conn.executemany(_CONV_INSERT_SQL, rows)
|
|
1620
|
+
# Refill scoped to this file's just-reinserted physical keys
|
|
1621
|
+
# (col 3=source_path, col 4=byte_offset per _conv_row_tuple).
|
|
1622
|
+
_fill_file_touches(
|
|
1623
|
+
conn, scope=[(r[3], r[4]) for r in rows])
|
|
1557
1624
|
_set_cache_meta(conn, "conversation_reingest_cursor", path_str)
|
|
1558
1625
|
conn.commit()
|
|
1559
1626
|
except BaseException:
|
|
@@ -1568,6 +1635,7 @@ def _resumable_reingest_conversation_messages(conn):
|
|
|
1568
1635
|
" 'conversation_reingest_enrichment_pending',"
|
|
1569
1636
|
" 'conversation_media_reingest_pending',"
|
|
1570
1637
|
" 'conversation_queued_prompt_reingest_pending',"
|
|
1638
|
+
" 'conversation_reingest_nested_agent_pending',"
|
|
1571
1639
|
" 'conversation_reingest_cursor',"
|
|
1572
1640
|
" 'conversation_reingest_cursor_gen')")
|
|
1573
1641
|
conn.commit()
|
|
@@ -1669,9 +1737,10 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
|
|
|
1669
1737
|
_project_label / _session_cost_map the rail's per-page Python path used), so
|
|
1670
1738
|
a filtered/displayed value equals what the live rail produced. cost is
|
|
1671
1739
|
rounded to 6dp to match list_conversations' per-row rounding.
|
|
1672
|
-
cache_rebuild_count is a per-session
|
|
1673
|
-
single-source-of-truth helper (
|
|
1674
|
-
|
|
1740
|
+
cache_rebuild_count is a per-session lightweight rebuild-count via the
|
|
1741
|
+
query kernel's single-source-of-truth helper (no full assembly — U1; the
|
|
1742
|
+
rule is shared with the reader path) — a whole-session property
|
|
1743
|
+
(recompute, never increment).
|
|
1675
1744
|
|
|
1676
1745
|
No-op when the columns are absent (a pre-migration-015 cache.db being
|
|
1677
1746
|
re-derived before its 015 ALTER lands), so an early/partial sync never
|
|
@@ -1758,6 +1827,153 @@ def _consume_search_split(conn) -> None:
|
|
|
1758
1827
|
conn.commit()
|
|
1759
1828
|
|
|
1760
1829
|
|
|
1830
|
+
def _consume_title_fts(conn) -> None:
|
|
1831
|
+
"""#217 S2 / E7: flock-held consumer for ``conversation_title_fts_backfill_pending``
|
|
1832
|
+
(set by cache migration 018). Populates the external-content title FTS over
|
|
1833
|
+
``conversation_ai_titles`` from existing history.
|
|
1834
|
+
|
|
1835
|
+
Uses the FTS5 ``'rebuild'`` command (the established consumer idiom —
|
|
1836
|
+
``_apply_cache_schema``'s recovery rebuild + ``_consume_search_split`` —
|
|
1837
|
+
NOT blind row inserts, P1-7): ``'rebuild'`` re-derives the whole index from
|
|
1838
|
+
the content table and is IDEMPOTENT even if migration 012's
|
|
1839
|
+
``ai_titles_backfill_pending`` ran first and already populated the index via
|
|
1840
|
+
the conv_title_fts_ai trigger (the 012-then-018 both-pending upgrade
|
|
1841
|
+
ordering) — re-running yields the same rows, no duplicates or conflict.
|
|
1842
|
+
|
|
1843
|
+
FTS5-unavailable (``fts5_unavailable`` set, P1-6): there is no usable vtable
|
|
1844
|
+
to rebuild and a ``'rebuild'`` would error on the absent fts5 module, so just
|
|
1845
|
+
clear the flag — ``kind=title`` degrades to a LIKE scan over
|
|
1846
|
+
conversation_ai_titles. Touches ONLY the title index (never
|
|
1847
|
+
conversation_messages — P1-2: this is NOT a message reingest); the flag is
|
|
1848
|
+
dropped LAST so a crash mid-rebuild re-runs cleanly on the next sync. A fresh
|
|
1849
|
+
install never sets the flag, so this is a cheap no-op there."""
|
|
1850
|
+
if conn.execute(
|
|
1851
|
+
"SELECT 1 FROM cache_meta "
|
|
1852
|
+
"WHERE key='conversation_title_fts_backfill_pending'"
|
|
1853
|
+
).fetchone() is None:
|
|
1854
|
+
return
|
|
1855
|
+
fts_off = conn.execute(
|
|
1856
|
+
"SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
|
|
1857
|
+
).fetchone() is not None
|
|
1858
|
+
if not fts_off:
|
|
1859
|
+
# The title FTS may not exist YET even on an FTS5-capable build: a pre-S6
|
|
1860
|
+
# install whose conversation_fts is still the legacy (text) shape makes
|
|
1861
|
+
# _apply_cache_schema early-return at its legacy_present guard BEFORE it
|
|
1862
|
+
# creates conversation_title_fts. When migrations 010 + 018 are both
|
|
1863
|
+
# pending in that open, _consume_search_split swaps only the MESSAGE FTS
|
|
1864
|
+
# (never the title FTS), so a blind 'rebuild' here would raise
|
|
1865
|
+
# "no such table: conversation_title_fts" — fts5_unavailable is NOT set
|
|
1866
|
+
# (FTS5 IS available), so the fts_off guard above does not protect it.
|
|
1867
|
+
# Swallow that OperationalError and RETURN before the flag-clear so the
|
|
1868
|
+
# flag survives: the NEXT open (message FTS now split → legacy_present
|
|
1869
|
+
# False → _apply_cache_schema creates the title FTS) re-runs this consumer
|
|
1870
|
+
# and completes the backfill. Match the message-FTS path's resilience.
|
|
1871
|
+
try:
|
|
1872
|
+
conn.execute(
|
|
1873
|
+
"INSERT INTO conversation_title_fts(conversation_title_fts) "
|
|
1874
|
+
"VALUES('rebuild')")
|
|
1875
|
+
except sqlite3.OperationalError:
|
|
1876
|
+
return # title FTS not yet created (legacy-shape pre-swap); leave
|
|
1877
|
+
# the flag set, retry on the next open
|
|
1878
|
+
conn.execute(
|
|
1879
|
+
"DELETE FROM cache_meta WHERE key='conversation_title_fts_backfill_pending'")
|
|
1880
|
+
conn.commit()
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
_FILE_TOUCH_INSERT_SQL = (
|
|
1884
|
+
"INSERT OR IGNORE INTO conversation_file_touches"
|
|
1885
|
+
"(message_id, session_id, uuid, file_path, tool) VALUES(?,?,?,?,?)")
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _fill_file_touches(conn, scope=None) -> None:
|
|
1889
|
+
"""#217 S2 / I-3: derive ``conversation_file_touches`` rows from
|
|
1890
|
+
``conversation_messages.blocks_json`` for the in-scope message rows.
|
|
1891
|
+
|
|
1892
|
+
``scope`` is an iterable of ``(source_path, byte_offset)`` physical keys, or
|
|
1893
|
+
``None`` for ALL rows (the backfill). We read FROM ``conversation_messages``
|
|
1894
|
+
(the source of truth) and resolve ``message_id`` from the row's own ``id``.
|
|
1895
|
+
|
|
1896
|
+
P1-3 (load-bearing): scope by the PHYSICAL key ``(source_path, byte_offset)``,
|
|
1897
|
+
NEVER by ``uuid``. ``conversation_messages.uuid`` is NOT unique (only
|
|
1898
|
+
``(source_path, byte_offset)`` is; the uuid index is ``(session_id, uuid)``),
|
|
1899
|
+
and resume/replay rows legitimately share a ``(session_id, uuid)`` — a
|
|
1900
|
+
``WHERE uuid=?`` fill would touch unrelated physical rows.
|
|
1901
|
+
|
|
1902
|
+
Decoupled from the message-insert rowcount ("dedup must not gate side
|
|
1903
|
+
effects"): a no-op INSERT OR IGNORE of an already-present message row (rowcount
|
|
1904
|
+
0) still has its touches derived here, because we read the row by physical key
|
|
1905
|
+
rather than from the insert's lastrowid/rowcount.
|
|
1906
|
+
|
|
1907
|
+
Cheap at steady state: scoped to the rows ingested this tick (proportional to
|
|
1908
|
+
new bytes), never re-parsing the whole session per tick. ``INSERT OR IGNORE``
|
|
1909
|
+
on ``UNIQUE(message_id, file_path, tool)`` makes it idempotent, and a row's
|
|
1910
|
+
``blocks_json`` is immutable, so accumulate-via-IGNORE needs no per-tick DELETE.
|
|
1911
|
+
The caller owns the commit (this helper never commits)."""
|
|
1912
|
+
def _emit(rows):
|
|
1913
|
+
for mid, sid, uuid_, bj in rows:
|
|
1914
|
+
if not sid:
|
|
1915
|
+
continue # a touch row's session_id is NOT NULL; skip null-session rows
|
|
1916
|
+
try:
|
|
1917
|
+
blocks = json.loads(bj) if bj else []
|
|
1918
|
+
except (TypeError, ValueError):
|
|
1919
|
+
blocks = []
|
|
1920
|
+
for fp, tool in _lib_conversation._derive_file_touches(blocks):
|
|
1921
|
+
conn.execute(_FILE_TOUCH_INSERT_SQL, (mid, sid, uuid_, fp, tool))
|
|
1922
|
+
|
|
1923
|
+
if scope is None:
|
|
1924
|
+
# Backfill: cursor-resumable 500-row batches keyed on the message rowid.
|
|
1925
|
+
# Resume from the stored cursor so an interrupt skips already-derived
|
|
1926
|
+
# batches (the fill is also idempotent via INSERT OR IGNORE, so a restart
|
|
1927
|
+
# from 0 would be correct but redundant).
|
|
1928
|
+
row = conn.execute(
|
|
1929
|
+
"SELECT value FROM cache_meta "
|
|
1930
|
+
"WHERE key='conversation_file_touches_cursor'").fetchone()
|
|
1931
|
+
last_id = int(row[0]) if row and row[0] is not None else 0
|
|
1932
|
+
while True:
|
|
1933
|
+
batch = conn.execute(
|
|
1934
|
+
"SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
|
|
1935
|
+
"WHERE id > ? ORDER BY id LIMIT 500",
|
|
1936
|
+
(last_id,)).fetchall()
|
|
1937
|
+
if not batch:
|
|
1938
|
+
break
|
|
1939
|
+
_emit(batch)
|
|
1940
|
+
last_id = batch[-1][0]
|
|
1941
|
+
_cctally_db_sib._set_cache_meta(
|
|
1942
|
+
conn, "conversation_file_touches_cursor", str(last_id))
|
|
1943
|
+
conn.commit()
|
|
1944
|
+
return
|
|
1945
|
+
for sp, off in scope:
|
|
1946
|
+
rows = conn.execute(
|
|
1947
|
+
"SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
|
|
1948
|
+
"WHERE source_path=? AND byte_offset=?", (sp, off)).fetchall()
|
|
1949
|
+
_emit(rows)
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _consume_file_touches(conn) -> None:
|
|
1953
|
+
"""#217 S2 / I-3: flock-held consumer for
|
|
1954
|
+
``conversation_reingest_file_touches_pending`` (set by cache migration 019).
|
|
1955
|
+
Derives ``conversation_file_touches`` from ALL existing ``blocks_json`` history
|
|
1956
|
+
via ``_fill_file_touches(conn, scope=None)`` (cursor-resumable, 500-row
|
|
1957
|
+
batches), then clears the flag + cursor.
|
|
1958
|
+
|
|
1959
|
+
Touches ONLY ``conversation_file_touches`` (never ``conversation_messages`` —
|
|
1960
|
+
P1-2: this is NOT a message reingest). The fill is idempotent (INSERT OR
|
|
1961
|
+
IGNORE on the UNIQUE key), so an interrupted backfill resumes cleanly on the
|
|
1962
|
+
next locked sync. A fresh install never sets the flag, so this is a cheap
|
|
1963
|
+
no-op there."""
|
|
1964
|
+
if conn.execute(
|
|
1965
|
+
"SELECT 1 FROM cache_meta "
|
|
1966
|
+
"WHERE key='conversation_reingest_file_touches_pending'"
|
|
1967
|
+
).fetchone() is None:
|
|
1968
|
+
return
|
|
1969
|
+
_fill_file_touches(conn, scope=None)
|
|
1970
|
+
conn.execute(
|
|
1971
|
+
"DELETE FROM cache_meta WHERE key IN "
|
|
1972
|
+
"('conversation_reingest_file_touches_pending',"
|
|
1973
|
+
" 'conversation_file_touches_cursor')")
|
|
1974
|
+
conn.commit()
|
|
1975
|
+
|
|
1976
|
+
|
|
1761
1977
|
def _consume_promote_command_args(conn) -> None:
|
|
1762
1978
|
"""#188 bug 4: flock-held consumer for ``conversation_promote_command_args_pending``
|
|
1763
1979
|
(set by cache migration 011). Cursor-resumable walk of
|
|
@@ -147,7 +147,7 @@ What stays in bin/cctally:
|
|
|
147
147
|
c.ORIGINAL_SYS_ARGV = list(sys.argv)``.
|
|
148
148
|
- ``eprint``, ``now_utc_iso``, ``parse_iso_datetime``, ``open_db``,
|
|
149
149
|
``load_config``, ``save_config``, ``_now_utc``, ``_command_as_of``,
|
|
150
|
-
``format_display_dt``,
|
|
150
|
+
``format_display_dt``,
|
|
151
151
|
``normalize_display_tz_value``, ``_render_migration_error_banner``,
|
|
152
152
|
``_aggregate_daily``, ``_aggregate_monthly``, ``_aggregate_weekly``,
|
|
153
153
|
``_calculate_entry_cost``, ``_canonical_5h_window_key``,
|
|
@@ -305,7 +305,6 @@ from _cctally_core import (
|
|
|
305
305
|
)
|
|
306
306
|
from _lib_display_tz import (
|
|
307
307
|
format_display_dt,
|
|
308
|
-
resolve_display_tz,
|
|
309
308
|
normalize_display_tz_value,
|
|
310
309
|
_compute_display_block,
|
|
311
310
|
)
|
|
@@ -5284,10 +5283,22 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
|
5284
5283
|
return vals[0] if vals else default
|
|
5285
5284
|
|
|
5286
5285
|
|
|
5287
|
-
# #177 S6: valid kind facets for the conversation
|
|
5288
|
-
# lockstep with ``_lib_conversation_query._SEARCH_KINDS``
|
|
5289
|
-
# ValueError on an unknown kind
|
|
5290
|
-
|
|
5286
|
+
# #177 S6 / #217 S2: valid kind facets for the conversation routes. Kept in
|
|
5287
|
+
# lockstep with the kernel (``_lib_conversation_query._SEARCH_KINDS`` /
|
|
5288
|
+
# ``_FIND_KINDS``; the kernel re-raises ValueError on an unknown kind, and the
|
|
5289
|
+
# handlers reject with a 400 BEFORE the call — ``_run_conversation_query``
|
|
5290
|
+
# collapses every kernel exception to a 500, so a per-route 4xx must be decided
|
|
5291
|
+
# in the handler, not via try/except around the kernel).
|
|
5292
|
+
#
|
|
5293
|
+
# P1-1 (load-bearing kind-validation SPLIT): the cross-session search route
|
|
5294
|
+
# accepts ``title`` and ``files``; the in-conversation ``/find`` route does NOT —
|
|
5295
|
+
# its kernel (``find_in_conversation``) indexes ``_FIND_KIND_COLUMNS[kind]``,
|
|
5296
|
+
# which has no ``title``/``files`` entry, so accepting them there would be a 500
|
|
5297
|
+
# KeyError. Two distinct tuples keep ``/find?kind=title`` and ``/find?kind=files``
|
|
5298
|
+
# a clean 400.
|
|
5299
|
+
_CONV_SEARCH_KINDS = (
|
|
5300
|
+
"all", "prompts", "assistant", "tools", "thinking", "title", "files")
|
|
5301
|
+
_CONV_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
5291
5302
|
|
|
5292
5303
|
|
|
5293
5304
|
class _BadConversationFilter(Exception):
|
|
@@ -7451,15 +7462,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7451
7462
|
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
7452
7463
|
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
7453
7464
|
|
|
7454
|
-
def _parse_search_kind(self, q):
|
|
7455
|
-
"""Read + validate the ``kind`` facet
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7465
|
+
def _parse_search_kind(self, q, valid=_CONV_SEARCH_KINDS):
|
|
7466
|
+
"""Read + validate the ``kind`` facet for a conversation route (#177 S6 /
|
|
7467
|
+
#217 S2). Returns the kind on success, or ``None`` after having ALREADY
|
|
7468
|
+
sent a 400 — callers just ``return`` on ``None``.
|
|
7469
|
+
|
|
7470
|
+
``valid`` is the per-route kind set (P1-1 split): the cross-session search
|
|
7471
|
+
route passes ``_CONV_SEARCH_KINDS`` (includes ``title``), the
|
|
7472
|
+
in-conversation ``/find`` route passes ``_CONV_FIND_KINDS`` (excludes
|
|
7473
|
+
``title``/``files``), so ``/find?kind=title`` is a 400 here — never a 500
|
|
7474
|
+
KeyError downstream in ``find_in_conversation``. Kept in lockstep with the
|
|
7475
|
+
kernel's ``_SEARCH_KINDS`` / ``_FIND_KINDS`` (the kernel module is
|
|
7476
|
+
resolved lazily per-request, so the handler keeps literal tuples rather
|
|
7477
|
+
than reaching across that import edge for a nit)."""
|
|
7461
7478
|
kind = _qs_str(q, "kind", "all")
|
|
7462
|
-
if kind not in
|
|
7479
|
+
if kind not in valid:
|
|
7463
7480
|
self._respond_json(400, {"error": f"unknown kind: {kind}"})
|
|
7464
7481
|
return None
|
|
7465
7482
|
return kind
|
|
@@ -7615,15 +7632,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7615
7632
|
"""``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
|
|
7616
7633
|
|
|
7617
7634
|
The id is percent-decoded so clients that encode reserved chars
|
|
7618
|
-
round-trip. Unknown id → 404. ``after``/``limit``
|
|
7635
|
+
round-trip. Unknown id → 404. ``after``/``before``/``tail``/``limit``
|
|
7636
|
+
page the items; ``after``/``before``/``tail`` are mutually exclusive
|
|
7637
|
+
(>1 supplied → 400). ``tail=1`` opens at the bottom; ``before=<id>``
|
|
7638
|
+
pages backward (#217 S2 / U4).
|
|
7619
7639
|
"""
|
|
7620
7640
|
if not self._require_transcripts_allowed():
|
|
7621
7641
|
return
|
|
7622
7642
|
import urllib.parse as _u
|
|
7623
7643
|
# ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
|
|
7624
|
-
# so the cursor params (?after=/?limit=) live ONLY on the
|
|
7625
|
-
# Sibling handlers read ``self.path`` directly — the
|
|
7626
|
-
# or every request re-serves the head and
|
|
7644
|
+
# so the cursor params (?after=/?before=/?tail=/?limit=) live ONLY on the
|
|
7645
|
+
# raw ``self.path``. Sibling handlers read ``self.path`` directly — the
|
|
7646
|
+
# detail route must too, or every request re-serves the head and
|
|
7647
|
+
# pagination is dead.
|
|
7627
7648
|
query_str = self.path.partition("?")[2]
|
|
7628
7649
|
session_id = _u.unquote(path[len("/api/conversation/"):])
|
|
7629
7650
|
if not session_id:
|
|
@@ -7631,10 +7652,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7631
7652
|
return
|
|
7632
7653
|
q = _u.parse_qs(query_str)
|
|
7633
7654
|
after = _qs_str(q, "after", None)
|
|
7655
|
+
before = _qs_str(q, "before", None)
|
|
7656
|
+
tail = _qs_str(q, "tail", None) in ("1", "true", "yes")
|
|
7634
7657
|
limit = _qs_int(q, "limit", 500)
|
|
7658
|
+
# Mutual-exclusion 400 (#217 S2 / U4). The kernel ALSO raises ValueError
|
|
7659
|
+
# on >1 cursor as its own invariant, but ``_run_conversation_query``
|
|
7660
|
+
# collapses every kernel exception to a 500, so the 400 must be decided
|
|
7661
|
+
# HERE, before the kernel call — this explicit pre-call check is the
|
|
7662
|
+
# authoritative backstop for the handler path.
|
|
7663
|
+
if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
|
|
7664
|
+
self.send_error(400, "after/before/tail are mutually exclusive")
|
|
7665
|
+
return
|
|
7635
7666
|
ok, body = self._run_conversation_query(
|
|
7636
7667
|
lambda conn: self._conversation_query().get_conversation(
|
|
7637
|
-
conn, session_id, after=after,
|
|
7668
|
+
conn, session_id, after=after, before=before, tail=tail,
|
|
7669
|
+
limit=limit),
|
|
7638
7670
|
"/api/conversation")
|
|
7639
7671
|
if not ok:
|
|
7640
7672
|
return
|
|
@@ -7755,6 +7787,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7755
7787
|
FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
|
|
7756
7788
|
``do_GET``. ``kind`` (#177 S6) is validated to ``_CONV_SEARCH_KINDS``
|
|
7757
7789
|
(else 400) before the kernel call.
|
|
7790
|
+
|
|
7791
|
+
#217 S2 / Filtered-search: the browse filters (date/project/cost/rebuild)
|
|
7792
|
+
are parsed by the SAME ``_parse_conversation_filters`` the browse rail uses
|
|
7793
|
+
(malformed → 400 already sent) and threaded into the kernel, applied as a
|
|
7794
|
+
session-scope restriction across every kind. The 400s (bad kind, bad
|
|
7795
|
+
filter) are decided HERE, before the kernel call — ``_run_conversation_query``
|
|
7796
|
+
collapses kernel exceptions to a 500.
|
|
7758
7797
|
"""
|
|
7759
7798
|
if not self._require_transcripts_allowed():
|
|
7760
7799
|
return
|
|
@@ -7766,9 +7805,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7766
7805
|
kind = self._parse_search_kind(q)
|
|
7767
7806
|
if kind is None:
|
|
7768
7807
|
return
|
|
7808
|
+
filters = self._parse_conversation_filters(q)
|
|
7809
|
+
if filters is None:
|
|
7810
|
+
return # a 400 has already been sent
|
|
7769
7811
|
ok, body = self._run_conversation_query(
|
|
7770
7812
|
lambda conn: self._conversation_query().search_conversations(
|
|
7771
|
-
conn, query, limit=limit, offset=offset, kind=kind),
|
|
7813
|
+
conn, query, limit=limit, offset=offset, kind=kind, **filters),
|
|
7772
7814
|
"/api/conversation/search")
|
|
7773
7815
|
if not ok:
|
|
7774
7816
|
return
|
|
@@ -7840,9 +7882,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7840
7882
|
find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
|
|
7841
7883
|
privacy gate as the sibling routes; unknown id → 404; an invalid ``kind``
|
|
7842
7884
|
→ 400. Matched BEFORE the ``<id>`` reader catch-all in ``do_GET``.
|
|
7885
|
+
|
|
7886
|
+
P1-1: validates against ``_CONV_FIND_KINDS`` (NOT the search set), so the
|
|
7887
|
+
cross-session-only ``kind=title``/``files`` return 400 here, never a 500.
|
|
7888
|
+
|
|
7889
|
+
#217 S4 / I-1.2: ``regex``/``case`` are truthy params. An invalid regex
|
|
7890
|
+
is PRE-COMPILED here, BEFORE dispatching to the kernel — exactly as the
|
|
7891
|
+
detail route pre-validates ``after/before/tail`` — because
|
|
7892
|
+
``_run_conversation_query`` collapses every kernel exception to a 500, so
|
|
7893
|
+
a ``re.error`` from the kernel's ``re.compile`` would otherwise leak as a
|
|
7894
|
+
500 instead of the actionable 400 the client maps to "invalid regex".
|
|
7843
7895
|
"""
|
|
7844
7896
|
if not self._require_transcripts_allowed():
|
|
7845
7897
|
return
|
|
7898
|
+
import re as _re
|
|
7846
7899
|
import urllib.parse as _u
|
|
7847
7900
|
session_id = _u.unquote(path[len("/api/conversation/"):-len("/find")])
|
|
7848
7901
|
if not session_id:
|
|
@@ -7850,12 +7903,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7850
7903
|
return
|
|
7851
7904
|
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7852
7905
|
query = _qs_str(q, "q", "")
|
|
7853
|
-
kind = self._parse_search_kind(q)
|
|
7906
|
+
kind = self._parse_search_kind(q, valid=_CONV_FIND_KINDS)
|
|
7854
7907
|
if kind is None:
|
|
7855
7908
|
return
|
|
7909
|
+
regex = _qs_str(q, "regex", None) in ("1", "true", "yes")
|
|
7910
|
+
case = _qs_str(q, "case", None) in ("1", "true", "yes")
|
|
7911
|
+
# Pre-validate the regex HERE (Codex P1): the kernel compiles the same
|
|
7912
|
+
# pattern, but its ``re.error`` would be swallowed into the generic 500
|
|
7913
|
+
# envelope below. Compiling first turns a bad pattern into a clean 400.
|
|
7914
|
+
if regex:
|
|
7915
|
+
try:
|
|
7916
|
+
_re.compile(query, 0 if case else _re.IGNORECASE)
|
|
7917
|
+
except _re.error as e:
|
|
7918
|
+
self._respond_json(400, {"error": f"invalid regex: {e}"})
|
|
7919
|
+
return
|
|
7856
7920
|
ok, body = self._run_conversation_query(
|
|
7857
7921
|
lambda conn: self._conversation_query().find_in_conversation(
|
|
7858
|
-
conn, session_id, query, kind=kind),
|
|
7922
|
+
conn, session_id, query, kind=kind, regex=regex, case=case),
|
|
7859
7923
|
"/api/conversation/find")
|
|
7860
7924
|
if not ok:
|
|
7861
7925
|
return
|
|
@@ -8472,11 +8536,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
8472
8536
|
_c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
|
|
8473
8537
|
_c_boot._UPDATE_WORKER = UpdateWorker()
|
|
8474
8538
|
|
|
8475
|
-
#
|
|
8476
|
-
#
|
|
8477
|
-
#
|
|
8539
|
+
# Load config for the bind-host + expose-transcripts resolution below. (#217
|
|
8540
|
+
# S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
|
|
8541
|
+
# write — nothing in the dashboard ever read it back; the envelope display
|
|
8542
|
+
# block it was speculatively staged for was never wired. Each report
|
|
8543
|
+
# subcommand resolves + reads its own ``args._resolved_tz`` independently.)
|
|
8478
8544
|
config = load_config()
|
|
8479
|
-
args._resolved_tz = resolve_display_tz(args, config)
|
|
8480
8545
|
|
|
8481
8546
|
# Resolve bind host: --host flag > config.dashboard.bind > argparse default.
|
|
8482
8547
|
# `--host` defaults to None (Task 2) so we can distinguish "user explicitly
|