backstory-cli 0.1.0__tar.gz
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_cli-0.1.0/PKG-INFO +5 -0
- backstory_cli-0.1.0/README.md +268 -0
- backstory_cli-0.1.0/pyproject.toml +18 -0
- backstory_cli-0.1.0/setup.cfg +4 -0
- backstory_cli-0.1.0/src/backstory/__init__.py +2 -0
- backstory_cli-0.1.0/src/backstory/__main__.py +6 -0
- backstory_cli-0.1.0/src/backstory/attach.py +131 -0
- backstory_cli-0.1.0/src/backstory/cli.py +381 -0
- backstory_cli-0.1.0/src/backstory/config.py +49 -0
- backstory_cli-0.1.0/src/backstory/contradiction.py +75 -0
- backstory_cli-0.1.0/src/backstory/dump.py +235 -0
- backstory_cli-0.1.0/src/backstory/git.py +32 -0
- backstory_cli-0.1.0/src/backstory/hooks.py +94 -0
- backstory_cli-0.1.0/src/backstory/init.py +96 -0
- backstory_cli-0.1.0/src/backstory/okf.py +346 -0
- backstory_cli-0.1.0/src/backstory/retrieval.py +239 -0
- backstory_cli-0.1.0/src/backstory/storage.py +58 -0
- backstory_cli-0.1.0/src/backstory/summarize.py +184 -0
- backstory_cli-0.1.0/src/backstory/transcript.py +177 -0
- backstory_cli-0.1.0/src/backstory_cli.egg-info/PKG-INFO +5 -0
- backstory_cli-0.1.0/src/backstory_cli.egg-info/SOURCES.txt +33 -0
- backstory_cli-0.1.0/src/backstory_cli.egg-info/dependency_links.txt +1 -0
- backstory_cli-0.1.0/src/backstory_cli.egg-info/entry_points.txt +2 -0
- backstory_cli-0.1.0/src/backstory_cli.egg-info/top_level.txt +1 -0
- backstory_cli-0.1.0/tests/test_attach.py +110 -0
- backstory_cli-0.1.0/tests/test_cli.py +93 -0
- backstory_cli-0.1.0/tests/test_config.py +47 -0
- backstory_cli-0.1.0/tests/test_contradiction.py +118 -0
- backstory_cli-0.1.0/tests/test_dump.py +184 -0
- backstory_cli-0.1.0/tests/test_hooks.py +93 -0
- backstory_cli-0.1.0/tests/test_init.py +98 -0
- backstory_cli-0.1.0/tests/test_retrieval.py +214 -0
- backstory_cli-0.1.0/tests/test_storage.py +64 -0
- backstory_cli-0.1.0/tests/test_summarize.py +113 -0
- backstory_cli-0.1.0/tests/test_transcript.py +179 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Backstory
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://cloud.google.com/blog/products/data-analytics)
|
|
5
|
+
|
|
6
|
+
**Git shows what changed. Backstory shows why.**
|
|
7
|
+
|
|
8
|
+
Backstory preserves the decision-making process behind AI-assisted code
|
|
9
|
+
changes. It captures session context from AI coding tools, extracts the
|
|
10
|
+
durable reasoning (decisions, risks, alternatives), stores it as local
|
|
11
|
+
markdown, and links it to Git commits so you can retrieve the *why* later.
|
|
12
|
+
|
|
13
|
+
> **Why not just write good commit messages?**
|
|
14
|
+
> Commit messages describe what changed. They rarely capture the rejected
|
|
15
|
+
> alternatives, the risks you accepted, or the reasoning trail across a
|
|
16
|
+
> multi-step AI session. Backstory fills that gap: it stores the decision
|
|
17
|
+
> trail the AI tool produced, not just the final diff.
|
|
18
|
+
|
|
19
|
+
- Local-first by default — everything stays in your repo
|
|
20
|
+
- Recovers *why* a decision was made, after the codebase has moved on
|
|
21
|
+
- Stores durable session memory as OKF markdown — human-readable,
|
|
22
|
+
Git-friendly, and agent-friendly
|
|
23
|
+
- Links memory to Git commits so the reasoning stays searchable
|
|
24
|
+
- Detects contradictions when later changes reverse earlier decisions
|
|
25
|
+
- Extracts decisions, risks, and follow-ups — not raw chat logs
|
|
26
|
+
|
|
27
|
+
Backstory is not an AI coding agent. It is the memory layer around
|
|
28
|
+
AI-assisted coding.
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
You ask an AI agent to fix subscription renewal logic.
|
|
33
|
+
|
|
34
|
+
Later, you can ask:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
backstory why HEAD
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
And get back something like:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
Commit: 8f21c9a
|
|
44
|
+
Message: Fix subscription renewal handling
|
|
45
|
+
Agent: Claude Code
|
|
46
|
+
|
|
47
|
+
Why this changed:
|
|
48
|
+
The webhook handler was not updating the next billing date after
|
|
49
|
+
successful recurring charges. Failed payments were not separated
|
|
50
|
+
from cancellations.
|
|
51
|
+
|
|
52
|
+
Key decisions:
|
|
53
|
+
- subscription.charged updates next_due_on
|
|
54
|
+
- payment.failed marks subscription as pending, not cancelled
|
|
55
|
+
- webhook handling must be idempotent
|
|
56
|
+
|
|
57
|
+
Files changed:
|
|
58
|
+
- app/api/webhooks/razorpay/route.ts
|
|
59
|
+
- lib/subscription.ts
|
|
60
|
+
|
|
61
|
+
Risks:
|
|
62
|
+
- Idempotency depends on storing Razorpay event IDs
|
|
63
|
+
- Existing subscriptions need a next_due_on backfill
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That is the useful part: not just what changed, but why it changed.
|
|
67
|
+
|
|
68
|
+
## Contradiction Detection
|
|
69
|
+
|
|
70
|
+
Backstory also watches for new changes that appear to reverse earlier recorded
|
|
71
|
+
decisions.
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
⚠ This change may contradict a decision from commit 8f21c9a:
|
|
75
|
+
"payment.failed should mark subscription as pending, not cancelled"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That turns the tool from an archive into a guardrail.
|
|
79
|
+
|
|
80
|
+
## What Gets Stored
|
|
81
|
+
|
|
82
|
+
Here is what that same session looks like on disk — only the extracted
|
|
83
|
+
reasoning, no raw conversation:
|
|
84
|
+
|
|
85
|
+
```markdown
|
|
86
|
+
---
|
|
87
|
+
type: Backstory Session
|
|
88
|
+
title: Fix subscription renewal handling
|
|
89
|
+
description: The webhook handler was not updating the next billing date after successful recurring charges.
|
|
90
|
+
resource: git:8f21c9a
|
|
91
|
+
tags: [backstory, ai-session]
|
|
92
|
+
timestamp: 2026-07-06T12:00:00+00:00
|
|
93
|
+
session_id: sha256:a1b2c3d4e5f6...
|
|
94
|
+
agent: claude-code
|
|
95
|
+
model: claude-sonnet-5
|
|
96
|
+
source: manual
|
|
97
|
+
branch: main
|
|
98
|
+
head: 8f21c9a...
|
|
99
|
+
commit_hash: 8f21c9a...
|
|
100
|
+
commit_message: Fix subscription renewal handling
|
|
101
|
+
files_changed:
|
|
102
|
+
- app/api/webhooks/razorpay/route.ts
|
|
103
|
+
- lib/subscription.ts
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
# Task
|
|
107
|
+
|
|
108
|
+
Fix subscription renewal handling
|
|
109
|
+
|
|
110
|
+
# Decisions
|
|
111
|
+
|
|
112
|
+
- subscription.charged updates next_due_on
|
|
113
|
+
- payment.failed marks subscription as pending, not cancelled
|
|
114
|
+
- webhook handling must be idempotent
|
|
115
|
+
|
|
116
|
+
# Risks
|
|
117
|
+
|
|
118
|
+
- Idempotency depends on storing Razorpay event IDs
|
|
119
|
+
- Existing subscriptions need a next_due_on backfill
|
|
120
|
+
|
|
121
|
+
# Follow-ups
|
|
122
|
+
|
|
123
|
+
- Add migration script for existing subscriptions
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This file lives at `.backstory/knowledge/sessions/sha256-a1b2c3d4....md`.
|
|
127
|
+
It is human-readable, Git-friendly, and stores only the durable
|
|
128
|
+
decisions — not the full chat transcript.
|
|
129
|
+
|
|
130
|
+
## How It Works
|
|
131
|
+
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+

|
|
135
|
+
|
|
136
|
+
The capture and retrieval pipeline has four steps:
|
|
137
|
+
|
|
138
|
+
1. **Capture** — A tool-native hook, callback, or transcript exporter
|
|
139
|
+
captures the AI coding session and hands it to Backstory.
|
|
140
|
+
2. **Ingest** — Backstory extracts the durable decisions, risks, follow-ups,
|
|
141
|
+
and changed files from the session. The raw conversation is discarded —
|
|
142
|
+
only the reasoning is kept.
|
|
143
|
+
3. **Link** — The extracted session is attached to the relevant Git commit
|
|
144
|
+
via `backstory attach HEAD`, creating a stable record in
|
|
145
|
+
`.backstory/knowledge/sessions/`.
|
|
146
|
+
4. **Retrieve** — Later, you can query the stored reasoning by commit, file,
|
|
147
|
+
line, range, or diff using commands like `backstory why HEAD`.
|
|
148
|
+
|
|
149
|
+
Git stays the linkage layer. Backstory stores the reasoning.
|
|
150
|
+
|
|
151
|
+
## Quick Start
|
|
152
|
+
|
|
153
|
+
Install the PyPI package:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python -m pip install backstory-cli
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The installed command is still:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
backstory --help
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
backstory init
|
|
167
|
+
backstory why HEAD
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Backstory is designed for tool-native capture. The AI tool should hand session
|
|
171
|
+
context to Backstory through its own hook, callback, or transcript exporter.
|
|
172
|
+
Backstory then ingests that session internally.
|
|
173
|
+
|
|
174
|
+
If you need to import a transcript export directly:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
backstory dump --agent claude --transcript ./transcript.md
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Core Commands
|
|
181
|
+
|
|
182
|
+
| Command | What it does |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `backstory init` | Set up Backstory in the current repo |
|
|
185
|
+
| `backstory dump` | Ingest an AI session into OKF markdown |
|
|
186
|
+
| `backstory attach HEAD` | Link a session to a commit |
|
|
187
|
+
| `backstory why HEAD` | Explain why a commit happened |
|
|
188
|
+
| `backstory show <session>` | View a stored session |
|
|
189
|
+
| `backstory search <query>` | Search past sessions and decisions |
|
|
190
|
+
| `backstory file <path>` | Show AI context relevant to a file |
|
|
191
|
+
| `backstory line <path>:<line>` | Show the decision behind a specific line |
|
|
192
|
+
| `backstory range <path>:<start>-<end>` | Show context for a range of lines |
|
|
193
|
+
| `backstory code <path>:<start>-<end>` | Show why a code block exists |
|
|
194
|
+
| `backstory diff` | Explain the reasoning behind the current diff |
|
|
195
|
+
| `backstory status` | Show Backstory state in this repo |
|
|
196
|
+
| `backstory redact` | Re-scan and redact sensitive data from stored sessions |
|
|
197
|
+
|
|
198
|
+
## Redaction
|
|
199
|
+
|
|
200
|
+
Backstory automatically scans for and redacts sensitive data before it reaches
|
|
201
|
+
disk. Here is what a session looks like when a transcript contains an API key:
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
Before redaction:
|
|
205
|
+
decisions:
|
|
206
|
+
- "Store the key in RAZORPAY_API_KEY=sk_live_abcd1234..."
|
|
207
|
+
- "Webhook endpoint is at https://api.example.com/webhooks"
|
|
208
|
+
|
|
209
|
+
After redaction:
|
|
210
|
+
decisions:
|
|
211
|
+
- "Store the key in RAZORPAY_API_KEY=***"
|
|
212
|
+
- "Webhook endpoint is at https://api.example.com/webhooks"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The redaction step runs during `backstory dump` and can be re-run later with
|
|
216
|
+
`backstory redact` if new patterns are added. Raw transcripts are never
|
|
217
|
+
stored as the durable session record — only the redacted, extracted reasoning
|
|
218
|
+
persists.
|
|
219
|
+
|
|
220
|
+
## Storage
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
.backstory/
|
|
224
|
+
config.json
|
|
225
|
+
index.sqlite
|
|
226
|
+
knowledge/
|
|
227
|
+
index.md
|
|
228
|
+
sessions/
|
|
229
|
+
index.md
|
|
230
|
+
latest.md
|
|
231
|
+
sha256-<session>.md
|
|
232
|
+
redactions/
|
|
233
|
+
tombstones.log
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Session memory is stored as OKF-style markdown so it stays human-readable,
|
|
237
|
+
Git-friendly, and agent-friendly.
|
|
238
|
+
|
|
239
|
+
## Privacy
|
|
240
|
+
|
|
241
|
+
Backstory is local-first.
|
|
242
|
+
|
|
243
|
+
- No cloud service is required
|
|
244
|
+
- No telemetry is required
|
|
245
|
+
- Session memory stays inside the repository
|
|
246
|
+
- Raw transcripts are not persisted as the durable session record
|
|
247
|
+
- Backstory stores extracted decisions, risks, follow-ups, changed files, and
|
|
248
|
+
Git context — not raw conversation
|
|
249
|
+
|
|
250
|
+
## Integration
|
|
251
|
+
|
|
252
|
+
Backstory is designed for tool-native integration with Claude, Codex, Cursor,
|
|
253
|
+
and similar AI tools.
|
|
254
|
+
|
|
255
|
+
The preferred path is a tool-specific hook, callback, or transcript exporter
|
|
256
|
+
that hands the session to Backstory automatically. `dump` exists as the
|
|
257
|
+
ingestion step, not the primary workflow.
|
|
258
|
+
|
|
259
|
+
## Documentation
|
|
260
|
+
|
|
261
|
+
- [Engineering walkthrough](docs/engineering-walkthrough.md)
|
|
262
|
+
- [Product spec](docs/prd.md)
|
|
263
|
+
- [Retrieval model](docs/retrieval.md)
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
If you find this useful, [starring the repo](https://github.com/arpitkath/backstory)
|
|
268
|
+
helps others discover it.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "backstory-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Every AI-assisted commit has a backstory. Never lose it again."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
backstory = "backstory.cli:main"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
package-dir = {"" = "src"}
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
@@ -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)
|