feat(scripts-session-stats): added session-stats search relevance plotting

- Changed multi-file search paging to skip whole files and page results in file windows.
- Added per-file match caps, round-robin file selection, and new file-limit truncation reporting.
- Replaced match/result limit metadata with fileLimitReached and perFileLimitReached.
- Lowered read.defaultLimit default to 300 with 1 lead and 3 trailing context lines.
- Replaced the search skip test with file-pagination coverage and added per-file cap tests.
- Added session-stats analytics tooling to classify searches, detect repeats, and render relevance plots.
This commit is contained in:
can1357
2026-05-13 11:38:09 +02:00
parent a541a63547
commit 9828764cb4
6 changed files with 741 additions and 81 deletions
+7 -1
View File
@@ -1,12 +1,18 @@
# Changelog
## [Unreleased]
### Added
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://<id>`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
### Changed
- Changed `search` pagination in multi-file scopes so `skip` now skips entire files and pages results in groups of up to 20 files, with output guiding the next `skip` value via `Showing files X-Y of N`
- Changed multi-file search result selection to cap each file at 20 matches and round-robin across files, so one noisy file no longer suppresses visibility of hits in other files and truncation now reports per-file limits
- Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly
- Lowered `read.defaultLimit` default from `500` to `300` lines, and split the per-range context padding into asymmetric `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` (was symmetric `RANGE_CONTEXT_LINES = 3`). Replay analysis over post-summarizer sessions (`scripts/session-stats/optimize_read_config.py`) showed that bare-path reads are over-provisioned at the median (file p50 = 220 lines) and that most follow-up reads are disjoint hops rather than adjacent extensions — so a smaller default plus narrower leading context reclaims tokens without measurably changing first-cover rate. Trailing context stays at 3 lines to keep anchor-stale recovery on narrow reads. Explicit `read.defaultLimit` overrides in settings are honoured unchanged.
## [15.0.0] - 2026-05-13
### Breaking Changes
+85 -55
View File
@@ -47,17 +47,36 @@ const searchSchema = Type.Object({
}),
i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
skip: Type.Optional(
Type.Number({
description:
"files to skip before collecting results — use to paginate when the prior call hit the file limit",
default: 0,
}),
),
});
export type SearchToolInput = Static<typeof searchSchema>;
export const DEFAULT_MATCH_LIMIT = 100;
/** Maximum number of distinct files surfaced in a single response. The
* agent paginates further pages via `skip`. */
export const DEFAULT_FILE_LIMIT = 20;
/** Per-file match cap for multi-file searches — keeps a single hot file
* from crowding out diverse hits. Applied in JS after grep returns. */
export const MULTI_FILE_PER_FILE_MATCHES = 20;
/** Per-file match cap for single-file searches — there's no diversity
* concern when the scope is one file. */
export const SINGLE_FILE_MATCHES = 200;
/** Hard safety ceiling on how many matches we fetch from native grep
* before JS-side grouping. Sized to comfortably cover the file window
* (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
* pagination headroom so the caller can see total file count. */
const INTERNAL_TOTAL_CAP = 2000;
export interface SearchToolDetails {
truncation?: TruncationResult;
matchLimitReached?: number;
resultLimitReached?: number;
fileLimitReached?: number;
perFileLimitReached?: number;
linesTruncated?: boolean;
meta?: OutputMeta;
scopePath?: string;
@@ -196,8 +215,12 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
}
const effectiveOutputMode = GrepOutputMode.Content;
const effectiveLimit = DEFAULT_MATCH_LIMIT;
const internalLimit = Math.min(effectiveLimit * 5, 2000);
// Multi-scope = more than one file may match. We fetch up to
// INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
// file, apply a per-file cap (so one hot file doesn't crowd the
// window), and round-robin emit from up to DEFAULT_FILE_LIMIT files.
const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets);
const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
// Run grep
let result: GrepResult;
@@ -221,7 +244,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
hidden: true,
gitignore: useGitignore,
cache: false,
maxCount: exactFilePaths ? undefined : internalLimit,
maxCount: INTERNAL_TOTAL_CAP,
contextBefore: normalizedContextBefore,
contextAfter: normalizedContextAfter,
maxColumns: DEFAULT_MAX_COLUMN,
@@ -238,11 +261,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
matches.push({ ...match, path: rebased });
}
}
const offsetMatches = matches.slice(normalizedSkip);
result = {
matches: offsetMatches,
totalMatches: exactFilePaths ? offsetMatches.length : totalMatches,
filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
matches,
totalMatches: exactFilePaths ? matches.length : totalMatches,
filesWithMatches: new Set(matches.map(match => match.path)).size,
filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
limitReached,
};
@@ -257,8 +279,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
hidden: true,
gitignore: useGitignore,
cache: false,
maxCount: internalLimit,
offset: normalizedSkip > 0 ? normalizedSkip : undefined,
maxCount: INTERNAL_TOTAL_CAP,
contextBefore: normalizedContextBefore,
contextAfter: normalizedContextAfter,
maxColumns: DEFAULT_MAX_COLUMN,
@@ -277,42 +298,51 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const formatPath = (filePath: string): string =>
formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
// Build output
const roundRobinSelect = (matches: GrepMatch[], limit: number): GrepMatch[] => {
if (matches.length <= limit) return matches;
const fileOrder: string[] = [];
const byFile = new Map<string, GrepMatch[]>();
for (const match of matches) {
if (!byFile.has(match.path)) {
fileOrder.push(match.path);
byFile.set(match.path, []);
}
byFile.get(match.path)!.push(match);
// Group matches by file in encounter order. Detect per-file overflow
// BEFORE truncation so the renderer can surface that a hot file was
// trimmed for diversity.
const fileOrder: string[] = [];
const matchesByPath = new Map<string, GrepMatch[]>();
for (const match of result.matches) {
if (!matchesByPath.has(match.path)) {
fileOrder.push(match.path);
matchesByPath.set(match.path, []);
}
const selected: GrepMatch[] = [];
const indices = new Map<string, number>(fileOrder.map(file => [file, 0]));
while (selected.length < limit) {
let anyAdded = false;
for (const file of fileOrder) {
if (selected.length >= limit) break;
const fileMatches = byFile.get(file)!;
const idx = indices.get(file)!;
if (idx < fileMatches.length) {
selected.push(fileMatches[idx]);
indices.set(file, idx + 1);
matchesByPath.get(match.path)!.push(match);
}
let perFileLimitReached = false;
for (const file of fileOrder) {
const list = matchesByPath.get(file)!;
if (list.length > perFileMatchCap) {
perFileLimitReached = true;
list.length = perFileMatchCap;
}
}
const totalFiles = fileOrder.length;
// Single-file scopes can't paginate — there is one file by definition.
const canPaginate = isMultiScope;
const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
const selectedMatches: GrepMatch[] = [];
if (windowFiles.length > 0) {
const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
const cursors = new Array<number>(lists.length).fill(0);
let anyAdded = true;
while (anyAdded) {
anyAdded = false;
for (let i = 0; i < lists.length; i++) {
if (cursors[i] < lists[i].length) {
selectedMatches.push(lists[i][cursors[i]++]);
anyAdded = true;
}
}
if (!anyAdded) break;
}
return selected;
};
const selectedMatches = isDirectory
? roundRobinSelect(result.matches, effectiveLimit)
: result.matches.slice(0, effectiveLimit);
const matchLimitReached = result.matches.length > effectiveLimit;
const nextSkip = normalizedSkip + selectedMatches.length;
const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
}
const nextSkip = skipFiles + windowFiles.length;
const limitMessage = fileLimitReached
? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFiles}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.`
: "";
const { record: recordFile, list: fileList } = createFileRecorder();
const fileMatchCounts = new Map<string, number>();
const missingPathsNote =
@@ -405,7 +435,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
displayLines.push(...rendered.display);
}
}
if (matchLimitReached || result.limitReached) {
if (limitMessage) {
outputLines.push("", limitMessage);
}
if (missingPathsNote) {
@@ -414,7 +444,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const output = truncation.content;
const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated || linesTruncated);
const truncated = Boolean(
fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
);
const details: SearchToolDetails = {
scopePath,
matchCount: selectedMatches.length,
@@ -425,8 +457,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
count: fileMatchCounts.get(path) ?? 0,
})),
truncated,
matchLimitReached: matchLimitReached ? effectiveLimit : undefined,
resultLimitReached: result.limitReached ? internalLimit : undefined,
fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
displayContent: displayLines.join("\n"),
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
@@ -530,9 +562,7 @@ export const searchToolRenderer = {
const fileCount = details?.fileCount ?? 0;
const truncation = details?.meta?.truncation;
const limits = details?.meta?.limits;
const truncated = Boolean(
details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
);
const truncated = Boolean(details?.truncated || truncation || limits?.columnTruncated);
const missingPathsList = details?.missingPaths ?? [];
const missingNote =
@@ -584,11 +614,11 @@ export const searchToolRenderer = {
}
}
const renderedMatchLimit = details?.matchLimitReached ?? limits?.matchLimit?.reached;
const renderedResultLimit = details?.resultLimitReached ?? limits?.resultLimit?.reached;
const renderedFileLimit = details?.fileLimitReached;
const renderedPerFileLimit = details?.perFileLimitReached;
const truncationReasons: string[] = [];
if (renderedMatchLimit) truncationReasons.push(`first ${renderedMatchLimit} matches`);
if (renderedResultLimit) truncationReasons.push(`first ${renderedResultLimit} results`);
if (renderedFileLimit) truncationReasons.push(`first ${renderedFileLimit} files (skip to paginate)`);
if (renderedPerFileLimit) truncationReasons.push(`first ${renderedPerFileLimit} matches per file`);
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
+70 -19
View File
@@ -15,7 +15,7 @@ import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find";
import { JobTool } from "@oh-my-pi/pi-coding-agent/tools/job";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { DEFAULT_MATCH_LIMIT, SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { DEFAULT_FILE_LIMIT, MULTI_FILE_PER_FILE_MATCHES, SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
import { $which, Snowflake } from "@oh-my-pi/pi-utils";
import { unzipSync } from "fflate";
@@ -1402,20 +1402,30 @@ function b() {
expect(output).toMatch(/\*5\|match two/);
});
it("should skip matches with the skip parameter", async () => {
const testFile = path.join(testDir, "skip.txt");
fs.writeFileSync(testFile, ["needle one", "needle two", "needle three"].join("\n"));
it("should paginate files via the skip parameter", async () => {
const skipDir = path.join(testDir, "skip-dir");
fs.mkdirSync(skipDir, { recursive: true });
for (let i = 1; i <= 4; i++) {
fs.writeFileSync(path.join(skipDir, `file-${i}.txt`), `needle ${i}`);
}
const result = await searchTool.execute("test-call-12-skip", {
const first = await searchTool.execute("test-call-12-skip-first", {
pattern: "needle",
paths: [testFile],
skip: 1,
paths: [skipDir],
});
expect(first.details?.fileCount).toBe(4);
const output = getTextOutput(result);
expect(output).not.toContain("needle one");
expect(output).toContain("needle two");
expect(output).toContain("needle three");
const second = await searchTool.execute("test-call-12-skip-page", {
pattern: "needle",
paths: [skipDir],
skip: 2,
});
const secondOutput = getTextOutput(second);
expect(second.details?.fileCount).toBe(2);
expect(secondOutput).not.toContain("# file-1.txt");
expect(secondOutput).not.toContain("# file-2.txt");
expect(secondOutput).toContain("# file-3.txt");
expect(secondOutput).toContain("# file-4.txt");
});
it("should group multi-file matches", async () => {
@@ -1541,19 +1551,60 @@ function b() {
expect(result.details?.fileCount).toBe(1);
expect(result.details?.matchCount).toBe(1);
});
it("should apply the fixed default match cap", async () => {
const lines = Array.from({ length: DEFAULT_MATCH_LIMIT + 100 }, (_, i) => `needle ${i + 1}`);
fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n"));
it("should cap distinct files and surface pagination", async () => {
const limitDir = path.join(testDir, "file-limit-dir");
fs.mkdirSync(limitDir, { recursive: true });
const totalFiles = DEFAULT_FILE_LIMIT + 4;
for (let i = 1; i <= totalFiles; i++) {
fs.writeFileSync(path.join(limitDir, `f-${String(i).padStart(2, "0")}.txt`), `needle ${i}`);
}
const result = await searchTool.execute("test-call-14-default-limit", {
const result = await searchTool.execute("test-call-14-file-limit", {
pattern: "needle",
paths: [testDir],
paths: [limitDir],
});
const output = getTextOutput(result);
expect(output).toContain(`Result limit reached; narrow paths or use skip=${DEFAULT_MATCH_LIMIT}.`);
expect(result.details?.matchCount).toBe(DEFAULT_MATCH_LIMIT);
expect(result.details?.matchLimitReached).toBe(DEFAULT_MATCH_LIMIT);
expect(result.details?.fileCount).toBe(DEFAULT_FILE_LIMIT);
expect(result.details?.matchCount).toBe(DEFAULT_FILE_LIMIT);
expect(result.details?.fileLimitReached).toBe(DEFAULT_FILE_LIMIT);
expect(output).toContain(`Showing files 1-${DEFAULT_FILE_LIMIT} of ${totalFiles}`);
expect(output).toContain(`Use skip=${DEFAULT_FILE_LIMIT}`);
});
it("should cap matches per file in multi-file scopes", async () => {
const concDir = path.join(testDir, "concentration-dir");
fs.mkdirSync(concDir, { recursive: true });
const hotMatches = MULTI_FILE_PER_FILE_MATCHES + 30;
fs.writeFileSync(
path.join(concDir, "hot.txt"),
Array.from({ length: hotMatches }, (_, i) => `needle ${i + 1}`).join("\n"),
);
fs.writeFileSync(path.join(concDir, "cool.txt"), "needle cool");
const result = await searchTool.execute("test-call-14-per-file-cap", {
pattern: "needle",
paths: [concDir],
});
const hotCount = result.details?.fileMatches?.find(entry => entry.path.endsWith("hot.txt"))?.count ?? 0;
expect(hotCount).toBe(MULTI_FILE_PER_FILE_MATCHES);
expect(result.details?.perFileLimitReached).toBe(MULTI_FILE_PER_FILE_MATCHES);
});
it("should let a single-file scope exceed the multi-file per-file cap", async () => {
const single = path.join(testDir, "single-file.txt");
const count = MULTI_FILE_PER_FILE_MATCHES + 30;
fs.writeFileSync(single, Array.from({ length: count }, (_, i) => `needle ${i + 1}`).join("\n"));
const result = await searchTool.execute("test-call-14-single-file-cap", {
pattern: "needle",
paths: [single],
});
expect(result.details?.matchCount).toBe(count);
expect(result.details?.fileLimitReached).toBeUndefined();
expect(result.details?.perFileLimitReached).toBeUndefined();
});
});
@@ -19,11 +19,7 @@ describe("searchToolRenderer", () => {
details: {
matchCount: 6,
fileCount: 3,
meta: {
limits: {
matchLimit: { reached: 6 },
},
},
fileLimitReached: 3,
},
};
@@ -39,7 +35,7 @@ describe("searchToolRenderer", () => {
const bodyLines = renderedLines.slice(1);
expect(bodyLines).toHaveLength(6);
expect(bodyLines.at(-1)).toContain("truncated: first 6 matches");
expect(bodyLines.at(-1)).toContain("truncated: first 3 files (skip to paginate)");
expect(bodyLines.some(line => line.includes("1 more match"))).toBe(true);
expect(bodyLines.some(line => line.includes("gamma:1"))).toBe(false);
});
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
How relevant are search/grep results?
For every search/grep call we extract the list of distinct file paths the
result mentioned (in order of first appearance), then look ahead at the
session's subsequent tool calls for engagement:
ENGAGED-READ : the model `read` a file from the result list.
We record the deepest index reached (1-based).
NEXT-PAGE : the model issued the same search/grep again with
`skip` or `offset` > 0 (asked for more results).
REFINED : the model issued a *different* search/grep before
engaging with any result (probably narrowed query).
ABANDONED : neither — switched topic / used something else.
Window: until the next user message, an end-of-session, or LOOKAHEAD calls,
whichever comes first.
Outputs scripts/session-stats/out/search-relevance.png.
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
DB_PATH = Path.home() / ".omp" / "stats.db"
OUT_DIR = Path(__file__).resolve().parent / "out"
DEFAULT_SINCE = "2026-04-01" # search/grep traffic before this is sparse
LOOKAHEAD = 30 # max tool calls to scan after a search
# --------------------------------------------------------------------------- #
# Path extraction from result_text
# Tree-style headers: `# packages/foo/bar` then `## └─ file.ext`
_TREE_DIR = re.compile(r"^#\s+(\S[^\n]*?)\s*$", re.M)
_TREE_FILE = re.compile(r"^##\s+└─\s+(\S[^\n]*?)\s*$", re.M)
# Flat path with line marker: `path/to/file.ext:fn_X>14|…` or `path:14|…`
# Require an extension on the filename so we don't match anchor-prefixed
# in-file results (`1136xo|…`).
_FLAT_PATH = re.compile(
r"(?m)^\s*([A-Za-z0-9_./~+\-][A-Za-z0-9_./~+\-]*\.[A-Za-z0-9]{1,8})[:#]",
)
def extract_paths(result_text: str | None) -> tuple[list[str], dict[str, int]]:
"""Returns (distinct paths in order, dict of path -> match-line count).
A "match line" is any indented match row attributed to that file
(tree format) or any line starting with `<path>:` (flat format).
Header lines and directory headers don't count.
"""
if not result_text:
return [], {}
seen: dict[str, None] = {}
counts: dict[str, int] = {}
current_dir: str | None = None
current_file: str | None = None
for line in result_text.splitlines():
stripped = line.rstrip()
# `## └─ filename` — new file under current_dir.
m = _TREE_FILE.match(stripped)
if m and current_dir is not None:
path = f"{current_dir.rstrip('/')}/{m.group(1).strip()}"
seen.setdefault(path, None)
counts.setdefault(path, 0)
current_file = path
continue
# `# dir` — directory header.
m = _TREE_DIR.match(stripped)
if m:
current_dir = m.group(1).strip()
current_file = None
continue
# Flat path with line marker (`path/file.ext:14|…`).
flat_match = None
for fm in _FLAT_PATH.finditer(stripped):
flat_match = fm
if flat_match is not None:
p = flat_match.group(1)
seen.setdefault(p, None)
counts[p] = counts.get(p, 0) + 1
current_file = None
continue
# Otherwise treat as a body row for the most recently named file.
if current_file is not None and stripped:
# Skip anchor markers like `@imp_2#WJMV` and pure separators.
if stripped.startswith(("@", "-@", "----@", "#")):
continue
counts[current_file] = counts.get(current_file, 0) + 1
return list(seen.keys()), counts
# --------------------------------------------------------------------------- #
# Tool-call helpers
def search_signature(arg_obj: dict) -> tuple:
"""Stable identity key for a search/grep call: (pattern, path-scope).
`pattern` is the regex/text query. `path` may be a single string or
list. Skip / offset / limit are deliberately excluded — they're the
pagination axis we want to detect.
"""
pattern = arg_obj.get("pattern")
path = arg_obj.get("path") or arg_obj.get("paths")
if isinstance(path, list):
path = tuple(sorted(str(p) for p in path))
elif isinstance(path, str):
path = (path,)
else:
path = ()
return (pattern, path)
def search_offset(arg_obj: dict) -> int:
for key in ("skip", "offset"):
v = arg_obj.get(key)
if isinstance(v, int):
return v
if isinstance(v, str):
try:
return int(v)
except ValueError:
continue
return 0
def read_path(arg_obj: dict) -> str | None:
p = arg_obj.get("path")
if not isinstance(p, str):
return None
# Strip selector for path matching.
tail_idx = p.rfind("/")
tail = p[tail_idx + 1 :]
colon = tail.rfind(":")
if colon > 0:
return p[: tail_idx + 1] + tail[:colon] if tail_idx >= 0 else tail[:colon]
return p
# --------------------------------------------------------------------------- #
# Per-session walk
def classify_sessions(conn: sqlite3.Connection, since_ms: int) -> list[dict]:
"""Walks each session in seq order, classifying every search/grep call."""
# Pull calls + paired results in one ordered stream per session.
sql = """
SELECT
c.session_file, c.seq, c.tool_name, c.arg_json,
r.result_text
FROM ss_tool_calls c
LEFT JOIN ss_tool_results r
ON r.session_file = c.session_file
AND r.call_id = c.call_id
AND r.seq >= c.seq
WHERE c.timestamp >= ?
ORDER BY c.session_file, c.seq
"""
by_session: dict[str, list[tuple]] = defaultdict(list)
for row in conn.execute(sql, (since_ms,)):
by_session[row[0]].append(row[1:])
# Also need user message seqs for window cutoffs.
user_seqs: dict[str, list[int]] = defaultdict(list)
for sess, seq in conn.execute(
"SELECT session_file, seq FROM ss_user_msgs WHERE timestamp >= ? ORDER BY session_file, seq",
(since_ms,),
):
user_seqs[sess].append(seq)
records: list[dict] = []
for sess, calls in by_session.items():
user_msg_seqs = user_seqs.get(sess, [])
for idx, (seq, tool, arg_json, result_text) in enumerate(calls):
if tool not in ("search", "grep"):
continue
try:
arg = json.loads(arg_json) if arg_json else {}
except json.JSONDecodeError:
continue
sig = search_signature(arg)
if sig[0] is None:
continue
paths, match_counts = extract_paths(result_text)
if not paths:
# Empty / unparseable results: skip — there's nothing to engage with.
continue
cutoff_seq = next((s for s in user_msg_seqs if s > seq), None)
outcome = walk_ahead(calls, idx, sig, paths, cutoff_seq)
outcome["session"] = sess
outcome["seq"] = seq
outcome["tool"] = tool
outcome["pattern"] = sig[0]
outcome["n_results"] = len(paths)
outcome["matches_per_file"] = [match_counts.get(p, 0) for p in paths]
records.append(outcome)
return records
def walk_ahead(calls, idx, sig, paths, cutoff_seq) -> dict:
"""Scan forward from `idx` and classify what the model did with the result list."""
path_to_index = {p: i for i, p in enumerate(paths)}
deepest_index: int | None = None
next_page = False
refined = False
engaged_count = 0
upper = min(len(calls), idx + 1 + LOOKAHEAD)
for j in range(idx + 1, upper):
seq, tool, arg_json, _result = calls[j]
if cutoff_seq is not None and seq >= cutoff_seq:
break
try:
arg = json.loads(arg_json) if arg_json else {}
except json.JSONDecodeError:
continue
if tool == "read":
path = read_path(arg)
if path is not None and path in path_to_index:
deepest_index = max(deepest_index or 0, path_to_index[path])
engaged_count += 1
continue
if tool in ("search", "grep"):
other_sig = search_signature(arg)
if other_sig == sig and search_offset(arg) > 0:
next_page = True
# Don't break — model may also read something afterward.
continue
if other_sig != sig:
# Different query — flag as potential refinement, but keep
# scanning in case the model later reads from THIS list.
refined = True
continue
# Outcome label.
if deepest_index is not None:
outcome = "engaged-read"
elif next_page:
outcome = "next-page"
elif refined:
outcome = "refined"
else:
outcome = "abandoned"
return {
"outcome": outcome,
"deepest_index": deepest_index,
"engaged_count": engaged_count,
"next_page": next_page,
"refined": refined,
}
# --------------------------------------------------------------------------- #
# Reporting
OUTCOME_COLORS = {
"engaged-read": "#16a34a",
"next-page": "#2563eb",
"refined": "#d97706",
"abandoned": "#9ca3af",
}
def report(records: list[dict]) -> None:
if not records:
print("no search/grep calls with extractable paths in window.")
return
total = len(records)
by_outcome = defaultdict(int)
for r in records:
by_outcome[r["outcome"]] += 1
print(f"\nsearch/grep calls analysed: {total:,}")
print(f" {'outcome':<14} {'count':>8} {'share':>7}")
for outcome in ("engaged-read", "next-page", "refined", "abandoned"):
n = by_outcome.get(outcome, 0)
print(f" {outcome:<14} {n:>8,} {100 * n / total:>6.1f}%")
engaged = [r for r in records if r["outcome"] == "engaged-read"]
if engaged:
deepest = np.array([r["deepest_index"] for r in engaged], dtype=np.int64)
result_counts = np.array([r["n_results"] for r in engaged], dtype=np.int64)
# +1 because deepest_index is 0-based.
deepest_1b = deepest + 1
coverage = deepest_1b / result_counts
engaged_n = np.array([r["engaged_count"] for r in engaged], dtype=np.int64)
print(f"\nfor engaged-read calls (n={len(engaged):,}):")
print(f" deepest index reached p50={int(np.median(deepest_1b))} "
f"p75={int(np.percentile(deepest_1b,75))} "
f"p90={int(np.percentile(deepest_1b,90))} "
f"max={int(deepest_1b.max())}")
print(f" result list length p50={int(np.median(result_counts))} "
f"p90={int(np.percentile(result_counts,90))} "
f"max={int(result_counts.max())}")
print(f" deepest / list size p50={np.median(coverage)*100:.0f}% "
f"p25={np.percentile(coverage,25)*100:.0f}%")
print(f" reads per result list p50={int(np.median(engaged_n))} "
f"p90={int(np.percentile(engaged_n,90))}")
next_page = sum(1 for r in records if r["next_page"])
refined = sum(1 for r in records if r["refined"])
print(f"\nbehaviours (not exclusive):")
print(f" any next-page request : {next_page:,} ({100*next_page/total:.1f}%)")
print(f" any refined-query : {refined:,} ({100*refined/total:.1f}%)")
# Shape of result lists — files per result, matches per file, and whether
# diversity (files-per-result / matches-per-file) correlates with engagement.
files_per_result = np.array([r["n_results"] for r in records], dtype=np.int64)
matches_per_file_flat = np.array(
[m for r in records for m in r["matches_per_file"] if m > 0],
dtype=np.int64,
)
print(f"\nresult shape across all {total:,} calls:")
print(f" files per result "
f"p50={int(np.median(files_per_result))} "
f"p75={int(np.percentile(files_per_result,75))} "
f"p90={int(np.percentile(files_per_result,90))} "
f"p99={int(np.percentile(files_per_result,99))} "
f"max={int(files_per_result.max())}")
if matches_per_file_flat.size:
print(f" matches per file (flat) "
f"p50={int(np.median(matches_per_file_flat))} "
f"p75={int(np.percentile(matches_per_file_flat,75))} "
f"p90={int(np.percentile(matches_per_file_flat,90))} "
f"p99={int(np.percentile(matches_per_file_flat,99))} "
f"max={int(matches_per_file_flat.max())}")
# Engagement vs shape: is the model more likely to read at all when there
# are more distinct files? When matches are more concentrated per file?
print(f"\nengagement vs result shape:")
print(f" {'files-per-result':<22} {'n calls':>9} {'engaged %':>10} {'p50 deepest':>12}")
bins = [(1, 1, "1"), (2, 2, "2"), (3, 5, "3-5"), (6, 10, "6-10"),
(11, 20, "11-20"), (21, 50, "21-50"), (51, 10**9, "51+")]
for lo, hi, label in bins:
bucket = [r for r in records if lo <= r["n_results"] <= hi]
if not bucket:
continue
eng = [r for r in bucket if r["outcome"] == "engaged-read"]
eng_share = 100 * len(eng) / len(bucket)
if eng:
p50_deep = int(np.median([r["deepest_index"] + 1 for r in eng]))
else:
p50_deep = 0
print(f" {label:<22} {len(bucket):>9,} {eng_share:>9.1f}% {p50_deep:>12}")
print(f"\n {'max matches/file':<22} {'n calls':>9} {'engaged %':>10} {'p50 deepest':>12}")
bins = [(1, 1, "1"), (2, 5, "2-5"), (6, 20, "6-20"),
(21, 100, "21-100"), (101, 10**9, "100+")]
for lo, hi, label in bins:
bucket = [r for r in records
if r["matches_per_file"]
and lo <= max(r["matches_per_file"]) <= hi]
if not bucket:
continue
eng = [r for r in bucket if r["outcome"] == "engaged-read"]
eng_share = 100 * len(eng) / len(bucket)
if eng:
p50_deep = int(np.median([r["deepest_index"] + 1 for r in eng]))
else:
p50_deep = 0
print(f" {label:<22} {len(bucket):>9,} {eng_share:>9.1f}% {p50_deep:>12}")
# --------------------------------------------------------------------------- #
# Plot
def plot(records: list[dict], since: str) -> Path | None:
if not records:
return None
OUT_DIR.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"figure.dpi": 110, "font.size": 10})
fig, axes = plt.subplots(3, 2, figsize=(15, 14))
# Panel A — outcome breakdown.
ax = axes[0, 0]
counts = defaultdict(int)
for r in records:
counts[r["outcome"]] += 1
ordered = ["engaged-read", "next-page", "refined", "abandoned"]
nvals = [counts.get(o, 0) for o in ordered]
total = sum(nvals)
pct = [100 * n / total for n in nvals]
colors = [OUTCOME_COLORS[o] for o in ordered]
bars = ax.bar(ordered, pct, color=colors, edgecolor="#1f2937", linewidth=0.5)
for b, p, n in zip(bars, pct, nvals):
ax.text(b.get_x() + b.get_width() / 2, p + 1.5,
f"{p:.1f}%\nn={n:,}", ha="center", va="bottom", fontsize=9)
ax.set_title(f"search outcome (n={total:,})")
ax.set_ylabel("share of calls")
ax.set_ylim(0, max(pct) + 12)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0f}%"))
ax.grid(True, axis="y", alpha=0.25, linestyle="--")
# Panel B — deepest result index touched.
ax = axes[0, 1]
engaged = [r for r in records if r["outcome"] == "engaged-read"]
if engaged:
deepest = np.array([r["deepest_index"] + 1 for r in engaged], dtype=np.int64)
edges = [1, 2, 3, 6, 11, 21, 51, 101, max(deepest.max(), 102) + 1]
labels = ["1", "2", "3-5", "6-10", "11-20", "21-50", "51-100", "100+"]
hist, _ = np.histogram(deepest, bins=edges)
pct = 100 * hist / deepest.size
bars = ax.bar(labels, pct, color="#0f766e", edgecolor="#134e4a", linewidth=0.5)
for b, p, n in zip(bars, pct, hist):
ax.text(b.get_x() + b.get_width() / 2, p + 1.2,
f"{p:.1f}%\nn={n:,}", ha="center", va="bottom", fontsize=8)
ax.set_title("deepest result index the model read")
ax.set_ylabel("share of engaged-read calls")
ax.set_ylim(0, max(pct) + 12)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0f}%"))
ax.grid(True, axis="y", alpha=0.25, linestyle="--")
# Panel C — coverage ratio (deepest / list-size) CDF.
ax = axes[1, 0]
if engaged:
coverage = np.array(
[(r["deepest_index"] + 1) / r["n_results"] for r in engaged],
dtype=float,
)
coverage.sort()
cdf = np.arange(1, coverage.size + 1) / coverage.size
ax.plot(coverage, cdf, color="#7c3aed", linewidth=2.0)
ax.axvline(0.1, color="#9ca3af", linestyle="--", linewidth=1, label="10% of list")
ax.axvline(0.5, color="#9ca3af", linestyle=":", linewidth=1, label="50% of list")
ax.set_title("coverage CDF — deepest read / list size")
ax.set_xlabel("fraction of list reached")
ax.set_ylabel("CDF of engaged-read calls")
ax.set_xlim(0, 1.0)
ax.set_ylim(0, 1.0)
ax.legend(loc="lower right", frameon=False)
ax.grid(True, which="both", alpha=0.25, linestyle="--")
# Panel D — result-list size distribution per outcome.
ax = axes[1, 1]
bins = np.logspace(0, np.log10(max(r["n_results"] for r in records) + 1), 30)
for outcome in ordered:
sizes = [r["n_results"] for r in records if r["outcome"] == outcome]
if not sizes:
continue
ax.hist(sizes, bins=bins, histtype="step", linewidth=1.8,
color=OUTCOME_COLORS[outcome],
label=f"{outcome} (p50={int(np.median(sizes))})")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("result list size")
ax.set_ylabel("calls")
ax.set_title("result list size by outcome")
ax.legend(loc="upper right", frameon=False, fontsize=9)
ax.grid(True, which="both", alpha=0.2, linestyle="--")
# Panel E — engagement rate vs files-per-result, with p50 deepest overlay.
ax = axes[2, 0]
bins = [(1, 1, "1"), (2, 2, "2"), (3, 5, "3-5"), (6, 10, "6-10"),
(11, 20, "11-20"), (21, 50, "21-50"), (51, 10**9, "51+")]
labels = []
eng_share = []
deep_p50 = []
n_calls = []
for lo, hi, label in bins:
bucket = [r for r in records if lo <= r["n_results"] <= hi]
if not bucket:
continue
labels.append(label)
n_calls.append(len(bucket))
engs = [r for r in bucket if r["outcome"] == "engaged-read"]
eng_share.append(100 * len(engs) / len(bucket))
deep_p50.append(int(np.median([r["deepest_index"] + 1 for r in engs])) if engs else 0)
x = np.arange(len(labels))
bars = ax.bar(x, eng_share, color="#16a34a", edgecolor="#14532d",
linewidth=0.5, label="engaged %")
for b, p, n in zip(bars, eng_share, n_calls):
ax.text(b.get_x() + b.get_width() / 2, p + 0.8,
f"{p:.0f}%\nn={n:,}", ha="center", va="bottom", fontsize=8)
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("engaged %", color="#15803d")
ax.tick_params(axis="y", labelcolor="#15803d")
ax.set_ylim(0, max(eng_share) + 12)
ax.set_title("engagement vs files-per-result")
ax.set_xlabel("files in result")
ax2 = ax.twinx()
ax2.plot(x, deep_p50, color="#7c3aed", marker="o", linewidth=1.8,
label="p50 deepest index")
ax2.set_ylabel("p50 deepest index", color="#5b21b6")
ax2.tick_params(axis="y", labelcolor="#5b21b6")
ax.grid(True, axis="y", alpha=0.25, linestyle="--")
# Panel F — engagement rate vs max matches-per-file.
ax = axes[2, 1]
bins = [(1, 1, "1"), (2, 5, "2-5"), (6, 20, "6-20"),
(21, 100, "21-100"), (101, 10**9, "100+")]
labels = []
eng_share = []
deep_p50 = []
n_calls = []
for lo, hi, label in bins:
bucket = [r for r in records
if r["matches_per_file"]
and lo <= max(r["matches_per_file"]) <= hi]
if not bucket:
continue
labels.append(label)
n_calls.append(len(bucket))
engs = [r for r in bucket if r["outcome"] == "engaged-read"]
eng_share.append(100 * len(engs) / len(bucket))
deep_p50.append(int(np.median([r["deepest_index"] + 1 for r in engs])) if engs else 0)
x = np.arange(len(labels))
bars = ax.bar(x, eng_share, color="#dc2626", edgecolor="#7f1d1d",
linewidth=0.5, label="engaged %")
for b, p, n in zip(bars, eng_share, n_calls):
ax.text(b.get_x() + b.get_width() / 2, p + 0.8,
f"{p:.0f}%\nn={n:,}", ha="center", va="bottom", fontsize=8)
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("engaged %", color="#991b1b")
ax.tick_params(axis="y", labelcolor="#991b1b")
ax.set_ylim(0, max(eng_share) + 12)
ax.set_title("engagement vs concentration (max matches in one file)")
ax.set_xlabel("max matches in single file")
ax2 = ax.twinx()
ax2.plot(x, deep_p50, color="#7c3aed", marker="o", linewidth=1.8,
label="p50 deepest index")
ax2.set_ylabel("p50 deepest index", color="#5b21b6")
ax2.tick_params(axis="y", labelcolor="#5b21b6")
ax.grid(True, axis="y", alpha=0.25, linestyle="--")
fig.suptitle(f"search/grep result relevance — calls since {since}",
fontsize=13, y=1.0)
fig.tight_layout()
p = OUT_DIR / "search-relevance.png"
fig.savefig(p, bbox_inches="tight")
plt.close(fig)
return p
# --------------------------------------------------------------------------- #
# Entry
def main() -> int:
ap = argparse.ArgumentParser(description="search/grep result relevance analysis")
ap.add_argument("--since", default=DEFAULT_SINCE,
help=f"only calls after this date (default {DEFAULT_SINCE})")
args = ap.parse_args()
since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
since_ms = int(since.timestamp() * 1000)
if not DB_PATH.exists():
sys.exit(f"db missing: {DB_PATH}")
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
records = classify_sessions(conn, since_ms)
conn.close()
report(records)
out = plot(records, args.since)
if out:
print(f"\nwrote {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB