sliceagent 0.1.0__py3-none-any.whl
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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/scheduler.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Conflict-aware tool scheduler.
|
|
2
|
+
|
|
3
|
+
Runs a batch of tool calls with maximum safe concurrency: non-conflicting calls
|
|
4
|
+
overlap, conflicting calls serialize, and results are returned in PROVIDER ORDER
|
|
5
|
+
(deterministic for the model) regardless of completion order.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .access import Accesses, conflict
|
|
14
|
+
|
|
15
|
+
Task = tuple[Accesses, Callable[[], str]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_scheduled(tasks: list[Task], max_workers: int = 8, timeout: float | None = None) -> list[str]:
|
|
19
|
+
"""Run a batch with max safe concurrency, results in PROVIDER ORDER. `timeout` (seconds, opt-in) is a
|
|
20
|
+
per-task WALL-CLOCK deadline: a tool that overruns yields a timeout result so the turn proceeds instead
|
|
21
|
+
of hanging on a stuck tool (the orphaned thread is abandoned, not joined — Python can't force-kill a
|
|
22
|
+
thread, so this is a last-resort net above each tool's own subprocess/SIGALRM timeout). timeout=None
|
|
23
|
+
preserves the original behaviour exactly (wait for every task)."""
|
|
24
|
+
n = len(tasks)
|
|
25
|
+
if n == 0:
|
|
26
|
+
return []
|
|
27
|
+
if n == 1 and timeout is None:
|
|
28
|
+
try:
|
|
29
|
+
return [tasks[0][1]()]
|
|
30
|
+
except Exception as e:
|
|
31
|
+
return [f"Error: {e}"]
|
|
32
|
+
|
|
33
|
+
results: list[str | None] = [None] * n
|
|
34
|
+
accesses = [t[0] for t in tasks]
|
|
35
|
+
pending = list(range(n))
|
|
36
|
+
running: dict = {} # future -> index
|
|
37
|
+
started: dict = {} # future -> monotonic start time (deadline tracking)
|
|
38
|
+
|
|
39
|
+
pool = ThreadPoolExecutor(max_workers=min(max_workers, n))
|
|
40
|
+
abandoned_acc: list[Accesses] = [] # accesses of timed-out, still-LIVE threads — keep blocking conflicts
|
|
41
|
+
try:
|
|
42
|
+
while pending or running:
|
|
43
|
+
running_acc = [accesses[i] for i in running.values()] + abandoned_acc
|
|
44
|
+
selected_acc: list[Accesses] = []
|
|
45
|
+
for idx in list(pending):
|
|
46
|
+
a = accesses[idx]
|
|
47
|
+
# never run two conflicting tasks at once (vs running, an abandoned overrun, or already-selected)
|
|
48
|
+
if any(conflict(a, ra) for ra in running_acc) or any(conflict(a, sa) for sa in selected_acc):
|
|
49
|
+
continue
|
|
50
|
+
fut = pool.submit(tasks[idx][1])
|
|
51
|
+
running[fut] = idx
|
|
52
|
+
started[fut] = time.monotonic()
|
|
53
|
+
selected_acc.append(a)
|
|
54
|
+
pending.remove(idx)
|
|
55
|
+
if not running:
|
|
56
|
+
# nothing running and nothing startable → the remaining pending all conflict with an
|
|
57
|
+
# ABANDONED (never-finishing) thread. Resolve them so the loop can't spin forever.
|
|
58
|
+
for idx in pending:
|
|
59
|
+
results[idx] = ("Error: not run — conflicts with a tool that timed out and is still "
|
|
60
|
+
"running in the background")
|
|
61
|
+
pending.clear()
|
|
62
|
+
break
|
|
63
|
+
if running:
|
|
64
|
+
wait_t = None
|
|
65
|
+
if timeout is not None:
|
|
66
|
+
# wake no later than the SOONEST deadline = the OLDEST running task (MAX elapsed).
|
|
67
|
+
# (min elapsed = newest task = furthest-out deadline → would reap the oldest too late.)
|
|
68
|
+
elapsed_max = max(time.monotonic() - started[f] for f in running)
|
|
69
|
+
wait_t = max(0.05, timeout - elapsed_max)
|
|
70
|
+
done, _ = wait(list(running.keys()), timeout=wait_t, return_when=FIRST_COMPLETED)
|
|
71
|
+
for fut in done:
|
|
72
|
+
idx = running.pop(fut)
|
|
73
|
+
started.pop(fut, None)
|
|
74
|
+
try:
|
|
75
|
+
results[idx] = fut.result()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
results[idx] = f"Error: {e}"
|
|
78
|
+
if timeout is not None: # reap overruns: abandon the wait, let the turn continue
|
|
79
|
+
now = time.monotonic()
|
|
80
|
+
for fut in [f for f in running if now - started[f] >= timeout]:
|
|
81
|
+
idx = running.pop(fut)
|
|
82
|
+
started.pop(fut, None)
|
|
83
|
+
abandoned_acc.append(accesses[idx]) # still-live thread → keep it in the conflict set
|
|
84
|
+
results[idx] = (f"Error: tool timed out after {timeout:g}s "
|
|
85
|
+
"(abandoned; it may still be running in the background)")
|
|
86
|
+
# safety: the loop only exits when pending AND running are empty, so every slot is set — but
|
|
87
|
+
# never let a stray None escape to a caller that expects str.
|
|
88
|
+
return [r if r is not None else "Error: tool produced no result" for r in results]
|
|
89
|
+
finally:
|
|
90
|
+
# never block process teardown on an abandoned/stuck worker (all non-timed-out tasks have
|
|
91
|
+
# already completed by the time we exit the loop, so wait=False is lossless in the normal path).
|
|
92
|
+
pool.shutdown(wait=False)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Cross-session episode search — a durable SQLite FTS5 index over episodic records.
|
|
2
|
+
|
|
3
|
+
Three shapes (discovery / scroll / read), ZERO LLM — every shape returns actual
|
|
4
|
+
stored rows. sliceagent has no transcript, so we
|
|
5
|
+
index per-EPISODE records (one row per turn) appended by hippocampus.py's EpisodeSink. The index is an
|
|
6
|
+
ADDITIVE sidecar over the already-durable JSONL cache — the JSONL stays the source of
|
|
7
|
+
truth, this is a queryable mirror. Recall (hippocampus.py's recall_history) is single-session today; this lets
|
|
8
|
+
it search ACROSS sessions without ever feeding a growing transcript.
|
|
9
|
+
|
|
10
|
+
NO-TRANSCRIPT INVARIANT: this never enters the slice. It is a durable store the model
|
|
11
|
+
queries on demand (exactly like recall_history), bounded by `limit`/snippet length.
|
|
12
|
+
|
|
13
|
+
GRACEFUL DEGRADE: if sqlite3 or FTS5 is unavailable, `EpisodeIndex` construction returns
|
|
14
|
+
a no-op (index_episode does nothing, search returns []), so the JSONL path is untouched.
|
|
15
|
+
|
|
16
|
+
PUBLIC SIGNATURES (pinned — other agents code against these verbatim):
|
|
17
|
+
fts5_available() -> bool
|
|
18
|
+
class EpisodeIndex:
|
|
19
|
+
def __init__(self, db_path: str) -> None
|
|
20
|
+
is_active: bool # False when FTS5 unavailable / open failed
|
|
21
|
+
def index_episode(self, *, session_id: str, task_id: str, turn: int,
|
|
22
|
+
ts: str, title: str, note: str, text: str) -> None
|
|
23
|
+
def search(self, query: str, *, limit: int = 5,
|
|
24
|
+
exclude_session: str | None = None) -> list[dict]
|
|
25
|
+
def close(self) -> None
|
|
26
|
+
episode_searchable_text(record: dict) -> str
|
|
27
|
+
default_index_path() -> str
|
|
28
|
+
|
|
29
|
+
`search` returns a list of dicts:
|
|
30
|
+
{"session_id","task_id","turn","ts","title","note","snippet","score"}
|
|
31
|
+
ordered by FTS5 rank (best first). `snippet` is the FTS5-highlighted excerpt.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import threading
|
|
38
|
+
|
|
39
|
+
_FTS_TABLE = "episodes"
|
|
40
|
+
# Recency tie-break weight for search() — how much a hit's age can re-order it WITHIN the relevance band
|
|
41
|
+
# the floor keeps (bounded by the top lexical score, so it never overrides a clearly-stronger match). At
|
|
42
|
+
# 0.25 a recent hit scoring within ~25% of the top lexical can edge ahead — enough that "the latest review"
|
|
43
|
+
# wins over a stale turn that merely shares more keywords, without recency dominating relevance.
|
|
44
|
+
_RECENCY_W = 0.25
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fts_match_query(q: str) -> str:
|
|
48
|
+
"""Turn a free-text query into a SAFE FTS5 MATCH expression. Extract word tokens only and quote each
|
|
49
|
+
(so query punctuation/operators - " * : ( ) AND OR NEAR can never trigger a syntax error that silently
|
|
50
|
+
returns nothing), then OR-join them. OR (not AND) is the recall-correct default: a query carries terms
|
|
51
|
+
the target turn won't all contain — meta/ordinal words ("second", "finding"), the user's own framing,
|
|
52
|
+
stray operators — and AND-joining means ONE absent token zeroes the whole result (the 'can't locate my
|
|
53
|
+
second finding' bug: 'second' appeared in no review turn, so the AND failed). With OR, any term surfaces
|
|
54
|
+
the turn and BM25 rank + the relative floor in search() keep it precise. Empty → '' (no search)."""
|
|
55
|
+
toks = re.findall(r"\w+", q or "", flags=re.UNICODE)
|
|
56
|
+
return " OR ".join(f'"{t}"' for t in toks)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fts5_available() -> bool:
|
|
60
|
+
"""True iff this Python's sqlite3 can create an FTS5 virtual table."""
|
|
61
|
+
try:
|
|
62
|
+
import sqlite3
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
con = sqlite3.connect(":memory:")
|
|
67
|
+
try:
|
|
68
|
+
con.execute("CREATE VIRTUAL TABLE _probe USING fts5(x)")
|
|
69
|
+
return True
|
|
70
|
+
finally:
|
|
71
|
+
con.close()
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_index_path() -> str:
|
|
77
|
+
"""The index lives under the same vault as the episodic JSONL (memory._vault_root).
|
|
78
|
+
|
|
79
|
+
Lazy import keeps this module free of a hard memory.py dependency for tests.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from .memory import _vault_root
|
|
83
|
+
root = _vault_root()
|
|
84
|
+
except Exception:
|
|
85
|
+
root = os.path.join(os.path.expanduser("~"), ".sliceagent", "vault")
|
|
86
|
+
return os.path.join(root, "episodic", "index.db")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def episode_searchable_text(record: dict) -> str:
|
|
90
|
+
"""Flatten an episode `record` into one searchable blob (title + note + actions +
|
|
91
|
+
observations + files). Deterministic, bounded per-field so one huge observation can't
|
|
92
|
+
dominate the index. Mirrors what history.render_trace surfaces, so a search matches
|
|
93
|
+
what the model would actually read back."""
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
title = record.get("title") or ""
|
|
96
|
+
if title:
|
|
97
|
+
parts.append(title)
|
|
98
|
+
note = record.get("note") or ""
|
|
99
|
+
if note:
|
|
100
|
+
parts.append(note)
|
|
101
|
+
for st in record.get("steps", []) or []:
|
|
102
|
+
for a in st.get("action", []) or []:
|
|
103
|
+
name = a.get("name") or ""
|
|
104
|
+
args = a.get("args")
|
|
105
|
+
args = args if isinstance(args, dict) else {} # tolerate a non-dict in an OLD persisted episode
|
|
106
|
+
hint = ""
|
|
107
|
+
for k in ("path", "command", "query", "goal"):
|
|
108
|
+
if args.get(k):
|
|
109
|
+
hint = str(args[k])[:120]
|
|
110
|
+
break
|
|
111
|
+
parts.append(f"{name} {hint}".strip())
|
|
112
|
+
for o in st.get("observation", []) or []:
|
|
113
|
+
if isinstance(o, str) and o:
|
|
114
|
+
parts.append(o[:500]) # bounded — head of each observation
|
|
115
|
+
meta = record.get("meta", {}) or {}
|
|
116
|
+
for f in meta.get("files", []) or []:
|
|
117
|
+
parts.append(str(f))
|
|
118
|
+
return "\n".join(p for p in parts if p)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class EpisodeIndex:
|
|
122
|
+
"""SQLite FTS5 mirror of episodic records, queryable across sessions.
|
|
123
|
+
|
|
124
|
+
Best-effort throughout: every method swallows its own errors (an index hiccup must
|
|
125
|
+
never break a session — same discipline as memory.append_episode). When FTS5 is
|
|
126
|
+
unavailable or the DB can't open, `is_active` is False and all writes/reads no-op.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, db_path: str) -> None:
|
|
130
|
+
self.db_path = db_path
|
|
131
|
+
self.is_active = False
|
|
132
|
+
self._con = None
|
|
133
|
+
# the connection is shared (check_same_thread=False) across parallel explorer subagents — sqlite
|
|
134
|
+
# connections are NOT safe for concurrent cursors, so serialize every statement+fetch/commit.
|
|
135
|
+
self._lock = threading.Lock()
|
|
136
|
+
if not fts5_available():
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
import sqlite3
|
|
140
|
+
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
|
141
|
+
# check_same_thread=False so the OPT-IN background-review fork (item 16) can
|
|
142
|
+
# index from its worker thread without a second connection.
|
|
143
|
+
con = sqlite3.connect(db_path, check_same_thread=False)
|
|
144
|
+
con.execute(
|
|
145
|
+
f"CREATE VIRTUAL TABLE IF NOT EXISTS {_FTS_TABLE} USING fts5("
|
|
146
|
+
"session_id UNINDEXED, task_id UNINDEXED, turn UNINDEXED, "
|
|
147
|
+
"ts UNINDEXED, title, note, body, "
|
|
148
|
+
"tokenize='porter unicode61')"
|
|
149
|
+
)
|
|
150
|
+
con.commit()
|
|
151
|
+
self._con = con
|
|
152
|
+
self.is_active = True
|
|
153
|
+
except Exception:
|
|
154
|
+
self._con = None
|
|
155
|
+
self.is_active = False
|
|
156
|
+
|
|
157
|
+
def index_episode(self, *, session_id: str, task_id: str, turn: int,
|
|
158
|
+
ts: str, title: str, note: str, text: str) -> None:
|
|
159
|
+
"""Insert one episode row. Idempotent per (session_id, turn): a re-index deletes
|
|
160
|
+
the prior row first, so a replayed/duplicate append can't double-count."""
|
|
161
|
+
if not self.is_active or self._con is None:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
with self._lock: # serialize the write txn vs concurrent reads on the shared connection
|
|
165
|
+
# turn is stored as TEXT (FTS5 UNINDEXED preserves the exact type), so the DELETE
|
|
166
|
+
# must bind the SAME str — int 2 != stored '2' and the row would never be removed.
|
|
167
|
+
self._con.execute(
|
|
168
|
+
f"DELETE FROM {_FTS_TABLE} WHERE session_id = ? AND turn = ?",
|
|
169
|
+
(session_id, str(turn)),
|
|
170
|
+
)
|
|
171
|
+
self._con.execute(
|
|
172
|
+
f"INSERT INTO {_FTS_TABLE} "
|
|
173
|
+
"(session_id, task_id, turn, ts, title, note, body) "
|
|
174
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
175
|
+
(session_id, task_id, str(turn), ts, title or "", note or "", text or ""),
|
|
176
|
+
)
|
|
177
|
+
self._con.commit()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def search(self, query: str, *, limit: int = 5,
|
|
182
|
+
exclude_session: str | None = None,
|
|
183
|
+
only_session: str | None = None) -> list[dict]:
|
|
184
|
+
"""FTS5 discovery over indexed sessions. Returns bounded hit dicts ordered by rank.
|
|
185
|
+
`exclude_session` drops the current session lineage (cross-session recall). `only_session`
|
|
186
|
+
restricts to ONE session (within-session content recall of the long tail — turns past the
|
|
187
|
+
manifest/index window). Opposite scopings; pass at most one. Never raises."""
|
|
188
|
+
if not self.is_active or self._con is None:
|
|
189
|
+
return []
|
|
190
|
+
match = _fts_match_query(query)
|
|
191
|
+
if not match:
|
|
192
|
+
return []
|
|
193
|
+
try:
|
|
194
|
+
lim = max(1, min(int(limit), 20))
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
lim = 5
|
|
197
|
+
sel = (f"SELECT session_id, task_id, turn, ts, title, note, "
|
|
198
|
+
f"snippet({_FTS_TABLE}, 6, '«', '»', ' … ', 12) AS snip, "
|
|
199
|
+
f"rank AS score "
|
|
200
|
+
f"FROM {_FTS_TABLE} WHERE {_FTS_TABLE} MATCH ? ")
|
|
201
|
+
try:
|
|
202
|
+
with self._lock: # serialize cursor use on the shared connection (parallel explorers)
|
|
203
|
+
if only_session:
|
|
204
|
+
# session_id is an UNINDEXED FTS5 column → push the filter into SQL. A globally-ranked
|
|
205
|
+
# over-fetch + Python post-filter STARVES to [] when other sessions out-rank the target
|
|
206
|
+
# (the long-tail recall this serves is exactly that case). No +10 needed: nothing dropped.
|
|
207
|
+
rows = self._con.execute(
|
|
208
|
+
sel + "AND session_id = ? ORDER BY rank LIMIT ?",
|
|
209
|
+
(match, only_session, lim),
|
|
210
|
+
).fetchall()
|
|
211
|
+
else:
|
|
212
|
+
rows = self._con.execute(
|
|
213
|
+
sel + "ORDER BY rank LIMIT ?",
|
|
214
|
+
(match, lim + 10), # over-fetch so exclude_session can't starve the result
|
|
215
|
+
).fetchall()
|
|
216
|
+
except Exception:
|
|
217
|
+
return []
|
|
218
|
+
out: list[dict] = []
|
|
219
|
+
for r in rows:
|
|
220
|
+
session_id = r[0]
|
|
221
|
+
if exclude_session and session_id == exclude_session:
|
|
222
|
+
continue
|
|
223
|
+
if only_session and session_id != only_session:
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
turn = int(r[2])
|
|
227
|
+
except (TypeError, ValueError):
|
|
228
|
+
turn = r[2]
|
|
229
|
+
out.append({
|
|
230
|
+
"session_id": session_id,
|
|
231
|
+
"task_id": r[1],
|
|
232
|
+
"turn": turn,
|
|
233
|
+
"ts": r[3],
|
|
234
|
+
"title": r[4],
|
|
235
|
+
"note": r[5],
|
|
236
|
+
"snippet": r[6],
|
|
237
|
+
# #41: FTS5 `rank` is negative (more-negative = better). Negate so callers reading `score`
|
|
238
|
+
# get an intuitive higher-is-better number; result ORDER already follows `rank` directly.
|
|
239
|
+
"score": -float(r[7]) if r[7] is not None else 0.0,
|
|
240
|
+
})
|
|
241
|
+
# collect ALL candidates (no early break) — the floor + recency re-rank below need them.
|
|
242
|
+
# RELATIVE FLOOR (counterweight to OR-breadth): keep only hits scoring within 15% of the top hit,
|
|
243
|
+
# always keeping #1. OR-join maximizes recall; this trims the long tail of turns that matched on a
|
|
244
|
+
# single weak/common term, so a precise query still returns a precise set (and a vague one degrades
|
|
245
|
+
# to "the few most relevant", not "30 loosely-related turns"). Degenerate scores (≤0) → keep all.
|
|
246
|
+
if out:
|
|
247
|
+
top = out[0]["score"]
|
|
248
|
+
if top > 0:
|
|
249
|
+
cut = top * 0.15
|
|
250
|
+
out = [out[0]] + [h for h in out[1:] if h["score"] >= cut]
|
|
251
|
+
# RECENCY RE-RANK: among the comparably-relevant survivors the floor kept, prefer the more RECENT
|
|
252
|
+
# turn — a fresh discussion of a topic should win over a stale one ("the findings" = the latest
|
|
253
|
+
# review, not an older mention that shares more keywords). The bonus is bounded by the TOP lexical
|
|
254
|
+
# score, so recency only re-orders WITHIN the relevance band; it can never pull in a turn the floor
|
|
255
|
+
# rejected (relevance stays the gate, recency is the tie-break — this is the recall channel, so the
|
|
256
|
+
# moat's "relevant push" stays primary). Age order = ts then turn.
|
|
257
|
+
if len(out) > 1:
|
|
258
|
+
tops = out[0]["score"] or 1.0
|
|
259
|
+
def _age_key(h):
|
|
260
|
+
t = h.get("turn") or 0
|
|
261
|
+
try:
|
|
262
|
+
t = int(t)
|
|
263
|
+
except (TypeError, ValueError):
|
|
264
|
+
t = 0 # a str/None turn must not make sort compare str vs int (TypeError)
|
|
265
|
+
return ((h.get("ts") or ""), t)
|
|
266
|
+
by_age = sorted(out, key=_age_key) # oldest→newest
|
|
267
|
+
n = len(by_age)
|
|
268
|
+
for k, h in enumerate(by_age):
|
|
269
|
+
h["_blend"] = h["score"] + _RECENCY_W * tops * (k / (n - 1))
|
|
270
|
+
out.sort(key=lambda h: h["_blend"], reverse=True)
|
|
271
|
+
for h in out:
|
|
272
|
+
h.pop("_blend", None)
|
|
273
|
+
return out[:lim]
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
with self._lock: # serialize close vs an in-flight index_episode/search on the shared connection
|
|
277
|
+
if self._con is not None:
|
|
278
|
+
try:
|
|
279
|
+
self._con.close()
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
self._con = None
|
|
283
|
+
self.is_active = False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def make_episode_index(db_path: str | None = None) -> EpisodeIndex:
|
|
287
|
+
"""Factory. `db_path` defaults to default_index_path(). Always returns an EpisodeIndex
|
|
288
|
+
(its `is_active` flag tells callers whether FTS5 actually came up)."""
|
|
289
|
+
return EpisodeIndex(db_path or default_index_path())
|