Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
- **Provider backfill controls (SESF-6)** — multi-harness ingestion (`codex`, `opencode`, `antigravity_cli`, `antigravity_desktop`) shares one embedding budget. Tune via `SESSIONFLOW_EMBED_BATCH_SIZE`, `SESSIONFLOW_EMBED_COOLDOWN_MS` (floor 200ms — MLX Metal SIGSEGVs lower), `SESSIONFLOW_BACKFILL_MODE` (`recent`|`incremental`|`full`), `SESSIONFLOW_BACKFILL_MAX_TURNS_PER_RUN`, `SESSIONFLOW_BACKFILL_MAX_FILES_PER_RUN`, `SESSIONFLOW_BACKFILL_RECENT_DAYS`, `SESSIONFLOW_BACKFILL_PAUSED`. Pause/resume + per-provider control via `python cleanup.py backfill {status|pause|resume|enqueue} [--provider <name>]`. Queue state is durable across restarts.
- **Claude Desktop is `claude_code_cli` (SESF-59)** — Desktop Code-tab sessions, former CoWork projects included, write normal transcripts to `~/.claude/projects/` and are indexed as `claude_code_cli`. The `claude-code-sessions/**/local_*.json` files are only metadata pointing at those transcripts (`cliSessionId`), so the old `claude_desktop_cowork` probe was retired. Legacy Cowork agent-mode transcripts under `local-agent-mode-sessions/` are a separate provider, `claude_cowork_legacy` (SESF-60).
- **Legacy Cowork agent mode (SESF-60)** — `claude_cowork_legacy` ingests `local-agent-mode-sessions/*/*/local_*/.claude/projects/**/*.jsonl` with the inherited CLI parser. Subagent transcripts are included; `audit.jsonl` is skipped. The transcript `cwd` is a VM path (`/sessions/<name>`), so `project_root` comes from the sibling `local_<id>.json`. It uses `userSelectedFolders[0]`, else a non-sandbox `cwd`, else `unknown`. No other metadata key is read. This is a one-time source and is not in the startup backfill list. Enqueue it with `cleanup.py backfill enqueue --provider claude_cowork_legacy --mode full`. Desktop may prune or move the folder (Unified Claude, 2026-11-10). To re-ingest from a backup, set `SESSIONFLOW_COWORK_LEGACY_ROOT` to the copy.
- **Moved project folders (SESF-73)** — project filters match `project_root` exactly, so turns tagged with a folder's old path are missed. `SESSIONFLOW_PROJECT_PATH_MAP` rewrites the root at ingest. Its format is `old=>new` pairs separated by `;`. Both sides are absolute paths. The longest prefix wins, and a prefix only matches whole path components. Only `claude_cowork_legacy` reads it so far. launchd does not pass shell env, so set it in the LaunchAgent plist (or `launchctl setenv`) before a re-ingest. To fix rows already indexed, run `cleanup.py retag-project --from <old> --to <new> [--provider P]`. It is a dry run by default, and `--apply` refuses without `--yes`. It calls `rag_engine.update_row_metadata`, which rewrites Milvus and FTS but keeps the stored vector. It also reads FTS, so a re-run picks up any turn whose FTS rewrite failed.
- **Project scope (SESF-64)** — `project_scope.resolve_project_scope` turns the `project_root` argument (or the `X-Project-Root` header) into a list of indexed roots. A path covers its subfolders, and a path inside `.worktrees/` or `.claude/worktrees/` resolves to its repository. An unindexed path falls back to its nearest indexed parent, and a bare name matches root basenames case-insensitively. A miss searches every project. Search and stats output opens with a `Scope:` line that names the roots and the reason. Known roots come from `rag_engine.known_project_roots` (FTS `DISTINCT`, cached `KNOWN_ROOTS_TTL_S`). Codex worktrees under `~/.codex/worktrees/` sit outside the repo path, so only a name lookup reaches them.
- **Moved project folders (SESF-73)** — project filters cover a root and its subfolders, but turns tagged with a folder's old path are missed. `SESSIONFLOW_PROJECT_PATH_MAP` rewrites the root at ingest. Its format is `old=>new` pairs separated by `;`. Both sides are absolute paths. The longest prefix wins, and a prefix only matches whole path components. Only `claude_cowork_legacy` reads it so far. launchd does not pass shell env, so set it in the LaunchAgent plist (or `launchctl setenv`) before a re-ingest. To fix rows already indexed, run `cleanup.py retag-project --from <old> --to <new> [--provider P]`. It is a dry run by default, and `--apply` refuses without `--yes`. It calls `rag_engine.update_row_metadata`, which rewrites Milvus and FTS but keeps the stored vector. It also reads FTS, so a re-run picks up any turn whose FTS rewrite failed.

