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.
- sessionanchor/__init__.py +3 -0
- sessionanchor/__main__.py +5 -0
- sessionanchor/boot.py +90 -0
- sessionanchor/cli.py +86 -0
- sessionanchor/compact.py +126 -0
- sessionanchor/ignore.py +97 -0
- sessionanchor/index.py +459 -0
- sessionanchor/init.py +133 -0
- sessionanchor/query.py +91 -0
- sessionanchor/save.py +162 -0
- sessionanchor/store.py +530 -0
- sessionanchor/templates/claude_md_section.txt +54 -0
- sessionanchor/templates/contextignore +32 -0
- sessionanchor/tokens.py +12 -0
- sessionanchor-0.1.0.dist-info/METADATA +297 -0
- sessionanchor-0.1.0.dist-info/RECORD +19 -0
- sessionanchor-0.1.0.dist-info/WHEEL +4 -0
- sessionanchor-0.1.0.dist-info/entry_points.txt +2 -0
- sessionanchor-0.1.0.dist-info/licenses/LICENSE +661 -0
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")
|
sessionanchor/compact.py
ADDED
|
@@ -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}")
|
sessionanchor/ignore.py
ADDED
|
@@ -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)
|