fridai 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.
- fridai/__init__.py +1 -0
- fridai/cli.py +98 -0
- fridai/core/__init__.py +0 -0
- fridai/core/config.py +37 -0
- fridai/core/embeddings.py +65 -0
- fridai/core/models.py +36 -0
- fridai/core/redact.py +87 -0
- fridai/core/search.py +136 -0
- fridai/core/sources/__init__.py +1 -0
- fridai/core/sources/agent_recall.py +176 -0
- fridai/core/sources/claude_recall.py +143 -0
- fridai/core/sources/code.py +232 -0
- fridai/core/sources/codex_recall.py +122 -0
- fridai/core/sources/commits.py +115 -0
- fridai/core/sources/gemini_recall.py +114 -0
- fridai/core/store.py +289 -0
- fridai/server.py +107 -0
- fridai-0.1.0.dist-info/METADATA +207 -0
- fridai-0.1.0.dist-info/RECORD +22 -0
- fridai-0.1.0.dist-info/WHEEL +4 -0
- fridai-0.1.0.dist-info/entry_points.txt +2 -0
- fridai-0.1.0.dist-info/licenses/LICENSE +21 -0
fridai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fridai package
|
fridai/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""fridai CLI (stdlib argparse) — just two things: indexing + running the MCP server.
|
|
2
|
+
|
|
3
|
+
fridai index [--source all|code|commits|agent] [--path DIR]
|
|
4
|
+
fridai mcp # MCP server (agents recall via the recall tool)
|
|
5
|
+
fridai stats
|
|
6
|
+
No local LLM / answer generation (search & recall only). Embedding via fastembed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .core import config, embeddings
|
|
14
|
+
from .core.store import Store
|
|
15
|
+
from .core.sources import agent_recall, code, commits
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _open_store(redact: bool = True) -> Store:
|
|
19
|
+
config.ensure_home()
|
|
20
|
+
fresh = not Path(config.DB_PATH).exists()
|
|
21
|
+
store = Store(config.DB_PATH, redact=redact)
|
|
22
|
+
if fresh:
|
|
23
|
+
print(f"👋 fridai first run — creating the index ({config.HOME})")
|
|
24
|
+
print(" embedding (fastembed):",
|
|
25
|
+
"ON (semantic)" if embeddings.get_embedder() else "OFF (lexical only — `pip install fastembed`)")
|
|
26
|
+
return store
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_index(args) -> None:
|
|
30
|
+
valid = ("agent", "code", "commits", "all")
|
|
31
|
+
if args.source not in valid:
|
|
32
|
+
raise SystemExit(f"--source must be one of {valid}")
|
|
33
|
+
store = _open_store(redact=not args.no_redact)
|
|
34
|
+
print("secret redaction:", "OFF (--no-redact)" if args.no_redact else "ON")
|
|
35
|
+
embedder = None if args.no_embed else embeddings.get_embedder()
|
|
36
|
+
print("semantic embedding:", "ON (fastembed)" if embedder else "OFF (lexical only)")
|
|
37
|
+
|
|
38
|
+
if args.source in ("agent", "all"):
|
|
39
|
+
r = agent_recall.index_all(store, embedder=embedder, reindex=args.reindex)
|
|
40
|
+
print(f" conversations (Claude+Codex+Gemini): {r['turns']} turns / {r['files']} sessions (skipped {r['skipped']})")
|
|
41
|
+
|
|
42
|
+
path = args.path or "."
|
|
43
|
+
if args.source in ("code", "all"):
|
|
44
|
+
r = code.index_code(path, store, reindex=args.reindex, embedder=embedder,
|
|
45
|
+
prune=not args.no_prune)
|
|
46
|
+
print(f" code: {r['chunks']} chunks / {r['files']} files (skipped {r['skipped']}, pruned {r['pruned']})")
|
|
47
|
+
if args.source in ("commits", "all"):
|
|
48
|
+
r = commits.index_commits(path, store, reindex=args.reindex, embedder=embedder)
|
|
49
|
+
print(f" commits: {r['commits']}")
|
|
50
|
+
|
|
51
|
+
store.close()
|
|
52
|
+
print(f"Done → {config.DB_PATH}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_mcp(args) -> None:
|
|
56
|
+
from . import server
|
|
57
|
+
server.serve()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_stats(args) -> None:
|
|
61
|
+
store = _open_store()
|
|
62
|
+
st = store.stats()
|
|
63
|
+
store.close()
|
|
64
|
+
print(f"\nTotal documents: {st['total']}")
|
|
65
|
+
if st["by_type"]:
|
|
66
|
+
print("By source:", ", ".join(f"{k}={v}" for k, v in st["by_type"].items()))
|
|
67
|
+
print("Embedding (semantic):", "ON" if embeddings.get_embedder() else "OFF (lexical search)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
ap = argparse.ArgumentParser(prog="fridai",
|
|
72
|
+
description="fridai MCP server — recall past code/commits/AI conversations (search-only)")
|
|
73
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
74
|
+
|
|
75
|
+
ix = sub.add_parser("index", help="index sources (code/commits/AI conversations)")
|
|
76
|
+
ix.add_argument("--source", default="all", help="agent | code | commits | all (default: all)")
|
|
77
|
+
ix.add_argument("--path", help="target repo path for code/commits (default: current dir)")
|
|
78
|
+
ix.add_argument("--reindex", action="store_true", help="ignore incremental state and reindex everything")
|
|
79
|
+
ix.add_argument("--no-embed", action="store_true", help="skip embeddings (lexical only)")
|
|
80
|
+
ix.add_argument("--no-prune", action="store_true", help="skip pruning deleted files (code)")
|
|
81
|
+
ix.add_argument("--no-redact", action="store_true", help="turn off secret redaction (default ON)")
|
|
82
|
+
ix.set_defaults(func=cmd_index)
|
|
83
|
+
|
|
84
|
+
mp = sub.add_parser("mcp", help="run the MCP server — agents recall past memory via `recall`")
|
|
85
|
+
mp.set_defaults(func=cmd_mcp)
|
|
86
|
+
|
|
87
|
+
st = sub.add_parser("stats", help="index overview")
|
|
88
|
+
st.set_defaults(func=cmd_stats)
|
|
89
|
+
return ap
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv=None) -> None:
|
|
93
|
+
args = build_parser().parse_args(argv)
|
|
94
|
+
args.func(args)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
fridai/core/__init__.py
ADDED
|
File without changes
|
fridai/core/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""fridai global settings — paths/constants. No external dependencies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Data home (kept separate from the working repo). Override with FRIDAI_HOME (for tests).
|
|
8
|
+
HOME = Path(os.environ.get("FRIDAI_HOME", Path.home() / ".fridai"))
|
|
9
|
+
DB_PATH = HOME / "index.db"
|
|
10
|
+
|
|
11
|
+
# F4 sources: coding-agent conversation locations. Silently skipped if absent.
|
|
12
|
+
CLAUDE_PROJECTS = Path(
|
|
13
|
+
os.environ.get("FRIDAI_CLAUDE_PROJECTS", Path.home() / ".claude" / "projects")
|
|
14
|
+
)
|
|
15
|
+
CODEX_SESSIONS = Path(
|
|
16
|
+
os.environ.get("FRIDAI_CODEX_SESSIONS", Path.home() / ".codex" / "sessions")
|
|
17
|
+
)
|
|
18
|
+
GEMINI_SESSIONS = Path(
|
|
19
|
+
os.environ.get("FRIDAI_GEMINI_SESSIONS", Path.home() / ".gemini" / "tmp")
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# High-entropy secret heuristic. Off by default (precise rules only) — long camelCase
|
|
23
|
+
# identifiers etc. produce too many false positives.
|
|
24
|
+
REDACT_ENTROPY = os.environ.get("FRIDAI_REDACT_ENTROPY", "").lower() in ("1", "true", "yes")
|
|
25
|
+
|
|
26
|
+
# How far to demote agent_turns with no work signal (pure question/recall turns) in ranking.
|
|
27
|
+
# Larger = solution artifacts (code/commits/work turns) rank first. 0 disables the rerank.
|
|
28
|
+
WORK_PENALTY = int(os.environ.get("FRIDAI_WORK_PENALTY", "8"))
|
|
29
|
+
|
|
30
|
+
# Time window (minutes) for matching a question to its resulting commit.
|
|
31
|
+
COMMIT_WINDOW_MIN = int(os.environ.get("FRIDAI_COMMIT_WINDOW_MIN", "180"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ensure_home() -> Path:
|
|
35
|
+
"""Ensure the data-home directory exists and return it."""
|
|
36
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return HOME
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Embedding provider — fastembed (onnx) only + fallback.
|
|
2
|
+
|
|
3
|
+
fridai uses only fastembed so it runs 100% offline (no local LLM / remote API).
|
|
4
|
+
`get_embedder()`: FastEmbedEmbedder if fastembed is installed, else None (lexical fallback).
|
|
5
|
+
Disable with `FRIDAI_EMBED_BACKEND=none`. `.model_id` identifies index-query consistency.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# fastembed (onnxruntime, no torch). If absent -> None -> lexical (FTS) fallback.
|
|
12
|
+
try:
|
|
13
|
+
from fastembed import TextEmbedding as _FastEmbed
|
|
14
|
+
except Exception:
|
|
15
|
+
_FastEmbed = None
|
|
16
|
+
|
|
17
|
+
_FASTEMBED_MODEL = os.environ.get("FRIDAI_FASTEMBED_MODEL", "nomic-ai/nomic-embed-text-v1.5")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FastEmbedEmbedder:
|
|
21
|
+
"""onnx-based local embedder (no external server). Model is lazy-loaded once."""
|
|
22
|
+
_model = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, model: str | None = None):
|
|
25
|
+
self.model = model or _FASTEMBED_MODEL
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def model_id(self) -> str:
|
|
29
|
+
return f"fastembed:{self.model}"
|
|
30
|
+
|
|
31
|
+
def available(self) -> bool:
|
|
32
|
+
return _FastEmbed is not None
|
|
33
|
+
|
|
34
|
+
def embed(self, text: str) -> list[float] | None:
|
|
35
|
+
if _FastEmbed is None:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
if FastEmbedEmbedder._model is None:
|
|
39
|
+
FastEmbedEmbedder._model = _FastEmbed(model_name=self.model)
|
|
40
|
+
vecs = list(FastEmbedEmbedder._model.embed([text[:2000]]))
|
|
41
|
+
return [float(x) for x in vecs[0]] if vecs else None
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_embedder(model: str | None = None):
|
|
47
|
+
"""Pick the available embedder. fastembed -> None. Disable with FRIDAI_EMBED_BACKEND=none."""
|
|
48
|
+
if os.environ.get("FRIDAI_EMBED_BACKEND", "").lower() == "none":
|
|
49
|
+
return None
|
|
50
|
+
fe = FastEmbedEmbedder(model)
|
|
51
|
+
return fe if fe.available() else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _embed_input(doc) -> str:
|
|
55
|
+
"""Embedding input. If an enrichment summary exists, prepend it so the 'why' is in the vector too."""
|
|
56
|
+
summary = (doc.meta or {}).get("summary")
|
|
57
|
+
return f"{summary}\n{doc.text}" if summary else doc.text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def embed_documents(docs, embedder) -> list:
|
|
61
|
+
"""Fill in embeddings for docs (skip those already set). Leaves None if the embedder fails."""
|
|
62
|
+
for d in docs:
|
|
63
|
+
if d.embedding is None:
|
|
64
|
+
d.embedding = embedder.embed(_embed_input(d))
|
|
65
|
+
return docs
|
fridai/core/models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Unified data model. Normalizes every source (code / commit / note / AI conversation) into a Document."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Document:
|
|
11
|
+
"""Basic unit of indexing and search."""
|
|
12
|
+
id: str
|
|
13
|
+
source_type: str # "code" | "commit" | "note" | "agent_turn"
|
|
14
|
+
repo: str
|
|
15
|
+
path: str # file path / commit hash / session id
|
|
16
|
+
title: str
|
|
17
|
+
text: str
|
|
18
|
+
timestamp: datetime | None = None
|
|
19
|
+
meta: dict = field(default_factory=dict)
|
|
20
|
+
embedding: list[float] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SearchHit:
|
|
25
|
+
document: Document
|
|
26
|
+
score: float # higher = more relevant
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_id(source_type: str, *parts: str) -> str:
|
|
30
|
+
"""Stable document id from source/path/location (identical across reindexing)."""
|
|
31
|
+
h = hashlib.sha1()
|
|
32
|
+
h.update(source_type.encode())
|
|
33
|
+
for p in parts:
|
|
34
|
+
h.update(b"\x00")
|
|
35
|
+
h.update(str(p).encode())
|
|
36
|
+
return f"{source_type}:{h.hexdigest()[:16]}"
|
fridai/core/redact.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Secret redaction. Masks secrets as an indexing pre-step.
|
|
2
|
+
|
|
3
|
+
Core to user trust — even locally, secrets shouldn't leak into a plaintext index.
|
|
4
|
+
A precise regex ruleset + a conservative high-entropy heuristic. Disable with `--no-redact`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
|
|
14
|
+
_MASK = "«REDACTED:{}»"
|
|
15
|
+
|
|
16
|
+
# Precise patterns (almost no false positives)
|
|
17
|
+
_RULES: list[tuple[re.Pattern, str]] = [
|
|
18
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
19
|
+
re.DOTALL), "PRIVATE_KEY"),
|
|
20
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS_ACCESS_KEY"),
|
|
21
|
+
(re.compile(r"ghp_[0-9A-Za-z]{36}"), "GITHUB_TOKEN"),
|
|
22
|
+
(re.compile(r"gh[oprsu]_[0-9A-Za-z]{36,}"), "GITHUB_TOKEN"),
|
|
23
|
+
(re.compile(r"xox[baprs]-[0-9A-Za-z-]{10,}"), "SLACK_TOKEN"),
|
|
24
|
+
(re.compile(r"sk-[A-Za-z0-9]{20,}"), "API_KEY"),
|
|
25
|
+
(re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "JWT"),
|
|
26
|
+
# key=value style secrets (mask the value only)
|
|
27
|
+
# excludes the mask char («) from the value charset so already-masked tokens aren't re-matched
|
|
28
|
+
(re.compile(r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|"
|
|
29
|
+
r"client[_-]?secret)\b(\s*[=:]\s*)([\"']?)([^\s\"'«]{6,})\3"),
|
|
30
|
+
"SECRET_KV"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# High-entropy candidate token: 32+ chars, mixed upper/lower/digits (excludes git SHA (lowercase hex) & snake_case)
|
|
34
|
+
_HE_TOKEN = re.compile(r"[A-Za-z0-9+/=_-]{32,}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _shannon(s: str) -> float:
|
|
38
|
+
if not s:
|
|
39
|
+
return 0.0
|
|
40
|
+
counts = Counter(s)
|
|
41
|
+
n = len(s)
|
|
42
|
+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _looks_secret(tok: str) -> bool:
|
|
46
|
+
return (any(c.isupper() for c in tok) and any(c.islower() for c in tok)
|
|
47
|
+
and any(c.isdigit() for c in tok) and _shannon(tok) > 4.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_text(text: str, entropy: bool | None = None) -> tuple[str, int]:
|
|
51
|
+
"""Returns (masked text, mask count). entropy=None follows config.REDACT_ENTROPY (default OFF)."""
|
|
52
|
+
if not text:
|
|
53
|
+
return text, 0
|
|
54
|
+
if entropy is None:
|
|
55
|
+
entropy = config.REDACT_ENTROPY
|
|
56
|
+
n = 0
|
|
57
|
+
for rx, label in _RULES:
|
|
58
|
+
if label == "SECRET_KV":
|
|
59
|
+
text, c = rx.subn(lambda m: f"{m.group(1)}{m.group(2)}{_MASK.format('SECRET')}", text)
|
|
60
|
+
else:
|
|
61
|
+
text, c = rx.subn(_MASK.format(label), text)
|
|
62
|
+
n += c
|
|
63
|
+
|
|
64
|
+
if entropy: # off by default (many false positives); only when explicitly enabled
|
|
65
|
+
def _he(m):
|
|
66
|
+
nonlocal n
|
|
67
|
+
if _looks_secret(m.group(0)):
|
|
68
|
+
n += 1
|
|
69
|
+
return _MASK.format("HIGH_ENTROPY")
|
|
70
|
+
return m.group(0)
|
|
71
|
+
text = _HE_TOKEN.sub(_he, text)
|
|
72
|
+
return text, n
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_META_KEYS = ("question", "answer", "answer_summary", "summary")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def redact_document(doc) -> int:
|
|
79
|
+
"""Mask the document's text/title and key meta strings in place. Returns total mask count."""
|
|
80
|
+
total = 0
|
|
81
|
+
doc.text, c = redact_text(doc.text); total += c
|
|
82
|
+
doc.title, c = redact_text(doc.title); total += c
|
|
83
|
+
for k in _META_KEYS:
|
|
84
|
+
v = doc.meta.get(k)
|
|
85
|
+
if isinstance(v, str):
|
|
86
|
+
doc.meta[k], c = redact_text(v); total += c
|
|
87
|
+
return total
|
fridai/core/search.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Pure retrieval (search). No LLM dependency.
|
|
2
|
+
|
|
3
|
+
Lexical (BM25) / vector (cosine) / hybrid (RRF) retrieval + source citation + context serialization.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
from .models import Document, SearchHit
|
|
9
|
+
from .store import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def citation(doc: Document) -> str:
|
|
13
|
+
"""Human-readable source string per document type."""
|
|
14
|
+
when = doc.timestamp.astimezone().strftime("%Y-%m-%d") if doc.timestamp else "?"
|
|
15
|
+
if doc.source_type == "code":
|
|
16
|
+
return f"{doc.repo}/{doc.meta.get('path', doc.path)}:" \
|
|
17
|
+
f"{doc.meta.get('start_line')}-{doc.meta.get('end_line')}"
|
|
18
|
+
if doc.source_type == "commit":
|
|
19
|
+
return f"{doc.repo}@{doc.meta.get('sha', doc.path)} \"{doc.title}\""
|
|
20
|
+
if doc.source_type == "agent_turn":
|
|
21
|
+
agent = doc.meta.get("agent")
|
|
22
|
+
tag = f"[{agent}] " if agent and agent != "claude" else "" # mark non-Claude agents
|
|
23
|
+
return f"{doc.repo} {when} {tag}session:{doc.meta.get('session_title') or doc.title}"
|
|
24
|
+
if doc.source_type == "note":
|
|
25
|
+
return f"note {when}"
|
|
26
|
+
return doc.path or doc.id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
RRF_C = 60 # RRF constant (larger softens rank-gap impact). Conventional value 60.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rrf_fuse(ranked_lists: list[list[SearchHit]], k: int, c: int = RRF_C) -> list[SearchHit]:
|
|
33
|
+
"""Reciprocal Rank Fusion: fuse multiple ranked results by rank.
|
|
34
|
+
|
|
35
|
+
A document at rank r (0-based) in each list gets 1/(c+r+1) added to its score.
|
|
36
|
+
Robust even when score scales differ (BM25 vs cosine).
|
|
37
|
+
"""
|
|
38
|
+
scores: dict[str, float] = {}
|
|
39
|
+
docs: dict[str, SearchHit] = {}
|
|
40
|
+
for hits in ranked_lists:
|
|
41
|
+
for rank, h in enumerate(hits):
|
|
42
|
+
did = h.document.id
|
|
43
|
+
scores[did] = scores.get(did, 0.0) + 1.0 / (c + rank + 1)
|
|
44
|
+
docs.setdefault(did, h)
|
|
45
|
+
fused = [SearchHit(docs[i].document, s) for i, s in scores.items()]
|
|
46
|
+
fused.sort(key=lambda x: -x.score)
|
|
47
|
+
return fused[:k]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hybrid_retrieve(store: Store, query: str, k: int = 5, *, embedder,
|
|
51
|
+
repo=None, source_type=None, since=None) -> list[SearchHit]:
|
|
52
|
+
"""Fuse lexical (BM25) and vector (cosine) via RRF. Returns lexical only if no vectors."""
|
|
53
|
+
pool = max(k * 2, 10)
|
|
54
|
+
lex = store.search_lexical(query, k=pool, repo=repo, source_type=source_type, since=since)
|
|
55
|
+
qv = embedder.embed(query)
|
|
56
|
+
vec = store.search_vector(qv, k=pool, repo=repo, source_type=source_type,
|
|
57
|
+
since=since) if qv else []
|
|
58
|
+
if not vec:
|
|
59
|
+
return lex[:k]
|
|
60
|
+
return rrf_fuse([lex, vec], k)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def work_signal(doc: Document) -> bool:
|
|
64
|
+
"""Is this document 'actual work output'? code/commit/note are inherently work output.
|
|
65
|
+
An agent_turn counts as a work turn only if it has file edits (files) or a **file-matched
|
|
66
|
+
resulting commit**. (Time-proximity 'guessed' commits also attach to question turns, so
|
|
67
|
+
they don't count as a work signal.)"""
|
|
68
|
+
if doc.source_type != "agent_turn":
|
|
69
|
+
return True
|
|
70
|
+
m = doc.meta
|
|
71
|
+
if m.get("files"):
|
|
72
|
+
return True
|
|
73
|
+
return any(len(c) > 2 and c[2] == "file" for c in (m.get("commits") or []))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rerank_work_signal(hits: list[SearchHit], penalty: int | None = None) -> list[SearchHit]:
|
|
77
|
+
"""Demote agent_turns with no work signal (pure question/recall turns) by `penalty` positions.
|
|
78
|
+
|
|
79
|
+
Mitigates recursive self-pollution: 'how did I do this?' question turns pushing out the
|
|
80
|
+
actual solution turns/commits. Position-based penalty (ties preserve original order).
|
|
81
|
+
penalty=None uses config.WORK_PENALTY.
|
|
82
|
+
"""
|
|
83
|
+
if penalty is None:
|
|
84
|
+
penalty = config.WORK_PENALTY
|
|
85
|
+
keyed = [(i + (0 if work_signal(h.document) else penalty), i, h)
|
|
86
|
+
for i, h in enumerate(hits)]
|
|
87
|
+
keyed.sort(key=lambda x: (x[0], x[1]))
|
|
88
|
+
return [h for _, _, h in keyed]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
import re as _re
|
|
92
|
+
|
|
93
|
+
DEDUP_JACCARD = 0.8 # question-token Jaccard at/above this = treated as the same question
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _q_tokens(doc: Document) -> set:
|
|
97
|
+
text = doc.meta.get("question") or doc.title or doc.text or ""
|
|
98
|
+
return set(_re.findall(r"\w+", text.lower()))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dedup_results(hits: list[SearchHit], thr: float = DEDUP_JACCARD) -> list[SearchHit]:
|
|
102
|
+
"""Collapse near-duplicate agent_turns (repeated same/similar questions) to one representative.
|
|
103
|
+
|
|
104
|
+
Preserves post-rerank order, keeping the first (= top-ranked / work-signal) item as the
|
|
105
|
+
representative. code/commit/note are not affected.
|
|
106
|
+
"""
|
|
107
|
+
seen: list[set] = []
|
|
108
|
+
out = []
|
|
109
|
+
for h in hits:
|
|
110
|
+
if h.document.source_type == "agent_turn":
|
|
111
|
+
toks = _q_tokens(h.document)
|
|
112
|
+
if toks and any(len(toks & s) / len(toks | s) >= thr for s in seen):
|
|
113
|
+
continue # near-identical to a question already seen -> skip
|
|
114
|
+
seen.append(toks)
|
|
115
|
+
out.append(h)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def retrieve(store: Store, query: str, k: int = 5, *, embedder=None,
|
|
120
|
+
repo=None, source_type=None, since=None) -> list[SearchHit]:
|
|
121
|
+
"""Hybrid (BM25+vector RRF) if embedder given, else lexical. Work-signal rerank + dedup, then top-k."""
|
|
122
|
+
pool = max(k * 3, 15) # retrieve generously, then trim after rerank/dedup
|
|
123
|
+
if embedder is not None:
|
|
124
|
+
hits = hybrid_retrieve(store, query, pool, embedder=embedder,
|
|
125
|
+
repo=repo, source_type=source_type, since=since)
|
|
126
|
+
else:
|
|
127
|
+
hits = store.search_lexical(query, k=pool, repo=repo, source_type=source_type, since=since)
|
|
128
|
+
return dedup_results(rerank_work_signal(hits))[:k]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_context(hits: list[SearchHit]) -> str:
|
|
132
|
+
"""Context for an LLM prompt. Numbers each block and attaches its source."""
|
|
133
|
+
return "\n\n".join(
|
|
134
|
+
f"[{i}] ({citation(h.document)})\n{h.document.text}"
|
|
135
|
+
for i, h in enumerate(hits, 1)
|
|
136
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Indexing sources (code / commits / agent recall: claude·codex·gemini)."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""F4 shared base — common data structures & engine for coding-agent conversations
|
|
2
|
+
(agent-independent).
|
|
3
|
+
|
|
4
|
+
Per-agent parsers live in sibling modules: claude_recall / codex_recall / gemini_recall.
|
|
5
|
+
Each parser turns session files into a list of `Turn` and reuses the shared machinery here:
|
|
6
|
+
- `Turn` : normalized unit of question/answer/context (cwd·branch·files·commits)
|
|
7
|
+
- `link_commits` : match a question to its resulting commit (time window ∩ touched files)
|
|
8
|
+
- `turn_to_document` : Turn -> indexed Document (records source agent in meta['agent'])
|
|
9
|
+
- `index_sessions` : mtime-incremental indexing engine over session files (parser callback)
|
|
10
|
+
- `index_all` : sum indexing across all registered agents
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .. import config, embeddings
|
|
21
|
+
from ..models import Document, make_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Turn:
|
|
26
|
+
question: str
|
|
27
|
+
answer: str = ""
|
|
28
|
+
when: datetime | None = None
|
|
29
|
+
cwd: str = ""
|
|
30
|
+
repo: str = ""
|
|
31
|
+
branch: str = ""
|
|
32
|
+
session_id: str = ""
|
|
33
|
+
session_title: str = ""
|
|
34
|
+
tools: list[str] = field(default_factory=list)
|
|
35
|
+
files: list[str] = field(default_factory=list)
|
|
36
|
+
commits: list[tuple[str, str, str]] = field(default_factory=list) # (hash, subject, kind)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _clean(text: str) -> str:
|
|
40
|
+
"""Strip harness-injected blocks (system reminders / background task notifications). Shared by parsers."""
|
|
41
|
+
text = re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL)
|
|
42
|
+
text = re.sub(r"<task-notification>.*?</task-notification>", "", text, flags=re.DOTALL)
|
|
43
|
+
return text.strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── question -> resulting-commit matching ──
|
|
47
|
+
_commit_cache: dict[str, list[tuple[datetime, str, str, set]]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _repo_commits(cwd: str):
|
|
51
|
+
if not cwd or cwd in _commit_cache:
|
|
52
|
+
return _commit_cache.get(cwd, [])
|
|
53
|
+
out, cur = [], None
|
|
54
|
+
if (Path(cwd) / ".git").exists():
|
|
55
|
+
try:
|
|
56
|
+
raw = subprocess.run(
|
|
57
|
+
["git", "-C", cwd, "log", "--all", "--name-only",
|
|
58
|
+
"--pretty=format:@@@%cI%x09%h%x09%s"],
|
|
59
|
+
capture_output=True, text=True, timeout=20,
|
|
60
|
+
).stdout
|
|
61
|
+
for ln in raw.splitlines():
|
|
62
|
+
if ln.startswith("@@@"):
|
|
63
|
+
if cur:
|
|
64
|
+
out.append(cur)
|
|
65
|
+
parts = ln[3:].split("\t", 2)
|
|
66
|
+
cur = None
|
|
67
|
+
if len(parts) == 3:
|
|
68
|
+
try:
|
|
69
|
+
# the 'Z' suffix is only accepted by fromisoformat on 3.11+ -> normalize
|
|
70
|
+
cur = (datetime.fromisoformat(parts[0].replace("Z", "+00:00")),
|
|
71
|
+
parts[1], parts[2], set())
|
|
72
|
+
except ValueError:
|
|
73
|
+
cur = None
|
|
74
|
+
elif ln.strip() and cur:
|
|
75
|
+
cur[3].add(Path(ln.strip()).name)
|
|
76
|
+
if cur:
|
|
77
|
+
out.append(cur)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
_commit_cache[cwd] = out
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def link_commits(turn: Turn, window_min: int | None = None) -> None:
|
|
85
|
+
window_min = config.COMMIT_WINDOW_MIN if window_min is None else window_min
|
|
86
|
+
if not turn.when:
|
|
87
|
+
return
|
|
88
|
+
lo, hi = turn.when, turn.when + timedelta(minutes=window_min)
|
|
89
|
+
cands = [c for c in _repo_commits(turn.cwd) if lo <= c[0] <= hi]
|
|
90
|
+
if not cands:
|
|
91
|
+
turn.commits = []
|
|
92
|
+
return
|
|
93
|
+
turn_files = {Path(f).name for f in turn.files}
|
|
94
|
+
strong = [(len(turn_files & c[3]), c[0] - turn.when, c) for c in cands if turn_files & c[3]]
|
|
95
|
+
if strong:
|
|
96
|
+
strong.sort(key=lambda x: (-x[0], x[1]))
|
|
97
|
+
turn.commits = [(c[1], c[2], "file") for _, _, c in strong[:3]]
|
|
98
|
+
return
|
|
99
|
+
nonmerge = [c for c in cands if c[3]] or cands
|
|
100
|
+
nonmerge.sort(key=lambda c: c[0] - turn.when)
|
|
101
|
+
turn.commits = [(nonmerge[0][1], nonmerge[0][2], "time")]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── summarize / Document conversion ──
|
|
105
|
+
def summarize(answer: str, n: int = 130) -> str:
|
|
106
|
+
if not answer:
|
|
107
|
+
return ""
|
|
108
|
+
text = " ".join(answer.split())
|
|
109
|
+
parts = re.split(r"(?<=[.!?。])\s+|(?<=[다요죠음])\s+", text)
|
|
110
|
+
s = next((p for p in parts if len(p.strip()) > 8), text)
|
|
111
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def turn_to_document(turn: Turn, agent: str = "claude") -> Document:
|
|
115
|
+
title = turn.session_title or turn.question.splitlines()[0][:80]
|
|
116
|
+
return Document(
|
|
117
|
+
id=make_id("agent_turn", turn.session_id, str(turn.when), turn.question[:120]),
|
|
118
|
+
source_type="agent_turn", repo=turn.repo, path=turn.session_id,
|
|
119
|
+
title=title, text=f"{turn.question}\n\n{turn.answer}".strip(),
|
|
120
|
+
timestamp=turn.when,
|
|
121
|
+
meta={
|
|
122
|
+
"question": turn.question, "answer": turn.answer,
|
|
123
|
+
"answer_summary": summarize(turn.answer),
|
|
124
|
+
"branch": turn.branch, "cwd": turn.cwd,
|
|
125
|
+
"tools": turn.tools, "files": turn.files,
|
|
126
|
+
"session_title": turn.session_title, "session_id": turn.session_id,
|
|
127
|
+
"commits": turn.commits, "agent": agent, # which agent this record came from
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── shared incremental indexing engine ──
|
|
133
|
+
def index_sessions(store, files, parse, *, embedder=None, reindex: bool = False,
|
|
134
|
+
agent: str = "claude", state_prefix: str = "agent") -> dict:
|
|
135
|
+
"""Agent-independent indexing engine — index session files incrementally by mtime.
|
|
136
|
+
|
|
137
|
+
`files`: session file paths. `parse`: (Path)->list[Turn], the per-adapter parser.
|
|
138
|
+
Each file's mtime is stored in index_state (`{state_prefix}:<path>`) so unchanged files skip.
|
|
139
|
+
Turn ids are stable so re-upsert is idempotent (no delete handling). Returns {turns, files, skipped}.
|
|
140
|
+
"""
|
|
141
|
+
turns_total = files_done = skipped = 0
|
|
142
|
+
for path in sorted(files):
|
|
143
|
+
key = f"{state_prefix}:{path}"
|
|
144
|
+
try:
|
|
145
|
+
mtime = str(path.stat().st_mtime)
|
|
146
|
+
except OSError:
|
|
147
|
+
continue
|
|
148
|
+
if not reindex and store.get_state(key) == mtime:
|
|
149
|
+
skipped += 1
|
|
150
|
+
continue
|
|
151
|
+
docs = []
|
|
152
|
+
for t in parse(path):
|
|
153
|
+
link_commits(t)
|
|
154
|
+
docs.append(turn_to_document(t, agent=agent))
|
|
155
|
+
if embedder and docs:
|
|
156
|
+
embeddings.embed_documents(docs, embedder)
|
|
157
|
+
if docs:
|
|
158
|
+
store.upsert(docs)
|
|
159
|
+
store.set_state(key, mtime)
|
|
160
|
+
files_done += 1
|
|
161
|
+
turns_total += len(docs)
|
|
162
|
+
return {"turns": turns_total, "files": files_done, "skipped": skipped}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def index_all(store, projects_dir: Path | None = None, codex_dir: Path | None = None,
|
|
166
|
+
gemini_dir: Path | None = None, *, embedder=None, reindex: bool = False) -> dict:
|
|
167
|
+
"""Index and sum conversations across all registered agents (Claude Code + Codex CLI + Gemini CLI).
|
|
168
|
+
Each source yields 0 if its directory is absent. Called by `index --source agent`."""
|
|
169
|
+
from . import claude_recall, codex_recall, gemini_recall
|
|
170
|
+
total = {"turns": 0, "files": 0, "skipped": 0}
|
|
171
|
+
for r in (claude_recall.index_claude(store, projects_dir, embedder=embedder, reindex=reindex),
|
|
172
|
+
codex_recall.index_codex(store, codex_dir, embedder=embedder, reindex=reindex),
|
|
173
|
+
gemini_recall.index_gemini(store, gemini_dir, embedder=embedder, reindex=reindex)):
|
|
174
|
+
for k in total:
|
|
175
|
+
total[k] += r[k]
|
|
176
|
+
return total
|