Check warning on line 37 in AGENTS.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

AGENTS.md#L37

Duplicate instruction also in CLAUDE.md: "**moved project folders (sesf-73)** — project filters cover ..."

Check notice on line 37 in AGENTS.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

AGENTS.md#L37

Expected: 80; Actual: 857
- **Hosted embeddings deferred** — SESF-6 keeps embedding fully local (MLX). No hosted/OpenAI setup steps, credentials, or collections exist. Future hosted path would require a separate identity/collection to avoid vector mixing.
- **Optional LaunchAgent for multi-harness startup** — `./sessionflow-server.sh install-agent` writes `~/Library/LaunchAgents/cc.lbruton.sessionflow.plist` so the server starts at login before any harness hook races. OPTIONAL — `start/stop/status/restart` behavior is unchanged when not installed. `agent-status` / `uninstall-agent` manage it. `setup.sh` honors `SESSIONFLOW_INSTALL_AGENT=1` for non-interactive installs.
- **Optional hourly backfill LaunchAgent (SESF-7)** — `./sessionflow-server.sh install-backfill-agent` writes `cc.lbruton.sessionflow-backfill.plist` with a `StartInterval` (defaults to 3600s; override via `SESSIONFLOW_BACKFILL_INTERVAL_SECONDS`). It runs `python cleanup.py backfill enqueue --provider claude_code_cli --mode incremental`, decoupling catch-up indexing from MCP session activity. MCP-client SessionStart hooks should connect to the already-running server rather than racing to start it.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Semantic search over Claude Code session transcripts. Independent project, origi
- **Provider backfill controls (SESF-6)** — multi-harness ingestion (`codex`, `opencode`, `antigravity_cli`, `antigravity_desktop`) shares one embedding budget. Tune via `SESSIONFLOW_EMBED_BATCH_SIZE`, `SESSIONFLOW_EMBED_COOLDOWN_MS` (floor 200ms — MLX Metal SIGSEGVs lower), `SESSIONFLOW_BACKFILL_MODE` (`recent`|`incremental`|`full`), `SESSIONFLOW_BACKFILL_MAX_TURNS_PER_RUN`, `SESSIONFLOW_BACKFILL_MAX_FILES_PER_RUN`, `SESSIONFLOW_BACKFILL_RECENT_DAYS`, `SESSIONFLOW_BACKFILL_PAUSED`. Pause/resume + per-provider control via `python cleanup.py backfill {status|pause|resume|enqueue} [--provider <name>]`. Queue state is durable across restarts.
- **Claude Desktop is `claude_code_cli` (SESF-59)** — Desktop Code-tab sessions, former CoWork projects included, write normal transcripts to `~/.claude/projects/` and are indexed as `claude_code_cli`. The `claude-code-sessions/**/local_*.json` files are only metadata pointing at those transcripts (`cliSessionId`), so the old `claude_desktop_cowork` probe was retired. Legacy Cowork agent-mode transcripts under `local-agent-mode-sessions/` are a separate provider, `claude_cowork_legacy` (SESF-60).
- **Legacy Cowork agent mode (SESF-60)** — `claude_cowork_legacy` ingests `local-agent-mode-sessions/*/*/local_*/.claude/projects/**/*.jsonl` with the inherited CLI parser. Subagent transcripts are included; `audit.jsonl` is skipped. The transcript `cwd` is a VM path (`/sessions/<name>`), so `project_root` comes from the sibling `local_<id>.json`. It uses `userSelectedFolders[0]`, else a non-sandbox `cwd`, else `unknown`. No other metadata key is read. This is a one-time source and is not in the startup backfill list. Enqueue it with `cleanup.py backfill enqueue --provider claude_cowork_legacy --mode full`. Desktop may prune or move the folder (Unified Claude, 2026-11-10). To re-ingest from a backup, set `SESSIONFLOW_COWORK_LEGACY_ROOT` to the copy.
- **Moved project folders (SESF-73)** — project filters match `project_root` exactly, so turns tagged with a folder's old path are missed. `SESSIONFLOW_PROJECT_PATH_MAP` rewrites the root at ingest. Its format is `old=>new` pairs separated by `;`. Both sides are absolute paths. The longest prefix wins, and a prefix only matches whole path components. Only `claude_cowork_legacy` reads it so far. launchd does not pass shell env, so set it in the LaunchAgent plist (or `launchctl setenv`) before a re-ingest. To fix rows already indexed, run `cleanup.py retag-project --from <old> --to <new> [--provider P]`. It is a dry run by default, and `--apply` refuses without `--yes`. It calls `rag_engine.update_row_metadata`, which rewrites Milvus and FTS but keeps the stored vector. It also reads FTS, so a re-run picks up any turn whose FTS rewrite failed.
- **Project scope (SESF-64)** — `project_scope.resolve_project_scope` turns the `project_root` argument (or the `X-Project-Root` header) into a list of indexed roots. A path covers its subfolders, and a path inside `.worktrees/` or `.claude/worktrees/` resolves to its repository. An unindexed path falls back to its nearest indexed parent, and a bare name matches root basenames case-insensitively. A miss searches every project. Search and stats output opens with a `Scope:` line that names the roots and the reason. Known roots come from `rag_engine.known_project_roots` (FTS `DISTINCT`, cached `KNOWN_ROOTS_TTL_S`). Codex worktrees under `~/.codex/worktrees/` sit outside the repo path, so only a name lookup reaches them.
- **Moved project folders (SESF-73)** — project filters cover a root and its subfolders, but turns tagged with a folder's old path are missed. `SESSIONFLOW_PROJECT_PATH_MAP` rewrites the root at ingest. Its format is `old=>new` pairs separated by `;`. Both sides are absolute paths. The longest prefix wins, and a prefix only matches whole path components. Only `claude_cowork_legacy` reads it so far. launchd does not pass shell env, so set it in the LaunchAgent plist (or `launchctl setenv`) before a re-ingest. To fix rows already indexed, run `cleanup.py retag-project --from <old> --to <new> [--provider P]`. It is a dry run by default, and `--apply` refuses without `--yes`. It calls `rag_engine.update_row_metadata`, which rewrites Milvus and FTS but keeps the stored vector. It also reads FTS, so a re-run picks up any turn whose FTS rewrite failed.
- **Hosted embeddings deferred** — SESF-6 keeps embedding fully local (MLX). No hosted/OpenAI setup steps, credentials, or collections exist. Future hosted path would require a separate identity/collection to avoid vector mixing.
- **Optional LaunchAgent for multi-harness startup** — `./sessionflow-server.sh install-agent` writes `~/Library/LaunchAgents/cc.lbruton.sessionflow.plist` so the server starts at login before any harness hook races. OPTIONAL — `start/stop/status/restart` behavior is unchanged when not installed. `agent-status` / `uninstall-agent` manage it. `setup.sh` honors `SESSIONFLOW_INSTALL_AGENT=1` for non-interactive installs.
- **Optional hourly backfill LaunchAgent (SESF-7)** — `./sessionflow-server.sh install-backfill-agent` writes `cc.lbruton.sessionflow-backfill.plist` with a `StartInterval` (defaults to 3600s; override via `SESSIONFLOW_BACKFILL_INTERVAL_SECONDS`). It runs `python cleanup.py backfill enqueue --provider claude_code_cli --mode incremental`, decoupling catch-up indexing from MCP session activity. MCP-client SessionStart hooks should connect to the already-running server rather than racing to start it.
Expand Down
35 changes: 24 additions & 11 deletions fts_hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import sqlite3
import threading
from pathlib import Path
from typing import Dict, List, Optional, Set
from typing import Dict, List, Optional, Sequence, Set, Union

