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
|
@@ -26,6 +26,11 @@ from _lib_pricing import _calculate_entry_cost
|
|
|
26
26
|
# content block the same way the parser does at ingest — reuse the parser's
|
|
27
27
|
# _stringify so the full (un-capped) result text matches the cached/capped one.
|
|
28
28
|
from _lib_conversation import _stringify
|
|
29
|
+
# #217 S1 / U5: the payload-clip helpers moved DOWN into the parser module (low
|
|
30
|
+
# in the dependency graph) so _bound_input's total-cap backstop and this module's
|
|
31
|
+
# read_full_payload share one bound-guaranteeing implementation. Imported here
|
|
32
|
+
# (query depends on the parser, never the reverse — verified at #217).
|
|
33
|
+
from _lib_conversation import _clip_payload_input, _shrink_largest_leaf
|
|
29
34
|
# #177 S4: the media-route reader walks a content array with the SAME ordinal
|
|
30
35
|
# generator the ingest placeholders used, so "media item N" addresses one item.
|
|
31
36
|
from _lib_conversation import iter_media_items
|
|
@@ -272,39 +277,27 @@ def _subagent_key(source_path):
|
|
|
272
277
|
return stem or None
|
|
273
278
|
|
|
274
279
|
|
|
275
|
-
# §4 1a — nested (grandchild) subagent result parse. The new Claude
|
|
276
|
-
# emits a grandchild's spawn result as STRING content (no structured
|
|
277
|
-
#
|
|
278
|
-
# …)` line and an OPTIONAL `<usage>` totals wrapper.
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
# §4 1a / #217 S1 U6 — nested (grandchild) subagent result parse. The new Claude
|
|
281
|
+
# Code format emits a grandchild's spawn result as STRING content (no structured
|
|
282
|
+
# record-level toolUseResult.agentId), with a trailing `agentId: <hash> (use
|
|
283
|
+
# SendMessage …)` line and an OPTIONAL `<usage>` totals wrapper. The regex
|
|
284
|
+
# constants + the parse rule moved DOWN into the parser (_lib_conversation) so
|
|
285
|
+
# the INGEST stamp runs them over the FULL raw before the 16 KB clip (#217 S1
|
|
286
|
+
# U6); re-exported here for the read-time fallback over un-reingested rows.
|
|
287
|
+
from _lib_conversation import (
|
|
288
|
+
_NESTED_AGENT_ID_RE, _NESTED_USAGE_RE, _nested_agent_stamp_from_text,
|
|
284
289
|
)
|
|
285
290
|
|
|
286
291
|
|
|
287
292
|
def _parse_nested_agent_result(text):
|
|
288
|
-
"""
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return None
|
|
297
|
-
m = _NESTED_AGENT_ID_RE.search(text)
|
|
298
|
-
if not m:
|
|
299
|
-
return None
|
|
300
|
-
meta = {}
|
|
301
|
-
u = _NESTED_USAGE_RE.search(text)
|
|
302
|
-
if u:
|
|
303
|
-
meta["total_tokens"] = int(u.group(1))
|
|
304
|
-
meta["total_tool_use_count"] = int(u.group(2))
|
|
305
|
-
meta["total_duration_ms"] = int(u.group(3))
|
|
306
|
-
meta["status"] = "completed"
|
|
307
|
-
return m.group(1), meta
|
|
293
|
+
"""Read-time fallback for nested (grandchild) subagent results on rows that
|
|
294
|
+
were ingested BEFORE the #217 S1 U6 structured stamp (un-reingested history).
|
|
295
|
+
Delegates to the parser's single-source rule over the (already-clipped) block
|
|
296
|
+
text — so a >16 KB result whose `agentId:` trailer was cut survives only via
|
|
297
|
+
the ingest stamp + the migration-017 offset-0 reingest, never here. Returns
|
|
298
|
+
(agent_id, meta) or None when no `agentId:` is present. Linking on `agentId`
|
|
299
|
+
ALONE is sufficient (Codex P1-B)."""
|
|
300
|
+
return _nested_agent_stamp_from_text(text)
|
|
308
301
|
|
|
309
302
|
|
|
310
303
|
def _iso_ms(ts):
|
|
@@ -374,122 +367,338 @@ def _cache_read_saved_usd(model, cache_read):
|
|
|
374
367
|
return full - read
|
|
375
368
|
|
|
376
369
|
|
|
370
|
+
# A normalized cache-failure event. ``compaction`` marks a context-compaction
|
|
371
|
+
# boundary (resets the running-max); otherwise it is an assistant-token event
|
|
372
|
+
# carrying its per-thread/per-model key + the cache_creation/cache_read signal.
|
|
373
|
+
# Both the full-assembly stamp path and the lightweight rebuild-count path build
|
|
374
|
+
# a document-ordered list of these and feed it to the ONE predicate below — the
|
|
375
|
+
# rule is implemented exactly once (U1, #217 S1).
|
|
376
|
+
class _CFEvent:
|
|
377
|
+
__slots__ = ("compaction", "key", "cc", "cr", "model")
|
|
378
|
+
|
|
379
|
+
def __init__(self, *, compaction=False, key=None, cc=0, cr=0, model=None):
|
|
380
|
+
self.compaction = compaction
|
|
381
|
+
self.key = key
|
|
382
|
+
self.cc = cc
|
|
383
|
+
self.cr = cr
|
|
384
|
+
self.model = model
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _iter_cache_failures(events):
|
|
388
|
+
"""The single cache-failure rule (spec §1), as a generator over a
|
|
389
|
+
document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
|
|
390
|
+
model)`` for each FLAGGED assistant event — ``index`` is the event's position
|
|
391
|
+
in ``events`` so a caller can map a flag back to its source item.
|
|
392
|
+
|
|
393
|
+
Maintains a running-max of ``cache_read`` keyed by the event's ``key``
|
|
394
|
+
(``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
|
|
395
|
+
is per-thread AND per-model because Anthropic prompt caches are model-
|
|
396
|
+
specific: a model switch within a thread legitimately starts a fresh cache
|
|
397
|
+
(cr ~ 0) and must not read as a loss, and the compound key makes that the
|
|
398
|
+
first event under its own key (no prior rm). The whole map is RESET on a
|
|
399
|
+
compaction event, since compaction legitimately invalidates the prefix and
|
|
400
|
+
the post-compaction re-prime is not a failure.
|
|
401
|
+
|
|
402
|
+
For each assistant event — cc, cr, and rm (the running-max for this key
|
|
403
|
+
BEFORE this event) — flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and
|
|
404
|
+
cr <= COLLAPSE_FRACTION*rm and cc/(cc+cr) >= RECREATE_FRACTION. The fourth
|
|
405
|
+
term keeps the rule aligned with the Evidence: it requires that most of THIS
|
|
406
|
+
event's context was freshly created, not merely that cache_read dipped. After
|
|
407
|
+
the check, ``running_max[key] = max(rm, cr)`` (a failure's small cr never
|
|
408
|
+
lowers the high-water mark; the next healthy event re-establishes it).
|
|
409
|
+
|
|
410
|
+
``lost`` is the lost-prefix basis (NOT raw cc, which over-counts when the
|
|
411
|
+
turn also writes genuinely-new cacheable content): ``min(cc, max(0,
|
|
412
|
+
rm - cr))``. Order-dependent by construction; run over document-ordered
|
|
413
|
+
events."""
|
|
414
|
+
running_max = {} # (subagent_key, model) -> high-water cache_read
|
|
415
|
+
for i, ev in enumerate(events):
|
|
416
|
+
if ev.compaction:
|
|
417
|
+
# Reset the whole map (compaction invalidates every thread's prefix
|
|
418
|
+
# in this session view; a per-key reset would need a thread tag the
|
|
419
|
+
# meta row does not reliably carry).
|
|
420
|
+
running_max.clear()
|
|
421
|
+
continue
|
|
422
|
+
cc, cr = ev.cc, ev.cr
|
|
423
|
+
rm = running_max.get(ev.key, 0)
|
|
424
|
+
total = cc + cr
|
|
425
|
+
if (rm >= _CACHE_FAILURE_CACHE_FLOOR
|
|
426
|
+
and cc >= _CACHE_FAILURE_CREATE_FLOOR
|
|
427
|
+
and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
|
|
428
|
+
and total > 0
|
|
429
|
+
and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
|
|
430
|
+
lost = min(cc, max(0, rm - cr))
|
|
431
|
+
yield (i, rm, lost, ev.model)
|
|
432
|
+
running_max[ev.key] = max(rm, cr)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _cache_failure_count_over_events(events) -> int:
|
|
436
|
+
"""Count of flagged events in a normalized ``_CFEvent`` stream (the pure
|
|
437
|
+
predicate the lightweight rebuild-count path consumes). Single source of
|
|
438
|
+
truth — shares ``_iter_cache_failures`` with the full-assembly stamp."""
|
|
439
|
+
return sum(1 for _ in _iter_cache_failures(events))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _cache_failure_events_from_items(items):
|
|
443
|
+
"""Build the document-ordered ``_CFEvent`` stream from assembled reader
|
|
444
|
+
``items`` plus a parallel ``sources`` list (the source item per event, for
|
|
445
|
+
stamping). A compaction event per ``meta``/``compaction`` item; an assistant-
|
|
446
|
+
token event per assistant item carrying a ``tokens`` dict. Items lacking a
|
|
447
|
+
``tokens`` dict (no session_entries row, or non-assistant non-compaction
|
|
448
|
+
items) emit NOTHING and so neither flag nor move the running-max — exactly
|
|
449
|
+
the pre-U1 skip behavior."""
|
|
450
|
+
events = []
|
|
451
|
+
sources = [] # parallel list: the source item per event (for stamping)
|
|
452
|
+
for it in items:
|
|
453
|
+
if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
|
|
454
|
+
events.append(_CFEvent(compaction=True))
|
|
455
|
+
sources.append(it)
|
|
456
|
+
continue
|
|
457
|
+
if it.get("kind") != "assistant":
|
|
458
|
+
continue
|
|
459
|
+
tok = it.get("tokens")
|
|
460
|
+
if not isinstance(tok, dict):
|
|
461
|
+
continue # no session_entries row -> skip
|
|
462
|
+
events.append(_CFEvent(
|
|
463
|
+
key=(it.get("subagent_key"), it.get("model")),
|
|
464
|
+
cc=tok.get("cache_creation", 0) or 0,
|
|
465
|
+
cr=tok.get("cache_read", 0) or 0,
|
|
466
|
+
model=it.get("model")))
|
|
467
|
+
sources.append(it)
|
|
468
|
+
return events, sources
|
|
469
|
+
|
|
470
|
+
|
|
377
471
|
def _stamp_cache_failures(items):
|
|
378
472
|
"""Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
|
|
379
473
|
bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
|
|
380
474
|
place; healthy turns are left WITHOUT the key (absent, not zero — matching the
|
|
381
475
|
``tokens?`` "absent, not zero" convention).
|
|
382
476
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
(a ``meta`` item whose ``meta_kind == "compaction"``), since compaction
|
|
391
|
-
legitimately invalidates the prefix and the post-compaction re-prime is not a
|
|
392
|
-
failure.
|
|
393
|
-
|
|
394
|
-
For each assistant item with a ``tokens`` dict — cc = tokens["cache_creation"],
|
|
395
|
-
cr = tokens["cache_read"], rm = the running-max for this key BEFORE this turn —
|
|
396
|
-
flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and cr <= COLLAPSE_FRACTION*rm
|
|
397
|
-
and cc/(cc+cr) >= RECREATE_FRACTION. The fourth term keeps the rule aligned
|
|
398
|
-
with the Evidence: it requires that most of THIS turn's context was freshly
|
|
399
|
-
created, not merely that cache_read dipped. After the check, update
|
|
400
|
-
``running_max[key] = max(rm, cr)`` (a failure's small cr never lowers the
|
|
401
|
-
high-water mark; the next healthy turn re-establishes it).
|
|
402
|
-
|
|
403
|
-
Payload (lost-prefix basis — NOT raw cc, which would over-count when the turn
|
|
404
|
-
also writes genuinely-new cacheable content that was never cached):
|
|
477
|
+
Builds a document-ordered ``_CFEvent`` stream from the items (a compaction
|
|
478
|
+
event per compaction-meta, an assistant-token event per token-bearing
|
|
479
|
+
assistant turn) and runs the SAME ``_iter_cache_failures`` rule the
|
|
480
|
+
lightweight rebuild-count path uses (single source of truth — the rule lives
|
|
481
|
+
in exactly one place; U1). The payload is computed here on the lost-prefix
|
|
482
|
+
basis (NOT raw cc, which would over-count when the turn also writes
|
|
483
|
+
genuinely-new cacheable content that was never cached):
|
|
405
484
|
lost = min(cc, max(0, rm - cr))
|
|
406
485
|
tokens_recreated = lost
|
|
407
486
|
prev_cached = rm
|
|
408
487
|
est_wasted_usd = write(lost) - read(lost)
|
|
488
|
+
"""
|
|
489
|
+
events, sources = _cache_failure_events_from_items(items)
|
|
490
|
+
for idx, prev_cached, lost, model in _iter_cache_failures(events):
|
|
491
|
+
sources[idx]["cache_failure"] = {
|
|
492
|
+
"tokens_recreated": lost,
|
|
493
|
+
"prev_cached": prev_cached,
|
|
494
|
+
"est_wasted_usd": _cache_failure_wasted_usd(model, lost),
|
|
495
|
+
}
|
|
496
|
+
|
|
409
497
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
498
|
+
def _lightweight_rebuild_events(conn, session_id):
|
|
499
|
+
"""Build the document-ordered ``_CFEvent`` stream for a session WITHOUT a
|
|
500
|
+
full ``_assemble_session`` (U1, #217 S1). Skips the expensive assembly work
|
|
501
|
+
(no ``blocks_json`` body parse, no tool-result folding, no meta-classify /
|
|
502
|
+
ANSI strip, no subagent correlation) but faithfully reproduces the canonical
|
|
503
|
+
normalization the cache-failure predicate depends on:
|
|
504
|
+
|
|
505
|
+
- UUID dedup: canonical row per ``(session_id, uuid)`` = first occurrence
|
|
506
|
+
in (timestamp_utc, id) order (a replay carries the original uuid);
|
|
507
|
+
- turn grouping by ``(msg_id, req_id)``: an assistant turn — possibly split
|
|
508
|
+
across non-consecutive fragments interleaved by a tool_result — emits ONE
|
|
509
|
+
event at the FIRST fragment's position, with cost-once-per-(msg_id,req_id)
|
|
510
|
+
token attribution from the deduped session_entries row;
|
|
511
|
+
- first-prose MODEL promotion: the turn's model is the FIRST prose-bearing
|
|
512
|
+
fragment's model (the canonical anchor), matching ``_build_turn`` /
|
|
513
|
+
``_fold_fragment``; a turn with no prose keeps its seed fragment's model;
|
|
514
|
+
- seed ``source_path -> subagent_key`` derivation (``_subagent_key``);
|
|
515
|
+
- compaction-reset detection: an ``entry_type in ('meta','human')`` row
|
|
516
|
+
whose all-text body is a compaction summary (``_is_compaction_body``)
|
|
517
|
+
and which is NOT a slash-command invocation — mirroring the assembly
|
|
518
|
+
meta-classify — emits a compaction event.
|
|
519
|
+
|
|
520
|
+
The event ORDER is the turn's first-fragment document position, byte-for-byte
|
|
521
|
+
the order ``_assemble_session`` walks (which emits each turn item at its first
|
|
522
|
+
fragment, and each meta/human row at its own position). That preserves the
|
|
523
|
+
running-max walk the predicate requires.
|
|
413
524
|
"""
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
525
|
+
exists = conn.execute(
|
|
526
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
527
|
+
(session_id,)).fetchone()
|
|
528
|
+
if exists is None:
|
|
529
|
+
return []
|
|
530
|
+
# Narrow column set: enough to dedup, group turns, derive the subagent_key,
|
|
531
|
+
# detect compaction, and join the token row. No blocks_json body parse beyond
|
|
532
|
+
# the all-text compaction guard (which needs text OR the text-block join).
|
|
533
|
+
rows = conn.execute(
|
|
534
|
+
"SELECT id, uuid, entry_type, text, blocks_json, model, "
|
|
535
|
+
" msg_id, req_id, source_path "
|
|
536
|
+
"FROM conversation_messages WHERE session_id=? "
|
|
537
|
+
"ORDER BY timestamp_utc, id", (session_id,)).fetchall()
|
|
538
|
+
seen_uuid = set()
|
|
539
|
+
logical = []
|
|
540
|
+
for row in rows:
|
|
541
|
+
u = row[1]
|
|
542
|
+
if u in seen_uuid:
|
|
543
|
+
continue # UUID dedup: keep the first (canonical) occurrence
|
|
544
|
+
seen_uuid.add(u)
|
|
545
|
+
logical.append(row)
|
|
546
|
+
|
|
547
|
+
# Group assistant turns by (msg_id, req_id) at the first-fragment position;
|
|
548
|
+
# later same-key fragments fold their model (first-prose promotion) without
|
|
549
|
+
# adding a second event. A `None`-msg_id assistant row carries no turn key and
|
|
550
|
+
# no session_entries cost, so it never contributes a token event.
|
|
551
|
+
events = []
|
|
552
|
+
sources = [] # parallel: the turn key per assistant event
|
|
553
|
+
turn_index = {} # (msg_id, req_id) -> index into events
|
|
554
|
+
turn_has_prose = {} # (msg_id, req_id) -> bool (model promoted yet?)
|
|
555
|
+
turn_keys = [] # ordered list of grouped turn keys (for the token map)
|
|
556
|
+
for (rid, u, etype, text, blocks, model, msg_id, req_id,
|
|
557
|
+
source_path) in logical:
|
|
558
|
+
if etype == "assistant" and msg_id is not None:
|
|
559
|
+
key = (msg_id, req_id)
|
|
560
|
+
frag_text = (text or "").strip()
|
|
561
|
+
ev_idx = turn_index.get(key)
|
|
562
|
+
if ev_idx is None:
|
|
563
|
+
ev_idx = len(events)
|
|
564
|
+
turn_index[key] = ev_idx
|
|
565
|
+
turn_keys.append(key)
|
|
566
|
+
ev = _CFEvent(key=(_subagent_key(source_path), model),
|
|
567
|
+
model=model)
|
|
568
|
+
events.append(ev)
|
|
569
|
+
sources.append(key)
|
|
570
|
+
# The seed model holds until the first prose fragment promotes it.
|
|
571
|
+
turn_has_prose[key] = bool(frag_text)
|
|
572
|
+
else:
|
|
573
|
+
ev = events[ev_idx]
|
|
574
|
+
# First prose fragment promotes the turn's model anchor. The full
|
|
575
|
+
# path (`_build_turn`) SEEDS subagent_key from the first fragment
|
|
576
|
+
# and re-promotes ONLY the model; mirror that here by keeping the
|
|
577
|
+
# seeded `ev.key[0]` and re-promoting just the model (#218 I-A.1).
|
|
578
|
+
# subagent_key is per-file-invariant across a turn's fragments (one
|
|
579
|
+
# (msg_id,req_id) = one file), so this matches in all real data and
|
|
580
|
+
# is exact structural parity for the engineered cross-file split.
|
|
581
|
+
if not turn_has_prose.get(key) and frag_text:
|
|
582
|
+
ev.key = (ev.key[0], model)
|
|
583
|
+
ev.model = model
|
|
584
|
+
turn_has_prose[key] = True
|
|
585
|
+
elif etype in ("meta", "human"):
|
|
586
|
+
# Mirror the assembly meta-classify's compaction branch: an all-text
|
|
587
|
+
# body that is a compaction summary AND is not a slash-command
|
|
588
|
+
# invocation. A command invocation (#188) is promoted to a human turn
|
|
589
|
+
# BEFORE meta-classify, so a body carrying <command-args> never reads
|
|
590
|
+
# as compaction; compaction bodies have no <command-args>, so in
|
|
591
|
+
# practice the invocation check is a faithful belt-and-suspenders.
|
|
592
|
+
try:
|
|
593
|
+
parsed_blocks = _json.loads(blocks or "[]")
|
|
594
|
+
except (ValueError, TypeError):
|
|
595
|
+
parsed_blocks = []
|
|
596
|
+
body = text or _join_text_blocks(parsed_blocks)
|
|
597
|
+
all_text = all(b.get("kind") == "text"
|
|
598
|
+
for b in (parsed_blocks or []))
|
|
599
|
+
if (all_text and _is_compaction_body(body)
|
|
600
|
+
and _extract_command_invocation(parsed_blocks, body) is None):
|
|
601
|
+
events.append(_CFEvent(compaction=True))
|
|
602
|
+
sources.append(None)
|
|
603
|
+
# tool_result rows and null-msg_id assistant rows carry no token event.
|
|
604
|
+
|
|
605
|
+
# Cost-once token attribution: ONE deduped session_entries row per
|
|
606
|
+
# (msg_id, req_id) — the same map the full path stamps from. An assistant
|
|
607
|
+
# turn key absent from session_entries carries NO tokens, so its event is
|
|
608
|
+
# dropped (neither flags nor moves the running-max) — parity with
|
|
609
|
+
# _cache_failure_events_from_items's `tokens` skip.
|
|
610
|
+
usage = _turn_usage_map(conn, turn_keys)
|
|
611
|
+
filtered = []
|
|
612
|
+
si = 0
|
|
613
|
+
for ev in events:
|
|
614
|
+
if ev.compaction:
|
|
615
|
+
filtered.append(ev)
|
|
616
|
+
si += 1
|
|
425
617
|
continue
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
and cc >= _CACHE_FAILURE_CREATE_FLOOR
|
|
436
|
-
and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
|
|
437
|
-
and total > 0
|
|
438
|
-
and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
|
|
439
|
-
lost = min(cc, max(0, rm - cr))
|
|
440
|
-
it["cache_failure"] = {
|
|
441
|
-
"tokens_recreated": lost,
|
|
442
|
-
"prev_cached": rm,
|
|
443
|
-
"est_wasted_usd": _cache_failure_wasted_usd(it.get("model"), lost),
|
|
444
|
-
}
|
|
445
|
-
running_max[key] = max(rm, cr)
|
|
618
|
+
key = sources[si]
|
|
619
|
+
si += 1
|
|
620
|
+
tok = usage.get(key)
|
|
621
|
+
if tok is None:
|
|
622
|
+
continue # no session_entries row -> skip (don't move max)
|
|
623
|
+
ev.cc = tok.get("cache_creation", 0) or 0
|
|
624
|
+
ev.cr = tok.get("cache_read", 0) or 0
|
|
625
|
+
filtered.append(ev)
|
|
626
|
+
return filtered
|
|
446
627
|
|
|
447
628
|
|
|
448
629
|
def session_cache_rebuild_count(conn, session_id) -> int:
|
|
449
630
|
"""Per-session count of cache-rebuild (cache-failure) turns.
|
|
450
631
|
|
|
451
|
-
Single source of truth:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
632
|
+
Single source of truth: builds a document-ordered cache-failure event stream
|
|
633
|
+
via the LIGHTWEIGHT builder (``_lightweight_rebuild_events`` — no full
|
|
634
|
+
``_assemble_session``, so this stays cheap on the flock-held rollup path; U1,
|
|
635
|
+
#217 S1) and runs it through the SAME ``_cache_failure_count_over_events``
|
|
636
|
+
predicate the reader's full assembly stamps with. The light builder
|
|
637
|
+
reproduces the canonical normalization (UUID dedup, turn grouping, first-prose
|
|
638
|
+
model promotion, seed subagent_key, cost-once token attribution, compaction
|
|
639
|
+
reset), so the count is byte-identical to the full-assembly count (guarded by
|
|
640
|
+
``test_session_cache_rebuild_count_lightweight_matches_full_assembly``).
|
|
641
|
+
|
|
642
|
+
/outline omits stats.cache_failures when the count is 0, so "no flagged
|
|
643
|
+
items" -> 0 (byte-identical to outline). Stored on the conversation_sessions
|
|
644
|
+
rollup by _recompute_conversation_sessions; filtered by
|
|
645
|
+
list_conversations(rebuild_min=...).
|
|
463
646
|
"""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
647
|
+
return _cache_failure_count_over_events(
|
|
648
|
+
_lightweight_rebuild_events(conn, session_id))
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _turn_costs_for_keys(conn, keys):
|
|
652
|
+
"""{(msg_id, req_id): cost_usd} for the given turn keys, joined ONCE to the
|
|
653
|
+
deduped session_entries row per (msg_id, req_id) (#217 S1 / U7b — the SINGLE
|
|
654
|
+
cost-attribution chokepoint both ``_turn_cost_map`` and ``_session_cost_map``
|
|
655
|
+
delegate to, so the cost-once-per-turn rule is never reimplemented).
|
|
656
|
+
|
|
657
|
+
NULL-filters the keys, chunks the OR-of-pairs to stay well under SQLite's
|
|
658
|
+
variable limit, and computes each cost via the shared pricing helper
|
|
659
|
+
(honoring a vendor ``cost_usd_raw`` override). Keys absent from
|
|
660
|
+
session_entries (e.g. ``<synthetic>`` walker-skipped rows) are simply not
|
|
661
|
+
present in the result -> cost 0 by omission. Parity guard:
|
|
662
|
+
``test_session_and_turn_cost_map_share_helper_parity``."""
|
|
663
|
+
costs = {}
|
|
664
|
+
norm = [(m, r) for (m, r) in keys if m is not None and r is not None]
|
|
665
|
+
if not norm:
|
|
666
|
+
return costs
|
|
667
|
+
for i in range(0, len(norm), 400):
|
|
668
|
+
chunk = norm[i:i + 400]
|
|
669
|
+
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
670
|
+
params = [v for pair in chunk for v in pair]
|
|
671
|
+
sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
|
|
672
|
+
"cache_create_tokens, cache_read_tokens, cost_usd_raw "
|
|
673
|
+
"FROM session_entries WHERE " + cond)
|
|
674
|
+
for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
|
|
675
|
+
costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
|
|
676
|
+
return costs
|
|
470
677
|
|
|
471
678
|
|
|
472
679
|
def _session_cost_map(conn, session_ids):
|
|
473
|
-
"""{session_id: total_cost_usd} for the given sessions.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
(msg_id, req_id) is globally unique in
|
|
477
|
-
one session_id, so per-session sums are
|
|
680
|
+
"""{session_id: total_cost_usd} for the given sessions. Resolves each
|
|
681
|
+
session's distinct (msg_id, req_id) turn keys, then attributes cost via the
|
|
682
|
+
shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
|
|
683
|
+
row), summing into the owning session. (msg_id, req_id) is globally unique in
|
|
684
|
+
session_entries and maps to exactly one session_id, so per-session sums are
|
|
685
|
+
clean and a turn replayed across files contributes once."""
|
|
478
686
|
costs = {sid: 0.0 for sid in session_ids}
|
|
479
687
|
if not session_ids:
|
|
480
688
|
return costs
|
|
481
689
|
placeholders = ",".join("?" for _ in session_ids)
|
|
482
|
-
|
|
483
|
-
"SELECT
|
|
484
|
-
"
|
|
485
|
-
"
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
690
|
+
pairs = conn.execute(
|
|
691
|
+
"SELECT DISTINCT session_id, msg_id, req_id "
|
|
692
|
+
"FROM conversation_messages "
|
|
693
|
+
"WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
|
|
694
|
+
% placeholders,
|
|
695
|
+
list(session_ids),
|
|
696
|
+
).fetchall()
|
|
697
|
+
if not pairs:
|
|
698
|
+
return costs
|
|
699
|
+
key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
|
|
700
|
+
for sid, m, r in pairs:
|
|
701
|
+
costs[sid] = costs.get(sid, 0.0) + key_cost.get((m, r), 0.0)
|
|
493
702
|
return costs
|
|
494
703
|
|
|
495
704
|
|
|
@@ -513,23 +722,31 @@ def _session_latest_meta_map(conn, session_ids):
|
|
|
513
722
|
"""{session_id: (cwd, git_branch)} using the most-recent NON-NULL value per
|
|
514
723
|
column — the SAME posture as get_conversation's _latest, so the rail and the
|
|
515
724
|
reader agree on a session whose cwd/branch changed over its lifetime (a plain
|
|
516
|
-
MAX() picks the lexical max, not the latest).
|
|
517
|
-
|
|
518
|
-
|
|
725
|
+
MAX() picks the lexical max, not the latest). The latest non-null cwd and the
|
|
726
|
+
latest non-null git_branch may land on DIFFERENT rows (the newest row can
|
|
727
|
+
carry one but not the other), so each is resolved independently.
|
|
728
|
+
|
|
729
|
+
#217 S1 / U7b: consolidated from the prior TWIN correlated subqueries into a
|
|
730
|
+
SINGLE windowed scan — two ``FIRST_VALUE`` window functions over one
|
|
731
|
+
partition pass per column, each ordered ``(<col> IS NULL), timestamp_utc DESC,
|
|
732
|
+
id DESC`` so the most-recent non-null value sorts first (and an all-null
|
|
733
|
+
session yields NULL, exactly as the LIMIT-1 subquery did). Guarded by
|
|
734
|
+
``test_session_latest_meta_map_parity_*`` against the prior semantics; if the
|
|
735
|
+
window form could not match it, the old correlated SQL would be kept (it
|
|
736
|
+
matched cleanly, so the consolidation lands)."""
|
|
519
737
|
meta = {sid: (None, None) for sid in session_ids}
|
|
520
738
|
if not session_ids:
|
|
521
739
|
return meta
|
|
522
740
|
placeholders = ",".join("?" for _ in session_ids)
|
|
523
741
|
sql = (
|
|
524
|
-
"SELECT
|
|
525
|
-
" (
|
|
526
|
-
"
|
|
527
|
-
"
|
|
528
|
-
" (
|
|
529
|
-
"
|
|
530
|
-
"
|
|
531
|
-
"FROM
|
|
532
|
-
" WHERE session_id IN (%s)) s" % placeholders
|
|
742
|
+
"SELECT DISTINCT session_id, "
|
|
743
|
+
" FIRST_VALUE(cwd) OVER ("
|
|
744
|
+
" PARTITION BY session_id "
|
|
745
|
+
" ORDER BY (cwd IS NULL), timestamp_utc DESC, id DESC), "
|
|
746
|
+
" FIRST_VALUE(git_branch) OVER ("
|
|
747
|
+
" PARTITION BY session_id "
|
|
748
|
+
" ORDER BY (git_branch IS NULL), timestamp_utc DESC, id DESC) "
|
|
749
|
+
"FROM conversation_messages WHERE session_id IN (%s)" % placeholders
|
|
533
750
|
)
|
|
534
751
|
for sid, cwd, branch in conn.execute(sql, list(session_ids)):
|
|
535
752
|
meta[sid] = (cwd, branch)
|
|
@@ -561,16 +778,38 @@ def session_source_paths(conn, session_id):
|
|
|
561
778
|
_SORTS = {
|
|
562
779
|
"recent": "last_activity_utc DESC, session_id DESC",
|
|
563
780
|
"oldest": "started_utc ASC, session_id ASC",
|
|
781
|
+
# #217 S4 / I-2.1: stored-column rollup sorts. Each tie-broken by
|
|
782
|
+
# session_id for stable pagination. ``project`` uses an explicit CASE so a
|
|
783
|
+
# NULL label (not-yet-filled row) or '' (the no-cwd _project_label(None)
|
|
784
|
+
# sentinel) sorts LAST — a plain COLLATE NOCASE ASC would put them first.
|
|
785
|
+
"cost": "cost_usd DESC, session_id DESC",
|
|
786
|
+
"messages": "msg_count DESC, session_id DESC",
|
|
787
|
+
"project": ("CASE WHEN project_label IS NULL OR project_label = '' "
|
|
788
|
+
"THEN 1 ELSE 0 END, project_label COLLATE NOCASE ASC, "
|
|
789
|
+
"session_id ASC"),
|
|
564
790
|
}
|
|
565
791
|
|
|
566
792
|
# The matching ORDER BY for the live GROUP BY fallback: the rollup's
|
|
567
793
|
# last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
|
|
568
794
|
# so the two branches paginate in byte-identical order (the load-bearing
|
|
569
795
|
# invariant). Keep this table in lockstep with _SORTS.
|
|
796
|
+
#
|
|
797
|
+
# ``messages`` has FULL live parity (COUNT(*) DESC over the raw messages == the
|
|
798
|
+
# rollup's stored msg_count). ``cost`` and ``project`` have NO raw-message
|
|
799
|
+
# column, so they are INTENTIONALLY ABSENT here — list_conversations resolves
|
|
800
|
+
# them to the ``recent`` live ordering and flags page.sort_degraded (a brief,
|
|
801
|
+
# self-correcting non-authoritative window). #217 S4 / I-2.1.
|
|
570
802
|
_SORTS_LIVE = {
|
|
571
803
|
"recent": "MAX(timestamp_utc) DESC, session_id DESC",
|
|
572
804
|
"oldest": "MIN(timestamp_utc) ASC, session_id ASC",
|
|
805
|
+
"messages": "COUNT(*) DESC, session_id DESC",
|
|
573
806
|
}
|
|
807
|
+
# The rollup-only sorts — expressible on the rollup branch but NOT in the live
|
|
808
|
+
# GROUP BY fallback. A request for one of these under the live branch falls back
|
|
809
|
+
# to the recent ordering AND sets page.sort_degraded (keyed to the REQUESTED
|
|
810
|
+
# sort, never the effective order — an unknown sort also falls back to recent
|
|
811
|
+
# but must stay byte-stable, so it is excluded here).
|
|
812
|
+
_DEGRADABLE_SORTS = frozenset(("cost", "project"))
|
|
574
813
|
|
|
575
814
|
|
|
576
815
|
def _rollup_authoritative(conn) -> bool:
|
|
@@ -663,6 +902,47 @@ def _live_having(filters):
|
|
|
663
902
|
return (" HAVING " + " AND ".join(having)) if having else "", params
|
|
664
903
|
|
|
665
904
|
|
|
905
|
+
def _resolve_search_session_filter(conn, filters):
|
|
906
|
+
"""Resolve the filtered-search session-scope restriction (#217 S2 /
|
|
907
|
+
Filtered-search). Returns ``(subquery_sql, params, degraded)`` where
|
|
908
|
+
``subquery_sql`` is a complete ``"SELECT session_id FROM ... "`` the caller
|
|
909
|
+
wraps as ``" AND <col> IN (<subquery_sql>)"``, or ``""`` (no restriction) when
|
|
910
|
+
no axis is set.
|
|
911
|
+
|
|
912
|
+
Mirrors the browse dual-branch parity (``list_conversations``):
|
|
913
|
+
|
|
914
|
+
* Rollup AUTHORITATIVE (``conversation_sessions_backfill_pending`` clear):
|
|
915
|
+
every axis is a stored column on the rollup, so restrict to
|
|
916
|
+
``SELECT session_id FROM conversation_sessions <_rollup_where(filters)>``
|
|
917
|
+
(the SAME ``_rollup_where`` the browse rail uses, verbatim). ``degraded``
|
|
918
|
+
is False.
|
|
919
|
+
* Rollup PENDING (live fallback) AND a rollup-only axis
|
|
920
|
+
(project/cost/rebuild) is requested (P1-5): drop the rollup-only axes,
|
|
921
|
+
set ``degraded`` True, and express ONLY the date axis via a LIVE session
|
|
922
|
+
prefilter over ``conversation_messages`` keyed on
|
|
923
|
+
``MAX(timestamp_utc)`` — the SAME session-activity semantics as
|
|
924
|
+
``_live_having``/``_list_session_rows_live``. We restrict by session
|
|
925
|
+
ACTIVITY, never by a matched row's own timestamp (a session whose match is
|
|
926
|
+
old but whose activity is in-window must stay).
|
|
927
|
+
* Rollup pending but ONLY date axis requested: the rollup is not needed
|
|
928
|
+
(date is expressible live), so use the live prefilter and ``degraded``
|
|
929
|
+
stays False (no rollup-only axis was dropped — byte-stable with browse).
|
|
930
|
+
"""
|
|
931
|
+
any_axis = any(filters[k] is not None for k in _FILTER_KEYS)
|
|
932
|
+
if not any_axis:
|
|
933
|
+
return "", [], False
|
|
934
|
+
if _rollup_authoritative(conn):
|
|
935
|
+
where, params = _rollup_where(filters)
|
|
936
|
+
return "SELECT session_id FROM conversation_sessions" + where, params, False
|
|
937
|
+
# Live fallback: only the date axis is expressible over raw messages.
|
|
938
|
+
degraded = any(filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
|
|
939
|
+
having, hparams = _live_having(filters)
|
|
940
|
+
sub = (
|
|
941
|
+
"SELECT session_id FROM conversation_messages "
|
|
942
|
+
"WHERE session_id IS NOT NULL GROUP BY session_id" + having)
|
|
943
|
+
return sub, hparams, degraded
|
|
944
|
+
|
|
945
|
+
|
|
666
946
|
def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
|
|
667
947
|
"""FAST path: read the pre-aggregated rail rows straight from the
|
|
668
948
|
conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
|
|
@@ -754,6 +1034,7 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
754
1034
|
"rebuild_min": rebuild_min,
|
|
755
1035
|
}
|
|
756
1036
|
degraded = False
|
|
1037
|
+
sort_degraded = False
|
|
757
1038
|
if _rollup_authoritative(conn):
|
|
758
1039
|
order = _SORTS.get(sort, _SORTS["recent"])
|
|
759
1040
|
rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
|
|
@@ -761,6 +1042,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
761
1042
|
order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
|
|
762
1043
|
degraded = any(
|
|
763
1044
|
filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
|
|
1045
|
+
# sort_degraded is keyed to the REQUESTED sort, not the effective order:
|
|
1046
|
+
# an unknown sort ALSO falls back to recent here (via _SORTS_LIVE.get),
|
|
1047
|
+
# but must stay byte-stable, so only the known rollup-only sorts flag it.
|
|
1048
|
+
sort_degraded = sort in _DEGRADABLE_SORTS
|
|
764
1049
|
rows = _list_session_rows_live(conn, order, limit, offset, filters)
|
|
765
1050
|
has_more = len(rows) > limit
|
|
766
1051
|
rows = rows[:limit]
|
|
@@ -792,6 +1077,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
792
1077
|
# The rail surfaces this: project/cost/rebuild filters apply once the
|
|
793
1078
|
# rollup finishes indexing; only the date axis held this page.
|
|
794
1079
|
page["filter_degraded"] = True
|
|
1080
|
+
if sort_degraded:
|
|
1081
|
+
# The rail surfaces this: cost/project ordering becomes available once
|
|
1082
|
+
# the rollup finishes indexing; this page fell back to recent order.
|
|
1083
|
+
page["sort_degraded"] = True
|
|
795
1084
|
return {
|
|
796
1085
|
"conversations": conversations,
|
|
797
1086
|
"page": page,
|
|
@@ -801,22 +1090,13 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
801
1090
|
def _turn_cost_map(conn, turn_keys):
|
|
802
1091
|
"""{(msg_id, req_id): cost_usd} for the given non-null turn keys, joined ONCE
|
|
803
1092
|
to the deduped session_entries row. Keys absent from session_entries (e.g.
|
|
804
|
-
<synthetic> walker-skipped rows) are simply not present → cost 0 by omission.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
chunk = keys[i:i + 400]
|
|
812
|
-
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
813
|
-
params = [v for pair in chunk for v in pair]
|
|
814
|
-
sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
|
|
815
|
-
"cache_create_tokens, cache_read_tokens, cost_usd_raw "
|
|
816
|
-
"FROM session_entries WHERE " + cond)
|
|
817
|
-
for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
|
|
818
|
-
costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
|
|
819
|
-
return costs
|
|
1093
|
+
<synthetic> walker-skipped rows) are simply not present → cost 0 by omission.
|
|
1094
|
+
|
|
1095
|
+
Thin wrapper over the shared ``_turn_costs_for_keys`` chokepoint (#217 S1 /
|
|
1096
|
+
U7b) — the search path's ``_attach_costs`` still consumes this name + its
|
|
1097
|
+
float-valued contract, so the public signature is unchanged while the body
|
|
1098
|
+
is single-sourced with ``_session_cost_map``."""
|
|
1099
|
+
return _turn_costs_for_keys(conn, turn_keys)
|
|
820
1100
|
|
|
821
1101
|
|
|
822
1102
|
def _turn_usage_map(conn, turn_keys):
|
|
@@ -996,6 +1276,14 @@ def _assemble_session(conn, session_id):
|
|
|
996
1276
|
tlist_ = b.pop("task_list", None)
|
|
997
1277
|
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
998
1278
|
task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
|
|
1279
|
+
# §4 symmetry (#218 I-B.5): the structured-agent_id populate above runs
|
|
1280
|
+
# mid-loop, before spawn_kind is complete, so it cannot self-gate on spawn
|
|
1281
|
+
# ownership. Re-validate here now that spawn_kind is final — drop any
|
|
1282
|
+
# agent_link entry whose owning tool_use is NOT a spawn, mirroring the nested
|
|
1283
|
+
# fallback's `_tuid in spawn_kind` gate so BOTH link paths only ever carry
|
|
1284
|
+
# spawn-owned results. Inert today (every agent_link consumer is itself
|
|
1285
|
+
# spawn-gated); this protects a future, un-gated agent_link consumer.
|
|
1286
|
+
agent_link = {t: v for t, v in agent_link.items() if t in spawn_kind}
|
|
999
1287
|
# §4 1a — nested grandchild results: gate the text-parse on the owning
|
|
1000
1288
|
# tool_use being a spawn (in spawn_kind), so an ordinary tool result that
|
|
1001
1289
|
# merely mentions "agentId" never matches.
|
|
@@ -1285,13 +1573,27 @@ def _assemble_session(conn, session_id):
|
|
|
1285
1573
|
"subagent_meta": subagent_meta, "header_cost": header_cost}
|
|
1286
1574
|
|
|
1287
1575
|
|
|
1288
|
-
def get_conversation(conn, session_id, *, after=None,
|
|
1576
|
+
def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
|
|
1577
|
+
limit=500):
|
|
1289
1578
|
"""Reader payload for one session (spec §3.2). Returns None for an unknown
|
|
1290
1579
|
session. Dedups logical messages by (session_id, uuid) (canonical = earliest
|
|
1291
1580
|
timestamp), groups assistant fragments into turn items by (msg_id, req_id),
|
|
1292
1581
|
joins cost once, anchors a turn on its prose-bearing fragment, and exposes
|
|
1293
1582
|
every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
|
|
1294
|
-
id); ~500 items/page.
|
|
1583
|
+
id); ~500 items/page.
|
|
1584
|
+
|
|
1585
|
+
Three mutually-exclusive cursor modes over the fully-assembled ascending
|
|
1586
|
+
`items` list (#217 S2 / U4): `after=X` pages forward (existing), `before=X`
|
|
1587
|
+
pages backward (returns the `limit` items immediately before X), and
|
|
1588
|
+
`tail=1` opens at the bottom (returns the last page in one request). More
|
|
1589
|
+
than one supplied raises ValueError (the handler maps it to 400). The
|
|
1590
|
+
boundary keys are mode-uniform (`has_more = end < N`, `has_prev = start > 0`)
|
|
1591
|
+
so a short `before` head-page still reports `has_more` (P2-8); the existing
|
|
1592
|
+
`after`/no-cursor responses stay byte-stable except the two additive
|
|
1593
|
+
`prev_before`/`has_prev` keys (for them `start + limit < N == end < N`, under
|
|
1594
|
+
`end = min(start+limit, N)`)."""
|
|
1595
|
+
if sum(1 for x in (after is not None, before is not None, bool(tail)) if x) > 1:
|
|
1596
|
+
raise ValueError("after/before/tail are mutually exclusive")
|
|
1295
1597
|
limit = max(1, min(int(limit), 1000))
|
|
1296
1598
|
asm = _assemble_session(conn, session_id)
|
|
1297
1599
|
if asm is None:
|
|
@@ -1322,34 +1624,60 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1322
1624
|
_la = items[-1]["anchor"]
|
|
1323
1625
|
last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
|
|
1324
1626
|
|
|
1325
|
-
# Cursor pagination over the item list (anchored to each item's canonical
|
|
1326
|
-
#
|
|
1327
|
-
#
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1627
|
+
# Cursor pagination over the item list (anchored to each item's canonical
|
|
1628
|
+
# id). Resolve `start`/`end` per mode, then `page = items[start:end]` and
|
|
1629
|
+
# compute the boundary keys UNIFORMLY (#217 S2 / U4 — P2-8). A non-None
|
|
1630
|
+
# `after`/`before` that matches no item's anchor (stale/deleted cursor)
|
|
1631
|
+
# yields an EMPTY page — never silently re-serves the head/tail (M1).
|
|
1632
|
+
N = len(items)
|
|
1633
|
+
|
|
1634
|
+
def _idx(cur):
|
|
1331
1635
|
for k, it in enumerate(items):
|
|
1332
|
-
if str(it["anchor"]["id"]) == str(
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1636
|
+
if str(it["anchor"]["id"]) == str(cur):
|
|
1637
|
+
return k
|
|
1638
|
+
return None
|
|
1639
|
+
|
|
1640
|
+
def _stale_empty_page():
|
|
1641
|
+
return {
|
|
1642
|
+
"session_id": session_id,
|
|
1643
|
+
"title": _title,
|
|
1644
|
+
"project_label": _pl,
|
|
1645
|
+
"git_branch": _latest(logical, 11),
|
|
1646
|
+
"started_utc": logical[0][2],
|
|
1647
|
+
"last_activity_utc": logical[-1][2],
|
|
1648
|
+
"cost_usd": header_cost,
|
|
1649
|
+
"models": sorted({r[6] for r in logical if r[6]}),
|
|
1650
|
+
"last_anchor": last_anchor,
|
|
1651
|
+
"items": [],
|
|
1652
|
+
"subagent_meta": subagent_meta,
|
|
1653
|
+
"page": {"next_after": None, "has_more": False,
|
|
1654
|
+
"prev_before": None, "has_prev": False},
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if tail:
|
|
1658
|
+
end = N
|
|
1659
|
+
start = max(0, N - limit)
|
|
1660
|
+
elif before is not None:
|
|
1661
|
+
b = _idx(before)
|
|
1662
|
+
if b is None: # stale cursor → empty page (M1)
|
|
1663
|
+
return _stale_empty_page()
|
|
1664
|
+
end = b
|
|
1665
|
+
start = max(0, end - limit)
|
|
1666
|
+
elif after is not None:
|
|
1667
|
+
a = _idx(after)
|
|
1668
|
+
if a is None: # stale cursor → empty page (M1)
|
|
1669
|
+
return _stale_empty_page()
|
|
1670
|
+
start = a + 1
|
|
1671
|
+
end = min(start + limit, N)
|
|
1672
|
+
else:
|
|
1673
|
+
start = 0
|
|
1674
|
+
end = min(limit, N)
|
|
1675
|
+
|
|
1676
|
+
page = items[start:end]
|
|
1677
|
+
has_more = end < N
|
|
1678
|
+
has_prev = start > 0
|
|
1352
1679
|
next_after = page[-1]["anchor"]["id"] if (page and has_more) else None
|
|
1680
|
+
prev_before = page[0]["anchor"]["id"] if (page and has_prev) else None
|
|
1353
1681
|
|
|
1354
1682
|
# Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
|
|
1355
1683
|
# the dict literals are built session-agnostic, so fill it here where the
|
|
@@ -1381,7 +1709,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1381
1709
|
"last_anchor": last_anchor,
|
|
1382
1710
|
"items": page,
|
|
1383
1711
|
"subagent_meta": subagent_meta,
|
|
1384
|
-
"page": {"next_after": next_after, "has_more": has_more
|
|
1712
|
+
"page": {"next_after": next_after, "has_more": has_more,
|
|
1713
|
+
"prev_before": prev_before, "has_prev": has_prev},
|
|
1385
1714
|
}
|
|
1386
1715
|
|
|
1387
1716
|
|
|
@@ -1432,6 +1761,23 @@ def get_conversation_outline(conn, session_id):
|
|
|
1432
1761
|
cf_wasted = 0.0
|
|
1433
1762
|
cf_rebuilds = [] # per-rebuild list (worst-first), spec §1
|
|
1434
1763
|
cache_saved = 0.0 # session cache-value-saved, spec §1
|
|
1764
|
+
# #217 S3 E6(a) — per-subagent cost, summed COST-ONCE over the assembled
|
|
1765
|
+
# items (each item already carries the cost-once-per-turn `cost_usd`, so a
|
|
1766
|
+
# plain per-subagent_key sum never double-counts across folded fragments —
|
|
1767
|
+
# the same discipline `cache_saved` above uses). DISPLAY-ONLY (never a
|
|
1768
|
+
# reconciled / budget / snapshot figure, like `cache_saved_usd`). Emitted as
|
|
1769
|
+
# a SEPARATE top-level `subagent_costs` map — NOT inside `subagent_meta` —
|
|
1770
|
+
# so the outline↔reader `subagent_meta` parity assert stays byte-for-byte
|
|
1771
|
+
# unchanged AND every bucket is covered, including a subagent whose
|
|
1772
|
+
# `subagent_meta` is absent (the s7 case: deriveOutline emits a row for it,
|
|
1773
|
+
# so its cost must be present to render). Every non-null subagent_key bucket
|
|
1774
|
+
# seeds at 0.0 so a zero-cost bucket still appears.
|
|
1775
|
+
subagent_costs = {} # subagent_key -> cost (cost-once)
|
|
1776
|
+
for it in items:
|
|
1777
|
+
sk = it.get("subagent_key")
|
|
1778
|
+
if sk is not None:
|
|
1779
|
+
subagent_costs.setdefault(sk, 0.0)
|
|
1780
|
+
subagent_costs[sk] += it.get("cost_usd") or 0.0
|
|
1435
1781
|
for it in items:
|
|
1436
1782
|
kind = it["kind"]
|
|
1437
1783
|
turn_counts["total"] += 1
|
|
@@ -1519,6 +1865,9 @@ def get_conversation_outline(conn, session_id):
|
|
|
1519
1865
|
stats["cache_saved_usd"] = cache_saved
|
|
1520
1866
|
return {"session_id": session_id,
|
|
1521
1867
|
"subagent_meta": asm["subagent_meta"],
|
|
1868
|
+
# #217 S3 E6(a) — display-only per-subagent cost (cost-once). 6dp,
|
|
1869
|
+
# matching the per-item / header cost display precision.
|
|
1870
|
+
"subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
|
|
1522
1871
|
"stats": stats,
|
|
1523
1872
|
"turns": turns}
|
|
1524
1873
|
|
|
@@ -1758,7 +2107,9 @@ def _search_depth(conn) -> str:
|
|
|
1758
2107
|
|
|
1759
2108
|
|
|
1760
2109
|
def search_conversations(conn, query, *, limit=50, offset=0,
|
|
1761
|
-
kind="all", fts_available=None
|
|
2110
|
+
kind="all", fts_available=None,
|
|
2111
|
+
date_from=None, date_to=None, projects=None,
|
|
2112
|
+
cost_min=None, cost_max=None, rebuild_min=None) -> dict:
|
|
1762
2113
|
"""Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
|
|
1763
2114
|
snippet); else a LIKE scan with a manual snippet. Hits deduped by
|
|
1764
2115
|
(session_id, uuid); each carries the turn's cost. `fts_available` overrides
|
|
@@ -1767,11 +2118,27 @@ def search_conversations(conn, query, *, limit=50, offset=0,
|
|
|
1767
2118
|
#177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
|
|
1768
2119
|
family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
|
|
1769
2120
|
column + entry_type, ``tools``/``thinking`` filter the split index columns.
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2121
|
+
#217 S2 / E7: ``title`` searches the external-content title FTS over
|
|
2122
|
+
conversation_ai_titles → one SESSION-level hit per matching session, anchored
|
|
2123
|
+
to that session's first turn, ``match_kinds:["title"]``, snippet = the matched
|
|
2124
|
+
title. #217 S2 / I-3: ``files`` searches ``conversation_file_touches`` (the
|
|
2125
|
+
write-class file-touch axis) → one hit per DISTINCT ``(session_id, file_path)``,
|
|
2126
|
+
anchored to that group's MOST-RECENT touch, ``match_kinds:["file"]``, snippet =
|
|
2127
|
+
the file path. Every hit gains ``match_kinds`` (sorted badges; prose never badges).
|
|
2128
|
+
The response carries additive ``kind`` + ``search_depth`` so the client can
|
|
2129
|
+
degrade the Tools/Thinking facets during the one-time column split
|
|
2130
|
+
(``search_depth == 'prose-only'`` short-circuits those two kinds to empty).
|
|
2131
|
+
An unknown ``kind`` raises ``ValueError`` (route → 400).
|
|
2132
|
+
|
|
2133
|
+
#217 S2 / Filtered-search: the browse filters (``date_from``/``date_to``/
|
|
2134
|
+
``projects``/``cost_min``/``cost_max``/``rebuild_min``) restrict the search to
|
|
2135
|
+
matching sessions, applied uniformly across EVERY kind as a session-scope
|
|
2136
|
+
``session_id IN (...)`` predicate (``_resolve_search_session_filter`` reuses
|
|
2137
|
+
``_rollup_where`` verbatim). When the rollup backfill is pending and a
|
|
2138
|
+
rollup-only axis is requested, only the date axis applies (via a live
|
|
2139
|
+
``MAX(timestamp_utc)`` prefilter, P1-5) and the response carries additive
|
|
2140
|
+
``filter_degraded: True`` — exactly mirroring browse. A no-filter request
|
|
2141
|
+
produces byte-stable existing output (no ``filter_degraded`` key)."""
|
|
1775
2142
|
if kind not in _SEARCH_KINDS:
|
|
1776
2143
|
raise ValueError(f"unknown kind: {kind}")
|
|
1777
2144
|
q = (query or "").strip()
|
|
@@ -1783,20 +2150,60 @@ def search_conversations(conn, query, *, limit=50, offset=0,
|
|
|
1783
2150
|
mode = "fts" if fts_available else "like"
|
|
1784
2151
|
base = {"query": q, "mode": mode, "hits": [], "total": 0,
|
|
1785
2152
|
"kind": kind, "search_depth": depth}
|
|
2153
|
+
# Resolve the filtered-search session-scope restriction ONCE (reused by every
|
|
2154
|
+
# kind). ``filt_sql`` is "" when no axis is set, so the no-filter path stays
|
|
2155
|
+
# byte-stable; ``degraded`` flags the dropped rollup-only axes (P1-5).
|
|
2156
|
+
filters = {
|
|
2157
|
+
"date_from": date_from, "date_to": date_to,
|
|
2158
|
+
"projects": list(projects) if projects else None,
|
|
2159
|
+
"cost_min": cost_min, "cost_max": cost_max, "rebuild_min": rebuild_min,
|
|
2160
|
+
}
|
|
2161
|
+
filt_sql, filt_params, degraded = _resolve_search_session_filter(conn, filters)
|
|
2162
|
+
if degraded:
|
|
2163
|
+
base["filter_degraded"] = True
|
|
2164
|
+
|
|
2165
|
+
def _finish(out):
|
|
2166
|
+
# Stamp the additive degraded flag onto whatever the search path returns,
|
|
2167
|
+
# without disturbing the no-filter byte-stable shape.
|
|
2168
|
+
if degraded:
|
|
2169
|
+
out["filter_degraded"] = True
|
|
2170
|
+
return out
|
|
2171
|
+
|
|
1786
2172
|
# Prose-only interim: the split columns are not yet indexed, so tools /
|
|
1787
2173
|
# thinking can't match — short-circuit them to empty (spec §1 interim).
|
|
1788
2174
|
if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
|
|
1789
2175
|
return base
|
|
2176
|
+
if kind == "title":
|
|
2177
|
+
if fts_available:
|
|
2178
|
+
try:
|
|
2179
|
+
out = _search_title(conn, q, limit, offset, True,
|
|
2180
|
+
filt_sql, filt_params)
|
|
2181
|
+
out.update(kind="title", search_depth=depth)
|
|
2182
|
+
return _finish(out)
|
|
2183
|
+
except sqlite3.OperationalError:
|
|
2184
|
+
pass # title FTS missing/corrupt at query time (the flag is
|
|
2185
|
+
# derived from fts5_unavailable, not a per-table probe) →
|
|
2186
|
+
# fall through to the title LIKE scan, mirroring the
|
|
2187
|
+
# message-FTS path's resilience
|
|
2188
|
+
out = _search_title(conn, q, limit, offset, False,
|
|
2189
|
+
filt_sql, filt_params)
|
|
2190
|
+
out.update(kind="title", search_depth=depth)
|
|
2191
|
+
return _finish(out)
|
|
2192
|
+
if kind == "files":
|
|
2193
|
+
out = _search_files(conn, q, limit, offset, filt_sql, filt_params)
|
|
2194
|
+
out.update(kind="files", search_depth=depth)
|
|
2195
|
+
return _finish(out)
|
|
1790
2196
|
if fts_available:
|
|
1791
2197
|
try:
|
|
1792
|
-
out = _search_fts(conn, q, limit, offset, kind, depth
|
|
2198
|
+
out = _search_fts(conn, q, limit, offset, kind, depth,
|
|
2199
|
+
filt_sql, filt_params)
|
|
1793
2200
|
out.update(kind=kind, search_depth=depth)
|
|
1794
|
-
return out
|
|
2201
|
+
return _finish(out)
|
|
1795
2202
|
except sqlite3.OperationalError:
|
|
1796
2203
|
pass # corrupt/missing FTS at query time → fall through to LIKE
|
|
1797
|
-
out = _search_like(conn, q, limit, offset, kind, depth)
|
|
2204
|
+
out = _search_like(conn, q, limit, offset, kind, depth, filt_sql, filt_params)
|
|
1798
2205
|
out.update(kind=kind, mode="like", search_depth=depth)
|
|
1799
|
-
return out
|
|
2206
|
+
return _finish(out)
|
|
1800
2207
|
|
|
1801
2208
|
|
|
1802
2209
|
def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
|
|
@@ -1841,13 +2248,40 @@ def _attach_titles(conn, page):
|
|
|
1841
2248
|
return page
|
|
1842
2249
|
|
|
1843
2250
|
|
|
1844
|
-
def
|
|
1845
|
-
"""
|
|
2251
|
+
def _session_filter_pred(col, filt_sql):
|
|
2252
|
+
"""The session-scope filter fragment shared by the four ``_search_*``
|
|
2253
|
+
subroutines (#219 S2.2): ``" AND <col> IN (<filt_sql>)"`` when a browse filter
|
|
2254
|
+
is active, else ``""`` (the no-filter fast path). ``col`` is the caller's
|
|
2255
|
+
table-qualified session_id column (``cm.session_id`` / ``t.session_id`` /
|
|
2256
|
+
``ft.session_id`` / bare ``session_id``)."""
|
|
2257
|
+
return f" AND {col} IN ({filt_sql})" if filt_sql else ""
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _attach_search_meta(conn, hits):
|
|
2261
|
+
"""The shared search-hit enrichment tail (#219 S2.2): per-turn cost then
|
|
2262
|
+
per-session title/label. Every ``_search_*`` subroutine returns
|
|
2263
|
+
``_attach_titles(conn, _attach_costs(conn, hits))`` — centralized here so the
|
|
2264
|
+
two-step order has one definition."""
|
|
2265
|
+
return _attach_titles(conn, _attach_costs(conn, hits))
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def _like_escape(q):
|
|
2269
|
+
"""Escape the LIKE wildcards/escape-char in ``q`` (no surrounding ``%``),
|
|
2270
|
+
paired with ``ESCAPE '\\'``. The ESCAPE char (``\\``) is escaped FIRST, then
|
|
1846
2271
|
the wildcards — otherwise a query containing a backslash (incl. a trailing
|
|
1847
|
-
one) mis-escapes
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2272
|
+
one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
|
|
2273
|
+
|
|
2274
|
+
Single source of the escape chain (#217 S2 / I-3 review Minor #3):
|
|
2275
|
+
``_like_pattern`` (substring) and ``_search_files``'s prefix pattern both build
|
|
2276
|
+
on this so the escaping is defined exactly once."""
|
|
2277
|
+
return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def _like_pattern(q):
|
|
2281
|
+
"""Build the SUBSTRING LIKE pattern for `q` — ``%`` + escaped(q) + ``%`` —
|
|
2282
|
+
paired with ``ESCAPE '\\'`` in the queries below. The escape chain is
|
|
2283
|
+
single-sourced in ``_like_escape``."""
|
|
2284
|
+
return "%" + _like_escape(q) + "%"
|
|
1851
2285
|
|
|
1852
2286
|
|
|
1853
2287
|
def _fts_snippets(conn, fts_q, ids, col=0):
|
|
@@ -1915,7 +2349,8 @@ def _match_kinds(conn, fts_q, rids_by_group):
|
|
|
1915
2349
|
for grp, rids in rids_by_group.items()}
|
|
1916
2350
|
|
|
1917
2351
|
|
|
1918
|
-
def _search_fts(conn, q, limit, offset, kind, depth
|
|
2352
|
+
def _search_fts(conn, q, limit, offset, kind, depth,
|
|
2353
|
+
filt_sql="", filt_params=()):
|
|
1919
2354
|
# All of dedup + paging + total live in SQL (#149) so Python never holds
|
|
1920
2355
|
# more than one page of hits/snippets, regardless of corpus match count.
|
|
1921
2356
|
#
|
|
@@ -1932,6 +2367,11 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1932
2367
|
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
1933
2368
|
et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
|
|
1934
2369
|
et_args = (entry_type,) if entry_type is not None else ()
|
|
2370
|
+
# #217 S2 / Filtered-search: session-scope restriction (empty when no filter,
|
|
2371
|
+
# so the no-filter path stays byte-stable). cm.session_id is the FTS join's
|
|
2372
|
+
# message column.
|
|
2373
|
+
fpred = _session_filter_pred("cm.session_id", filt_sql)
|
|
2374
|
+
fargs = tuple(filt_params)
|
|
1935
2375
|
# Exact post-dedup logical total — counted in C with no snippet generation
|
|
1936
2376
|
# and no Python row materialization.
|
|
1937
2377
|
total = conn.execute(
|
|
@@ -1939,8 +2379,8 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1939
2379
|
" SELECT DISTINCT cm.session_id, cm.uuid "
|
|
1940
2380
|
" FROM conversation_fts "
|
|
1941
2381
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1942
|
-
f" WHERE conversation_fts MATCH ?{et_pred})",
|
|
1943
|
-
(match_expr, *et_args),
|
|
2382
|
+
f" WHERE conversation_fts MATCH ?{et_pred}{fpred})",
|
|
2383
|
+
(match_expr, *et_args, *fargs),
|
|
1944
2384
|
).fetchone()[0]
|
|
1945
2385
|
# One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
|
|
1946
2386
|
# the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
|
|
@@ -1964,7 +2404,7 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1964
2404
|
f" {bm25_expr} AS rank "
|
|
1965
2405
|
" FROM conversation_fts "
|
|
1966
2406
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1967
|
-
f" WHERE conversation_fts MATCH ?{et_pred}), "
|
|
2407
|
+
f" WHERE conversation_fts MATCH ?{et_pred}{fpred}), "
|
|
1968
2408
|
"ranked AS ("
|
|
1969
2409
|
" SELECT *, ROW_NUMBER() OVER ("
|
|
1970
2410
|
" PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
|
|
@@ -1972,14 +2412,14 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1972
2412
|
" FROM matched) "
|
|
1973
2413
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
1974
2414
|
"ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
1975
|
-
(match_expr, *et_args, limit, offset),
|
|
2415
|
+
(match_expr, *et_args, *fargs, limit, offset),
|
|
1976
2416
|
).fetchall()
|
|
1977
2417
|
page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
|
|
1978
2418
|
if legacy:
|
|
1979
2419
|
badges = {grp: [] for grp in page_groups}
|
|
1980
2420
|
else:
|
|
1981
2421
|
rids_by_group = _all_matched_rids_by_group(
|
|
1982
|
-
conn,
|
|
2422
|
+
conn, et_pred, et_args, list(page_groups))
|
|
1983
2423
|
badges = _match_kinds(conn, fts_q, rids_by_group)
|
|
1984
2424
|
snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
|
|
1985
2425
|
# For hits badged tool/thinking but with no prose match, draw the snippet
|
|
@@ -1990,48 +2430,86 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1990
2430
|
match_kinds=badges.get((sid, uuid), []))
|
|
1991
2431
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
1992
2432
|
return {"query": q, "mode": "fts",
|
|
1993
|
-
"hits":
|
|
2433
|
+
"hits": _attach_search_meta(conn, hits),
|
|
1994
2434
|
"total": total}
|
|
1995
2435
|
|
|
1996
2436
|
|
|
1997
|
-
def _all_matched_rids_by_group(conn,
|
|
1998
|
-
"""{(sid, uuid) -> [rids]} for the page groups: ALL
|
|
1999
|
-
|
|
2437
|
+
def _all_matched_rids_by_group(conn, et_pred, et_args, groups):
|
|
2438
|
+
"""{(sid, uuid) -> [rids]} for the page groups: ALL physical rows of each
|
|
2439
|
+
page group (a SUPERSET of the FTS-matched subset), so badges aggregate
|
|
2440
|
+
completely. ``_match_kinds`` re-runs a per-column MATCH restricted to these
|
|
2441
|
+
rids, so returning every physical row of the ≤200 page groups stays correct
|
|
2442
|
+
for the column facets (the per-column MATCH filters).
|
|
2443
|
+
|
|
2444
|
+
U3 (#217 S1): a BOUNDED base-table lookup of the page groups' rows — NOT a
|
|
2445
|
+
third full-corpus ``conversation_fts MATCH``. We stop scanning the corpus a
|
|
2446
|
+
third time per request. The term expression is carried by the per-column
|
|
2447
|
+
MATCH in ``_match_kinds``, so this lookup needs no match expression — the
|
|
2448
|
+
formerly-vestigial ``match_expr`` param was dropped (#218 I-A.2).
|
|
2449
|
+
|
|
2450
|
+
Facet-scope fix (Codex P2): carry the SAME ``et_pred``/``et_args`` the page
|
|
2451
|
+
query used (``kind=prompts``/``assistant`` filter ``cm.entry_type``), so a
|
|
2452
|
+
same-group physical row OUTSIDE the facet can never contribute a badge. The
|
|
2453
|
+
``(session_id, uuid)`` match is NULL-safe (``IS``), since a pre-006 row may
|
|
2454
|
+
carry a NULL uuid that a plain ``IN`` row-value would silently drop."""
|
|
2000
2455
|
if not groups:
|
|
2001
2456
|
return {}
|
|
2002
2457
|
rids_by_group = {g: [] for g in groups}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
(
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2458
|
+
# Chunk the OR-of-pairs to stay well under SQLite's variable limit (≤200
|
|
2459
|
+
# page groups, 2 params each → one or two chunks in practice).
|
|
2460
|
+
for i in range(0, len(groups), 300):
|
|
2461
|
+
chunk = groups[i:i + 300]
|
|
2462
|
+
cond = " OR ".join(
|
|
2463
|
+
"(cm.session_id IS ? AND cm.uuid IS ?)" for _ in chunk)
|
|
2464
|
+
params = [v for g in chunk for v in g]
|
|
2465
|
+
rows = conn.execute(
|
|
2466
|
+
"SELECT cm.id, cm.session_id, cm.uuid FROM conversation_messages cm "
|
|
2467
|
+
f"WHERE ({cond}){et_pred}",
|
|
2468
|
+
(*params, *et_args),
|
|
2469
|
+
).fetchall()
|
|
2470
|
+
for rid, sid, uuid in rows:
|
|
2471
|
+
g = (sid, uuid)
|
|
2472
|
+
if g in rids_by_group:
|
|
2473
|
+
rids_by_group[g].append(rid)
|
|
2013
2474
|
return rids_by_group
|
|
2014
2475
|
|
|
2015
2476
|
|
|
2016
2477
|
def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
|
|
2017
2478
|
"""Replace a hit's prose snippet with its matched column's snippet when the
|
|
2018
|
-
prose column did NOT match (prose → tool → thinking preference). Probes
|
|
2019
|
-
|
|
2479
|
+
prose column did NOT match (prose → tool → thinking preference). Probes which
|
|
2480
|
+
of the badged survivors match prose, then re-snippets the non-prose ones from
|
|
2481
|
+
their preferred column.
|
|
2482
|
+
|
|
2483
|
+
U3 (#217 S1): the per-hit ``rowid = ?`` prose probe (one query per badged
|
|
2484
|
+
page hit, up to ~200 at limit=200) is collapsed into ONE bounded
|
|
2485
|
+
``rowid IN (…) AND MATCH text:(…)`` returning the prose-matching set;
|
|
2486
|
+
subtracting gives the non-prose survivors routed to the already-batched
|
|
2487
|
+
tool/thinking snippet calls."""
|
|
2488
|
+
# The badged survivors are the only candidates (a hit with no badges keeps
|
|
2489
|
+
# its col-0 prose snippet). Collect their survivor rowids.
|
|
2490
|
+
badged = [(rid, sid, uuid)
|
|
2491
|
+
for (rid, sid, uuid, ts, cwd, mid, rqd) in page
|
|
2492
|
+
if badges.get((sid, uuid))]
|
|
2493
|
+
if not badged:
|
|
2494
|
+
return snips
|
|
2495
|
+
badged_rids = [rid for (rid, _s, _u) in badged]
|
|
2496
|
+
# ONE bounded probe: which badged survivors ALSO match prose? Keep their
|
|
2497
|
+
# col-0 snippet; the rest fall through to their preferred column.
|
|
2498
|
+
prose_hits = set()
|
|
2499
|
+
for i in range(0, len(badged_rids), 300):
|
|
2500
|
+
chunk = badged_rids[i:i + 300]
|
|
2501
|
+
ph = ",".join("?" for _ in chunk)
|
|
2502
|
+
rows = conn.execute(
|
|
2503
|
+
"SELECT conversation_fts.rowid FROM conversation_fts "
|
|
2504
|
+
f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
|
|
2505
|
+
(f"{{text}}: ({fts_q})", *chunk)).fetchall()
|
|
2506
|
+
prose_hits.update(r[0] for r in rows)
|
|
2020
2507
|
by_col = {} # snippet column index -> [rids needing it]
|
|
2021
|
-
for (rid, sid, uuid
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
if not kinds:
|
|
2025
|
-
continue # prose hit (or unbadged) — keep col-0 snippet
|
|
2026
|
-
# Does THIS survivor row match prose? If so keep col 0.
|
|
2027
|
-
prose_hit = conn.execute(
|
|
2028
|
-
"SELECT 1 FROM conversation_fts "
|
|
2029
|
-
"WHERE conversation_fts MATCH ? AND conversation_fts.rowid = ?",
|
|
2030
|
-
(f"{{text}}: ({fts_q})", rid)).fetchone()
|
|
2031
|
-
if prose_hit:
|
|
2032
|
-
continue
|
|
2508
|
+
for (rid, sid, uuid) in badged:
|
|
2509
|
+
if rid in prose_hits:
|
|
2510
|
+
continue # prose matched the survivor → keep col 0
|
|
2033
2511
|
for label, col in _SNIPPET_COL_PREFERENCE:
|
|
2034
|
-
if label in
|
|
2512
|
+
if label in badges.get((sid, uuid), []):
|
|
2035
2513
|
by_col.setdefault(col, []).append(rid)
|
|
2036
2514
|
break
|
|
2037
2515
|
for col, rids in by_col.items():
|
|
@@ -2042,7 +2520,8 @@ def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
|
|
|
2042
2520
|
return snips
|
|
2043
2521
|
|
|
2044
2522
|
|
|
2045
|
-
def _search_like(conn, q, limit, offset, kind, depth
|
|
2523
|
+
def _search_like(conn, q, limit, offset, kind, depth,
|
|
2524
|
+
filt_sql="", filt_params=()):
|
|
2046
2525
|
# SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
|
|
2047
2526
|
# COUNT + page each scan the table once (the degraded path already lacks an
|
|
2048
2527
|
# index for the substring match). #177 S6: kind → column list (single-
|
|
@@ -2062,11 +2541,14 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2062
2541
|
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
2063
2542
|
et_pred = " AND entry_type = ?" if entry_type is not None else ""
|
|
2064
2543
|
et_args = (entry_type,) if entry_type is not None else ()
|
|
2544
|
+
# #217 S2 / Filtered-search: session-scope restriction (empty when no filter).
|
|
2545
|
+
fpred = _session_filter_pred("session_id", filt_sql)
|
|
2546
|
+
fargs = tuple(filt_params)
|
|
2065
2547
|
total = conn.execute(
|
|
2066
2548
|
"SELECT COUNT(*) FROM ("
|
|
2067
2549
|
" SELECT DISTINCT session_id, uuid FROM conversation_messages "
|
|
2068
|
-
f" WHERE {col_pred}{et_pred})",
|
|
2069
|
-
(*like_args, *et_args),
|
|
2550
|
+
f" WHERE {col_pred}{et_pred}{fpred})",
|
|
2551
|
+
(*like_args, *et_args, *fargs),
|
|
2070
2552
|
).fetchone()[0]
|
|
2071
2553
|
page = conn.execute(
|
|
2072
2554
|
"WITH ranked AS ("
|
|
@@ -2077,10 +2559,10 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2077
2559
|
" ORDER BY timestamp_utc DESC, id DESC"
|
|
2078
2560
|
" ) AS rn "
|
|
2079
2561
|
" FROM conversation_messages "
|
|
2080
|
-
f" WHERE {col_pred}{et_pred}) "
|
|
2562
|
+
f" WHERE {col_pred}{et_pred}{fpred}) "
|
|
2081
2563
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
2082
2564
|
"ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
2083
|
-
(*like_args, *et_args, limit, offset),
|
|
2565
|
+
(*like_args, *et_args, *fargs, limit, offset),
|
|
2084
2566
|
).fetchall()
|
|
2085
2567
|
texts = _texts_for_ids(conn, [r[0] for r in page])
|
|
2086
2568
|
if legacy:
|
|
@@ -2093,7 +2575,171 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2093
2575
|
match_kinds=badges.get((sid, uuid), []))
|
|
2094
2576
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
2095
2577
|
return {"query": q, "mode": "like",
|
|
2096
|
-
"hits":
|
|
2578
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2579
|
+
"total": total}
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_params=()):
|
|
2583
|
+
"""#217 S2 / E7: ``kind=title`` cross-session search over the per-session AI
|
|
2584
|
+
title (conversation_ai_titles). Emits ONE SESSION-level hit per matching
|
|
2585
|
+
session, anchored to that session's FIRST turn (the earliest
|
|
2586
|
+
``(timestamp_utc, id)`` conversation_messages row), ``match_kinds:["title"]``,
|
|
2587
|
+
snippet = the matched title.
|
|
2588
|
+
|
|
2589
|
+
FTS5 available: ``MATCH ai_title`` over the external-content
|
|
2590
|
+
``conversation_title_fts``, mapping each hit rowid →
|
|
2591
|
+
``conversation_ai_titles.session_id``; the snippet comes from FTS ``snippet()``.
|
|
2592
|
+
Else: a LIKE scan over ``conversation_ai_titles`` with the whole title as the
|
|
2593
|
+
snippet (degraded; no marked span).
|
|
2594
|
+
|
|
2595
|
+
P2-9 (load-bearing): a session's title counts toward ``total`` and the page
|
|
2596
|
+
ONLY when it has an anchorable first turn. Both the COUNT and the page JOIN
|
|
2597
|
+
the SAME first-message anchor (``INNER JOIN`` against the per-session
|
|
2598
|
+
first-turn CTE), so a title row whose session has no surviving message rows is
|
|
2599
|
+
excluded from both — no lying count.
|
|
2600
|
+
|
|
2601
|
+
Filtered-search: the ``filt_sql`` session-scope restriction (over
|
|
2602
|
+
conversation_ai_titles.session_id) applies the same as the message kinds.
|
|
2603
|
+
"""
|
|
2604
|
+
fpred = _session_filter_pred("t.session_id", filt_sql)
|
|
2605
|
+
fargs = tuple(filt_params)
|
|
2606
|
+
# First-turn anchor per session: earliest (timestamp_utc, id) row. Rides
|
|
2607
|
+
# idx_conv_session_ts. One row per session_id.
|
|
2608
|
+
anchor_cte = (
|
|
2609
|
+
"anchor AS ("
|
|
2610
|
+
" SELECT session_id, aid, auuid, ats, acwd, amid, arqd FROM ("
|
|
2611
|
+
" SELECT session_id, id AS aid, uuid AS auuid, timestamp_utc AS ats, "
|
|
2612
|
+
" cwd AS acwd, msg_id AS amid, req_id AS arqd, "
|
|
2613
|
+
" ROW_NUMBER() OVER (PARTITION BY session_id "
|
|
2614
|
+
" ORDER BY timestamp_utc, id) AS rn "
|
|
2615
|
+
" FROM conversation_messages WHERE session_id IS NOT NULL"
|
|
2616
|
+
" ) WHERE rn = 1)")
|
|
2617
|
+
|
|
2618
|
+
if fts_available:
|
|
2619
|
+
# matched titles: rowid → session_id + snippet, INNER-joined to the
|
|
2620
|
+
# anchor so only anchorable sessions survive (P2-9).
|
|
2621
|
+
fts_q = _fts_query(q, prefix_last=True)
|
|
2622
|
+
total = conn.execute(
|
|
2623
|
+
"WITH " + anchor_cte + " "
|
|
2624
|
+
"SELECT COUNT(*) FROM conversation_title_fts f "
|
|
2625
|
+
"JOIN conversation_ai_titles t ON t.rowid = f.rowid "
|
|
2626
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2627
|
+
f"WHERE f.conversation_title_fts MATCH ?{fpred}",
|
|
2628
|
+
(fts_q, *fargs)).fetchone()[0]
|
|
2629
|
+
rows = conn.execute(
|
|
2630
|
+
"WITH " + anchor_cte + " "
|
|
2631
|
+
"SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
|
|
2632
|
+
" snippet(conversation_title_fts, 0, '[', ']', ' … ', 12) AS snip "
|
|
2633
|
+
"FROM conversation_title_fts f "
|
|
2634
|
+
"JOIN conversation_ai_titles t ON t.rowid = f.rowid "
|
|
2635
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2636
|
+
f"WHERE f.conversation_title_fts MATCH ?{fpred} "
|
|
2637
|
+
"ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
|
|
2638
|
+
(fts_q, *fargs, limit, offset)).fetchall()
|
|
2639
|
+
mode = "fts"
|
|
2640
|
+
else:
|
|
2641
|
+
like = _like_pattern(q)
|
|
2642
|
+
total = conn.execute(
|
|
2643
|
+
"WITH " + anchor_cte + " "
|
|
2644
|
+
"SELECT COUNT(*) FROM conversation_ai_titles t "
|
|
2645
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2646
|
+
f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred}",
|
|
2647
|
+
(like, *fargs)).fetchone()[0]
|
|
2648
|
+
rows = conn.execute(
|
|
2649
|
+
"WITH " + anchor_cte + " "
|
|
2650
|
+
"SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
|
|
2651
|
+
" t.ai_title AS snip "
|
|
2652
|
+
"FROM conversation_ai_titles t "
|
|
2653
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2654
|
+
f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred} "
|
|
2655
|
+
"ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
|
|
2656
|
+
(like, *fargs, limit, offset)).fetchall()
|
|
2657
|
+
mode = "like"
|
|
2658
|
+
|
|
2659
|
+
hits = [_row_to_hit(auuid, sid, ats, acwd, snip, amid, arqd,
|
|
2660
|
+
match_kinds=["title"])
|
|
2661
|
+
for (sid, auuid, ats, acwd, amid, arqd, snip) in rows]
|
|
2662
|
+
return {"query": q, "mode": mode,
|
|
2663
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2664
|
+
"total": total}
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
# #217 S2 / I-3: a path query "looks like a prefix" when it carries no leading
|
|
2668
|
+
# path separator — ``bin/cctally`` is a prefix probe, ``/dashboard`` (or a query
|
|
2669
|
+
# that intentionally starts mid-path) forces the substring scan. The set is the
|
|
2670
|
+
# common path separators (POSIX + Windows).
|
|
2671
|
+
_PATH_SEPARATORS = ("/", "\\")
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
|
|
2675
|
+
"""#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
|
|
2676
|
+
file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
|
|
2677
|
+
``(session_id, file_path)``, anchored to that group's MOST-RECENT touch
|
|
2678
|
+
(``MAX(message_id)`` → that message's ``(uuid, ts, cwd, msg_id, req_id)``),
|
|
2679
|
+
``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
|
|
2680
|
+
``(session_id, file_path)`` count.
|
|
2681
|
+
|
|
2682
|
+
P3-10 (match shape): a PREFIX-looking query (no leading path separator) binds a
|
|
2683
|
+
literal-prefix pattern (escaped(q) + ``%``) into ``file_path LIKE ? ESCAPE '\\'``
|
|
2684
|
+
and is GENUINELY index-assisted — it rides ``idx_file_touches_path`` as a SEARCH
|
|
2685
|
+
over a key range (verified by EXPLAIN QUERY PLAN in
|
|
2686
|
+
``test_kind_files_prefix_branch_uses_index_substring_scans``). That assistance
|
|
2687
|
+
depends on ``idx_file_touches_path`` being ``COLLATE NOCASE``: the default
|
|
2688
|
+
``LIKE`` is case-insensitive, and SQLite only optimizes ``LIKE 'prefix%'`` onto
|
|
2689
|
+
a btree when the index folds the same way (a BINARY index leaves it a full
|
|
2690
|
+
SCAN). The ``ESCAPE '\\'`` clause does NOT defeat the optimization — the literal
|
|
2691
|
+
prefix is still constant. A query with a leading separator binds ``%`` +
|
|
2692
|
+
escaped(q) + ``%`` and is a full SCAN (a leading wildcard can never use the
|
|
2693
|
+
index — the intentional, documented substring fallback over the modest table).
|
|
2694
|
+
LIKE is case-insensitive for ASCII (matching the NOCASE collation exactly).
|
|
2695
|
+
|
|
2696
|
+
Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
|
|
2697
|
+
``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
|
|
2698
|
+
dedicated session index (the filter is a small IN over the already-narrowed
|
|
2699
|
+
match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
|
|
2700
|
+
"""
|
|
2701
|
+
esc = _like_escape(q)
|
|
2702
|
+
prefix = not q.startswith(_PATH_SEPARATORS)
|
|
2703
|
+
pat = (esc + "%") if prefix else ("%" + esc + "%")
|
|
2704
|
+
fpred = _session_filter_pred("ft.session_id", filt_sql)
|
|
2705
|
+
fargs = tuple(filt_params)
|
|
2706
|
+
|
|
2707
|
+
# The anchor per (session_id, file_path) group is its MAX(message_id). Compute
|
|
2708
|
+
# the groups (filtered + matched), then join to conversation_messages on the
|
|
2709
|
+
# anchor id for the turn's render/cost metadata.
|
|
2710
|
+
#
|
|
2711
|
+
# P2-9 (#219 S2.1): the COUNT INNER-JOINs the SAME anchor the page joins, so a
|
|
2712
|
+
# group whose MAX(message_id) anchor is absent from conversation_messages is
|
|
2713
|
+
# excluded from BOTH (never a lying count where total > page-reachable). Inert
|
|
2714
|
+
# in a quiescent cache (touches are deleted with their message), but it keeps
|
|
2715
|
+
# `total` provably equal to the page-reachable set — matching `_search_title`.
|
|
2716
|
+
total = conn.execute(
|
|
2717
|
+
"WITH g AS ("
|
|
2718
|
+
" SELECT ft.session_id AS sid, ft.file_path AS fp, "
|
|
2719
|
+
" MAX(ft.message_id) AS aid "
|
|
2720
|
+
" FROM conversation_file_touches ft "
|
|
2721
|
+
f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
|
|
2722
|
+
" GROUP BY ft.session_id, ft.file_path) "
|
|
2723
|
+
"SELECT COUNT(*) FROM g JOIN conversation_messages cm ON cm.id = g.aid",
|
|
2724
|
+
(pat, *fargs)).fetchone()[0]
|
|
2725
|
+
rows = conn.execute(
|
|
2726
|
+
"WITH g AS ("
|
|
2727
|
+
" SELECT ft.session_id AS sid, ft.file_path AS fp, "
|
|
2728
|
+
" MAX(ft.message_id) AS aid "
|
|
2729
|
+
" FROM conversation_file_touches ft "
|
|
2730
|
+
f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
|
|
2731
|
+
" GROUP BY ft.session_id, ft.file_path) "
|
|
2732
|
+
"SELECT g.sid, g.fp, cm.uuid, cm.timestamp_utc, cm.cwd, "
|
|
2733
|
+
" cm.msg_id, cm.req_id "
|
|
2734
|
+
"FROM g JOIN conversation_messages cm ON cm.id = g.aid "
|
|
2735
|
+
"ORDER BY cm.timestamp_utc DESC, g.sid DESC, g.fp DESC "
|
|
2736
|
+
"LIMIT ? OFFSET ?",
|
|
2737
|
+
(pat, *fargs, limit, offset)).fetchall()
|
|
2738
|
+
|
|
2739
|
+
hits = [_row_to_hit(uuid_, sid, ts, cwd, fp, mid, rqd, match_kinds=["file"])
|
|
2740
|
+
for (sid, fp, uuid_, ts, cwd, mid, rqd) in rows]
|
|
2741
|
+
return {"query": q, "mode": "like",
|
|
2742
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2097
2743
|
"total": total}
|
|
2098
2744
|
|
|
2099
2745
|
|
|
@@ -2118,9 +2764,19 @@ def _like_badges(conn, like, groups):
|
|
|
2118
2764
|
|
|
2119
2765
|
# #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
|
|
2120
2766
|
# filter the prose column AND the entry_type; `tools`/`thinking` filter the
|
|
2121
|
-
# split index columns.
|
|
2122
|
-
# (
|
|
2123
|
-
|
|
2767
|
+
# split index columns. #217 S2 / E7: `title` is a CROSS-SESSION-SEARCH-ONLY kind
|
|
2768
|
+
# (external-content title FTS over conversation_ai_titles, session-level hits).
|
|
2769
|
+
# Validated in search_conversations (an unknown kind raises ValueError → the
|
|
2770
|
+
# route maps it to a 400).
|
|
2771
|
+
#
|
|
2772
|
+
# P1-1 (load-bearing kind-validation SPLIT): `_SEARCH_KINDS` (cross-session
|
|
2773
|
+
# search) carries `title` (and is ready for `files` in I-3); `_FIND_KINDS` (the
|
|
2774
|
+
# in-conversation /find route) does NOT — `find_in_conversation` indexes
|
|
2775
|
+
# `_FIND_KIND_COLUMNS[kind]`, which has no entry for `title`/`files`, so accepting
|
|
2776
|
+
# them there would KeyError → 500. Keeping the two enums distinct makes
|
|
2777
|
+
# `/find?kind=title` a clean 400, never a 500.
|
|
2778
|
+
_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
|
|
2779
|
+
_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
2124
2780
|
_KIND_COLUMN = {"prompts": "text", "assistant": "text",
|
|
2125
2781
|
"tools": "search_tool", "thinking": "search_thinking"}
|
|
2126
2782
|
_KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
|
|
@@ -2156,6 +2812,14 @@ def _kind_match_expr(kind, fts_q):
|
|
|
2156
2812
|
|
|
2157
2813
|
_FIND_ANCHOR_CAP = 500
|
|
2158
2814
|
|
|
2815
|
+
# #217 S4 / I-1.1: bound the regex/case Python-scan (Codex P2, ReDoS/perf).
|
|
2816
|
+
# An over-long pattern/query is rejected outright (empty result, no scan); each
|
|
2817
|
+
# scanned column value is also clipped before the predicate runs so a single
|
|
2818
|
+
# huge tool result can't pin a catastrophic-backtracking regex. Best-effort —
|
|
2819
|
+
# not a full ReDoS defense, but it bounds the worst case to a fixed budget.
|
|
2820
|
+
_FIND_REGEX_MAX_LEN = 1000
|
|
2821
|
+
_FIND_SCAN_TEXT_CAP = 200_000
|
|
2822
|
+
|
|
2159
2823
|
# Which physical-row columns the find match probes per kind, and the badge label
|
|
2160
2824
|
# each non-prose column contributes. ``text`` maps to the synthetic ``prose``
|
|
2161
2825
|
# label so a prose-only match still anchors a turn but never badges.
|
|
@@ -2170,7 +2834,8 @@ _FIND_KIND_COLUMNS = {
|
|
|
2170
2834
|
|
|
2171
2835
|
|
|
2172
2836
|
def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
2173
|
-
fts_available=None, cap=_FIND_ANCHOR_CAP
|
|
2837
|
+
fts_available=None, cap=_FIND_ANCHOR_CAP,
|
|
2838
|
+
regex=False, case=False):
|
|
2174
2839
|
"""Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
|
|
2175
2840
|
|
|
2176
2841
|
Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
|
|
@@ -2183,8 +2848,29 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2183
2848
|
rendered-turn anchors PRE-cap; the list caps at ``cap`` with
|
|
2184
2849
|
``anchors_truncated``. Returns None for an unknown session; an unknown
|
|
2185
2850
|
``kind`` raises ValueError (route → 400). Empty/whitespace query → empty;
|
|
2186
|
-
prose-only depth + tools/thinking kinds → empty (the split index is pending).
|
|
2187
|
-
|
|
2851
|
+
prose-only depth + tools/thinking kinds → empty (the split index is pending).
|
|
2852
|
+
|
|
2853
|
+
P1-1: validates against ``_FIND_KINDS`` (NOT ``_SEARCH_KINDS``), so the
|
|
2854
|
+
cross-session-only ``title``/``files`` kinds raise ValueError here (the route
|
|
2855
|
+
→ 400) rather than reaching ``_FIND_KIND_COLUMNS[kind]`` and KeyError → 500.
|
|
2856
|
+
|
|
2857
|
+
#217 S4 / I-1.1 — ``regex`` / ``case``: when either is set the FTS/LIKE fast
|
|
2858
|
+
path is bypassed for a **physical-row Python scan** of ``conversation_messages``
|
|
2859
|
+
(Codex P0 — *not* the rendered ``_assemble_session`` items, which drop the
|
|
2860
|
+
``search_tool``/``search_thinking`` corpus). The scan reuses the SAME column
|
|
2861
|
+
derivation the FTS path matches on (``_find_kind_columns(kind, "full")`` +
|
|
2862
|
+
``_KIND_ENTRY_TYPE``) and produces the SAME ``{uuid -> labels}`` map, then
|
|
2863
|
+
feeds the SAME assembly-to-anchor loop, so anchors / ordering / member_uuids /
|
|
2864
|
+
match_kinds / the cap are byte-identical to the FTS path — only the matcher
|
|
2865
|
+
differs. ``case`` only → case-sensitive substring (``mode == "like"``);
|
|
2866
|
+
``regex`` → per-row ``re.search`` compiled once (``re.IGNORECASE`` unless
|
|
2867
|
+
``case``; ``mode == "regex"``). Because the scan reads the full corpus
|
|
2868
|
+
columns, ``search_depth == "full"`` — regex/case is NOT gated by the
|
|
2869
|
+
prose-only split-index window. The scan is bounded by ``_FIND_REGEX_MAX_LEN``
|
|
2870
|
+
(query/pattern length) and ``_FIND_SCAN_TEXT_CAP`` (per-row scanned text).
|
|
2871
|
+
Default ``regex=False, case=False`` leaves every existing response
|
|
2872
|
+
byte-stable (the ``_find_matched_rows`` path is untouched)."""
|
|
2873
|
+
if kind not in _FIND_KINDS:
|
|
2188
2874
|
raise ValueError(f"unknown kind: {kind}")
|
|
2189
2875
|
# Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
|
|
2190
2876
|
# empty/prose-only-blocked query opening the find bar pays nothing — yet the
|
|
@@ -2198,17 +2884,55 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2198
2884
|
if fts_available is None:
|
|
2199
2885
|
fts_available = not _fts_flag_unavailable(conn)
|
|
2200
2886
|
q = (query or "").strip()
|
|
2887
|
+
# #217 S4 / I-1.1: a regex/case request scans the full corpus columns, so it
|
|
2888
|
+
# reports search_depth "full" regardless of the prose-only split-index state,
|
|
2889
|
+
# and is never blocked by it (the scan reads the base table, not the FTS).
|
|
2890
|
+
scan = bool(regex or case)
|
|
2891
|
+
eff_depth = "full" if scan else depth
|
|
2201
2892
|
base = {"total": 0, "anchors": [], "anchors_truncated": False,
|
|
2202
|
-
"search_depth":
|
|
2203
|
-
"mode": "
|
|
2204
|
-
|
|
2893
|
+
"search_depth": eff_depth, "kind": kind,
|
|
2894
|
+
"mode": ("regex" if regex else "like") if scan
|
|
2895
|
+
else ("fts" if fts_available else "like")}
|
|
2896
|
+
if not q:
|
|
2205
2897
|
return base
|
|
2898
|
+
if scan:
|
|
2899
|
+
# Bound the scan (Codex P2): reject an over-long pattern/query outright
|
|
2900
|
+
# rather than risk a catastrophic-backtracking regex over the corpus.
|
|
2901
|
+
if len(q) > _FIND_REGEX_MAX_LEN:
|
|
2902
|
+
return base
|
|
2903
|
+
mode = "regex" if regex else "like"
|
|
2904
|
+
matched = _find_matched_scan(conn, session_id, q, kind, regex, case)
|
|
2905
|
+
else:
|
|
2906
|
+
if depth == "prose-only" and kind in ("tools", "thinking"):
|
|
2907
|
+
return base
|
|
2908
|
+
# U2 (#217 S1): run the match probe BEFORE assembly so a non-empty query
|
|
2909
|
+
# that matches ZERO rows in this session returns early — skipping the full
|
|
2910
|
+
# `_assemble_session` walk a no-result find used to pay. The two functions
|
|
2911
|
+
# are independent (neither consumes the other's output); a non-empty match
|
|
2912
|
+
# still assembles, since the uuid → member_uuids anchor mapping needs the
|
|
2913
|
+
# items. `mode` is taken from the match probe (it may fall through to LIKE
|
|
2914
|
+
# on an FTS error) so the empty-match base carries the actually-used mode,
|
|
2915
|
+
# not the `fts_available` default — byte-identical to the prior
|
|
2916
|
+
# post-assembly answer.
|
|
2917
|
+
mode, matched = _find_matched_rows(
|
|
2918
|
+
conn, session_id, q, kind, depth, fts_available)
|
|
2919
|
+
if not matched:
|
|
2920
|
+
return {**base, "mode": mode}
|
|
2206
2921
|
asm = _assemble_session(conn, session_id)
|
|
2207
2922
|
if asm is None:
|
|
2208
2923
|
return None
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2924
|
+
anchors = _find_anchors_from_matched(asm, matched)
|
|
2925
|
+
total = len(anchors)
|
|
2926
|
+
return {**base, "mode": mode, "total": total,
|
|
2927
|
+
"anchors": anchors[:cap], "anchors_truncated": total > cap}
|
|
2928
|
+
|
|
2929
|
+
|
|
2930
|
+
def _find_anchors_from_matched(asm, matched):
|
|
2931
|
+
"""Map the ``{uuid -> set(labels)}`` match map onto document-ordered
|
|
2932
|
+
rendered-turn anchors (shared by the FTS/LIKE path and the regex/case scan,
|
|
2933
|
+
so the two can never diverge on ordering / member folding / match_kinds).
|
|
2934
|
+
``matched`` labels are in ``{"prose", "tool", "thinking"}``; the synthetic
|
|
2935
|
+
``prose`` label anchors a turn but never badges."""
|
|
2212
2936
|
anchors = []
|
|
2213
2937
|
for it in asm["items"]:
|
|
2214
2938
|
hit_kinds = set()
|
|
@@ -2222,9 +2946,51 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2222
2946
|
anchors.append({
|
|
2223
2947
|
"uuid": it["anchor"]["uuid"],
|
|
2224
2948
|
"match_kinds": sorted(k for k in hit_kinds if k != "prose")})
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2949
|
+
return anchors
|
|
2950
|
+
|
|
2951
|
+
|
|
2952
|
+
def _find_matched_scan(conn, session_id, q, kind, regex, case):
|
|
2953
|
+
"""({uuid -> {labels}}) for a regex/case PHYSICAL-ROW scan (Codex P0). Reads
|
|
2954
|
+
the same corpus columns the FTS path matches on (``_find_kind_columns(kind,
|
|
2955
|
+
"full")``) over the base ``conversation_messages`` rows — NOT the rendered
|
|
2956
|
+
``_assemble_session`` items, which drop the ``search_tool``/``search_thinking``
|
|
2957
|
+
columns. Honors ``_KIND_ENTRY_TYPE`` exactly like ``_find_matched_like``.
|
|
2958
|
+
Each scanned value is clipped to ``_FIND_SCAN_TEXT_CAP`` before the predicate
|
|
2959
|
+
so one huge tool result can't pin the regex engine.
|
|
2960
|
+
|
|
2961
|
+
Precondition: ``regex or case``. ``find_in_conversation`` only enters the
|
|
2962
|
+
physical-row scan when ``scan = bool(regex or case)`` (#217 S4 / I-1.1), so a
|
|
2963
|
+
plain case-insensitive-substring scan is never requested here — the FTS/LIKE
|
|
2964
|
+
path (``_find_matched_rows``) owns that case. The two regex/case predicates
|
|
2965
|
+
below are therefore exhaustive; the assert documents (and enforces) that
|
|
2966
|
+
rather than carrying an unreachable third branch."""
|
|
2967
|
+
assert regex or case, "_find_matched_scan requires regex or case"
|
|
2968
|
+
cols = _find_kind_columns(kind, "full")
|
|
2969
|
+
if not cols:
|
|
2970
|
+
return {}
|
|
2971
|
+
et = _KIND_ENTRY_TYPE.get(kind)
|
|
2972
|
+
et_pred = " AND entry_type = ?" if et is not None else ""
|
|
2973
|
+
et_args = (et,) if et is not None else ()
|
|
2974
|
+
if regex:
|
|
2975
|
+
rx = re.compile(q, 0 if case else re.IGNORECASE)
|
|
2976
|
+
pred = lambda text: rx.search(text) is not None
|
|
2977
|
+
else: # case-sensitive substring (case=True, regex=False)
|
|
2978
|
+
pred = lambda text: q in text
|
|
2979
|
+
out = {}
|
|
2980
|
+
col_list = ", ".join(c for c, _ in cols)
|
|
2981
|
+
rows = conn.execute(
|
|
2982
|
+
f"SELECT uuid, {col_list} FROM conversation_messages "
|
|
2983
|
+
f"WHERE session_id = ?{et_pred}",
|
|
2984
|
+
(session_id, *et_args)).fetchall()
|
|
2985
|
+
for row in rows:
|
|
2986
|
+
u = row[0]
|
|
2987
|
+
for idx, (_col, label) in enumerate(cols):
|
|
2988
|
+
val = row[idx + 1]
|
|
2989
|
+
if not val:
|
|
2990
|
+
continue
|
|
2991
|
+
if pred(val[:_FIND_SCAN_TEXT_CAP]):
|
|
2992
|
+
out.setdefault(u, set()).add(label)
|
|
2993
|
+
return out
|
|
2228
2994
|
|
|
2229
2995
|
|
|
2230
2996
|
def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
|
|
@@ -2347,74 +3113,6 @@ def locate_tool_payload(conn, session_id, tool_use_id, which):
|
|
|
2347
3113
|
return None
|
|
2348
3114
|
|
|
2349
3115
|
|
|
2350
|
-
def _clip_payload_input(inp, ceiling):
|
|
2351
|
-
"""Clip a structured input so the returned dict serializes to ``ceiling`` chars
|
|
2352
|
-
or fewer, and report whether anything was clipped — the input-side analogue of
|
|
2353
|
-
the result-side ceiling. A degenerate multi-MB input (one giant leaf OR many
|
|
2354
|
-
sub-ceiling leaves that sum past the ceiling) is bounded so the HTTP server /
|
|
2355
|
-
browser is protected, while every real payload returns whole.
|
|
2356
|
-
|
|
2357
|
-
The guarantee is AGGREGATE, not merely per-leaf: a shared remaining-char budget
|
|
2358
|
-
is threaded through the walk (mirroring ``_bound_input``'s total-size backstop) —
|
|
2359
|
-
each string leaf is clipped against the running budget and the budget is
|
|
2360
|
-
decremented as we go, so once it is exhausted later leaves clip to ''.
|
|
2361
|
-
``truncated`` is True iff any leaf was clipped (or the post-walk serialized size
|
|
2362
|
-
still exceeds ``ceiling``, the structural-overhead backstop). Post-condition:
|
|
2363
|
-
``len(json.dumps(clipped, ensure_ascii=False)) <= ceiling`` always."""
|
|
2364
|
-
truncated = False
|
|
2365
|
-
remaining = [ceiling] # boxed so the nested walk can decrement it
|
|
2366
|
-
|
|
2367
|
-
def walk(v):
|
|
2368
|
-
nonlocal truncated
|
|
2369
|
-
if isinstance(v, str):
|
|
2370
|
-
if len(v) > remaining[0]:
|
|
2371
|
-
truncated = True
|
|
2372
|
-
v = v[:remaining[0]]
|
|
2373
|
-
remaining[0] -= len(v)
|
|
2374
|
-
return v
|
|
2375
|
-
if isinstance(v, dict):
|
|
2376
|
-
return {k: walk(x) for k, x in v.items()}
|
|
2377
|
-
if isinstance(v, list):
|
|
2378
|
-
return [walk(x) for x in v]
|
|
2379
|
-
return v
|
|
2380
|
-
|
|
2381
|
-
clipped = walk(inp)
|
|
2382
|
-
# Backstop: structural JSON overhead (braces/quotes/keys) can push a
|
|
2383
|
-
# budget-exact payload a few chars past the ceiling. Hard-clip the largest
|
|
2384
|
-
# remaining string leaf(s) until the whole dict serializes within the ceiling.
|
|
2385
|
-
while len(_json.dumps(clipped, ensure_ascii=False)) > ceiling:
|
|
2386
|
-
truncated = True
|
|
2387
|
-
if not _shrink_largest_leaf(clipped):
|
|
2388
|
-
break # no string leaf left to shrink (e.g. pure numeric/structural)
|
|
2389
|
-
return clipped, truncated
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
def _shrink_largest_leaf(obj):
|
|
2393
|
-
"""Halve the longest string leaf reachable in ``obj`` (a dict/list/scalar),
|
|
2394
|
-
in place, and return True if one was shrunk — the post-walk backstop for the
|
|
2395
|
-
rare structural-overhead overshoot. A leaf already at length 1 is truncated to
|
|
2396
|
-
''. Returns False when no non-empty string leaf exists."""
|
|
2397
|
-
best = {"len": 0, "container": None, "key": None}
|
|
2398
|
-
|
|
2399
|
-
def scan(v, container, key):
|
|
2400
|
-
if isinstance(v, str):
|
|
2401
|
-
if len(v) > best["len"]:
|
|
2402
|
-
best.update(len=len(v), container=container, key=key)
|
|
2403
|
-
elif isinstance(v, dict):
|
|
2404
|
-
for k, x in v.items():
|
|
2405
|
-
scan(x, v, k)
|
|
2406
|
-
elif isinstance(v, list):
|
|
2407
|
-
for i, x in enumerate(v):
|
|
2408
|
-
scan(x, v, i)
|
|
2409
|
-
|
|
2410
|
-
scan(obj, None, None)
|
|
2411
|
-
if best["container"] is None or best["len"] == 0:
|
|
2412
|
-
return False
|
|
2413
|
-
s = best["container"][best["key"]]
|
|
2414
|
-
best["container"][best["key"]] = s[: len(s) // 2]
|
|
2415
|
-
return True
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
3116
|
def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
2419
3117
|
"""Re-read the raw JSONL line at ``(source_path, byte_offset)`` and return the
|
|
2420
3118
|
FULL (un-capped) payload for ``tool_use_id``:
|
|
@@ -2471,6 +3169,12 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
|
2471
3169
|
if (isinstance(tur, dict) and isinstance(tur.get("stderr"), str)
|
|
2472
3170
|
and tur.get("stderr")):
|
|
2473
3171
|
resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
|
|
3172
|
+
# #217 S1 / U5 (data-contract honesty): `truncated` describes ONLY
|
|
3173
|
+
# the `text` stream, but `stderr` is bounded INDEPENDENTLY against
|
|
3174
|
+
# the same ceiling — so the client cannot tell from `truncated`
|
|
3175
|
+
# alone whether stderr was clipped. Emit a dedicated per-stream
|
|
3176
|
+
# flag whenever stderr is present (mirrors `stderr` itself).
|
|
3177
|
+
resp["stderr_truncated"] = len(tur["stderr"]) > _FULL_PAYLOAD_CEILING
|
|
2474
3178
|
return resp
|
|
2475
3179
|
return None
|
|
2476
3180
|
|