cctally 1.53.0 → 1.55.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 +55 -0
- package/bin/_cctally_dashboard.py +78 -0
- package/bin/_cctally_db.py +9 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +204 -48
- package/dashboard/static/assets/index-Cnjzrt1O.css +1 -0
- package/dashboard/static/assets/index-CwWzvgaI.js +78 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Bli-0vNb.css +0 -1
- package/dashboard/static/assets/index-Dwu_Neot.js +0 -71
|
@@ -287,6 +287,103 @@ def _subagent_key(source_path):
|
|
|
287
287
|
from _lib_conversation import (
|
|
288
288
|
_NESTED_AGENT_ID_RE, _NESTED_USAGE_RE, _nested_agent_stamp_from_text,
|
|
289
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}
|
|
290
387
|
|
|
291
388
|
|
|
292
389
|
def _parse_nested_agent_result(text):
|
|
@@ -1126,6 +1223,13 @@ def _turn_usage_map(conn, turn_keys):
|
|
|
1126
1223
|
return usage
|
|
1127
1224
|
|
|
1128
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
|
+
|
|
1129
1233
|
def _assemble_session(conn, session_id):
|
|
1130
1234
|
"""Shared assembly for get_conversation / get_conversation_outline (#177 S5).
|
|
1131
1235
|
|
|
@@ -1223,12 +1327,15 @@ def _assemble_session(conn, session_id):
|
|
|
1223
1327
|
# tool_call/tool_result block shapes are unchanged — the only new output is
|
|
1224
1328
|
# the top-level subagent_meta map (no undocumented block keys leak). Join is
|
|
1225
1329
|
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
1226
|
-
spawn_kind = {} # tool_use id -> subagent_type
|
|
1227
|
-
|
|
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)
|
|
1228
1335
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
1229
1336
|
nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
|
|
1230
1337
|
# results with no structured agent_id, resolved after
|
|
1231
|
-
#
|
|
1338
|
+
# the spawn maps are complete (text still present pre-fold)
|
|
1232
1339
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
1233
1340
|
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
1234
1341
|
web_search_link = {} # tool_use id -> web_search payload (#177 S4)
|
|
@@ -1239,15 +1346,23 @@ def _assemble_session(conn, session_id):
|
|
|
1239
1346
|
k = b.get("kind")
|
|
1240
1347
|
if k == "tool_use":
|
|
1241
1348
|
st = b.pop("subagent_type", None)
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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.
|
|
1247
1362
|
_inp = b.get("input")
|
|
1248
1363
|
_d = _inp.get("description") if isinstance(_inp, dict) else None
|
|
1249
1364
|
if isinstance(_d, str) and _d.strip():
|
|
1250
|
-
spawn_desc[
|
|
1365
|
+
spawn_desc[_id] = _d
|
|
1251
1366
|
elif k == "tool_result":
|
|
1252
1367
|
aid = b.pop("agent_id", None)
|
|
1253
1368
|
meta = b.pop("subagent_meta", None)
|
|
@@ -1255,7 +1370,7 @@ def _assemble_session(conn, session_id):
|
|
|
1255
1370
|
agent_link[b["tool_use_id"]] = (aid, meta or {})
|
|
1256
1371
|
elif b.get("tool_use_id") is not None:
|
|
1257
1372
|
# §4 1a candidate — a tool_result with no structured agentId.
|
|
1258
|
-
# Resolved AFTER
|
|
1373
|
+
# Resolved AFTER the spawn maps are complete (text still present,
|
|
1259
1374
|
# pre-Phase-2-fold). The block's `text` holds the result body.
|
|
1260
1375
|
nested_candidates.append((b["tool_use_id"], b))
|
|
1261
1376
|
ans = b.pop("ask_answers", None) # #177 S2
|
|
@@ -1276,30 +1391,33 @@ def _assemble_session(conn, session_id):
|
|
|
1276
1391
|
tlist_ = b.pop("task_list", None)
|
|
1277
1392
|
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
1278
1393
|
task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
|
|
1279
|
-
# §4 symmetry (#218 I-B.5):
|
|
1280
|
-
#
|
|
1281
|
-
#
|
|
1282
|
-
#
|
|
1283
|
-
#
|
|
1284
|
-
# spawn
|
|
1285
|
-
# spawn-gated
|
|
1286
|
-
agent_link = {t: v for t, v in agent_link.items() if t in
|
|
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}
|
|
1287
1402
|
# §4 1a — nested grandchild results: gate the text-parse on the owning
|
|
1288
|
-
# tool_use being a spawn (in
|
|
1403
|
+
# tool_use being a spawn (in spawn_ids), so an ordinary tool result that
|
|
1289
1404
|
# merely mentions "agentId" never matches.
|
|
1290
1405
|
for _tuid, _block in nested_candidates:
|
|
1291
|
-
if _tuid in
|
|
1406
|
+
if _tuid in spawn_ids and _tuid not in agent_link:
|
|
1292
1407
|
parsed = _parse_nested_agent_result(_block.get("text"))
|
|
1293
1408
|
if parsed is not None:
|
|
1294
1409
|
agent_link[_tuid] = parsed
|
|
1295
1410
|
subagent_meta = {}
|
|
1296
|
-
for _tuid
|
|
1411
|
+
for _tuid in spawn_ids: # document order (ordered dict)
|
|
1297
1412
|
_link = agent_link.get(_tuid)
|
|
1298
1413
|
if _link is None:
|
|
1299
1414
|
continue # spawn with no (yet) result -> title-only
|
|
1300
1415
|
_aid, _raw = _link
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
|
1303
1421
|
_entry["description"] = spawn_desc[_tuid]
|
|
1304
1422
|
for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
|
|
1305
1423
|
if _raw.get(_f) is not None:
|
|
@@ -1869,9 +1987,60 @@ def get_conversation_outline(conn, session_id):
|
|
|
1869
1987
|
# matching the per-item / header cost display precision.
|
|
1870
1988
|
"subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
|
|
1871
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),
|
|
1872
1996
|
"turns": turns}
|
|
1873
1997
|
|
|
1874
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
|
+
|
|
1875
2044
|
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
1876
2045
|
|
|
1877
2046
|
|
|
@@ -2272,8 +2441,8 @@ def _like_escape(q):
|
|
|
2272
2441
|
one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
|
|
2273
2442
|
|
|
2274
2443
|
Single source of the escape chain (#217 S2 / I-3 review Minor #3):
|
|
2275
|
-
``_like_pattern`` (substring) and ``_search_files``'s
|
|
2276
|
-
on this so the escaping is defined exactly once."""
|
|
2444
|
+
``_like_pattern`` (substring) and ``_search_files``'s substring pattern both
|
|
2445
|
+
build on this so the escaping is defined exactly once."""
|
|
2277
2446
|
return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
|
2278
2447
|
|
|
2279
2448
|
|
|
@@ -2664,13 +2833,6 @@ def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_param
|
|
|
2664
2833
|
"total": total}
|
|
2665
2834
|
|
|
2666
2835
|
|
|
2667
|
-
# #217 S2 / I-3: a path query "looks like a prefix" when it carries no leading
|
|
2668
|
-
# path separator — ``bin/cctally`` is a prefix probe, ``/dashboard`` (or a query
|
|
2669
|
-
# that intentionally starts mid-path) forces the substring scan. The set is the
|
|
2670
|
-
# common path separators (POSIX + Windows).
|
|
2671
|
-
_PATH_SEPARATORS = ("/", "\\")
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
2836
|
def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
|
|
2675
2837
|
"""#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
|
|
2676
2838
|
file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
|
|
@@ -2679,28 +2841,22 @@ def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
|
|
|
2679
2841
|
``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
|
|
2680
2842
|
``(session_id, file_path)`` count.
|
|
2681
2843
|
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
``
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
prefix is still constant. A query with a leading separator binds ``%`` +
|
|
2692
|
-
escaped(q) + ``%`` and is a full SCAN (a leading wildcard can never use the
|
|
2693
|
-
index — the intentional, documented substring fallback over the modest table).
|
|
2694
|
-
LIKE is case-insensitive for ASCII (matching the NOCASE collation exactly).
|
|
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.
|
|
2695
2853
|
|
|
2696
2854
|
Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
|
|
2697
2855
|
``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
|
|
2698
2856
|
dedicated session index (the filter is a small IN over the already-narrowed
|
|
2699
2857
|
match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
|
|
2700
2858
|
"""
|
|
2701
|
-
|
|
2702
|
-
prefix = not q.startswith(_PATH_SEPARATORS)
|
|
2703
|
-
pat = (esc + "%") if prefix else ("%" + esc + "%")
|
|
2859
|
+
pat = _like_pattern(q)
|
|
2704
2860
|
fpred = _session_filter_pred("ft.session_id", filt_sql)
|
|
2705
2861
|
fargs = tuple(filt_params)
|
|
2706
2862
|
|