backstory-cli 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.
backstory/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """backstory - AI commit memory layer."""
2
+
backstory/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
backstory/attach.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from backstory.dump import clear_pending_session, load_pending_session
8
+ from backstory.okf import parse_session_markdown, render_session_markdown, session_id_to_filename
9
+ from backstory.storage import build_storage_paths, ensure_storage_layout
10
+
11
+
12
+ def attach_pending_to_commit(repo_root: Path, commit_hash: str) -> dict[str, Any] | None:
13
+ """Attach the latest pending AI session to a Git commit.
14
+
15
+ Steps:
16
+ 1. Load the pending session.
17
+ 2. Update it with the commit hash.
18
+ 3. Write a summary file.
19
+ 4. Save commit-to-session mapping via Git notes.
20
+ 5. Clear the pending session.
21
+
22
+ Returns the updated session dict, or ``None`` if no pending session exists.
23
+ """
24
+ session = load_pending_session(repo_root)
25
+ if session is None:
26
+ return None
27
+
28
+ # --- Link session to commit ---
29
+ commit_msg = _get_commit_message(repo_root, commit_hash)
30
+ session["commit"] = {
31
+ "hash": commit_hash,
32
+ "message": commit_msg or "",
33
+ }
34
+
35
+ paths = ensure_storage_layout(repo_root)
36
+ stable_path = paths.sessions / session_id_to_filename(session["session_id"])
37
+ stable_path.write_text(render_session_markdown(session), encoding="utf-8")
38
+
39
+ # --- Write a Git note ---
40
+ _write_git_note(repo_root, commit_hash, session)
41
+
42
+ # --- Clear pending ---
43
+ clear_pending_session(repo_root)
44
+
45
+ return session
46
+
47
+
48
+ def _get_commit_message(repo_root: Path, commit_hash: str) -> str | None:
49
+ """Get the subject line of a commit."""
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "log", "-1", "--format=%s", commit_hash],
53
+ cwd=repo_root,
54
+ capture_output=True,
55
+ text=True,
56
+ check=False,
57
+ )
58
+ if result.returncode == 0:
59
+ return result.stdout.strip() or None
60
+ return None
61
+ except OSError:
62
+ return None
63
+
64
+
65
+ def _write_git_note(repo_root: Path, commit_hash: str, session: dict) -> None:
66
+ """Attach a Git note with session metadata to the commit."""
67
+ import json
68
+
69
+ note = json.dumps(
70
+ {
71
+ "ai_session": session.get("session_id"),
72
+ "agent": session.get("agent", {}).get("name"),
73
+ "created_at": session.get("created_at"),
74
+ },
75
+ indent=2,
76
+ )
77
+ try:
78
+ subprocess.run(
79
+ ["git", "notes", "add", "-f", "-m", note, commit_hash],
80
+ cwd=repo_root,
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ except OSError:
86
+ pass # Git notes are best-effort
87
+
88
+
89
+ def _render_summary(session: dict, commit_hash: str, commit_msg: str | None) -> str:
90
+ """Render a human-readable summary markdown file."""
91
+ rendered = render_session_markdown(session)
92
+ parsed = parse_session_markdown(rendered)
93
+ lines: list[str] = []
94
+ lines.append(f"# backstory for Commit {commit_hash}")
95
+ lines.append("")
96
+ if commit_msg:
97
+ lines.append(f"**{commit_msg}**")
98
+ lines.append("")
99
+ lines.append("## Task")
100
+ lines.append("")
101
+ lines.append(parsed.task_title)
102
+ lines.append("")
103
+ if parsed.why:
104
+ lines.append("## Why")
105
+ lines.append("")
106
+ lines.append(parsed.why)
107
+ lines.append("")
108
+ if parsed.files_changed:
109
+ lines.append("## Files Changed")
110
+ lines.append("")
111
+ for f in parsed.files_changed:
112
+ lines.append(f"- `{f}`")
113
+ lines.append("")
114
+ if parsed.decisions:
115
+ lines.append("## Key Decisions")
116
+ lines.append("")
117
+ for d in parsed.decisions:
118
+ lines.append(f"- {d}")
119
+ lines.append("")
120
+ if parsed.risks:
121
+ lines.append("## Risks")
122
+ lines.append("")
123
+ for r in parsed.risks:
124
+ lines.append(f"- {r}")
125
+ lines.append("")
126
+ lines.append(f"Agent: {parsed.agent_name}")
127
+ if parsed.agent_model:
128
+ lines.append(f"Model: {parsed.agent_model}")
129
+ lines.append("")
130
+ lines.append(f"Session ID: {parsed.session_id or session.get('session_id', 'unknown')}")
131
+ return "\n".join(lines)
backstory/cli.py ADDED
@@ -0,0 +1,381 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from backstory.attach import attach_pending_to_commit
8
+ from backstory.contradiction import detect_potential_contradictions
9
+ from backstory.dump import capture_session, save_pending_session
10
+ from backstory.dump import discover_transcript_path
11
+ from backstory.init import initialize_repo, print_init_summary
12
+ from backstory.retrieval import (
13
+ commit_for_line,
14
+ commits_for_file,
15
+ commits_for_range,
16
+ files_in_diff,
17
+ format_retrieval_result,
18
+ resolve_repo_root,
19
+ )
20
+ from backstory.summarize import summarize_transcript
21
+ from backstory.transcript import (
22
+ ExtractedDecisions,
23
+ detect_agent_name,
24
+ detect_model,
25
+ format_decisions,
26
+ import_transcript,
27
+ normalize_messages,
28
+ )
29
+
30
+ COMMANDS = [
31
+ "init",
32
+ "dump",
33
+ "attach",
34
+ "why",
35
+ "show",
36
+ "search",
37
+ "context",
38
+ "status",
39
+ "redact",
40
+ "repair",
41
+ ]
42
+
43
+ RETRIEVAL_COMMANDS = [
44
+ "file",
45
+ "line",
46
+ "range",
47
+ "diff",
48
+ "code",
49
+ ]
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ parser = argparse.ArgumentParser(prog="backstory")
54
+ subparsers = parser.add_subparsers(dest="command", required=True)
55
+
56
+ # --- init ---
57
+ init_p = subparsers.add_parser("init", help="Initialize backstory in this repo")
58
+ init_p.add_argument("--no-hooks", action="store_true", help="Skip Git hook installation")
59
+ init_p.add_argument("--force", action="store_true", help="Overwrite existing config and hooks")
60
+
61
+ # --- dump ---
62
+ dump_p = subparsers.add_parser("dump", help="Capture an AI session")
63
+ dump_p.add_argument("--agent", default=None, help="Agent name (claude, codex)")
64
+ dump_p.add_argument("--transcript", type=Path, default=None, help="Path to transcript JSON file")
65
+ dump_p.add_argument("--task", default=None, help="Short task description")
66
+ dump_p.add_argument("--hook", default=None, help=argparse.SUPPRESS) # internal: called from hook
67
+
68
+ # --- attach ---
69
+ attach_p = subparsers.add_parser("attach", help="Attach pending session to a commit")
70
+ attach_p.add_argument("commit_spec", nargs="?", default="HEAD", help="Commit hash or reference")
71
+ attach_p.add_argument("--hook", default=None, help=argparse.SUPPRESS) # internal
72
+
73
+ # --- basic commands (no extra args yet) ---
74
+ for name in ("why", "show", "search", "context", "status", "redact", "repair"):
75
+ subparsers.add_parser(name)
76
+
77
+ # --- retrieval commands ---
78
+ file_p = subparsers.add_parser("file", help="Show commits affecting a file")
79
+ file_p.add_argument("path", help="File path to query")
80
+
81
+ line_p = subparsers.add_parser("line", help="Show what changed a specific line")
82
+ line_p.add_argument("spec", help="path/to/file.py:42")
83
+
84
+ range_p = subparsers.add_parser("range", help="Show commits affecting a line range")
85
+ range_p.add_argument("spec", help="path/to/file.py:10-20")
86
+
87
+ code_p = subparsers.add_parser("code", help="Alias for range")
88
+ code_p.add_argument("spec", help="path/to/file.py:10-20")
89
+
90
+ subparsers.add_parser("diff", help="Show prior context for uncommitted changes")
91
+
92
+ return parser
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ args = build_parser().parse_args(argv)
97
+ return _dispatch(args)
98
+
99
+
100
+ def _dispatch(args: argparse.Namespace) -> int:
101
+ handler = _get_handler(args.command)
102
+ if handler is None:
103
+ print(f"Unknown command: {args.command}", file=sys.stderr)
104
+ return 1
105
+ return handler(args)
106
+
107
+
108
+ def _get_handler(command: str):
109
+ return {
110
+ "init": _handle_init,
111
+ "dump": _handle_dump,
112
+ "attach": _handle_attach,
113
+ "file": _handle_file,
114
+ "line": _handle_line,
115
+ "range": _handle_range,
116
+ "code": _handle_range,
117
+ "diff": _handle_diff,
118
+ }.get(command)
119
+
120
+
121
+ def _resolve_repo() -> Path | None:
122
+ return resolve_repo_root(Path.cwd())
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # init handler
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _handle_init(args: argparse.Namespace) -> int:
131
+ repo = _resolve_repo()
132
+ if repo is None:
133
+ print("Not in a Git repository.", file=sys.stderr)
134
+ print("Run this command inside a Git repository.", file=sys.stderr)
135
+ return 1
136
+
137
+ result = initialize_repo(
138
+ repo_root=repo,
139
+ install_git_hooks=not args.no_hooks,
140
+ force=args.force,
141
+ )
142
+ print_init_summary(repo, result)
143
+ return 0
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # dump handler
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def _handle_dump(args: argparse.Namespace) -> int:
152
+ repo = _resolve_repo()
153
+ if repo is None:
154
+ print("Not in a Git repository.", file=sys.stderr)
155
+ return 1
156
+
157
+ # Resolve agent name
158
+ agent_name = args.agent or "manual"
159
+ model: str | None = None
160
+ decisions = None
161
+
162
+ transcript_path = args.transcript
163
+ if transcript_path is None:
164
+ transcript_path = discover_transcript_path(repo)
165
+ if transcript_path is not None:
166
+ print(f"Auto-detected transcript: {transcript_path}", file=sys.stderr)
167
+
168
+ if transcript_path:
169
+ # Step 1: Read the raw transcript file
170
+ raw = import_transcript(transcript_path)
171
+ if raw is None:
172
+ print(f"Warning: could not read transcript from {transcript_path}", file=sys.stderr)
173
+ else:
174
+ # Step 2: Detect agent metadata from the transcript envelope
175
+ agent_name = detect_agent_name(raw) or agent_name
176
+ model = detect_model(raw)
177
+
178
+ # Step 3: Normalize to message list
179
+ messages = normalize_messages(raw)
180
+
181
+ if messages:
182
+ # Step 4: Ask the agent to summarize its own transcript
183
+ print(f"Asking {agent_name} to summarize transcript...", file=sys.stderr)
184
+ decisions = summarize_transcript(
185
+ messages=messages,
186
+ agent_name=agent_name,
187
+ model=model,
188
+ )
189
+
190
+ if decisions:
191
+ print(format_decisions(decisions))
192
+ print()
193
+ else:
194
+ print(
195
+ " (agent summarization unavailable — "
196
+ "install claude CLI for automatic extraction)",
197
+ file=sys.stderr,
198
+ )
199
+ # Still capture the session with basic info
200
+ decisions = ExtractedDecisions(
201
+ agent_name=agent_name,
202
+ model=model,
203
+ task=args.task or "",
204
+ decisions=[],
205
+ )
206
+ else:
207
+ print("Warning: no messages found in transcript", file=sys.stderr)
208
+
209
+ # Override agent name from --agent flag if explicitly provided
210
+ if args.agent:
211
+ agent_name = args.agent
212
+
213
+ # Capture session (NO raw conversation stored — only extracted decisions)
214
+ session = capture_session(
215
+ repo_root=repo,
216
+ task=args.task,
217
+ agent=agent_name,
218
+ decisions=decisions,
219
+ )
220
+
221
+ path = save_pending_session(repo, session)
222
+ print(f"Session saved: {session['session_id']}")
223
+ print(f"Pending: {path}")
224
+
225
+ return 0
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # attach handler
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def _handle_attach(args: argparse.Namespace) -> int:
234
+ repo = _resolve_repo()
235
+ if repo is None:
236
+ print("Not in a Git repository.", file=sys.stderr)
237
+ return 1
238
+
239
+ result = attach_pending_to_commit(repo, args.commit_spec)
240
+ if result is None:
241
+ if not args.hook:
242
+ print("No pending session to attach.")
243
+ return 0
244
+
245
+ print(f"Attached session {result['session_id']} to commit {args.commit_spec}")
246
+ return 0
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # file handler
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _handle_file(args: argparse.Namespace) -> int:
255
+ repo = _resolve_repo()
256
+ if repo is None:
257
+ print("Not in a Git repository.", file=sys.stderr)
258
+ return 1
259
+
260
+ commits = commits_for_file(repo, args.path)
261
+ output = format_retrieval_result(
262
+ file_path=args.path,
263
+ line_range=None,
264
+ commits=commits,
265
+ )
266
+ print(output)
267
+ return 0
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Parse helpers for line/range specs
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _parse_line_spec(spec: str) -> tuple[str, int] | None:
276
+ """Parse 'path/to/file.py:42' into (path, 42)."""
277
+ if ":" not in spec:
278
+ return None
279
+ parts = spec.rsplit(":", 1)
280
+ try:
281
+ return parts[0], int(parts[1])
282
+ except (ValueError, IndexError):
283
+ return None
284
+
285
+
286
+ def _parse_range_spec(spec: str) -> tuple[str, int, int] | None:
287
+ """Parse 'path/to/file.py:10-20' into (path, 10, 20)."""
288
+ if ":" not in spec or "-" not in spec:
289
+ return None
290
+ path_part, range_part = spec.rsplit(":", 1)
291
+ if "-" not in range_part:
292
+ return None
293
+ try:
294
+ start_str, end_str = range_part.split("-", 1)
295
+ return path_part, int(start_str), int(end_str)
296
+ except (ValueError, IndexError):
297
+ return None
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # line / range / diff handlers
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def _handle_line(args: argparse.Namespace) -> int:
306
+ repo = _resolve_repo()
307
+ if repo is None:
308
+ print("Not in a Git repository.", file=sys.stderr)
309
+ return 1
310
+
311
+ parsed = _parse_line_spec(args.spec)
312
+ if parsed is None:
313
+ print("Invalid format. Use: backstory line <file>:<line>", file=sys.stderr)
314
+ return 1
315
+
316
+ path, line = parsed
317
+ commit = commit_for_line(repo, path, line)
318
+ commits = [commit] if commit else []
319
+ output = format_retrieval_result(
320
+ file_path=path,
321
+ line_range=(line, line),
322
+ commits=commits,
323
+ )
324
+ print(output)
325
+ return 0
326
+
327
+
328
+ def _handle_range(args: argparse.Namespace) -> int:
329
+ repo = _resolve_repo()
330
+ if repo is None:
331
+ print("Not in a Git repository.", file=sys.stderr)
332
+ return 1
333
+
334
+ parsed = _parse_range_spec(args.spec)
335
+ if parsed is None:
336
+ print("Invalid format. Use: backstory range <file>:<start>-<end>", file=sys.stderr)
337
+ return 1
338
+
339
+ path, start, end = parsed
340
+ commits = commits_for_range(repo, path, start, end)
341
+ output = format_retrieval_result(
342
+ file_path=path,
343
+ line_range=(start, end),
344
+ commits=commits,
345
+ )
346
+ print(output)
347
+ return 0
348
+
349
+
350
+ def _handle_diff(args: argparse.Namespace) -> int:
351
+ repo = _resolve_repo()
352
+ if repo is None:
353
+ print("Not in a Git repository.", file=sys.stderr)
354
+ return 1
355
+
356
+ changed_files = files_in_diff(repo)
357
+ if not changed_files:
358
+ print("No uncommitted changes detected.")
359
+ return 0
360
+
361
+ print("Relevant prior context for current diff:")
362
+ print("")
363
+
364
+ for idx, file_path in enumerate(changed_files, 1):
365
+ commits = commits_for_file(repo, file_path)
366
+ print(f"{idx}. {file_path}")
367
+ if commits:
368
+ print(f" Most recent commit: {commits[0].hash} - {commits[0].message}")
369
+ print(f" Author: {commits[0].author}")
370
+ else:
371
+ print(" No previous commits found.")
372
+ print()
373
+
374
+ warnings = detect_potential_contradictions(repo, changed_files)
375
+ if warnings:
376
+ print("Potential contradictions:")
377
+ for warning in warnings:
378
+ print(f" - {warning}")
379
+ print()
380
+
381
+ return 0
backstory/config.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from backstory.storage import (
7
+ INDEX_DB_NAME,
8
+ KNOWLEDGE_DIR_NAME,
9
+ PENDING_SESSION_NAME,
10
+ REDACTIONS_DIR_NAME,
11
+ SESSIONS_DIR_NAME,
12
+ STORAGE_DIR_NAME,
13
+ ensure_storage_layout,
14
+ )
15
+
16
+
17
+ DEFAULT_CONFIG = {
18
+ "version": 1,
19
+ "storage": {
20
+ "root": STORAGE_DIR_NAME,
21
+ "knowledge_dir": KNOWLEDGE_DIR_NAME,
22
+ "sessions_dir": SESSIONS_DIR_NAME,
23
+ "pending_file": PENDING_SESSION_NAME,
24
+ "redactions_dir": REDACTIONS_DIR_NAME,
25
+ "index_db": INDEX_DB_NAME,
26
+ },
27
+ "capture": {
28
+ "store_git_diff": True,
29
+ "store_transcripts": True,
30
+ },
31
+ "redaction": {
32
+ "enabled": True,
33
+ },
34
+ }
35
+
36
+
37
+ def config_path(repo_root: Path) -> Path:
38
+ return repo_root / ".backstory" / "config.json"
39
+
40
+
41
+ def write_default_config(repo_root: Path) -> Path:
42
+ ensure_storage_layout(repo_root)
43
+ path = config_path(repo_root)
44
+ path.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
45
+ return path
46
+
47
+
48
+ def load_config(repo_root: Path) -> dict:
49
+ return json.loads(config_path(repo_root).read_text())
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from backstory.okf import parse_session_markdown
8
+ from backstory.storage import build_storage_paths
9
+
10
+ NEGATION_PATTERNS = (
11
+ r"\bnot\b",
12
+ r"\bnever\b",
13
+ r"\binstead\b",
14
+ r"\bavoid\b",
15
+ r"\bprevent\b",
16
+ r"\bno longer\b",
17
+ r"\brather than\b",
18
+ )
19
+
20
+
21
+ def detect_potential_contradictions(repo_root: Path, changed_files: list[str]) -> list[str]:
22
+ """Return human-readable warnings for likely contradictions.
23
+
24
+ The first implementation is conservative: it only warns when a current
25
+ changed file overlaps with a previously attached session and that session
26
+ contains at least one negated or reversing decision.
27
+ """
28
+ changed = {path for path in changed_files if path}
29
+ if not changed:
30
+ return []
31
+
32
+ warnings: list[str] = []
33
+ for session in _load_attached_sessions(repo_root):
34
+ session_files = set(session.get("files", {}).get("changed", []))
35
+ overlap = sorted(changed.intersection(session_files))
36
+ if not overlap:
37
+ continue
38
+
39
+ reasoning = session.get("reasoning_summary", {})
40
+ decisions = reasoning.get("decisions", [])
41
+ for decision in decisions:
42
+ if _looks_like_reversal(decision):
43
+ commit_hash = session.get("commit", {}).get("hash") or session.get("session_id", "unknown")
44
+ warnings.append(
45
+ f"Potential contradiction from {str(commit_hash)[:7]} on {', '.join(overlap)}: {decision}"
46
+ )
47
+ break
48
+
49
+ return warnings
50
+
51
+
52
+ def _load_attached_sessions(repo_root: Path) -> list[dict[str, Any]]:
53
+ """Load attached session objects from local storage."""
54
+ paths = build_storage_paths(repo_root)
55
+ sessions: list[dict[str, Any]] = []
56
+
57
+ if not paths.sessions.exists():
58
+ return sessions
59
+
60
+ for path in sorted(paths.sessions.glob("*.md")):
61
+ if path.name == "latest.md":
62
+ continue
63
+ try:
64
+ knowledge = parse_session_markdown(path.read_text(encoding="utf-8"))
65
+ except OSError:
66
+ continue
67
+ sessions.append(knowledge.to_session_dict())
68
+
69
+ return sessions
70
+
71
+
72
+ def _looks_like_reversal(text: str) -> bool:
73
+ """Detect the negated phrasing typically used in a contradictory decision."""
74
+ lowered = text.lower()
75
+ return any(re.search(pattern, lowered) for pattern in NEGATION_PATTERNS)