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 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()
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