logger = logging.getLogger("fts-hybrid")

Expand Down Expand Up @@ -241,28 +241,37 @@ def insert(self, conn: sqlite3.Connection, records: List[Dict]):
)
conn.commit()

def count_rows(self, conn: sqlite3.Connection, project_root: Optional[str] = None) -> int:
def count_rows(self, conn: sqlite3.Connection,
project_root: Optional[Union[str, Sequence[str]]] = None) -> int:
"""Return the number of rows in the FTS table.

Runs `SELECT count(*) FROM {self.table_name}`, adding a
`WHERE project_root = ?` filter when `project_root` is supplied (the
turns_fts table stores project_root), so callers can read either the
global row count or a single project's count.

Args:
conn: the per-thread sqlite connection for this FTS index.
project_root: when provided, count only rows whose project_root matches.
project_root: when provided, count only rows whose project_root
equals it, or any of its values when it is a list.

Returns:
int: row count, optionally filtered by project_root.
"""
sql = f"SELECT count(*) FROM {self.table_name}"
params: tuple = ()
if project_root is not None:
if isinstance(project_root, str):
sql += " WHERE project_root = ?"
params = (project_root,)
elif project_root:
sql += f" WHERE project_root IN ({', '.join('?' for _ in project_root)})"
params = tuple(project_root)
return int(conn.execute(sql, params).fetchone()[0])

def distinct_values(self, conn: sqlite3.Connection, column: str) -> List[str]:
"""Return the distinct non-null values of a metadata column."""
if column not in self.metadata_columns:
raise ValueError(f"Unknown FTS column: {column!r}")
rows = conn.execute(
f"SELECT DISTINCT {column} FROM {self.table_name} WHERE {column} IS NOT NULL"
).fetchall()
return [r[0] for r in rows]

