cctally 1.52.1 → 1.54.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 +47 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +169 -26
- package/bin/_cctally_db.py +348 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +231 -12
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +1191 -331
- package/bin/_lib_doctor.py +85 -0
- package/dashboard/static/assets/index-DXzPdFUE.js +78 -0
- package/dashboard/static/assets/index-Yau7oYp8.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -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,124 @@ 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
|
)
|
|
290
|
+
# #217 S5 F2 — the true per-edit +N/-M stat, recomputed at outline-build time for
|
|
291
|
+
# non-truncated edits (the stamped `block["edit_stat"]` only exists on truncated
|
|
292
|
+
# inputs, where the bounded copy can't be recounted; Codex P1-7).
|
|
293
|
+
from _lib_conversation import _edit_stat_for
|
|
294
|
+
|
|
295
|
+
# #217 S5 F2 — the files-touched aggregation is DELIBERATELY scoped to the three
|
|
296
|
+
# named edit tools, NOT `_lib_conversation._FILE_TOUCH_TOOLS` (which also includes
|
|
297
|
+
# NotebookEdit — rare; out of scope, Codex P2-4). Lower-cased for case-insensitive
|
|
298
|
+
# block-name matching.
|
|
299
|
+
_EXPORT_EDIT_TOOLS = {"edit", "multiedit", "write"}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _collect_files(items):
|
|
303
|
+
"""Whole-session files-touched aggregation (#217 S5 F2, spec §2).
|
|
304
|
+
|
|
305
|
+
For every Edit / MultiEdit / Write ``tool_call`` block (NOT NotebookEdit —
|
|
306
|
+
the narrower set is deliberate, Codex P2-4) carrying a string ``file_path``,
|
|
307
|
+
emit one ``touch`` per call and a per-path running ``+N/-M`` sum, preserving
|
|
308
|
+
first-touch document order. The signed stat is the stamped ``edit_stat`` when
|
|
309
|
+
present (truncated inputs), else a recompute via ``_edit_stat_for``; when
|
|
310
|
+
NEITHER is available the touch is STILL listed with ``add``/``del`` = ``None``
|
|
311
|
+
and the file total sums only the known values (``None`` only when every touch
|
|
312
|
+
is unknown — Codex P1-7). Pure over the assembled ``items``.
|
|
313
|
+
"""
|
|
314
|
+
order, agg = [], {}
|
|
315
|
+
for it in items:
|
|
316
|
+
anchor_uuid = it["anchor"]["uuid"]
|
|
317
|
+
for b in it.get("blocks", []):
|
|
318
|
+
if b.get("kind") not in ("tool_call", "tool_use"):
|
|
319
|
+
continue
|
|
320
|
+
nm = (b.get("name") or "").lower()
|
|
321
|
+
if nm not in _EXPORT_EDIT_TOOLS:
|
|
322
|
+
continue
|
|
323
|
+
inp = b.get("input")
|
|
324
|
+
path = inp.get("file_path") if isinstance(inp, dict) else None
|
|
325
|
+
if not isinstance(path, str) or not path:
|
|
326
|
+
continue
|
|
327
|
+
st = b.get("edit_stat")
|
|
328
|
+
if st is None:
|
|
329
|
+
st = _edit_stat_for(b.get("name"), inp)
|
|
330
|
+
add = st.get("add") if isinstance(st, dict) else None
|
|
331
|
+
dele = st.get("del") if isinstance(st, dict) else None
|
|
332
|
+
if path not in agg:
|
|
333
|
+
agg[path] = {"path": path, "add": None, "del": None, "touches": []}
|
|
334
|
+
order.append(path)
|
|
335
|
+
entry = agg[path]
|
|
336
|
+
entry["touches"].append({
|
|
337
|
+
"uuid": anchor_uuid,
|
|
338
|
+
"tool_use_id": b.get("tool_use_id") or b.get("id"),
|
|
339
|
+
"op": nm,
|
|
340
|
+
"add": add,
|
|
341
|
+
"del": dele,
|
|
342
|
+
})
|
|
343
|
+
if add is not None:
|
|
344
|
+
entry["add"] = (entry["add"] or 0) + add
|
|
345
|
+
if dele is not None:
|
|
346
|
+
entry["del"] = (entry["del"] or 0) + dele
|
|
347
|
+
return [agg[p] for p in order]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _task_completion(items):
|
|
351
|
+
"""Whole-session task-completion detection (#217 S5 F7, spec §3).
|
|
352
|
+
|
|
353
|
+
Scan the assembled ``items`` in document order, MAIN THREAD ONLY
|
|
354
|
+
(``subagent_key is None`` AND not ``is_sidechain`` — Codex P1-1, because
|
|
355
|
+
``_fold_task_runs`` keeps task state PER subagent thread, so a global
|
|
356
|
+
last-snapshot could describe a subagent's checklist rather than the
|
|
357
|
+
session's). Take the LAST main-thread block carrying a folded
|
|
358
|
+
``task_snapshot`` (the Task* family, stamped by ``_fold_task_runs``) or a
|
|
359
|
+
legacy ``TodoWrite`` (``name == "TodoWrite"``, ``input.todos``). Returns
|
|
360
|
+
``{all_done, total, completed, anchor_uuid}`` or ``None`` when the
|
|
361
|
+
main thread has no tasks. Pure over the assembled ``items``.
|
|
362
|
+
"""
|
|
363
|
+
final = None
|
|
364
|
+
final_uuid = None
|
|
365
|
+
for it in items:
|
|
366
|
+
if it.get("subagent_key") is not None or it.get("is_sidechain"):
|
|
367
|
+
continue
|
|
368
|
+
for b in it.get("blocks", []):
|
|
369
|
+
if b.get("kind") not in ("tool_call", "tool_use"):
|
|
370
|
+
continue
|
|
371
|
+
snap = b.get("task_snapshot")
|
|
372
|
+
if snap is None and b.get("name") == "TodoWrite":
|
|
373
|
+
inp = b.get("input")
|
|
374
|
+
snap = inp.get("todos") if isinstance(inp, dict) else None
|
|
375
|
+
if isinstance(snap, list):
|
|
376
|
+
final = snap
|
|
377
|
+
final_uuid = it["anchor"]["uuid"]
|
|
378
|
+
if not final:
|
|
379
|
+
return None
|
|
380
|
+
total = len(final)
|
|
381
|
+
completed = sum(1 for t in final
|
|
382
|
+
if isinstance(t, dict) and t.get("status") == "completed")
|
|
383
|
+
return {"all_done": total > 0 and completed == total,
|
|
384
|
+
"total": total,
|
|
385
|
+
"completed": completed,
|
|
386
|
+
"anchor_uuid": final_uuid}
|
|
285
387
|
|
|
286
388
|
|
|
287
389
|
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
|
|
390
|
+
"""Read-time fallback for nested (grandchild) subagent results on rows that
|
|
391
|
+
were ingested BEFORE the #217 S1 U6 structured stamp (un-reingested history).
|
|
392
|
+
Delegates to the parser's single-source rule over the (already-clipped) block
|
|
393
|
+
text — so a >16 KB result whose `agentId:` trailer was cut survives only via
|
|
394
|
+
the ingest stamp + the migration-017 offset-0 reingest, never here. Returns
|
|
395
|
+
(agent_id, meta) or None when no `agentId:` is present. Linking on `agentId`
|
|
396
|
+
ALONE is sufficient (Codex P1-B)."""
|
|
397
|
+
return _nested_agent_stamp_from_text(text)
|
|
308
398
|
|
|
309
399
|
|
|
310
400
|
def _iso_ms(ts):
|
|
@@ -374,122 +464,338 @@ def _cache_read_saved_usd(model, cache_read):
|
|
|
374
464
|
return full - read
|
|
375
465
|
|
|
376
466
|
|
|
467
|
+
# A normalized cache-failure event. ``compaction`` marks a context-compaction
|
|
468
|
+
# boundary (resets the running-max); otherwise it is an assistant-token event
|
|
469
|
+
# carrying its per-thread/per-model key + the cache_creation/cache_read signal.
|
|
470
|
+
# Both the full-assembly stamp path and the lightweight rebuild-count path build
|
|
471
|
+
# a document-ordered list of these and feed it to the ONE predicate below — the
|
|
472
|
+
# rule is implemented exactly once (U1, #217 S1).
|
|
473
|
+
class _CFEvent:
|
|
474
|
+
__slots__ = ("compaction", "key", "cc", "cr", "model")
|
|
475
|
+
|
|
476
|
+
def __init__(self, *, compaction=False, key=None, cc=0, cr=0, model=None):
|
|
477
|
+
self.compaction = compaction
|
|
478
|
+
self.key = key
|
|
479
|
+
self.cc = cc
|
|
480
|
+
self.cr = cr
|
|
481
|
+
self.model = model
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _iter_cache_failures(events):
|
|
485
|
+
"""The single cache-failure rule (spec §1), as a generator over a
|
|
486
|
+
document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
|
|
487
|
+
model)`` for each FLAGGED assistant event — ``index`` is the event's position
|
|
488
|
+
in ``events`` so a caller can map a flag back to its source item.
|
|
489
|
+
|
|
490
|
+
Maintains a running-max of ``cache_read`` keyed by the event's ``key``
|
|
491
|
+
(``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
|
|
492
|
+
is per-thread AND per-model because Anthropic prompt caches are model-
|
|
493
|
+
specific: a model switch within a thread legitimately starts a fresh cache
|
|
494
|
+
(cr ~ 0) and must not read as a loss, and the compound key makes that the
|
|
495
|
+
first event under its own key (no prior rm). The whole map is RESET on a
|
|
496
|
+
compaction event, since compaction legitimately invalidates the prefix and
|
|
497
|
+
the post-compaction re-prime is not a failure.
|
|
498
|
+
|
|
499
|
+
For each assistant event — cc, cr, and rm (the running-max for this key
|
|
500
|
+
BEFORE this event) — flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and
|
|
501
|
+
cr <= COLLAPSE_FRACTION*rm and cc/(cc+cr) >= RECREATE_FRACTION. The fourth
|
|
502
|
+
term keeps the rule aligned with the Evidence: it requires that most of THIS
|
|
503
|
+
event's context was freshly created, not merely that cache_read dipped. After
|
|
504
|
+
the check, ``running_max[key] = max(rm, cr)`` (a failure's small cr never
|
|
505
|
+
lowers the high-water mark; the next healthy event re-establishes it).
|
|
506
|
+
|
|
507
|
+
``lost`` is the lost-prefix basis (NOT raw cc, which over-counts when the
|
|
508
|
+
turn also writes genuinely-new cacheable content): ``min(cc, max(0,
|
|
509
|
+
rm - cr))``. Order-dependent by construction; run over document-ordered
|
|
510
|
+
events."""
|
|
511
|
+
running_max = {} # (subagent_key, model) -> high-water cache_read
|
|
512
|
+
for i, ev in enumerate(events):
|
|
513
|
+
if ev.compaction:
|
|
514
|
+
# Reset the whole map (compaction invalidates every thread's prefix
|
|
515
|
+
# in this session view; a per-key reset would need a thread tag the
|
|
516
|
+
# meta row does not reliably carry).
|
|
517
|
+
running_max.clear()
|
|
518
|
+
continue
|
|
519
|
+
cc, cr = ev.cc, ev.cr
|
|
520
|
+
rm = running_max.get(ev.key, 0)
|
|
521
|
+
total = cc + cr
|
|
522
|
+
if (rm >= _CACHE_FAILURE_CACHE_FLOOR
|
|
523
|
+
and cc >= _CACHE_FAILURE_CREATE_FLOOR
|
|
524
|
+
and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
|
|
525
|
+
and total > 0
|
|
526
|
+
and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
|
|
527
|
+
lost = min(cc, max(0, rm - cr))
|
|
528
|
+
yield (i, rm, lost, ev.model)
|
|
529
|
+
running_max[ev.key] = max(rm, cr)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _cache_failure_count_over_events(events) -> int:
|
|
533
|
+
"""Count of flagged events in a normalized ``_CFEvent`` stream (the pure
|
|
534
|
+
predicate the lightweight rebuild-count path consumes). Single source of
|
|
535
|
+
truth — shares ``_iter_cache_failures`` with the full-assembly stamp."""
|
|
536
|
+
return sum(1 for _ in _iter_cache_failures(events))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _cache_failure_events_from_items(items):
|
|
540
|
+
"""Build the document-ordered ``_CFEvent`` stream from assembled reader
|
|
541
|
+
``items`` plus a parallel ``sources`` list (the source item per event, for
|
|
542
|
+
stamping). A compaction event per ``meta``/``compaction`` item; an assistant-
|
|
543
|
+
token event per assistant item carrying a ``tokens`` dict. Items lacking a
|
|
544
|
+
``tokens`` dict (no session_entries row, or non-assistant non-compaction
|
|
545
|
+
items) emit NOTHING and so neither flag nor move the running-max — exactly
|
|
546
|
+
the pre-U1 skip behavior."""
|
|
547
|
+
events = []
|
|
548
|
+
sources = [] # parallel list: the source item per event (for stamping)
|
|
549
|
+
for it in items:
|
|
550
|
+
if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
|
|
551
|
+
events.append(_CFEvent(compaction=True))
|
|
552
|
+
sources.append(it)
|
|
553
|
+
continue
|
|
554
|
+
if it.get("kind") != "assistant":
|
|
555
|
+
continue
|
|
556
|
+
tok = it.get("tokens")
|
|
557
|
+
if not isinstance(tok, dict):
|
|
558
|
+
continue # no session_entries row -> skip
|
|
559
|
+
events.append(_CFEvent(
|
|
560
|
+
key=(it.get("subagent_key"), it.get("model")),
|
|
561
|
+
cc=tok.get("cache_creation", 0) or 0,
|
|
562
|
+
cr=tok.get("cache_read", 0) or 0,
|
|
563
|
+
model=it.get("model")))
|
|
564
|
+
sources.append(it)
|
|
565
|
+
return events, sources
|
|
566
|
+
|
|
567
|
+
|
|
377
568
|
def _stamp_cache_failures(items):
|
|
378
569
|
"""Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
|
|
379
570
|
bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
|
|
380
571
|
place; healthy turns are left WITHOUT the key (absent, not zero — matching the
|
|
381
572
|
``tokens?`` "absent, not zero" convention).
|
|
382
573
|
|
|
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):
|
|
574
|
+
Builds a document-ordered ``_CFEvent`` stream from the items (a compaction
|
|
575
|
+
event per compaction-meta, an assistant-token event per token-bearing
|
|
576
|
+
assistant turn) and runs the SAME ``_iter_cache_failures`` rule the
|
|
577
|
+
lightweight rebuild-count path uses (single source of truth — the rule lives
|
|
578
|
+
in exactly one place; U1). The payload is computed here on the lost-prefix
|
|
579
|
+
basis (NOT raw cc, which would over-count when the turn also writes
|
|
580
|
+
genuinely-new cacheable content that was never cached):
|
|
405
581
|
lost = min(cc, max(0, rm - cr))
|
|
406
582
|
tokens_recreated = lost
|
|
407
583
|
prev_cached = rm
|
|
408
584
|
est_wasted_usd = write(lost) - read(lost)
|
|
585
|
+
"""
|
|
586
|
+
events, sources = _cache_failure_events_from_items(items)
|
|
587
|
+
for idx, prev_cached, lost, model in _iter_cache_failures(events):
|
|
588
|
+
sources[idx]["cache_failure"] = {
|
|
589
|
+
"tokens_recreated": lost,
|
|
590
|
+
"prev_cached": prev_cached,
|
|
591
|
+
"est_wasted_usd": _cache_failure_wasted_usd(model, lost),
|
|
592
|
+
}
|
|
593
|
+
|
|
409
594
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
595
|
+
def _lightweight_rebuild_events(conn, session_id):
|
|
596
|
+
"""Build the document-ordered ``_CFEvent`` stream for a session WITHOUT a
|
|
597
|
+
full ``_assemble_session`` (U1, #217 S1). Skips the expensive assembly work
|
|
598
|
+
(no ``blocks_json`` body parse, no tool-result folding, no meta-classify /
|
|
599
|
+
ANSI strip, no subagent correlation) but faithfully reproduces the canonical
|
|
600
|
+
normalization the cache-failure predicate depends on:
|
|
601
|
+
|
|
602
|
+
- UUID dedup: canonical row per ``(session_id, uuid)`` = first occurrence
|
|
603
|
+
in (timestamp_utc, id) order (a replay carries the original uuid);
|
|
604
|
+
- turn grouping by ``(msg_id, req_id)``: an assistant turn — possibly split
|
|
605
|
+
across non-consecutive fragments interleaved by a tool_result — emits ONE
|
|
606
|
+
event at the FIRST fragment's position, with cost-once-per-(msg_id,req_id)
|
|
607
|
+
token attribution from the deduped session_entries row;
|
|
608
|
+
- first-prose MODEL promotion: the turn's model is the FIRST prose-bearing
|
|
609
|
+
fragment's model (the canonical anchor), matching ``_build_turn`` /
|
|
610
|
+
``_fold_fragment``; a turn with no prose keeps its seed fragment's model;
|
|
611
|
+
- seed ``source_path -> subagent_key`` derivation (``_subagent_key``);
|
|
612
|
+
- compaction-reset detection: an ``entry_type in ('meta','human')`` row
|
|
613
|
+
whose all-text body is a compaction summary (``_is_compaction_body``)
|
|
614
|
+
and which is NOT a slash-command invocation — mirroring the assembly
|
|
615
|
+
meta-classify — emits a compaction event.
|
|
616
|
+
|
|
617
|
+
The event ORDER is the turn's first-fragment document position, byte-for-byte
|
|
618
|
+
the order ``_assemble_session`` walks (which emits each turn item at its first
|
|
619
|
+
fragment, and each meta/human row at its own position). That preserves the
|
|
620
|
+
running-max walk the predicate requires.
|
|
413
621
|
"""
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
622
|
+
exists = conn.execute(
|
|
623
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
624
|
+
(session_id,)).fetchone()
|
|
625
|
+
if exists is None:
|
|
626
|
+
return []
|
|
627
|
+
# Narrow column set: enough to dedup, group turns, derive the subagent_key,
|
|
628
|
+
# detect compaction, and join the token row. No blocks_json body parse beyond
|
|
629
|
+
# the all-text compaction guard (which needs text OR the text-block join).
|
|
630
|
+
rows = conn.execute(
|
|
631
|
+
"SELECT id, uuid, entry_type, text, blocks_json, model, "
|
|
632
|
+
" msg_id, req_id, source_path "
|
|
633
|
+
"FROM conversation_messages WHERE session_id=? "
|
|
634
|
+
"ORDER BY timestamp_utc, id", (session_id,)).fetchall()
|
|
635
|
+
seen_uuid = set()
|
|
636
|
+
logical = []
|
|
637
|
+
for row in rows:
|
|
638
|
+
u = row[1]
|
|
639
|
+
if u in seen_uuid:
|
|
640
|
+
continue # UUID dedup: keep the first (canonical) occurrence
|
|
641
|
+
seen_uuid.add(u)
|
|
642
|
+
logical.append(row)
|
|
643
|
+
|
|
644
|
+
# Group assistant turns by (msg_id, req_id) at the first-fragment position;
|
|
645
|
+
# later same-key fragments fold their model (first-prose promotion) without
|
|
646
|
+
# adding a second event. A `None`-msg_id assistant row carries no turn key and
|
|
647
|
+
# no session_entries cost, so it never contributes a token event.
|
|
648
|
+
events = []
|
|
649
|
+
sources = [] # parallel: the turn key per assistant event
|
|
650
|
+
turn_index = {} # (msg_id, req_id) -> index into events
|
|
651
|
+
turn_has_prose = {} # (msg_id, req_id) -> bool (model promoted yet?)
|
|
652
|
+
turn_keys = [] # ordered list of grouped turn keys (for the token map)
|
|
653
|
+
for (rid, u, etype, text, blocks, model, msg_id, req_id,
|
|
654
|
+
source_path) in logical:
|
|
655
|
+
if etype == "assistant" and msg_id is not None:
|
|
656
|
+
key = (msg_id, req_id)
|
|
657
|
+
frag_text = (text or "").strip()
|
|
658
|
+
ev_idx = turn_index.get(key)
|
|
659
|
+
if ev_idx is None:
|
|
660
|
+
ev_idx = len(events)
|
|
661
|
+
turn_index[key] = ev_idx
|
|
662
|
+
turn_keys.append(key)
|
|
663
|
+
ev = _CFEvent(key=(_subagent_key(source_path), model),
|
|
664
|
+
model=model)
|
|
665
|
+
events.append(ev)
|
|
666
|
+
sources.append(key)
|
|
667
|
+
# The seed model holds until the first prose fragment promotes it.
|
|
668
|
+
turn_has_prose[key] = bool(frag_text)
|
|
669
|
+
else:
|
|
670
|
+
ev = events[ev_idx]
|
|
671
|
+
# First prose fragment promotes the turn's model anchor. The full
|
|
672
|
+
# path (`_build_turn`) SEEDS subagent_key from the first fragment
|
|
673
|
+
# and re-promotes ONLY the model; mirror that here by keeping the
|
|
674
|
+
# seeded `ev.key[0]` and re-promoting just the model (#218 I-A.1).
|
|
675
|
+
# subagent_key is per-file-invariant across a turn's fragments (one
|
|
676
|
+
# (msg_id,req_id) = one file), so this matches in all real data and
|
|
677
|
+
# is exact structural parity for the engineered cross-file split.
|
|
678
|
+
if not turn_has_prose.get(key) and frag_text:
|
|
679
|
+
ev.key = (ev.key[0], model)
|
|
680
|
+
ev.model = model
|
|
681
|
+
turn_has_prose[key] = True
|
|
682
|
+
elif etype in ("meta", "human"):
|
|
683
|
+
# Mirror the assembly meta-classify's compaction branch: an all-text
|
|
684
|
+
# body that is a compaction summary AND is not a slash-command
|
|
685
|
+
# invocation. A command invocation (#188) is promoted to a human turn
|
|
686
|
+
# BEFORE meta-classify, so a body carrying <command-args> never reads
|
|
687
|
+
# as compaction; compaction bodies have no <command-args>, so in
|
|
688
|
+
# practice the invocation check is a faithful belt-and-suspenders.
|
|
689
|
+
try:
|
|
690
|
+
parsed_blocks = _json.loads(blocks or "[]")
|
|
691
|
+
except (ValueError, TypeError):
|
|
692
|
+
parsed_blocks = []
|
|
693
|
+
body = text or _join_text_blocks(parsed_blocks)
|
|
694
|
+
all_text = all(b.get("kind") == "text"
|
|
695
|
+
for b in (parsed_blocks or []))
|
|
696
|
+
if (all_text and _is_compaction_body(body)
|
|
697
|
+
and _extract_command_invocation(parsed_blocks, body) is None):
|
|
698
|
+
events.append(_CFEvent(compaction=True))
|
|
699
|
+
sources.append(None)
|
|
700
|
+
# tool_result rows and null-msg_id assistant rows carry no token event.
|
|
701
|
+
|
|
702
|
+
# Cost-once token attribution: ONE deduped session_entries row per
|
|
703
|
+
# (msg_id, req_id) — the same map the full path stamps from. An assistant
|
|
704
|
+
# turn key absent from session_entries carries NO tokens, so its event is
|
|
705
|
+
# dropped (neither flags nor moves the running-max) — parity with
|
|
706
|
+
# _cache_failure_events_from_items's `tokens` skip.
|
|
707
|
+
usage = _turn_usage_map(conn, turn_keys)
|
|
708
|
+
filtered = []
|
|
709
|
+
si = 0
|
|
710
|
+
for ev in events:
|
|
711
|
+
if ev.compaction:
|
|
712
|
+
filtered.append(ev)
|
|
713
|
+
si += 1
|
|
425
714
|
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)
|
|
715
|
+
key = sources[si]
|
|
716
|
+
si += 1
|
|
717
|
+
tok = usage.get(key)
|
|
718
|
+
if tok is None:
|
|
719
|
+
continue # no session_entries row -> skip (don't move max)
|
|
720
|
+
ev.cc = tok.get("cache_creation", 0) or 0
|
|
721
|
+
ev.cr = tok.get("cache_read", 0) or 0
|
|
722
|
+
filtered.append(ev)
|
|
723
|
+
return filtered
|
|
446
724
|
|
|
447
725
|
|
|
448
726
|
def session_cache_rebuild_count(conn, session_id) -> int:
|
|
449
727
|
"""Per-session count of cache-rebuild (cache-failure) turns.
|
|
450
728
|
|
|
451
|
-
Single source of truth:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
729
|
+
Single source of truth: builds a document-ordered cache-failure event stream
|
|
730
|
+
via the LIGHTWEIGHT builder (``_lightweight_rebuild_events`` — no full
|
|
731
|
+
``_assemble_session``, so this stays cheap on the flock-held rollup path; U1,
|
|
732
|
+
#217 S1) and runs it through the SAME ``_cache_failure_count_over_events``
|
|
733
|
+
predicate the reader's full assembly stamps with. The light builder
|
|
734
|
+
reproduces the canonical normalization (UUID dedup, turn grouping, first-prose
|
|
735
|
+
model promotion, seed subagent_key, cost-once token attribution, compaction
|
|
736
|
+
reset), so the count is byte-identical to the full-assembly count (guarded by
|
|
737
|
+
``test_session_cache_rebuild_count_lightweight_matches_full_assembly``).
|
|
738
|
+
|
|
739
|
+
/outline omits stats.cache_failures when the count is 0, so "no flagged
|
|
740
|
+
items" -> 0 (byte-identical to outline). Stored on the conversation_sessions
|
|
741
|
+
rollup by _recompute_conversation_sessions; filtered by
|
|
742
|
+
list_conversations(rebuild_min=...).
|
|
463
743
|
"""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
744
|
+
return _cache_failure_count_over_events(
|
|
745
|
+
_lightweight_rebuild_events(conn, session_id))
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _turn_costs_for_keys(conn, keys):
|
|
749
|
+
"""{(msg_id, req_id): cost_usd} for the given turn keys, joined ONCE to the
|
|
750
|
+
deduped session_entries row per (msg_id, req_id) (#217 S1 / U7b — the SINGLE
|
|
751
|
+
cost-attribution chokepoint both ``_turn_cost_map`` and ``_session_cost_map``
|
|
752
|
+
delegate to, so the cost-once-per-turn rule is never reimplemented).
|
|
753
|
+
|
|
754
|
+
NULL-filters the keys, chunks the OR-of-pairs to stay well under SQLite's
|
|
755
|
+
variable limit, and computes each cost via the shared pricing helper
|
|
756
|
+
(honoring a vendor ``cost_usd_raw`` override). Keys absent from
|
|
757
|
+
session_entries (e.g. ``<synthetic>`` walker-skipped rows) are simply not
|
|
758
|
+
present in the result -> cost 0 by omission. Parity guard:
|
|
759
|
+
``test_session_and_turn_cost_map_share_helper_parity``."""
|
|
760
|
+
costs = {}
|
|
761
|
+
norm = [(m, r) for (m, r) in keys if m is not None and r is not None]
|
|
762
|
+
if not norm:
|
|
763
|
+
return costs
|
|
764
|
+
for i in range(0, len(norm), 400):
|
|
765
|
+
chunk = norm[i:i + 400]
|
|
766
|
+
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
767
|
+
params = [v for pair in chunk for v in pair]
|
|
768
|
+
sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
|
|
769
|
+
"cache_create_tokens, cache_read_tokens, cost_usd_raw "
|
|
770
|
+
"FROM session_entries WHERE " + cond)
|
|
771
|
+
for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
|
|
772
|
+
costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
|
|
773
|
+
return costs
|
|
470
774
|
|
|
471
775
|
|
|
472
776
|
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
|
|
777
|
+
"""{session_id: total_cost_usd} for the given sessions. Resolves each
|
|
778
|
+
session's distinct (msg_id, req_id) turn keys, then attributes cost via the
|
|
779
|
+
shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
|
|
780
|
+
row), summing into the owning session. (msg_id, req_id) is globally unique in
|
|
781
|
+
session_entries and maps to exactly one session_id, so per-session sums are
|
|
782
|
+
clean and a turn replayed across files contributes once."""
|
|
478
783
|
costs = {sid: 0.0 for sid in session_ids}
|
|
479
784
|
if not session_ids:
|
|
480
785
|
return costs
|
|
481
786
|
placeholders = ",".join("?" for _ in session_ids)
|
|
482
|
-
|
|
483
|
-
"SELECT
|
|
484
|
-
"
|
|
485
|
-
"
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
787
|
+
pairs = conn.execute(
|
|
788
|
+
"SELECT DISTINCT session_id, msg_id, req_id "
|
|
789
|
+
"FROM conversation_messages "
|
|
790
|
+
"WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
|
|
791
|
+
% placeholders,
|
|
792
|
+
list(session_ids),
|
|
793
|
+
).fetchall()
|
|
794
|
+
if not pairs:
|
|
795
|
+
return costs
|
|
796
|
+
key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
|
|
797
|
+
for sid, m, r in pairs:
|
|
798
|
+
costs[sid] = costs.get(sid, 0.0) + key_cost.get((m, r), 0.0)
|
|
493
799
|
return costs
|
|
494
800
|
|
|
495
801
|
|
|
@@ -513,23 +819,31 @@ def _session_latest_meta_map(conn, session_ids):
|
|
|
513
819
|
"""{session_id: (cwd, git_branch)} using the most-recent NON-NULL value per
|
|
514
820
|
column — the SAME posture as get_conversation's _latest, so the rail and the
|
|
515
821
|
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
|
-
|
|
822
|
+
MAX() picks the lexical max, not the latest). The latest non-null cwd and the
|
|
823
|
+
latest non-null git_branch may land on DIFFERENT rows (the newest row can
|
|
824
|
+
carry one but not the other), so each is resolved independently.
|
|
825
|
+
|
|
826
|
+
#217 S1 / U7b: consolidated from the prior TWIN correlated subqueries into a
|
|
827
|
+
SINGLE windowed scan — two ``FIRST_VALUE`` window functions over one
|
|
828
|
+
partition pass per column, each ordered ``(<col> IS NULL), timestamp_utc DESC,
|
|
829
|
+
id DESC`` so the most-recent non-null value sorts first (and an all-null
|
|
830
|
+
session yields NULL, exactly as the LIMIT-1 subquery did). Guarded by
|
|
831
|
+
``test_session_latest_meta_map_parity_*`` against the prior semantics; if the
|
|
832
|
+
window form could not match it, the old correlated SQL would be kept (it
|
|
833
|
+
matched cleanly, so the consolidation lands)."""
|
|
519
834
|
meta = {sid: (None, None) for sid in session_ids}
|
|
520
835
|
if not session_ids:
|
|
521
836
|
return meta
|
|
522
837
|
placeholders = ",".join("?" for _ in session_ids)
|
|
523
838
|
sql = (
|
|
524
|
-
"SELECT
|
|
525
|
-
" (
|
|
526
|
-
"
|
|
527
|
-
"
|
|
528
|
-
" (
|
|
529
|
-
"
|
|
530
|
-
"
|
|
531
|
-
"FROM
|
|
532
|
-
" WHERE session_id IN (%s)) s" % placeholders
|
|
839
|
+
"SELECT DISTINCT session_id, "
|
|
840
|
+
" FIRST_VALUE(cwd) OVER ("
|
|
841
|
+
" PARTITION BY session_id "
|
|
842
|
+
" ORDER BY (cwd IS NULL), timestamp_utc DESC, id DESC), "
|
|
843
|
+
" FIRST_VALUE(git_branch) OVER ("
|
|
844
|
+
" PARTITION BY session_id "
|
|
845
|
+
" ORDER BY (git_branch IS NULL), timestamp_utc DESC, id DESC) "
|
|
846
|
+
"FROM conversation_messages WHERE session_id IN (%s)" % placeholders
|
|
533
847
|
)
|
|
534
848
|
for sid, cwd, branch in conn.execute(sql, list(session_ids)):
|
|
535
849
|
meta[sid] = (cwd, branch)
|
|
@@ -561,16 +875,38 @@ def session_source_paths(conn, session_id):
|
|
|
561
875
|
_SORTS = {
|
|
562
876
|
"recent": "last_activity_utc DESC, session_id DESC",
|
|
563
877
|
"oldest": "started_utc ASC, session_id ASC",
|
|
878
|
+
# #217 S4 / I-2.1: stored-column rollup sorts. Each tie-broken by
|
|
879
|
+
# session_id for stable pagination. ``project`` uses an explicit CASE so a
|
|
880
|
+
# NULL label (not-yet-filled row) or '' (the no-cwd _project_label(None)
|
|
881
|
+
# sentinel) sorts LAST — a plain COLLATE NOCASE ASC would put them first.
|
|
882
|
+
"cost": "cost_usd DESC, session_id DESC",
|
|
883
|
+
"messages": "msg_count DESC, session_id DESC",
|
|
884
|
+
"project": ("CASE WHEN project_label IS NULL OR project_label = '' "
|
|
885
|
+
"THEN 1 ELSE 0 END, project_label COLLATE NOCASE ASC, "
|
|
886
|
+
"session_id ASC"),
|
|
564
887
|
}
|
|
565
888
|
|
|
566
889
|
# The matching ORDER BY for the live GROUP BY fallback: the rollup's
|
|
567
890
|
# last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
|
|
568
891
|
# so the two branches paginate in byte-identical order (the load-bearing
|
|
569
892
|
# invariant). Keep this table in lockstep with _SORTS.
|
|
893
|
+
#
|
|
894
|
+
# ``messages`` has FULL live parity (COUNT(*) DESC over the raw messages == the
|
|
895
|
+
# rollup's stored msg_count). ``cost`` and ``project`` have NO raw-message
|
|
896
|
+
# column, so they are INTENTIONALLY ABSENT here — list_conversations resolves
|
|
897
|
+
# them to the ``recent`` live ordering and flags page.sort_degraded (a brief,
|
|
898
|
+
# self-correcting non-authoritative window). #217 S4 / I-2.1.
|
|
570
899
|
_SORTS_LIVE = {
|
|
571
900
|
"recent": "MAX(timestamp_utc) DESC, session_id DESC",
|
|
572
901
|
"oldest": "MIN(timestamp_utc) ASC, session_id ASC",
|
|
902
|
+
"messages": "COUNT(*) DESC, session_id DESC",
|
|
573
903
|
}
|
|
904
|
+
# The rollup-only sorts — expressible on the rollup branch but NOT in the live
|
|
905
|
+
# GROUP BY fallback. A request for one of these under the live branch falls back
|
|
906
|
+
# to the recent ordering AND sets page.sort_degraded (keyed to the REQUESTED
|
|
907
|
+
# sort, never the effective order — an unknown sort also falls back to recent
|
|
908
|
+
# but must stay byte-stable, so it is excluded here).
|
|
909
|
+
_DEGRADABLE_SORTS = frozenset(("cost", "project"))
|
|
574
910
|
|
|
575
911
|
|
|
576
912
|
def _rollup_authoritative(conn) -> bool:
|
|
@@ -663,6 +999,47 @@ def _live_having(filters):
|
|
|
663
999
|
return (" HAVING " + " AND ".join(having)) if having else "", params
|
|
664
1000
|
|
|
665
1001
|
|
|
1002
|
+
def _resolve_search_session_filter(conn, filters):
|
|
1003
|
+
"""Resolve the filtered-search session-scope restriction (#217 S2 /
|
|
1004
|
+
Filtered-search). Returns ``(subquery_sql, params, degraded)`` where
|
|
1005
|
+
``subquery_sql`` is a complete ``"SELECT session_id FROM ... "`` the caller
|
|
1006
|
+
wraps as ``" AND <col> IN (<subquery_sql>)"``, or ``""`` (no restriction) when
|
|
1007
|
+
no axis is set.
|
|
1008
|
+
|
|
1009
|
+
Mirrors the browse dual-branch parity (``list_conversations``):
|
|
1010
|
+
|
|
1011
|
+
* Rollup AUTHORITATIVE (``conversation_sessions_backfill_pending`` clear):
|
|
1012
|
+
every axis is a stored column on the rollup, so restrict to
|
|
1013
|
+
``SELECT session_id FROM conversation_sessions <_rollup_where(filters)>``
|
|
1014
|
+
(the SAME ``_rollup_where`` the browse rail uses, verbatim). ``degraded``
|
|
1015
|
+
is False.
|
|
1016
|
+
* Rollup PENDING (live fallback) AND a rollup-only axis
|
|
1017
|
+
(project/cost/rebuild) is requested (P1-5): drop the rollup-only axes,
|
|
1018
|
+
set ``degraded`` True, and express ONLY the date axis via a LIVE session
|
|
1019
|
+
prefilter over ``conversation_messages`` keyed on
|
|
1020
|
+
``MAX(timestamp_utc)`` — the SAME session-activity semantics as
|
|
1021
|
+
``_live_having``/``_list_session_rows_live``. We restrict by session
|
|
1022
|
+
ACTIVITY, never by a matched row's own timestamp (a session whose match is
|
|
1023
|
+
old but whose activity is in-window must stay).
|
|
1024
|
+
* Rollup pending but ONLY date axis requested: the rollup is not needed
|
|
1025
|
+
(date is expressible live), so use the live prefilter and ``degraded``
|
|
1026
|
+
stays False (no rollup-only axis was dropped — byte-stable with browse).
|
|
1027
|
+
"""
|
|
1028
|
+
any_axis = any(filters[k] is not None for k in _FILTER_KEYS)
|
|
1029
|
+
if not any_axis:
|
|
1030
|
+
return "", [], False
|
|
1031
|
+
if _rollup_authoritative(conn):
|
|
1032
|
+
where, params = _rollup_where(filters)
|
|
1033
|
+
return "SELECT session_id FROM conversation_sessions" + where, params, False
|
|
1034
|
+
# Live fallback: only the date axis is expressible over raw messages.
|
|
1035
|
+
degraded = any(filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
|
|
1036
|
+
having, hparams = _live_having(filters)
|
|
1037
|
+
sub = (
|
|
1038
|
+
"SELECT session_id FROM conversation_messages "
|
|
1039
|
+
"WHERE session_id IS NOT NULL GROUP BY session_id" + having)
|
|
1040
|
+
return sub, hparams, degraded
|
|
1041
|
+
|
|
1042
|
+
|
|
666
1043
|
def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
|
|
667
1044
|
"""FAST path: read the pre-aggregated rail rows straight from the
|
|
668
1045
|
conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
|
|
@@ -754,6 +1131,7 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
754
1131
|
"rebuild_min": rebuild_min,
|
|
755
1132
|
}
|
|
756
1133
|
degraded = False
|
|
1134
|
+
sort_degraded = False
|
|
757
1135
|
if _rollup_authoritative(conn):
|
|
758
1136
|
order = _SORTS.get(sort, _SORTS["recent"])
|
|
759
1137
|
rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
|
|
@@ -761,6 +1139,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
761
1139
|
order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
|
|
762
1140
|
degraded = any(
|
|
763
1141
|
filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
|
|
1142
|
+
# sort_degraded is keyed to the REQUESTED sort, not the effective order:
|
|
1143
|
+
# an unknown sort ALSO falls back to recent here (via _SORTS_LIVE.get),
|
|
1144
|
+
# but must stay byte-stable, so only the known rollup-only sorts flag it.
|
|
1145
|
+
sort_degraded = sort in _DEGRADABLE_SORTS
|
|
764
1146
|
rows = _list_session_rows_live(conn, order, limit, offset, filters)
|
|
765
1147
|
has_more = len(rows) > limit
|
|
766
1148
|
rows = rows[:limit]
|
|
@@ -792,6 +1174,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
792
1174
|
# The rail surfaces this: project/cost/rebuild filters apply once the
|
|
793
1175
|
# rollup finishes indexing; only the date axis held this page.
|
|
794
1176
|
page["filter_degraded"] = True
|
|
1177
|
+
if sort_degraded:
|
|
1178
|
+
# The rail surfaces this: cost/project ordering becomes available once
|
|
1179
|
+
# the rollup finishes indexing; this page fell back to recent order.
|
|
1180
|
+
page["sort_degraded"] = True
|
|
795
1181
|
return {
|
|
796
1182
|
"conversations": conversations,
|
|
797
1183
|
"page": page,
|
|
@@ -801,22 +1187,13 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
801
1187
|
def _turn_cost_map(conn, turn_keys):
|
|
802
1188
|
"""{(msg_id, req_id): cost_usd} for the given non-null turn keys, joined ONCE
|
|
803
1189
|
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
|
|
1190
|
+
<synthetic> walker-skipped rows) are simply not present → cost 0 by omission.
|
|
1191
|
+
|
|
1192
|
+
Thin wrapper over the shared ``_turn_costs_for_keys`` chokepoint (#217 S1 /
|
|
1193
|
+
U7b) — the search path's ``_attach_costs`` still consumes this name + its
|
|
1194
|
+
float-valued contract, so the public signature is unchanged while the body
|
|
1195
|
+
is single-sourced with ``_session_cost_map``."""
|
|
1196
|
+
return _turn_costs_for_keys(conn, turn_keys)
|
|
820
1197
|
|
|
821
1198
|
|
|
822
1199
|
def _turn_usage_map(conn, turn_keys):
|
|
@@ -846,6 +1223,13 @@ def _turn_usage_map(conn, turn_keys):
|
|
|
846
1223
|
return usage
|
|
847
1224
|
|
|
848
1225
|
|
|
1226
|
+
# #225: the two tool names that spawn a subagent. `Agent` = modern, `Task` =
|
|
1227
|
+
# older. Exact-match (does NOT match the TaskCreate/TaskUpdate/TaskList checklist
|
|
1228
|
+
# family). A spawn is identified by this name even when it carried no
|
|
1229
|
+
# subagent_type (the default general-purpose dispatch).
|
|
1230
|
+
_SPAWN_TOOL_NAMES = ("Agent", "Task")
|
|
1231
|
+
|
|
1232
|
+
|
|
849
1233
|
def _assemble_session(conn, session_id):
|
|
850
1234
|
"""Shared assembly for get_conversation / get_conversation_outline (#177 S5).
|
|
851
1235
|
|
|
@@ -943,12 +1327,15 @@ def _assemble_session(conn, session_id):
|
|
|
943
1327
|
# tool_call/tool_result block shapes are unchanged — the only new output is
|
|
944
1328
|
# the top-level subagent_meta map (no undocumented block keys leak). Join is
|
|
945
1329
|
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
946
|
-
spawn_kind = {} # tool_use id -> subagent_type
|
|
947
|
-
|
|
1330
|
+
spawn_kind = {} # tool_use id -> subagent_type (kind), only when declared
|
|
1331
|
+
spawn_ids = {} # tool_use id -> None: ordered set of Agent/Task spawn ids
|
|
1332
|
+
# (#225, typed OR untyped). spawn_kind ⊆ spawn_ids. Dict
|
|
1333
|
+
# (NOT a set) so iteration stays document-ordered.
|
|
1334
|
+
spawn_desc = {} # tool_use id -> spawning Agent/Task description (#193/#225)
|
|
948
1335
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
949
1336
|
nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
|
|
950
1337
|
# results with no structured agent_id, resolved after
|
|
951
|
-
#
|
|
1338
|
+
# the spawn maps are complete (text still present pre-fold)
|
|
952
1339
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
953
1340
|
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
954
1341
|
web_search_link = {} # tool_use id -> web_search payload (#177 S4)
|
|
@@ -959,15 +1346,23 @@ def _assemble_session(conn, session_id):
|
|
|
959
1346
|
k = b.get("kind")
|
|
960
1347
|
if k == "tool_use":
|
|
961
1348
|
st = b.pop("subagent_type", None)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1349
|
+
_id = b.get("id")
|
|
1350
|
+
# #225: a spawn is an Agent/Task tool_use. Identify it by tool
|
|
1351
|
+
# NAME (so an untyped general-purpose spawn — no subagent_type —
|
|
1352
|
+
# still counts) OR by a recorded subagent_type. The name gate is
|
|
1353
|
+
# what keeps a Bash/Read `description` (and a coincidental
|
|
1354
|
+
# structured agent_id, #218 I-B.5) from ever being read as a
|
|
1355
|
+
# spawn.
|
|
1356
|
+
if _id is not None and (st or b.get("name") in _SPAWN_TOOL_NAMES):
|
|
1357
|
+
spawn_ids[_id] = None
|
|
1358
|
+
if st:
|
|
1359
|
+
spawn_kind[_id] = st # #166: declared kind
|
|
1360
|
+
# #193/#225: harvest the spawn description from the bounded
|
|
1361
|
+
# input for ANY spawn (typed or untyped); name-gated above.
|
|
967
1362
|
_inp = b.get("input")
|
|
968
1363
|
_d = _inp.get("description") if isinstance(_inp, dict) else None
|
|
969
1364
|
if isinstance(_d, str) and _d.strip():
|
|
970
|
-
spawn_desc[
|
|
1365
|
+
spawn_desc[_id] = _d
|
|
971
1366
|
elif k == "tool_result":
|
|
972
1367
|
aid = b.pop("agent_id", None)
|
|
973
1368
|
meta = b.pop("subagent_meta", None)
|
|
@@ -975,7 +1370,7 @@ def _assemble_session(conn, session_id):
|
|
|
975
1370
|
agent_link[b["tool_use_id"]] = (aid, meta or {})
|
|
976
1371
|
elif b.get("tool_use_id") is not None:
|
|
977
1372
|
# §4 1a candidate — a tool_result with no structured agentId.
|
|
978
|
-
# Resolved AFTER
|
|
1373
|
+
# Resolved AFTER the spawn maps are complete (text still present,
|
|
979
1374
|
# pre-Phase-2-fold). The block's `text` holds the result body.
|
|
980
1375
|
nested_candidates.append((b["tool_use_id"], b))
|
|
981
1376
|
ans = b.pop("ask_answers", None) # #177 S2
|
|
@@ -996,22 +1391,33 @@ def _assemble_session(conn, session_id):
|
|
|
996
1391
|
tlist_ = b.pop("task_list", None)
|
|
997
1392
|
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
998
1393
|
task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
|
|
1394
|
+
# §4 symmetry (#218 I-B.5 / #225): drop any agent_link entry whose owning
|
|
1395
|
+
# tool_use is NOT a spawn — an ordinary tool result that coincidentally
|
|
1396
|
+
# carries a structured agent_id (e.g. a Read). Gated on spawn_ids (every
|
|
1397
|
+
# Agent/Task spawn, typed OR untyped) rather than the old spawn_kind, which
|
|
1398
|
+
# wrongly excluded untyped general-purpose spawns. A structured agent_id on a
|
|
1399
|
+
# real spawn is authoritative; the heuristic nested text-parse below stays
|
|
1400
|
+
# spawn-gated all the same.
|
|
1401
|
+
agent_link = {t: v for t, v in agent_link.items() if t in spawn_ids}
|
|
999
1402
|
# §4 1a — nested grandchild results: gate the text-parse on the owning
|
|
1000
|
-
# tool_use being a spawn (in
|
|
1403
|
+
# tool_use being a spawn (in spawn_ids), so an ordinary tool result that
|
|
1001
1404
|
# merely mentions "agentId" never matches.
|
|
1002
1405
|
for _tuid, _block in nested_candidates:
|
|
1003
|
-
if _tuid in
|
|
1406
|
+
if _tuid in spawn_ids and _tuid not in agent_link:
|
|
1004
1407
|
parsed = _parse_nested_agent_result(_block.get("text"))
|
|
1005
1408
|
if parsed is not None:
|
|
1006
1409
|
agent_link[_tuid] = parsed
|
|
1007
1410
|
subagent_meta = {}
|
|
1008
|
-
for _tuid
|
|
1411
|
+
for _tuid in spawn_ids: # document order (ordered dict)
|
|
1009
1412
|
_link = agent_link.get(_tuid)
|
|
1010
1413
|
if _link is None:
|
|
1011
1414
|
continue # spawn with no (yet) result -> title-only
|
|
1012
1415
|
_aid, _raw = _link
|
|
1013
|
-
|
|
1014
|
-
|
|
1416
|
+
# #225: kind defaults to "general-purpose" for an untyped Agent/Task
|
|
1417
|
+
# spawn (subagent_type omitted at dispatch ⟹ general-purpose; 0
|
|
1418
|
+
# counterexamples across 3085 real sidecars).
|
|
1419
|
+
_entry = {"kind": spawn_kind.get(_tuid, "general-purpose")}
|
|
1420
|
+
if spawn_desc.get(_tuid): # #193/#225: spawn description
|
|
1015
1421
|
_entry["description"] = spawn_desc[_tuid]
|
|
1016
1422
|
for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
|
|
1017
1423
|
if _raw.get(_f) is not None:
|
|
@@ -1285,13 +1691,27 @@ def _assemble_session(conn, session_id):
|
|
|
1285
1691
|
"subagent_meta": subagent_meta, "header_cost": header_cost}
|
|
1286
1692
|
|
|
1287
1693
|
|
|
1288
|
-
def get_conversation(conn, session_id, *, after=None,
|
|
1694
|
+
def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
|
|
1695
|
+
limit=500):
|
|
1289
1696
|
"""Reader payload for one session (spec §3.2). Returns None for an unknown
|
|
1290
1697
|
session. Dedups logical messages by (session_id, uuid) (canonical = earliest
|
|
1291
1698
|
timestamp), groups assistant fragments into turn items by (msg_id, req_id),
|
|
1292
1699
|
joins cost once, anchors a turn on its prose-bearing fragment, and exposes
|
|
1293
1700
|
every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
|
|
1294
|
-
id); ~500 items/page.
|
|
1701
|
+
id); ~500 items/page.
|
|
1702
|
+
|
|
1703
|
+
Three mutually-exclusive cursor modes over the fully-assembled ascending
|
|
1704
|
+
`items` list (#217 S2 / U4): `after=X` pages forward (existing), `before=X`
|
|
1705
|
+
pages backward (returns the `limit` items immediately before X), and
|
|
1706
|
+
`tail=1` opens at the bottom (returns the last page in one request). More
|
|
1707
|
+
than one supplied raises ValueError (the handler maps it to 400). The
|
|
1708
|
+
boundary keys are mode-uniform (`has_more = end < N`, `has_prev = start > 0`)
|
|
1709
|
+
so a short `before` head-page still reports `has_more` (P2-8); the existing
|
|
1710
|
+
`after`/no-cursor responses stay byte-stable except the two additive
|
|
1711
|
+
`prev_before`/`has_prev` keys (for them `start + limit < N == end < N`, under
|
|
1712
|
+
`end = min(start+limit, N)`)."""
|
|
1713
|
+
if sum(1 for x in (after is not None, before is not None, bool(tail)) if x) > 1:
|
|
1714
|
+
raise ValueError("after/before/tail are mutually exclusive")
|
|
1295
1715
|
limit = max(1, min(int(limit), 1000))
|
|
1296
1716
|
asm = _assemble_session(conn, session_id)
|
|
1297
1717
|
if asm is None:
|
|
@@ -1322,34 +1742,60 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1322
1742
|
_la = items[-1]["anchor"]
|
|
1323
1743
|
last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
|
|
1324
1744
|
|
|
1325
|
-
# Cursor pagination over the item list (anchored to each item's canonical
|
|
1326
|
-
#
|
|
1327
|
-
#
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1745
|
+
# Cursor pagination over the item list (anchored to each item's canonical
|
|
1746
|
+
# id). Resolve `start`/`end` per mode, then `page = items[start:end]` and
|
|
1747
|
+
# compute the boundary keys UNIFORMLY (#217 S2 / U4 — P2-8). A non-None
|
|
1748
|
+
# `after`/`before` that matches no item's anchor (stale/deleted cursor)
|
|
1749
|
+
# yields an EMPTY page — never silently re-serves the head/tail (M1).
|
|
1750
|
+
N = len(items)
|
|
1751
|
+
|
|
1752
|
+
def _idx(cur):
|
|
1331
1753
|
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
|
-
|
|
1754
|
+
if str(it["anchor"]["id"]) == str(cur):
|
|
1755
|
+
return k
|
|
1756
|
+
return None
|
|
1757
|
+
|
|
1758
|
+
def _stale_empty_page():
|
|
1759
|
+
return {
|
|
1760
|
+
"session_id": session_id,
|
|
1761
|
+
"title": _title,
|
|
1762
|
+
"project_label": _pl,
|
|
1763
|
+
"git_branch": _latest(logical, 11),
|
|
1764
|
+
"started_utc": logical[0][2],
|
|
1765
|
+
"last_activity_utc": logical[-1][2],
|
|
1766
|
+
"cost_usd": header_cost,
|
|
1767
|
+
"models": sorted({r[6] for r in logical if r[6]}),
|
|
1768
|
+
"last_anchor": last_anchor,
|
|
1769
|
+
"items": [],
|
|
1770
|
+
"subagent_meta": subagent_meta,
|
|
1771
|
+
"page": {"next_after": None, "has_more": False,
|
|
1772
|
+
"prev_before": None, "has_prev": False},
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if tail:
|
|
1776
|
+
end = N
|
|
1777
|
+
start = max(0, N - limit)
|
|
1778
|
+
elif before is not None:
|
|
1779
|
+
b = _idx(before)
|
|
1780
|
+
if b is None: # stale cursor → empty page (M1)
|
|
1781
|
+
return _stale_empty_page()
|
|
1782
|
+
end = b
|
|
1783
|
+
start = max(0, end - limit)
|
|
1784
|
+
elif after is not None:
|
|
1785
|
+
a = _idx(after)
|
|
1786
|
+
if a is None: # stale cursor → empty page (M1)
|
|
1787
|
+
return _stale_empty_page()
|
|
1788
|
+
start = a + 1
|
|
1789
|
+
end = min(start + limit, N)
|
|
1790
|
+
else:
|
|
1791
|
+
start = 0
|
|
1792
|
+
end = min(limit, N)
|
|
1793
|
+
|
|
1794
|
+
page = items[start:end]
|
|
1795
|
+
has_more = end < N
|
|
1796
|
+
has_prev = start > 0
|
|
1352
1797
|
next_after = page[-1]["anchor"]["id"] if (page and has_more) else None
|
|
1798
|
+
prev_before = page[0]["anchor"]["id"] if (page and has_prev) else None
|
|
1353
1799
|
|
|
1354
1800
|
# Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
|
|
1355
1801
|
# the dict literals are built session-agnostic, so fill it here where the
|
|
@@ -1381,7 +1827,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1381
1827
|
"last_anchor": last_anchor,
|
|
1382
1828
|
"items": page,
|
|
1383
1829
|
"subagent_meta": subagent_meta,
|
|
1384
|
-
"page": {"next_after": next_after, "has_more": has_more
|
|
1830
|
+
"page": {"next_after": next_after, "has_more": has_more,
|
|
1831
|
+
"prev_before": prev_before, "has_prev": has_prev},
|
|
1385
1832
|
}
|
|
1386
1833
|
|
|
1387
1834
|
|
|
@@ -1432,6 +1879,23 @@ def get_conversation_outline(conn, session_id):
|
|
|
1432
1879
|
cf_wasted = 0.0
|
|
1433
1880
|
cf_rebuilds = [] # per-rebuild list (worst-first), spec §1
|
|
1434
1881
|
cache_saved = 0.0 # session cache-value-saved, spec §1
|
|
1882
|
+
# #217 S3 E6(a) — per-subagent cost, summed COST-ONCE over the assembled
|
|
1883
|
+
# items (each item already carries the cost-once-per-turn `cost_usd`, so a
|
|
1884
|
+
# plain per-subagent_key sum never double-counts across folded fragments —
|
|
1885
|
+
# the same discipline `cache_saved` above uses). DISPLAY-ONLY (never a
|
|
1886
|
+
# reconciled / budget / snapshot figure, like `cache_saved_usd`). Emitted as
|
|
1887
|
+
# a SEPARATE top-level `subagent_costs` map — NOT inside `subagent_meta` —
|
|
1888
|
+
# so the outline↔reader `subagent_meta` parity assert stays byte-for-byte
|
|
1889
|
+
# unchanged AND every bucket is covered, including a subagent whose
|
|
1890
|
+
# `subagent_meta` is absent (the s7 case: deriveOutline emits a row for it,
|
|
1891
|
+
# so its cost must be present to render). Every non-null subagent_key bucket
|
|
1892
|
+
# seeds at 0.0 so a zero-cost bucket still appears.
|
|
1893
|
+
subagent_costs = {} # subagent_key -> cost (cost-once)
|
|
1894
|
+
for it in items:
|
|
1895
|
+
sk = it.get("subagent_key")
|
|
1896
|
+
if sk is not None:
|
|
1897
|
+
subagent_costs.setdefault(sk, 0.0)
|
|
1898
|
+
subagent_costs[sk] += it.get("cost_usd") or 0.0
|
|
1435
1899
|
for it in items:
|
|
1436
1900
|
kind = it["kind"]
|
|
1437
1901
|
turn_counts["total"] += 1
|
|
@@ -1519,10 +1983,64 @@ def get_conversation_outline(conn, session_id):
|
|
|
1519
1983
|
stats["cache_saved_usd"] = cache_saved
|
|
1520
1984
|
return {"session_id": session_id,
|
|
1521
1985
|
"subagent_meta": asm["subagent_meta"],
|
|
1986
|
+
# #217 S3 E6(a) — display-only per-subagent cost (cost-once). 6dp,
|
|
1987
|
+
# matching the per-item / header cost display precision.
|
|
1988
|
+
"subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
|
|
1522
1989
|
"stats": stats,
|
|
1990
|
+
# #217 S5 F2 — whole-session files-touched aggregation (always
|
|
1991
|
+
# present, possibly []; additive — existing consumers are byte-stable).
|
|
1992
|
+
"files": _collect_files(items),
|
|
1993
|
+
# #217 S5 F7 — main-thread task-completion (always present; None when
|
|
1994
|
+
# the main thread has no tasks; additive — byte-stable for consumers).
|
|
1995
|
+
"task_completion": _task_completion(items),
|
|
1523
1996
|
"turns": turns}
|
|
1524
1997
|
|
|
1525
1998
|
|
|
1999
|
+
def get_conversation_prompts(conn, session_id):
|
|
2000
|
+
"""``{session_id, prompts:[{uuid,text}]}`` — ordered main-thread human
|
|
2001
|
+
prompts with full text (#217 S7, F10 — the session-comparison spine).
|
|
2002
|
+
|
|
2003
|
+
Reuses the SAME ``_assemble_session`` pass as ``get_conversation_outline``
|
|
2004
|
+
(one grouping/dedup pipeline, never a parallel one) and the SAME main-thread
|
|
2005
|
+
predicate as the recipe/prompts export (``extract_prompt_entries`` →
|
|
2006
|
+
``_human_prompts``), so the spine aligns 1:1 with the outline's human turns
|
|
2007
|
+
and can never drift from the export. Returns ``None`` for an unknown session
|
|
2008
|
+
(the handler's 404 sentinel) — the same contract ``get_conversation_outline``
|
|
2009
|
+
uses (``asm is None``).
|
|
2010
|
+
"""
|
|
2011
|
+
asm = _assemble_session(conn, session_id)
|
|
2012
|
+
if asm is None:
|
|
2013
|
+
return None
|
|
2014
|
+
from _lib_conversation_export import extract_prompt_entries
|
|
2015
|
+
return {"session_id": session_id,
|
|
2016
|
+
"prompts": extract_prompt_entries(asm["items"])}
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def get_conversation_export(conn, session_id, scope):
|
|
2020
|
+
"""Whole-session Markdown export for one scope (#217 S5, F1/F5).
|
|
2021
|
+
|
|
2022
|
+
Runs the SAME full ``_assemble_session`` the reader/outline use, then hands
|
|
2023
|
+
the assembled ``items`` (+ ``subagent_meta``) to the pure
|
|
2024
|
+
``export_session_markdown`` serializer (`_lib_conversation_export.py`), so
|
|
2025
|
+
the renderer stays I/O-free and golden-testable. Returns the Markdown string,
|
|
2026
|
+
or ``None`` for an unknown session (the handler's 404 sentinel). ``scope`` is
|
|
2027
|
+
pre-validated by the handler (an unknown scope never reaches here)."""
|
|
2028
|
+
from _lib_conversation_export import export_session_markdown
|
|
2029
|
+
asm = _assemble_session(conn, session_id)
|
|
2030
|
+
if asm is None:
|
|
2031
|
+
return None
|
|
2032
|
+
items = asm["items"]
|
|
2033
|
+
subagent_meta = asm["subagent_meta"]
|
|
2034
|
+
logical = asm["logical"]
|
|
2035
|
+
# Title via the SAME fallback chain the reader header uses (#193):
|
|
2036
|
+
# ai-title → first human prompt → project label → session_id.
|
|
2037
|
+
_pl = _project_label(_latest(logical, 10))
|
|
2038
|
+
title = (_session_titles_map(conn, [session_id]).get(session_id)
|
|
2039
|
+
or _pl or session_id)
|
|
2040
|
+
return export_session_markdown(items, scope, subagent_meta=subagent_meta,
|
|
2041
|
+
title=title, session_id=session_id)
|
|
2042
|
+
|
|
2043
|
+
|
|
1526
2044
|
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
1527
2045
|
|
|
1528
2046
|
|
|
@@ -1758,7 +2276,9 @@ def _search_depth(conn) -> str:
|
|
|
1758
2276
|
|
|
1759
2277
|
|
|
1760
2278
|
def search_conversations(conn, query, *, limit=50, offset=0,
|
|
1761
|
-
kind="all", fts_available=None
|
|
2279
|
+
kind="all", fts_available=None,
|
|
2280
|
+
date_from=None, date_to=None, projects=None,
|
|
2281
|
+
cost_min=None, cost_max=None, rebuild_min=None) -> dict:
|
|
1762
2282
|
"""Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
|
|
1763
2283
|
snippet); else a LIKE scan with a manual snippet. Hits deduped by
|
|
1764
2284
|
(session_id, uuid); each carries the turn's cost. `fts_available` overrides
|
|
@@ -1767,11 +2287,27 @@ def search_conversations(conn, query, *, limit=50, offset=0,
|
|
|
1767
2287
|
#177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
|
|
1768
2288
|
family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
|
|
1769
2289
|
column + entry_type, ``tools``/``thinking`` filter the split index columns.
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2290
|
+
#217 S2 / E7: ``title`` searches the external-content title FTS over
|
|
2291
|
+
conversation_ai_titles → one SESSION-level hit per matching session, anchored
|
|
2292
|
+
to that session's first turn, ``match_kinds:["title"]``, snippet = the matched
|
|
2293
|
+
title. #217 S2 / I-3: ``files`` searches ``conversation_file_touches`` (the
|
|
2294
|
+
write-class file-touch axis) → one hit per DISTINCT ``(session_id, file_path)``,
|
|
2295
|
+
anchored to that group's MOST-RECENT touch, ``match_kinds:["file"]``, snippet =
|
|
2296
|
+
the file path. Every hit gains ``match_kinds`` (sorted badges; prose never badges).
|
|
2297
|
+
The response carries additive ``kind`` + ``search_depth`` so the client can
|
|
2298
|
+
degrade the Tools/Thinking facets during the one-time column split
|
|
2299
|
+
(``search_depth == 'prose-only'`` short-circuits those two kinds to empty).
|
|
2300
|
+
An unknown ``kind`` raises ``ValueError`` (route → 400).
|
|
2301
|
+
|
|
2302
|
+
#217 S2 / Filtered-search: the browse filters (``date_from``/``date_to``/
|
|
2303
|
+
``projects``/``cost_min``/``cost_max``/``rebuild_min``) restrict the search to
|
|
2304
|
+
matching sessions, applied uniformly across EVERY kind as a session-scope
|
|
2305
|
+
``session_id IN (...)`` predicate (``_resolve_search_session_filter`` reuses
|
|
2306
|
+
``_rollup_where`` verbatim). When the rollup backfill is pending and a
|
|
2307
|
+
rollup-only axis is requested, only the date axis applies (via a live
|
|
2308
|
+
``MAX(timestamp_utc)`` prefilter, P1-5) and the response carries additive
|
|
2309
|
+
``filter_degraded: True`` — exactly mirroring browse. A no-filter request
|
|
2310
|
+
produces byte-stable existing output (no ``filter_degraded`` key)."""
|
|
1775
2311
|
if kind not in _SEARCH_KINDS:
|
|
1776
2312
|
raise ValueError(f"unknown kind: {kind}")
|
|
1777
2313
|
q = (query or "").strip()
|
|
@@ -1783,20 +2319,60 @@ def search_conversations(conn, query, *, limit=50, offset=0,
|
|
|
1783
2319
|
mode = "fts" if fts_available else "like"
|
|
1784
2320
|
base = {"query": q, "mode": mode, "hits": [], "total": 0,
|
|
1785
2321
|
"kind": kind, "search_depth": depth}
|
|
2322
|
+
# Resolve the filtered-search session-scope restriction ONCE (reused by every
|
|
2323
|
+
# kind). ``filt_sql`` is "" when no axis is set, so the no-filter path stays
|
|
2324
|
+
# byte-stable; ``degraded`` flags the dropped rollup-only axes (P1-5).
|
|
2325
|
+
filters = {
|
|
2326
|
+
"date_from": date_from, "date_to": date_to,
|
|
2327
|
+
"projects": list(projects) if projects else None,
|
|
2328
|
+
"cost_min": cost_min, "cost_max": cost_max, "rebuild_min": rebuild_min,
|
|
2329
|
+
}
|
|
2330
|
+
filt_sql, filt_params, degraded = _resolve_search_session_filter(conn, filters)
|
|
2331
|
+
if degraded:
|
|
2332
|
+
base["filter_degraded"] = True
|
|
2333
|
+
|
|
2334
|
+
def _finish(out):
|
|
2335
|
+
# Stamp the additive degraded flag onto whatever the search path returns,
|
|
2336
|
+
# without disturbing the no-filter byte-stable shape.
|
|
2337
|
+
if degraded:
|
|
2338
|
+
out["filter_degraded"] = True
|
|
2339
|
+
return out
|
|
2340
|
+
|
|
1786
2341
|
# Prose-only interim: the split columns are not yet indexed, so tools /
|
|
1787
2342
|
# thinking can't match — short-circuit them to empty (spec §1 interim).
|
|
1788
2343
|
if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
|
|
1789
2344
|
return base
|
|
2345
|
+
if kind == "title":
|
|
2346
|
+
if fts_available:
|
|
2347
|
+
try:
|
|
2348
|
+
out = _search_title(conn, q, limit, offset, True,
|
|
2349
|
+
filt_sql, filt_params)
|
|
2350
|
+
out.update(kind="title", search_depth=depth)
|
|
2351
|
+
return _finish(out)
|
|
2352
|
+
except sqlite3.OperationalError:
|
|
2353
|
+
pass # title FTS missing/corrupt at query time (the flag is
|
|
2354
|
+
# derived from fts5_unavailable, not a per-table probe) →
|
|
2355
|
+
# fall through to the title LIKE scan, mirroring the
|
|
2356
|
+
# message-FTS path's resilience
|
|
2357
|
+
out = _search_title(conn, q, limit, offset, False,
|
|
2358
|
+
filt_sql, filt_params)
|
|
2359
|
+
out.update(kind="title", search_depth=depth)
|
|
2360
|
+
return _finish(out)
|
|
2361
|
+
if kind == "files":
|
|
2362
|
+
out = _search_files(conn, q, limit, offset, filt_sql, filt_params)
|
|
2363
|
+
out.update(kind="files", search_depth=depth)
|
|
2364
|
+
return _finish(out)
|
|
1790
2365
|
if fts_available:
|
|
1791
2366
|
try:
|
|
1792
|
-
out = _search_fts(conn, q, limit, offset, kind, depth
|
|
2367
|
+
out = _search_fts(conn, q, limit, offset, kind, depth,
|
|
2368
|
+
filt_sql, filt_params)
|
|
1793
2369
|
out.update(kind=kind, search_depth=depth)
|
|
1794
|
-
return out
|
|
2370
|
+
return _finish(out)
|
|
1795
2371
|
except sqlite3.OperationalError:
|
|
1796
2372
|
pass # corrupt/missing FTS at query time → fall through to LIKE
|
|
1797
|
-
out = _search_like(conn, q, limit, offset, kind, depth)
|
|
2373
|
+
out = _search_like(conn, q, limit, offset, kind, depth, filt_sql, filt_params)
|
|
1798
2374
|
out.update(kind=kind, mode="like", search_depth=depth)
|
|
1799
|
-
return out
|
|
2375
|
+
return _finish(out)
|
|
1800
2376
|
|
|
1801
2377
|
|
|
1802
2378
|
def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
|
|
@@ -1841,13 +2417,40 @@ def _attach_titles(conn, page):
|
|
|
1841
2417
|
return page
|
|
1842
2418
|
|
|
1843
2419
|
|
|
1844
|
-
def
|
|
1845
|
-
"""
|
|
2420
|
+
def _session_filter_pred(col, filt_sql):
|
|
2421
|
+
"""The session-scope filter fragment shared by the four ``_search_*``
|
|
2422
|
+
subroutines (#219 S2.2): ``" AND <col> IN (<filt_sql>)"`` when a browse filter
|
|
2423
|
+
is active, else ``""`` (the no-filter fast path). ``col`` is the caller's
|
|
2424
|
+
table-qualified session_id column (``cm.session_id`` / ``t.session_id`` /
|
|
2425
|
+
``ft.session_id`` / bare ``session_id``)."""
|
|
2426
|
+
return f" AND {col} IN ({filt_sql})" if filt_sql else ""
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
def _attach_search_meta(conn, hits):
|
|
2430
|
+
"""The shared search-hit enrichment tail (#219 S2.2): per-turn cost then
|
|
2431
|
+
per-session title/label. Every ``_search_*`` subroutine returns
|
|
2432
|
+
``_attach_titles(conn, _attach_costs(conn, hits))`` — centralized here so the
|
|
2433
|
+
two-step order has one definition."""
|
|
2434
|
+
return _attach_titles(conn, _attach_costs(conn, hits))
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
def _like_escape(q):
|
|
2438
|
+
"""Escape the LIKE wildcards/escape-char in ``q`` (no surrounding ``%``),
|
|
2439
|
+
paired with ``ESCAPE '\\'``. The ESCAPE char (``\\``) is escaped FIRST, then
|
|
1846
2440
|
the wildcards — otherwise a query containing a backslash (incl. a trailing
|
|
1847
|
-
one) mis-escapes
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2441
|
+
one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
|
|
2442
|
+
|
|
2443
|
+
Single source of the escape chain (#217 S2 / I-3 review Minor #3):
|
|
2444
|
+
``_like_pattern`` (substring) and ``_search_files``'s substring pattern both
|
|
2445
|
+
build on this so the escaping is defined exactly once."""
|
|
2446
|
+
return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
def _like_pattern(q):
|
|
2450
|
+
"""Build the SUBSTRING LIKE pattern for `q` — ``%`` + escaped(q) + ``%`` —
|
|
2451
|
+
paired with ``ESCAPE '\\'`` in the queries below. The escape chain is
|
|
2452
|
+
single-sourced in ``_like_escape``."""
|
|
2453
|
+
return "%" + _like_escape(q) + "%"
|
|
1851
2454
|
|
|
1852
2455
|
|
|
1853
2456
|
def _fts_snippets(conn, fts_q, ids, col=0):
|
|
@@ -1915,7 +2518,8 @@ def _match_kinds(conn, fts_q, rids_by_group):
|
|
|
1915
2518
|
for grp, rids in rids_by_group.items()}
|
|
1916
2519
|
|
|
1917
2520
|
|
|
1918
|
-
def _search_fts(conn, q, limit, offset, kind, depth
|
|
2521
|
+
def _search_fts(conn, q, limit, offset, kind, depth,
|
|
2522
|
+
filt_sql="", filt_params=()):
|
|
1919
2523
|
# All of dedup + paging + total live in SQL (#149) so Python never holds
|
|
1920
2524
|
# more than one page of hits/snippets, regardless of corpus match count.
|
|
1921
2525
|
#
|
|
@@ -1932,6 +2536,11 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1932
2536
|
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
1933
2537
|
et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
|
|
1934
2538
|
et_args = (entry_type,) if entry_type is not None else ()
|
|
2539
|
+
# #217 S2 / Filtered-search: session-scope restriction (empty when no filter,
|
|
2540
|
+
# so the no-filter path stays byte-stable). cm.session_id is the FTS join's
|
|
2541
|
+
# message column.
|
|
2542
|
+
fpred = _session_filter_pred("cm.session_id", filt_sql)
|
|
2543
|
+
fargs = tuple(filt_params)
|
|
1935
2544
|
# Exact post-dedup logical total — counted in C with no snippet generation
|
|
1936
2545
|
# and no Python row materialization.
|
|
1937
2546
|
total = conn.execute(
|
|
@@ -1939,8 +2548,8 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1939
2548
|
" SELECT DISTINCT cm.session_id, cm.uuid "
|
|
1940
2549
|
" FROM conversation_fts "
|
|
1941
2550
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1942
|
-
f" WHERE conversation_fts MATCH ?{et_pred})",
|
|
1943
|
-
(match_expr, *et_args),
|
|
2551
|
+
f" WHERE conversation_fts MATCH ?{et_pred}{fpred})",
|
|
2552
|
+
(match_expr, *et_args, *fargs),
|
|
1944
2553
|
).fetchone()[0]
|
|
1945
2554
|
# One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
|
|
1946
2555
|
# the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
|
|
@@ -1964,7 +2573,7 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1964
2573
|
f" {bm25_expr} AS rank "
|
|
1965
2574
|
" FROM conversation_fts "
|
|
1966
2575
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1967
|
-
f" WHERE conversation_fts MATCH ?{et_pred}), "
|
|
2576
|
+
f" WHERE conversation_fts MATCH ?{et_pred}{fpred}), "
|
|
1968
2577
|
"ranked AS ("
|
|
1969
2578
|
" SELECT *, ROW_NUMBER() OVER ("
|
|
1970
2579
|
" PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
|
|
@@ -1972,14 +2581,14 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1972
2581
|
" FROM matched) "
|
|
1973
2582
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
1974
2583
|
"ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
1975
|
-
(match_expr, *et_args, limit, offset),
|
|
2584
|
+
(match_expr, *et_args, *fargs, limit, offset),
|
|
1976
2585
|
).fetchall()
|
|
1977
2586
|
page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
|
|
1978
2587
|
if legacy:
|
|
1979
2588
|
badges = {grp: [] for grp in page_groups}
|
|
1980
2589
|
else:
|
|
1981
2590
|
rids_by_group = _all_matched_rids_by_group(
|
|
1982
|
-
conn,
|
|
2591
|
+
conn, et_pred, et_args, list(page_groups))
|
|
1983
2592
|
badges = _match_kinds(conn, fts_q, rids_by_group)
|
|
1984
2593
|
snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
|
|
1985
2594
|
# For hits badged tool/thinking but with no prose match, draw the snippet
|
|
@@ -1990,48 +2599,86 @@ def _search_fts(conn, q, limit, offset, kind, depth):
|
|
|
1990
2599
|
match_kinds=badges.get((sid, uuid), []))
|
|
1991
2600
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
1992
2601
|
return {"query": q, "mode": "fts",
|
|
1993
|
-
"hits":
|
|
2602
|
+
"hits": _attach_search_meta(conn, hits),
|
|
1994
2603
|
"total": total}
|
|
1995
2604
|
|
|
1996
2605
|
|
|
1997
|
-
def _all_matched_rids_by_group(conn,
|
|
1998
|
-
"""{(sid, uuid) -> [rids]} for the page groups: ALL
|
|
1999
|
-
|
|
2606
|
+
def _all_matched_rids_by_group(conn, et_pred, et_args, groups):
|
|
2607
|
+
"""{(sid, uuid) -> [rids]} for the page groups: ALL physical rows of each
|
|
2608
|
+
page group (a SUPERSET of the FTS-matched subset), so badges aggregate
|
|
2609
|
+
completely. ``_match_kinds`` re-runs a per-column MATCH restricted to these
|
|
2610
|
+
rids, so returning every physical row of the ≤200 page groups stays correct
|
|
2611
|
+
for the column facets (the per-column MATCH filters).
|
|
2612
|
+
|
|
2613
|
+
U3 (#217 S1): a BOUNDED base-table lookup of the page groups' rows — NOT a
|
|
2614
|
+
third full-corpus ``conversation_fts MATCH``. We stop scanning the corpus a
|
|
2615
|
+
third time per request. The term expression is carried by the per-column
|
|
2616
|
+
MATCH in ``_match_kinds``, so this lookup needs no match expression — the
|
|
2617
|
+
formerly-vestigial ``match_expr`` param was dropped (#218 I-A.2).
|
|
2618
|
+
|
|
2619
|
+
Facet-scope fix (Codex P2): carry the SAME ``et_pred``/``et_args`` the page
|
|
2620
|
+
query used (``kind=prompts``/``assistant`` filter ``cm.entry_type``), so a
|
|
2621
|
+
same-group physical row OUTSIDE the facet can never contribute a badge. The
|
|
2622
|
+
``(session_id, uuid)`` match is NULL-safe (``IS``), since a pre-006 row may
|
|
2623
|
+
carry a NULL uuid that a plain ``IN`` row-value would silently drop."""
|
|
2000
2624
|
if not groups:
|
|
2001
2625
|
return {}
|
|
2002
2626
|
rids_by_group = {g: [] for g in groups}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
(
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2627
|
+
# Chunk the OR-of-pairs to stay well under SQLite's variable limit (≤200
|
|
2628
|
+
# page groups, 2 params each → one or two chunks in practice).
|
|
2629
|
+
for i in range(0, len(groups), 300):
|
|
2630
|
+
chunk = groups[i:i + 300]
|
|
2631
|
+
cond = " OR ".join(
|
|
2632
|
+
"(cm.session_id IS ? AND cm.uuid IS ?)" for _ in chunk)
|
|
2633
|
+
params = [v for g in chunk for v in g]
|
|
2634
|
+
rows = conn.execute(
|
|
2635
|
+
"SELECT cm.id, cm.session_id, cm.uuid FROM conversation_messages cm "
|
|
2636
|
+
f"WHERE ({cond}){et_pred}",
|
|
2637
|
+
(*params, *et_args),
|
|
2638
|
+
).fetchall()
|
|
2639
|
+
for rid, sid, uuid in rows:
|
|
2640
|
+
g = (sid, uuid)
|
|
2641
|
+
if g in rids_by_group:
|
|
2642
|
+
rids_by_group[g].append(rid)
|
|
2013
2643
|
return rids_by_group
|
|
2014
2644
|
|
|
2015
2645
|
|
|
2016
2646
|
def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
|
|
2017
2647
|
"""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
|
-
|
|
2648
|
+
prose column did NOT match (prose → tool → thinking preference). Probes which
|
|
2649
|
+
of the badged survivors match prose, then re-snippets the non-prose ones from
|
|
2650
|
+
their preferred column.
|
|
2651
|
+
|
|
2652
|
+
U3 (#217 S1): the per-hit ``rowid = ?`` prose probe (one query per badged
|
|
2653
|
+
page hit, up to ~200 at limit=200) is collapsed into ONE bounded
|
|
2654
|
+
``rowid IN (…) AND MATCH text:(…)`` returning the prose-matching set;
|
|
2655
|
+
subtracting gives the non-prose survivors routed to the already-batched
|
|
2656
|
+
tool/thinking snippet calls."""
|
|
2657
|
+
# The badged survivors are the only candidates (a hit with no badges keeps
|
|
2658
|
+
# its col-0 prose snippet). Collect their survivor rowids.
|
|
2659
|
+
badged = [(rid, sid, uuid)
|
|
2660
|
+
for (rid, sid, uuid, ts, cwd, mid, rqd) in page
|
|
2661
|
+
if badges.get((sid, uuid))]
|
|
2662
|
+
if not badged:
|
|
2663
|
+
return snips
|
|
2664
|
+
badged_rids = [rid for (rid, _s, _u) in badged]
|
|
2665
|
+
# ONE bounded probe: which badged survivors ALSO match prose? Keep their
|
|
2666
|
+
# col-0 snippet; the rest fall through to their preferred column.
|
|
2667
|
+
prose_hits = set()
|
|
2668
|
+
for i in range(0, len(badged_rids), 300):
|
|
2669
|
+
chunk = badged_rids[i:i + 300]
|
|
2670
|
+
ph = ",".join("?" for _ in chunk)
|
|
2671
|
+
rows = conn.execute(
|
|
2672
|
+
"SELECT conversation_fts.rowid FROM conversation_fts "
|
|
2673
|
+
f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
|
|
2674
|
+
(f"{{text}}: ({fts_q})", *chunk)).fetchall()
|
|
2675
|
+
prose_hits.update(r[0] for r in rows)
|
|
2020
2676
|
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
|
|
2677
|
+
for (rid, sid, uuid) in badged:
|
|
2678
|
+
if rid in prose_hits:
|
|
2679
|
+
continue # prose matched the survivor → keep col 0
|
|
2033
2680
|
for label, col in _SNIPPET_COL_PREFERENCE:
|
|
2034
|
-
if label in
|
|
2681
|
+
if label in badges.get((sid, uuid), []):
|
|
2035
2682
|
by_col.setdefault(col, []).append(rid)
|
|
2036
2683
|
break
|
|
2037
2684
|
for col, rids in by_col.items():
|
|
@@ -2042,7 +2689,8 @@ def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
|
|
|
2042
2689
|
return snips
|
|
2043
2690
|
|
|
2044
2691
|
|
|
2045
|
-
def _search_like(conn, q, limit, offset, kind, depth
|
|
2692
|
+
def _search_like(conn, q, limit, offset, kind, depth,
|
|
2693
|
+
filt_sql="", filt_params=()):
|
|
2046
2694
|
# SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
|
|
2047
2695
|
# COUNT + page each scan the table once (the degraded path already lacks an
|
|
2048
2696
|
# index for the substring match). #177 S6: kind → column list (single-
|
|
@@ -2062,11 +2710,14 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2062
2710
|
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
2063
2711
|
et_pred = " AND entry_type = ?" if entry_type is not None else ""
|
|
2064
2712
|
et_args = (entry_type,) if entry_type is not None else ()
|
|
2713
|
+
# #217 S2 / Filtered-search: session-scope restriction (empty when no filter).
|
|
2714
|
+
fpred = _session_filter_pred("session_id", filt_sql)
|
|
2715
|
+
fargs = tuple(filt_params)
|
|
2065
2716
|
total = conn.execute(
|
|
2066
2717
|
"SELECT COUNT(*) FROM ("
|
|
2067
2718
|
" SELECT DISTINCT session_id, uuid FROM conversation_messages "
|
|
2068
|
-
f" WHERE {col_pred}{et_pred})",
|
|
2069
|
-
(*like_args, *et_args),
|
|
2719
|
+
f" WHERE {col_pred}{et_pred}{fpred})",
|
|
2720
|
+
(*like_args, *et_args, *fargs),
|
|
2070
2721
|
).fetchone()[0]
|
|
2071
2722
|
page = conn.execute(
|
|
2072
2723
|
"WITH ranked AS ("
|
|
@@ -2077,10 +2728,10 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2077
2728
|
" ORDER BY timestamp_utc DESC, id DESC"
|
|
2078
2729
|
" ) AS rn "
|
|
2079
2730
|
" FROM conversation_messages "
|
|
2080
|
-
f" WHERE {col_pred}{et_pred}) "
|
|
2731
|
+
f" WHERE {col_pred}{et_pred}{fpred}) "
|
|
2081
2732
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
2082
2733
|
"ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
2083
|
-
(*like_args, *et_args, limit, offset),
|
|
2734
|
+
(*like_args, *et_args, *fargs, limit, offset),
|
|
2084
2735
|
).fetchall()
|
|
2085
2736
|
texts = _texts_for_ids(conn, [r[0] for r in page])
|
|
2086
2737
|
if legacy:
|
|
@@ -2093,7 +2744,158 @@ def _search_like(conn, q, limit, offset, kind, depth):
|
|
|
2093
2744
|
match_kinds=badges.get((sid, uuid), []))
|
|
2094
2745
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
2095
2746
|
return {"query": q, "mode": "like",
|
|
2096
|
-
"hits":
|
|
2747
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2748
|
+
"total": total}
|
|
2749
|
+
|
|
2750
|
+
|
|
2751
|
+
def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_params=()):
|
|
2752
|
+
"""#217 S2 / E7: ``kind=title`` cross-session search over the per-session AI
|
|
2753
|
+
title (conversation_ai_titles). Emits ONE SESSION-level hit per matching
|
|
2754
|
+
session, anchored to that session's FIRST turn (the earliest
|
|
2755
|
+
``(timestamp_utc, id)`` conversation_messages row), ``match_kinds:["title"]``,
|
|
2756
|
+
snippet = the matched title.
|
|
2757
|
+
|
|
2758
|
+
FTS5 available: ``MATCH ai_title`` over the external-content
|
|
2759
|
+
``conversation_title_fts``, mapping each hit rowid →
|
|
2760
|
+
``conversation_ai_titles.session_id``; the snippet comes from FTS ``snippet()``.
|
|
2761
|
+
Else: a LIKE scan over ``conversation_ai_titles`` with the whole title as the
|
|
2762
|
+
snippet (degraded; no marked span).
|
|
2763
|
+
|
|
2764
|
+
P2-9 (load-bearing): a session's title counts toward ``total`` and the page
|
|
2765
|
+
ONLY when it has an anchorable first turn. Both the COUNT and the page JOIN
|
|
2766
|
+
the SAME first-message anchor (``INNER JOIN`` against the per-session
|
|
2767
|
+
first-turn CTE), so a title row whose session has no surviving message rows is
|
|
2768
|
+
excluded from both — no lying count.
|
|
2769
|
+
|
|
2770
|
+
Filtered-search: the ``filt_sql`` session-scope restriction (over
|
|
2771
|
+
conversation_ai_titles.session_id) applies the same as the message kinds.
|
|
2772
|
+
"""
|
|
2773
|
+
fpred = _session_filter_pred("t.session_id", filt_sql)
|
|
2774
|
+
fargs = tuple(filt_params)
|
|
2775
|
+
# First-turn anchor per session: earliest (timestamp_utc, id) row. Rides
|
|
2776
|
+
# idx_conv_session_ts. One row per session_id.
|
|
2777
|
+
anchor_cte = (
|
|
2778
|
+
"anchor AS ("
|
|
2779
|
+
" SELECT session_id, aid, auuid, ats, acwd, amid, arqd FROM ("
|
|
2780
|
+
" SELECT session_id, id AS aid, uuid AS auuid, timestamp_utc AS ats, "
|
|
2781
|
+
" cwd AS acwd, msg_id AS amid, req_id AS arqd, "
|
|
2782
|
+
" ROW_NUMBER() OVER (PARTITION BY session_id "
|
|
2783
|
+
" ORDER BY timestamp_utc, id) AS rn "
|
|
2784
|
+
" FROM conversation_messages WHERE session_id IS NOT NULL"
|
|
2785
|
+
" ) WHERE rn = 1)")
|
|
2786
|
+
|
|
2787
|
+
if fts_available:
|
|
2788
|
+
# matched titles: rowid → session_id + snippet, INNER-joined to the
|
|
2789
|
+
# anchor so only anchorable sessions survive (P2-9).
|
|
2790
|
+
fts_q = _fts_query(q, prefix_last=True)
|
|
2791
|
+
total = conn.execute(
|
|
2792
|
+
"WITH " + anchor_cte + " "
|
|
2793
|
+
"SELECT COUNT(*) FROM conversation_title_fts f "
|
|
2794
|
+
"JOIN conversation_ai_titles t ON t.rowid = f.rowid "
|
|
2795
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2796
|
+
f"WHERE f.conversation_title_fts MATCH ?{fpred}",
|
|
2797
|
+
(fts_q, *fargs)).fetchone()[0]
|
|
2798
|
+
rows = conn.execute(
|
|
2799
|
+
"WITH " + anchor_cte + " "
|
|
2800
|
+
"SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
|
|
2801
|
+
" snippet(conversation_title_fts, 0, '[', ']', ' … ', 12) AS snip "
|
|
2802
|
+
"FROM conversation_title_fts f "
|
|
2803
|
+
"JOIN conversation_ai_titles t ON t.rowid = f.rowid "
|
|
2804
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2805
|
+
f"WHERE f.conversation_title_fts MATCH ?{fpred} "
|
|
2806
|
+
"ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
|
|
2807
|
+
(fts_q, *fargs, limit, offset)).fetchall()
|
|
2808
|
+
mode = "fts"
|
|
2809
|
+
else:
|
|
2810
|
+
like = _like_pattern(q)
|
|
2811
|
+
total = conn.execute(
|
|
2812
|
+
"WITH " + anchor_cte + " "
|
|
2813
|
+
"SELECT COUNT(*) FROM conversation_ai_titles t "
|
|
2814
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2815
|
+
f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred}",
|
|
2816
|
+
(like, *fargs)).fetchone()[0]
|
|
2817
|
+
rows = conn.execute(
|
|
2818
|
+
"WITH " + anchor_cte + " "
|
|
2819
|
+
"SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
|
|
2820
|
+
" t.ai_title AS snip "
|
|
2821
|
+
"FROM conversation_ai_titles t "
|
|
2822
|
+
"JOIN anchor a ON a.session_id = t.session_id "
|
|
2823
|
+
f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred} "
|
|
2824
|
+
"ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
|
|
2825
|
+
(like, *fargs, limit, offset)).fetchall()
|
|
2826
|
+
mode = "like"
|
|
2827
|
+
|
|
2828
|
+
hits = [_row_to_hit(auuid, sid, ats, acwd, snip, amid, arqd,
|
|
2829
|
+
match_kinds=["title"])
|
|
2830
|
+
for (sid, auuid, ats, acwd, amid, arqd, snip) in rows]
|
|
2831
|
+
return {"query": q, "mode": mode,
|
|
2832
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2833
|
+
"total": total}
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
|
|
2837
|
+
"""#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
|
|
2838
|
+
file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
|
|
2839
|
+
``(session_id, file_path)``, anchored to that group's MOST-RECENT touch
|
|
2840
|
+
(``MAX(message_id)`` → that message's ``(uuid, ts, cwd, msg_id, req_id)``),
|
|
2841
|
+
``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
|
|
2842
|
+
``(session_id, file_path)`` count.
|
|
2843
|
+
|
|
2844
|
+
Match shape (#223): the query binds a SUBSTRING pattern (``%`` + escaped(q)
|
|
2845
|
+
+ ``%``) into ``file_path LIKE ? ESCAPE '\\'`` for EVERY query — a bare
|
|
2846
|
+
basename, a mid-path fragment, or a leading-separator path all match. This
|
|
2847
|
+
is a deliberate full SCAN over the modest ``conversation_file_touches``
|
|
2848
|
+
table (a leading wildcard can never use ``idx_file_touches_path``),
|
|
2849
|
+
consistent with how the content facets match; it replaces the former
|
|
2850
|
+
prefix/separator special-case so a basename like ``package.json`` is
|
|
2851
|
+
findable. ``LIKE`` stays case-insensitive (ASCII), matching the NOCASE path
|
|
2852
|
+
index collation even though the index no longer drives a prefix probe.
|
|
2853
|
+
|
|
2854
|
+
Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
|
|
2855
|
+
``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
|
|
2856
|
+
dedicated session index (the filter is a small IN over the already-narrowed
|
|
2857
|
+
match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
|
|
2858
|
+
"""
|
|
2859
|
+
pat = _like_pattern(q)
|
|
2860
|
+
fpred = _session_filter_pred("ft.session_id", filt_sql)
|
|
2861
|
+
fargs = tuple(filt_params)
|
|
2862
|
+
|
|
2863
|
+
# The anchor per (session_id, file_path) group is its MAX(message_id). Compute
|
|
2864
|
+
# the groups (filtered + matched), then join to conversation_messages on the
|
|
2865
|
+
# anchor id for the turn's render/cost metadata.
|
|
2866
|
+
#
|
|
2867
|
+
# P2-9 (#219 S2.1): the COUNT INNER-JOINs the SAME anchor the page joins, so a
|
|
2868
|
+
# group whose MAX(message_id) anchor is absent from conversation_messages is
|
|
2869
|
+
# excluded from BOTH (never a lying count where total > page-reachable). Inert
|
|
2870
|
+
# in a quiescent cache (touches are deleted with their message), but it keeps
|
|
2871
|
+
# `total` provably equal to the page-reachable set — matching `_search_title`.
|
|
2872
|
+
total = conn.execute(
|
|
2873
|
+
"WITH g AS ("
|
|
2874
|
+
" SELECT ft.session_id AS sid, ft.file_path AS fp, "
|
|
2875
|
+
" MAX(ft.message_id) AS aid "
|
|
2876
|
+
" FROM conversation_file_touches ft "
|
|
2877
|
+
f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
|
|
2878
|
+
" GROUP BY ft.session_id, ft.file_path) "
|
|
2879
|
+
"SELECT COUNT(*) FROM g JOIN conversation_messages cm ON cm.id = g.aid",
|
|
2880
|
+
(pat, *fargs)).fetchone()[0]
|
|
2881
|
+
rows = conn.execute(
|
|
2882
|
+
"WITH g AS ("
|
|
2883
|
+
" SELECT ft.session_id AS sid, ft.file_path AS fp, "
|
|
2884
|
+
" MAX(ft.message_id) AS aid "
|
|
2885
|
+
" FROM conversation_file_touches ft "
|
|
2886
|
+
f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
|
|
2887
|
+
" GROUP BY ft.session_id, ft.file_path) "
|
|
2888
|
+
"SELECT g.sid, g.fp, cm.uuid, cm.timestamp_utc, cm.cwd, "
|
|
2889
|
+
" cm.msg_id, cm.req_id "
|
|
2890
|
+
"FROM g JOIN conversation_messages cm ON cm.id = g.aid "
|
|
2891
|
+
"ORDER BY cm.timestamp_utc DESC, g.sid DESC, g.fp DESC "
|
|
2892
|
+
"LIMIT ? OFFSET ?",
|
|
2893
|
+
(pat, *fargs, limit, offset)).fetchall()
|
|
2894
|
+
|
|
2895
|
+
hits = [_row_to_hit(uuid_, sid, ts, cwd, fp, mid, rqd, match_kinds=["file"])
|
|
2896
|
+
for (sid, fp, uuid_, ts, cwd, mid, rqd) in rows]
|
|
2897
|
+
return {"query": q, "mode": "like",
|
|
2898
|
+
"hits": _attach_search_meta(conn, hits),
|
|
2097
2899
|
"total": total}
|
|
2098
2900
|
|
|
2099
2901
|
|
|
@@ -2118,9 +2920,19 @@ def _like_badges(conn, like, groups):
|
|
|
2118
2920
|
|
|
2119
2921
|
# #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
|
|
2120
2922
|
# filter the prose column AND the entry_type; `tools`/`thinking` filter the
|
|
2121
|
-
# split index columns.
|
|
2122
|
-
# (
|
|
2123
|
-
|
|
2923
|
+
# split index columns. #217 S2 / E7: `title` is a CROSS-SESSION-SEARCH-ONLY kind
|
|
2924
|
+
# (external-content title FTS over conversation_ai_titles, session-level hits).
|
|
2925
|
+
# Validated in search_conversations (an unknown kind raises ValueError → the
|
|
2926
|
+
# route maps it to a 400).
|
|
2927
|
+
#
|
|
2928
|
+
# P1-1 (load-bearing kind-validation SPLIT): `_SEARCH_KINDS` (cross-session
|
|
2929
|
+
# search) carries `title` (and is ready for `files` in I-3); `_FIND_KINDS` (the
|
|
2930
|
+
# in-conversation /find route) does NOT — `find_in_conversation` indexes
|
|
2931
|
+
# `_FIND_KIND_COLUMNS[kind]`, which has no entry for `title`/`files`, so accepting
|
|
2932
|
+
# them there would KeyError → 500. Keeping the two enums distinct makes
|
|
2933
|
+
# `/find?kind=title` a clean 400, never a 500.
|
|
2934
|
+
_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
|
|
2935
|
+
_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
2124
2936
|
_KIND_COLUMN = {"prompts": "text", "assistant": "text",
|
|
2125
2937
|
"tools": "search_tool", "thinking": "search_thinking"}
|
|
2126
2938
|
_KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
|
|
@@ -2156,6 +2968,14 @@ def _kind_match_expr(kind, fts_q):
|
|
|
2156
2968
|
|
|
2157
2969
|
_FIND_ANCHOR_CAP = 500
|
|
2158
2970
|
|
|
2971
|
+
# #217 S4 / I-1.1: bound the regex/case Python-scan (Codex P2, ReDoS/perf).
|
|
2972
|
+
# An over-long pattern/query is rejected outright (empty result, no scan); each
|
|
2973
|
+
# scanned column value is also clipped before the predicate runs so a single
|
|
2974
|
+
# huge tool result can't pin a catastrophic-backtracking regex. Best-effort —
|
|
2975
|
+
# not a full ReDoS defense, but it bounds the worst case to a fixed budget.
|
|
2976
|
+
_FIND_REGEX_MAX_LEN = 1000
|
|
2977
|
+
_FIND_SCAN_TEXT_CAP = 200_000
|
|
2978
|
+
|
|
2159
2979
|
# Which physical-row columns the find match probes per kind, and the badge label
|
|
2160
2980
|
# each non-prose column contributes. ``text`` maps to the synthetic ``prose``
|
|
2161
2981
|
# label so a prose-only match still anchors a turn but never badges.
|
|
@@ -2170,7 +2990,8 @@ _FIND_KIND_COLUMNS = {
|
|
|
2170
2990
|
|
|
2171
2991
|
|
|
2172
2992
|
def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
2173
|
-
fts_available=None, cap=_FIND_ANCHOR_CAP
|
|
2993
|
+
fts_available=None, cap=_FIND_ANCHOR_CAP,
|
|
2994
|
+
regex=False, case=False):
|
|
2174
2995
|
"""Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
|
|
2175
2996
|
|
|
2176
2997
|
Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
|
|
@@ -2183,8 +3004,29 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2183
3004
|
rendered-turn anchors PRE-cap; the list caps at ``cap`` with
|
|
2184
3005
|
``anchors_truncated``. Returns None for an unknown session; an unknown
|
|
2185
3006
|
``kind`` raises ValueError (route → 400). Empty/whitespace query → empty;
|
|
2186
|
-
prose-only depth + tools/thinking kinds → empty (the split index is pending).
|
|
2187
|
-
|
|
3007
|
+
prose-only depth + tools/thinking kinds → empty (the split index is pending).
|
|
3008
|
+
|
|
3009
|
+
P1-1: validates against ``_FIND_KINDS`` (NOT ``_SEARCH_KINDS``), so the
|
|
3010
|
+
cross-session-only ``title``/``files`` kinds raise ValueError here (the route
|
|
3011
|
+
→ 400) rather than reaching ``_FIND_KIND_COLUMNS[kind]`` and KeyError → 500.
|
|
3012
|
+
|
|
3013
|
+
#217 S4 / I-1.1 — ``regex`` / ``case``: when either is set the FTS/LIKE fast
|
|
3014
|
+
path is bypassed for a **physical-row Python scan** of ``conversation_messages``
|
|
3015
|
+
(Codex P0 — *not* the rendered ``_assemble_session`` items, which drop the
|
|
3016
|
+
``search_tool``/``search_thinking`` corpus). The scan reuses the SAME column
|
|
3017
|
+
derivation the FTS path matches on (``_find_kind_columns(kind, "full")`` +
|
|
3018
|
+
``_KIND_ENTRY_TYPE``) and produces the SAME ``{uuid -> labels}`` map, then
|
|
3019
|
+
feeds the SAME assembly-to-anchor loop, so anchors / ordering / member_uuids /
|
|
3020
|
+
match_kinds / the cap are byte-identical to the FTS path — only the matcher
|
|
3021
|
+
differs. ``case`` only → case-sensitive substring (``mode == "like"``);
|
|
3022
|
+
``regex`` → per-row ``re.search`` compiled once (``re.IGNORECASE`` unless
|
|
3023
|
+
``case``; ``mode == "regex"``). Because the scan reads the full corpus
|
|
3024
|
+
columns, ``search_depth == "full"`` — regex/case is NOT gated by the
|
|
3025
|
+
prose-only split-index window. The scan is bounded by ``_FIND_REGEX_MAX_LEN``
|
|
3026
|
+
(query/pattern length) and ``_FIND_SCAN_TEXT_CAP`` (per-row scanned text).
|
|
3027
|
+
Default ``regex=False, case=False`` leaves every existing response
|
|
3028
|
+
byte-stable (the ``_find_matched_rows`` path is untouched)."""
|
|
3029
|
+
if kind not in _FIND_KINDS:
|
|
2188
3030
|
raise ValueError(f"unknown kind: {kind}")
|
|
2189
3031
|
# Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
|
|
2190
3032
|
# empty/prose-only-blocked query opening the find bar pays nothing — yet the
|
|
@@ -2198,17 +3040,55 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2198
3040
|
if fts_available is None:
|
|
2199
3041
|
fts_available = not _fts_flag_unavailable(conn)
|
|
2200
3042
|
q = (query or "").strip()
|
|
3043
|
+
# #217 S4 / I-1.1: a regex/case request scans the full corpus columns, so it
|
|
3044
|
+
# reports search_depth "full" regardless of the prose-only split-index state,
|
|
3045
|
+
# and is never blocked by it (the scan reads the base table, not the FTS).
|
|
3046
|
+
scan = bool(regex or case)
|
|
3047
|
+
eff_depth = "full" if scan else depth
|
|
2201
3048
|
base = {"total": 0, "anchors": [], "anchors_truncated": False,
|
|
2202
|
-
"search_depth":
|
|
2203
|
-
"mode": "
|
|
2204
|
-
|
|
3049
|
+
"search_depth": eff_depth, "kind": kind,
|
|
3050
|
+
"mode": ("regex" if regex else "like") if scan
|
|
3051
|
+
else ("fts" if fts_available else "like")}
|
|
3052
|
+
if not q:
|
|
2205
3053
|
return base
|
|
3054
|
+
if scan:
|
|
3055
|
+
# Bound the scan (Codex P2): reject an over-long pattern/query outright
|
|
3056
|
+
# rather than risk a catastrophic-backtracking regex over the corpus.
|
|
3057
|
+
if len(q) > _FIND_REGEX_MAX_LEN:
|
|
3058
|
+
return base
|
|
3059
|
+
mode = "regex" if regex else "like"
|
|
3060
|
+
matched = _find_matched_scan(conn, session_id, q, kind, regex, case)
|
|
3061
|
+
else:
|
|
3062
|
+
if depth == "prose-only" and kind in ("tools", "thinking"):
|
|
3063
|
+
return base
|
|
3064
|
+
# U2 (#217 S1): run the match probe BEFORE assembly so a non-empty query
|
|
3065
|
+
# that matches ZERO rows in this session returns early — skipping the full
|
|
3066
|
+
# `_assemble_session` walk a no-result find used to pay. The two functions
|
|
3067
|
+
# are independent (neither consumes the other's output); a non-empty match
|
|
3068
|
+
# still assembles, since the uuid → member_uuids anchor mapping needs the
|
|
3069
|
+
# items. `mode` is taken from the match probe (it may fall through to LIKE
|
|
3070
|
+
# on an FTS error) so the empty-match base carries the actually-used mode,
|
|
3071
|
+
# not the `fts_available` default — byte-identical to the prior
|
|
3072
|
+
# post-assembly answer.
|
|
3073
|
+
mode, matched = _find_matched_rows(
|
|
3074
|
+
conn, session_id, q, kind, depth, fts_available)
|
|
3075
|
+
if not matched:
|
|
3076
|
+
return {**base, "mode": mode}
|
|
2206
3077
|
asm = _assemble_session(conn, session_id)
|
|
2207
3078
|
if asm is None:
|
|
2208
3079
|
return None
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
3080
|
+
anchors = _find_anchors_from_matched(asm, matched)
|
|
3081
|
+
total = len(anchors)
|
|
3082
|
+
return {**base, "mode": mode, "total": total,
|
|
3083
|
+
"anchors": anchors[:cap], "anchors_truncated": total > cap}
|
|
3084
|
+
|
|
3085
|
+
|
|
3086
|
+
def _find_anchors_from_matched(asm, matched):
|
|
3087
|
+
"""Map the ``{uuid -> set(labels)}`` match map onto document-ordered
|
|
3088
|
+
rendered-turn anchors (shared by the FTS/LIKE path and the regex/case scan,
|
|
3089
|
+
so the two can never diverge on ordering / member folding / match_kinds).
|
|
3090
|
+
``matched`` labels are in ``{"prose", "tool", "thinking"}``; the synthetic
|
|
3091
|
+
``prose`` label anchors a turn but never badges."""
|
|
2212
3092
|
anchors = []
|
|
2213
3093
|
for it in asm["items"]:
|
|
2214
3094
|
hit_kinds = set()
|
|
@@ -2222,9 +3102,51 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
|
2222
3102
|
anchors.append({
|
|
2223
3103
|
"uuid": it["anchor"]["uuid"],
|
|
2224
3104
|
"match_kinds": sorted(k for k in hit_kinds if k != "prose")})
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
3105
|
+
return anchors
|
|
3106
|
+
|
|
3107
|
+
|
|
3108
|
+
def _find_matched_scan(conn, session_id, q, kind, regex, case):
|
|
3109
|
+
"""({uuid -> {labels}}) for a regex/case PHYSICAL-ROW scan (Codex P0). Reads
|
|
3110
|
+
the same corpus columns the FTS path matches on (``_find_kind_columns(kind,
|
|
3111
|
+
"full")``) over the base ``conversation_messages`` rows — NOT the rendered
|
|
3112
|
+
``_assemble_session`` items, which drop the ``search_tool``/``search_thinking``
|
|
3113
|
+
columns. Honors ``_KIND_ENTRY_TYPE`` exactly like ``_find_matched_like``.
|
|
3114
|
+
Each scanned value is clipped to ``_FIND_SCAN_TEXT_CAP`` before the predicate
|
|
3115
|
+
so one huge tool result can't pin the regex engine.
|
|
3116
|
+
|
|
3117
|
+
Precondition: ``regex or case``. ``find_in_conversation`` only enters the
|
|
3118
|
+
physical-row scan when ``scan = bool(regex or case)`` (#217 S4 / I-1.1), so a
|
|
3119
|
+
plain case-insensitive-substring scan is never requested here — the FTS/LIKE
|
|
3120
|
+
path (``_find_matched_rows``) owns that case. The two regex/case predicates
|
|
3121
|
+
below are therefore exhaustive; the assert documents (and enforces) that
|
|
3122
|
+
rather than carrying an unreachable third branch."""
|
|
3123
|
+
assert regex or case, "_find_matched_scan requires regex or case"
|
|
3124
|
+
cols = _find_kind_columns(kind, "full")
|
|
3125
|
+
if not cols:
|
|
3126
|
+
return {}
|
|
3127
|
+
et = _KIND_ENTRY_TYPE.get(kind)
|
|
3128
|
+
et_pred = " AND entry_type = ?" if et is not None else ""
|
|
3129
|
+
et_args = (et,) if et is not None else ()
|
|
3130
|
+
if regex:
|
|
3131
|
+
rx = re.compile(q, 0 if case else re.IGNORECASE)
|
|
3132
|
+
pred = lambda text: rx.search(text) is not None
|
|
3133
|
+
else: # case-sensitive substring (case=True, regex=False)
|
|
3134
|
+
pred = lambda text: q in text
|
|
3135
|
+
out = {}
|
|
3136
|
+
col_list = ", ".join(c for c, _ in cols)
|
|
3137
|
+
rows = conn.execute(
|
|
3138
|
+
f"SELECT uuid, {col_list} FROM conversation_messages "
|
|
3139
|
+
f"WHERE session_id = ?{et_pred}",
|
|
3140
|
+
(session_id, *et_args)).fetchall()
|
|
3141
|
+
for row in rows:
|
|
3142
|
+
u = row[0]
|
|
3143
|
+
for idx, (_col, label) in enumerate(cols):
|
|
3144
|
+
val = row[idx + 1]
|
|
3145
|
+
if not val:
|
|
3146
|
+
continue
|
|
3147
|
+
if pred(val[:_FIND_SCAN_TEXT_CAP]):
|
|
3148
|
+
out.setdefault(u, set()).add(label)
|
|
3149
|
+
return out
|
|
2228
3150
|
|
|
2229
3151
|
|
|
2230
3152
|
def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
|
|
@@ -2347,74 +3269,6 @@ def locate_tool_payload(conn, session_id, tool_use_id, which):
|
|
|
2347
3269
|
return None
|
|
2348
3270
|
|
|
2349
3271
|
|
|
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
3272
|
def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
2419
3273
|
"""Re-read the raw JSONL line at ``(source_path, byte_offset)`` and return the
|
|
2420
3274
|
FULL (un-capped) payload for ``tool_use_id``:
|
|
@@ -2471,6 +3325,12 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
|
2471
3325
|
if (isinstance(tur, dict) and isinstance(tur.get("stderr"), str)
|
|
2472
3326
|
and tur.get("stderr")):
|
|
2473
3327
|
resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
|
|
3328
|
+
# #217 S1 / U5 (data-contract honesty): `truncated` describes ONLY
|
|
3329
|
+
# the `text` stream, but `stderr` is bounded INDEPENDENTLY against
|
|
3330
|
+
# the same ceiling — so the client cannot tell from `truncated`
|
|
3331
|
+
# alone whether stderr was clipped. Emit a dedicated per-stream
|
|
3332
|
+
# flag whenever stderr is present (mirrors `stderr` itself).
|
|
3333
|
+
resp["stderr_truncated"] = len(tur["stderr"]) > _FULL_PAYLOAD_CEILING
|
|
2474
3334
|
return resp
|
|
2475
3335
|
return None
|
|
2476
3336
|
|