SessionAnchor 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.
@@ -0,0 +1,3 @@
1
+ """SessionAnchor: One-command context memory for Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Support for `python -m sessionanchor`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
sessionanchor/boot.py ADDED
@@ -0,0 +1,90 @@
1
+ """Boot script — outputs a compact context briefing at session start.
2
+
3
+ Replaces loading large markdown files with a ~2000-token structured briefing.
4
+ Runs automatic compaction before loading to prevent L1 bloat.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ from .compact import compact_l1
11
+ from .store import (
12
+ detect_project_name,
13
+ expire_stale_entries,
14
+ format_boot_context,
15
+ get_connection,
16
+ get_db_path,
17
+ get_l0,
18
+ get_l1,
19
+ get_last_session,
20
+ get_stats,
21
+ init_db,
22
+ )
23
+
24
+
25
+ def boot(project: str | None = None, full: bool = False,
26
+ db_path: str | None = None) -> str:
27
+ """Generate the boot briefing. Returns the formatted string."""
28
+ db = db_path or get_db_path()
29
+ if not os.path.exists(db):
30
+ return (
31
+ "# No memory database found.\n"
32
+ "# Run `sessionanchor init` to set up context memory for this project."
33
+ )
34
+
35
+ conn = get_connection(db)
36
+ init_db(conn)
37
+
38
+ # Housekeeping
39
+ expire_stale_entries(conn)
40
+ compact_l1(conn, project=project)
41
+
42
+ # Auto-detect project if not specified
43
+ if not project:
44
+ project = detect_project_name()
45
+
46
+ # Retrieve tiered context
47
+ l0 = get_l0(conn, project=project)
48
+ l1 = get_l1(conn, project=project)
49
+
50
+ briefing = format_boot_context(l0, l1, project=project)
51
+
52
+ if full:
53
+ stats = get_stats(conn, project=project)
54
+ lines = [
55
+ briefing,
56
+ "## Memory Store Stats",
57
+ f" Entries by tier: {stats.get('by_tier', {})}",
58
+ f" Entries by status: {stats.get('by_status', {})}",
59
+ f" DB size: {stats.get('db_size_kb', '?')} KB",
60
+ f" Total sessions: {stats.get('total_sessions', 0)}",
61
+ ]
62
+ last = get_last_session(conn, project=project)
63
+ if last:
64
+ lines.append(f"\n## Last Session")
65
+ lines.append(f" Date: {last.get('ended_at', '?')}")
66
+ lines.append(f" Summary: {last.get('summary', 'No summary')}")
67
+ lines.append(
68
+ f" Tokens loaded: L0={last.get('l0_tokens', 0)} "
69
+ f"L1={last.get('l1_tokens', 0)} total={last.get('total_tokens', 0)}"
70
+ )
71
+ briefing = "\n".join(lines)
72
+
73
+ conn.close()
74
+ return briefing
75
+
76
+
77
+ def main(args=None):
78
+ """CLI entry point for boot command."""
79
+ import argparse
80
+
81
+ parser = argparse.ArgumentParser(description="Boot context briefing")
82
+ parser.add_argument("--project", "-p", default=None)
83
+ parser.add_argument("--full", action="store_true", help="Include stats and last session")
84
+ parser.add_argument("--db", default=None)
85
+ parsed = parser.parse_args(args)
86
+
87
+ output = boot(project=parsed.project, full=parsed.full, db_path=parsed.db)
88
+ sys.stdout.buffer.write(output.encode("utf-8"))
89
+ sys.stdout.buffer.write(b"\n")
90
+ sys.stdout.buffer.flush()
sessionanchor/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Unified CLI entry point for SessionAnchor.
2
+
3
+ Usage:
4
+ sessionanchor init # Bootstrap memory for current repo
5
+ sessionanchor boot # Print session briefing
6
+ sessionanchor save add ... # Save a context entry
7
+ sessionanchor save complete ... # Mark entry completed
8
+ sessionanchor save session-end # Record session summary
9
+ sessionanchor query "search" # Deep search
10
+ sessionanchor index # Re-index codebase
11
+ sessionanchor index find "X" # Search codebase
12
+ sessionanchor index map # Show structure
13
+ sessionanchor compact # Manual compaction
14
+ sessionanchor stats # Show memory stats
15
+ """
16
+
17
+ import sys
18
+
19
+
20
+ def main():
21
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
22
+ _print_help()
23
+ return
24
+
25
+ command = sys.argv[1]
26
+ rest = sys.argv[2:]
27
+
28
+ if command == "init":
29
+ from .init import main as init_main
30
+ init_main(rest)
31
+
32
+ elif command == "boot":
33
+ from .boot import main as boot_main
34
+ boot_main(rest)
35
+
36
+ elif command == "save":
37
+ from .save import main as save_main
38
+ save_main(rest)
39
+
40
+ elif command == "query":
41
+ from .query import main as query_main
42
+ query_main(rest)
43
+
44
+ elif command == "index":
45
+ from .index import main as index_main
46
+ index_main(rest)
47
+
48
+ elif command == "compact":
49
+ from .compact import main as compact_main
50
+ compact_main(rest)
51
+
52
+ elif command == "stats":
53
+ from .query import main as query_main
54
+ query_main(["--stats"] + rest)
55
+
56
+ elif command == "version":
57
+ from . import __version__
58
+ print(f"sessionanchor {__version__}")
59
+
60
+ else:
61
+ print(f"Unknown command: {command}")
62
+ print()
63
+ _print_help()
64
+ sys.exit(1)
65
+
66
+
67
+ def _print_help():
68
+ print("SessionAnchor - One-command context memory for Claude Code")
69
+ print()
70
+ print("Commands:")
71
+ print(" init Bootstrap memory for the current repo")
72
+ print(" boot Print session briefing (~2000 tokens)")
73
+ print(" save add ... Save a context entry")
74
+ print(" save complete ... Mark an entry as completed")
75
+ print(" save session-end Record end-of-session summary")
76
+ print(' query "search" Search memory (full-text)')
77
+ print(" index Re-index the codebase")
78
+ print(' index find "X" Search codebase files')
79
+ print(" index map Show structural overview")
80
+ print(" compact Run L1 compaction manually")
81
+ print(" stats Show memory store statistics")
82
+ print(" version Show version")
83
+ print()
84
+ print("Quick start:")
85
+ print(" sessionanchor init # Run once per project")
86
+ print(" sessionanchor boot # Run at start of every session")
@@ -0,0 +1,126 @@
1
+ """L1 compaction — prevents memory bloat over time.
2
+
3
+ Strategies:
4
+ - Time-based: L1 entries older than 14 days → demote to L2
5
+ - Count cap: Keep max 20 active L1 entries; excess → L2
6
+ - Completed: L1 entries completed > 3 days ago → L2
7
+ - Superseded: Archive entries that have been superseded
8
+
9
+ Runs automatically at boot, can also be triggered manually.
10
+ """
11
+
12
+ import sqlite3
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+
16
+ def compact_l1(
17
+ conn: sqlite3.Connection,
18
+ project: str | None = None,
19
+ max_age_days: int = 14,
20
+ max_entries: int = 20,
21
+ completed_grace_days: int = 3,
22
+ ) -> dict:
23
+ """Run all compaction strategies. Returns counts of demoted entries."""
24
+ now = datetime.now(timezone.utc).isoformat()
25
+ results = {"time_demoted": 0, "count_demoted": 0, "completed_demoted": 0, "superseded_archived": 0}
26
+
27
+ project_clause = ""
28
+ project_params: list = []
29
+ if project:
30
+ project_clause = " AND (project = ? OR project = 'global')"
31
+ project_params = [project]
32
+
33
+ # 1. Time-based: L1 entries older than max_age_days → L2
34
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=max_age_days)).isoformat()
35
+ cursor = conn.execute(
36
+ f"UPDATE entries SET tier = 'L2', updated_at = ? "
37
+ f"WHERE tier = 'L1' AND updated_at < ? AND status = 'active'{project_clause}",
38
+ [now, cutoff] + project_params,
39
+ )
40
+ results["time_demoted"] = cursor.rowcount
41
+
42
+ # 2. Completed items older than grace period → L2
43
+ comp_cutoff = (datetime.now(timezone.utc) - timedelta(days=completed_grace_days)).isoformat()
44
+ cursor = conn.execute(
45
+ f"UPDATE entries SET tier = 'L2', updated_at = ? "
46
+ f"WHERE tier = 'L1' AND status = 'completed' AND updated_at < ?{project_clause}",
47
+ [now, comp_cutoff] + project_params,
48
+ )
49
+ results["completed_demoted"] = cursor.rowcount
50
+
51
+ # 3. Superseded entries → archive
52
+ superseded_rows = conn.execute(
53
+ f"SELECT supersedes FROM entries "
54
+ f"WHERE supersedes IS NOT NULL AND TRIM(supersedes) != '' AND status = 'active'{project_clause}",
55
+ project_params,
56
+ ).fetchall()
57
+ superseded_ids: list[str] = []
58
+ for row in superseded_rows:
59
+ raw_value = row["supersedes"] if not isinstance(row, tuple) else row[0]
60
+ superseded_ids.extend(
61
+ entry_id.strip() for entry_id in raw_value.split(",") if entry_id.strip()
62
+ )
63
+ if superseded_ids:
64
+ placeholders = ",".join("?" * len(superseded_ids))
65
+ cursor = conn.execute(
66
+ f"UPDATE entries SET status = 'archived', updated_at = ? "
67
+ f"WHERE id IN ({placeholders}) AND status != 'archived'",
68
+ [now] + superseded_ids,
69
+ )
70
+ results["superseded_archived"] = cursor.rowcount
71
+
72
+ # 4. Count cap: if more than max_entries active L1, demote oldest
73
+ rows = conn.execute(
74
+ f"SELECT id FROM entries WHERE tier = 'L1' AND status = 'active'{project_clause} "
75
+ f"ORDER BY updated_at DESC",
76
+ project_params,
77
+ ).fetchall()
78
+
79
+ if len(rows) > max_entries:
80
+ excess_ids = [r["id"] for r in rows[max_entries:]]
81
+ placeholders = ",".join("?" * len(excess_ids))
82
+ cursor = conn.execute(
83
+ f"UPDATE entries SET tier = 'L2', updated_at = ? WHERE id IN ({placeholders})",
84
+ [now] + excess_ids,
85
+ )
86
+ results["count_demoted"] = cursor.rowcount
87
+
88
+ conn.commit()
89
+ return results
90
+
91
+
92
+ def main(args=None):
93
+ """CLI entry point for compact command."""
94
+ import argparse
95
+ import os
96
+
97
+ from .store import get_connection, get_db_path, init_db, detect_project_name
98
+
99
+ parser = argparse.ArgumentParser(description="Compact L1 memory")
100
+ parser.add_argument("--project", "-p", default=None)
101
+ parser.add_argument("--max-age", type=int, default=14, help="Max age in days for L1 entries")
102
+ parser.add_argument("--max-entries", type=int, default=20, help="Max active L1 entries")
103
+ parser.add_argument("--db", default=None)
104
+ parsed = parser.parse_args(args)
105
+
106
+ db_path = parsed.db or get_db_path()
107
+ if not os.path.exists(db_path):
108
+ print("No memory database found.")
109
+ return
110
+
111
+ conn = get_connection(db_path)
112
+ init_db(conn)
113
+
114
+ project = parsed.project or detect_project_name()
115
+ results = compact_l1(conn, project=project,
116
+ max_age_days=parsed.max_age, max_entries=parsed.max_entries)
117
+ conn.close()
118
+
119
+ total = sum(results.values())
120
+ if total == 0:
121
+ print("Nothing to compact.")
122
+ else:
123
+ print(f"Compacted {total} entries:")
124
+ for key, count in results.items():
125
+ if count:
126
+ print(f" {key}: {count}")
@@ -0,0 +1,97 @@
1
+ """Parser for .contextignore files.
2
+
3
+ Gitignore-style pattern matching to exclude sensitive files from indexing
4
+ and redact secret-like content from stored entries.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from fnmatch import fnmatch
10
+ from pathlib import Path
11
+
12
+ # Patterns that indicate secrets in file content
13
+ SECRET_PATTERNS = re.compile(
14
+ r"(?:"
15
+ r"AKIA[0-9A-Z]{16}" # AWS access key
16
+ r"|sk-[a-zA-Z0-9]{20,}" # OpenAI / Stripe secret key
17
+ r"|ghp_[a-zA-Z0-9]{36}" # GitHub personal access token
18
+ r"|gho_[a-zA-Z0-9]{36}" # GitHub OAuth token
19
+ r"|glpat-[a-zA-Z0-9\-]{20}" # GitLab PAT
20
+ r"|xox[bsarp]-[a-zA-Z0-9\-]+" # Slack token
21
+ r"|Bearer\s+[a-zA-Z0-9\-._~+/]+=*" # Bearer token
22
+ r"|eyJ[a-zA-Z0-9\-_]+\.eyJ" # JWT
23
+ r")",
24
+ re.ASCII,
25
+ )
26
+
27
+ DEFAULT_PATTERNS = """\
28
+ # Secrets & credentials
29
+ .env
30
+ .env.*
31
+ *.key
32
+ *.pem
33
+ *.p12
34
+ *.pfx
35
+
36
+ # Build artifacts
37
+ node_modules/
38
+ dist/
39
+ build/
40
+ .next/
41
+ __pycache__/
42
+ *.pyc
43
+ coverage/
44
+ .vercel/
45
+ vendor/
46
+
47
+ # Large/binary files
48
+ *.sqlite
49
+ *.db
50
+ *.wasm
51
+ *.zip
52
+ *.tar.gz
53
+ *.jar
54
+
55
+ # Claude's own memory
56
+ .claude/memory/
57
+ """
58
+
59
+
60
+ def load_patterns(repo_root: str) -> list[str]:
61
+ """Load ignore patterns from .contextignore, falling back to defaults."""
62
+ ignore_path = os.path.join(repo_root, ".contextignore")
63
+ if os.path.isfile(ignore_path):
64
+ with open(ignore_path, "r", encoding="utf-8", errors="replace") as f:
65
+ raw = f.read()
66
+ else:
67
+ raw = DEFAULT_PATTERNS
68
+
69
+ patterns = []
70
+ for line in raw.splitlines():
71
+ line = line.strip()
72
+ if line and not line.startswith("#"):
73
+ patterns.append(line)
74
+ return patterns
75
+
76
+
77
+ def should_ignore(rel_path: str, patterns: list[str]) -> bool:
78
+ """Check if a relative path matches any ignore pattern."""
79
+ parts = Path(rel_path).parts
80
+ for pattern in patterns:
81
+ # Directory pattern (ends with /)
82
+ if pattern.endswith("/"):
83
+ dir_name = pattern.rstrip("/")
84
+ if dir_name in parts:
85
+ return True
86
+ # File/glob pattern
87
+ else:
88
+ if fnmatch(os.path.basename(rel_path), pattern):
89
+ return True
90
+ if fnmatch(rel_path, pattern):
91
+ return True
92
+ return False
93
+
94
+
95
+ def redact_secrets(text: str) -> str:
96
+ """Replace detected secret patterns with [REDACTED]."""
97
+ return SECRET_PATTERNS.sub("[REDACTED]", text)