def delete(self, conn: sqlite3.Connection, column: str, value):
"""Delete rows where column == value."""
conn.execute(f"DELETE FROM {self.table_name} WHERE {column} = ?", (value,))
Expand All @@ -289,7 +298,8 @@ def search(self, query: str, n: int = 15,
Args:
query: Search text
n: Max results
filters: Dict of column -> value for exact-match filtering
filters: Dict of column -> value for exact-match filtering. A value
may be ``(op, operand)`` for a comparison or ``("in", values)``.
db_path: Milvus DB path (used to derive FTS path)

Returns:
Expand All @@ -313,7 +323,10 @@ def search(self, query: str, n: int = 15,
for key, val in filters.items():
if val is None:
continue
if isinstance(val, tuple) and len(val) == 2:
if isinstance(val, tuple) and len(val) == 2 and val[0] == "in":
where_parts.append(f"{key} IN ({', '.join('?' for _ in val[1])})")
params.extend(val[1])
elif isinstance(val, tuple) and len(val) == 2:
op, operand = val
col = key.rsplit("_", 1)[0] if key.endswith(("_gte", "_lte", "_gt", "_lt")) else key
where_parts.append(f"{col} {op} ?")
Expand Down
97 changes: 97 additions & 0 deletions project_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Resolve a search's project argument to the indexed ``project_root`` values it covers."""

from __future__ import annotations

import posixpath
from dataclasses import dataclass
from typing import Iterable, List, Optional, Tuple

# Worktree folders inside a repository: ours, and the Claude desktop app's.
_WORKTREE_MARKERS = ("/.worktrees/", "/.claude/worktrees/")
_LABEL_ROOTS = 3


@dataclass(frozen=True)
class ProjectScope:
"""The project roots a search covers.

Attributes:
roots: Sorted ``project_root`` values to match, or None for every project.
note: Why the scope differs from the argument, or an empty string.
"""

roots: Optional[Tuple[str, ...]]
note: str = ""

def filter_value(self) -> Optional[List[str]]:
"""Return the roots as a list for the engine filters, or None for every project."""
return list(self.roots) if self.roots else None

def label(self) -> str:
"""Return the roots for display, truncated after three."""
if not self.roots:
return "all projects"
shown = ", ".join(self.roots[:_LABEL_ROOTS])
extra = len(self.roots) - _LABEL_ROOTS
return f"{shown} (+{extra} more)" if extra > 0 else shown


def _with_subfolders(base: str, known: List[str]) -> List[str]:
if base == "/":
return [r for r in known if r == "/"]
return [r for r in known if r == base or r.startswith(base + "/")]


def _nearest_parent(path: str, known: List[str]) -> Optional[str]:
parents = [r for r in known if r != "/" and path.startswith(r + "/")]
return max(parents, key=len) if parents else None


def _basename(root: str) -> str:
return root.rstrip("/").rsplit("/", 1)[-1]


def resolve_project_scope(arg: Optional[str], header_root: Optional[str],
known_roots: Iterable[str]) -> ProjectScope:
"""Resolve a project argument against the indexed roots.

``'*'`` covers every project. An empty argument falls back to ``header_root``.
A path covers its indexed root and subfolders; a path inside a worktree folder
resolves to its repository first, and an unindexed path to its nearest indexed
parent. A bare name matches root basenames case-insensitively. A miss covers
every project and says why in ``note``.

Args:
arg: The caller's ``project_root`` argument.
header_root: The current project from the request header.
known_roots: Every ``project_root`` value in the index.

Returns:
The resolved scope.
"""
if arg == "*":
return ProjectScope(roots=None)
target = (arg or "").strip() or (header_root or "").strip()
if not target:
return ProjectScope(roots=None, note="no current project")
known = sorted(set(known_roots))

if target.startswith("/"):
# normpath keeps exactly two leading slashes, so collapse them.
path = "/" + posixpath.normpath(target).lstrip("/")
for marker in _WORKTREE_MARKERS:
path = path.split(marker, 1)[0]
matched = _with_subfolders(path, known)
if matched:
return ProjectScope(roots=tuple(matched))
parent = _nearest_parent(path, known)
if parent:
return ProjectScope(roots=(parent,), note=f"nearest indexed parent of {target}")
return ProjectScope(roots=None, note=f"no indexed turns under {target}")

name = target.lower()
matched = sorted({sub for r in known if _basename(r).lower() == name
for sub in _with_subfolders(r, known)})
if matched:
return ProjectScope(roots=tuple(matched))
return ProjectScope(roots=None, note=f'no project named "{target}"')
Loading
Loading