Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/context_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,17 @@ def init(ctx: click.Context) -> None:
" " + click.style("Indexing project", fg="cyan", bold=True) + "..."
)
asyncio.run(_run_index(config, str(project_dir), full=True))

# 7. Project summary — extractive, no LLM dep. Runs after indexing
# so the tech-stack scan sees the freshly-populated vector store,
# and is the data SessionStart will inject on every Claude/Codex
# boot from here on.
try:
_refresh_project_summary(config, project_dir)
_ok("Project summary captured " + _dim("(injected on every Claude Code session)"))
except Exception as exc: # pragma: no cover — best effort
_warn(f"Project summary skipped: {exc}")

click.echo("")
click.echo(
click.style(" Done!", fg="green", bold=True) +
Expand All @@ -763,6 +774,84 @@ def init(ctx: click.Context) -> None:
click.echo("")


def _refresh_project_summary(config, project_dir: Path) -> dict:
"""Rebuild the project_summary row for `project_dir` if missing or stale.

Returns the (possibly-just-regenerated) summary dict. Safe to call on
every `cce init` and from the `cce summarize` command.
"""
from context_engine.memory import db as memory_db
from context_engine.memory.project_summary import (
build_project_summary, is_stale, load_project_summary,
upsert_project_summary,
)
from context_engine.storage.local_backend import LocalBackend

project_name = project_dir.name
storage_base = Path(config.storage_path) / project_name
backend = LocalBackend(base_path=str(storage_base))
conn = memory_db.connect(memory_db.memory_db_path(storage_base))
try:
existing = load_project_summary(conn, project_name)
if existing and not is_stale(existing):
return existing
summary = build_project_summary(
project_dir=project_dir,
memory_conn=conn,
vector_store=backend._vector_store,
)
upsert_project_summary(conn, project_name, summary)
return summary
finally:
conn.close()


@main.command()
@click.option(
"--force", is_flag=True,
help="Regenerate even if the cached summary is fresh.",
)
@click.pass_context
def summarize(ctx: click.Context, force: bool) -> None:
"""Refresh the project summary that SessionStart injects.

Pulled extractively from README/pyproject + indexed chunks +
recent code_areas — no LLM needed. Called automatically by
`cce init` and refreshed every 7 days otherwise; run this
manually after a major architectural change so the next Claude
Code session sees the new shape.
"""
from context_engine.memory.project_summary import format_summary_block
config = ctx.obj["config"]
project_dir = _safe_cwd()
project_name = project_dir.name

if force:
# Drop the existing row so _refresh_project_summary always
# rebuilds rather than honouring the freshness check.
from context_engine.memory import db as memory_db
storage_base = Path(config.storage_path) / project_name
conn = memory_db.connect(memory_db.memory_db_path(storage_base))
try:
conn.execute(
"DELETE FROM project_summary WHERE project = ?",
(project_name,),
)
conn.commit()
finally:
conn.close()

summary = _refresh_project_summary(config, project_dir)
block = format_summary_block(summary)
if not block:
_warn("No summary content available yet — try `cce index` first.")
return
click.echo("")
click.echo(block)
click.echo("")
_ok(f"Project summary stored for {project_name}")


@main.command()
@click.option("--full", is_flag=True, help="Force full re-index of every file")
@click.option("--path", type=str, default=None, help="Index only this file or directory")
Expand Down
29 changes: 27 additions & 2 deletions src/context_engine/memory/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

log = logging.getLogger(__name__)

CURRENT_VERSION = 3
CURRENT_VERSION = 4

# bge-small-en-v1.5 — the default embedder used everywhere else in cce.
# If the project's embedder swaps to a different model, vec tables are
Expand Down Expand Up @@ -229,6 +229,25 @@
]


# v4: project_summary. One row per project so SessionStart and the MCP
# bootstrap path can prepend a stable "this is what the project does"
# block to every resumed session. The build path is extractive (no LLM
# dependency) so the row can be populated on `cce init` without
# requiring Ollama. Regenerated when older than `_PROJECT_SUMMARY_TTL`.
_SCHEMA_V4 = [
"""
CREATE TABLE IF NOT EXISTS project_summary (
project TEXT PRIMARY KEY,
pitch TEXT NOT NULL DEFAULT '',
tech_stack TEXT NOT NULL DEFAULT '',
recent_focus TEXT NOT NULL DEFAULT '',
source_file_count INTEGER NOT NULL DEFAULT 0,
generated_at_epoch INTEGER NOT NULL
)
""",
]


def _vec_table_stmts(dim: int) -> list[str]:
"""vec0 virtual tables for the two surfaces session_recall actually reads.

Expand Down Expand Up @@ -324,6 +343,8 @@ def _ensure_schema(conn: sqlite3.Connection, *, has_vec: bool) -> None:
cur.execute(stmt)
for stmt in _SCHEMA_V3:
cur.execute(stmt)
for stmt in _SCHEMA_V4:
cur.execute(stmt)
cur.execute(
"INSERT INTO schema_versions (version, applied_at_epoch) "
"VALUES (?, strftime('%s','now'))",
Expand All @@ -338,7 +359,8 @@ def _ensure_schema(conn: sqlite3.Connection, *, has_vec: bool) -> None:
# Existing db — apply additive upgrades up to CURRENT_VERSION.
# v1 → v2: add vec tables + cleanup triggers (needs sqlite-vec).
# v2 → v3: add savings_log (no extension dependency).
# If sqlite-vec is unavailable we can still apply v3, but we don't
# v3 → v4: add project_summary (no extension dependency).
# If sqlite-vec is unavailable we can still apply v3/v4, but we don't
# stamp the version row so a future connection with vec loaded will
# complete the v1 → v2 step.
current = schema_version(conn)
Expand All @@ -354,6 +376,9 @@ def _ensure_schema(conn: sqlite3.Connection, *, has_vec: bool) -> None:
if current < 3:
for stmt in _SCHEMA_V3:
cur.execute(stmt)
if current < 4:
for stmt in _SCHEMA_V4:
cur.execute(stmt)
if current < 2 and not has_vec:
# No version bump — vec step still pending.
conn.commit()
Expand Down
75 changes: 63 additions & 12 deletions src/context_engine/memory/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ def _conn(request: web.Request) -> sqlite3.Connection:

_RESUME_RECENT_DECISIONS = 5
_RESUME_DECISION_REASON_CHARS = 200
# How many prior-session rollups to surface. Originally 1, but a single
# rollup loses the trajectory of multi-day work — three is enough to
# show "what we did Mon, Tue, Wed" without bloating the resume block.
_RESUME_RECENT_SESSIONS = 3


def _build_savings_line(conn: sqlite3.Connection) -> str:
Expand Down Expand Up @@ -99,15 +103,49 @@ def build_session_resume(conn: sqlite3.Connection, project: str) -> str:
conversation start — so this is the mechanism that prevents "decisions
you made last week have to be re-explained today." Empty string for
a brand-new project so there's no awkward header on the first session.

Layout (each section omitted when empty):

## CCE memory · resuming <project>

**Project summary** ← from project_summary table
<pitch>
_Stack:_ <tech_stack>
_Recent focus:_
- <file> — <description>

**Savings** ← from savings_log

**Previous sessions** ← last 3 sessions w/ rollup
(session sid · ended_at)
<rollup>

**Recent decisions** ← last 5 decisions

Footer with session_recall / session_timeline hints.
"""
parts: list[str] = []

last_rollup = conn.execute(
# ── Project summary (v4) ───────────────────────────────────────────
from context_engine.memory.project_summary import (
format_summary_block, load_project_summary,
)
try:
summary = load_project_summary(conn, project)
except sqlite3.Error:
# project_summary table may not exist yet on a partially-migrated
# db. Treat as absent and continue — the rest of the resume is
# independent.
summary = None
summary_block = format_summary_block(summary) if summary else ""

recent_sessions = list(conn.execute(
"SELECT id, rollup_summary, ended_at "
"FROM sessions "
"WHERE rollup_summary IS NOT NULL AND rollup_summary != '' "
"ORDER BY started_at_epoch DESC LIMIT 1"
).fetchone()
"ORDER BY started_at_epoch DESC LIMIT ?",
(_RESUME_RECENT_SESSIONS,),
))

decisions = list(conn.execute(
"SELECT decision, reason, source, session_id, created_at "
Expand All @@ -118,27 +156,40 @@ def build_session_resume(conn: sqlite3.Connection, project: str) -> str:

savings_line = _build_savings_line(conn)

if not last_rollup and not decisions and not savings_line:
if not (recent_sessions or decisions or savings_line or summary_block):
return ""

parts.append(f"## CCE memory · resuming {project}")
# Stored values went through grammar.compress on the write side; expand
# before display so the resume reads as natural prose.
from context_engine.memory.grammar import expand as _grammar_expand

if summary_block:
parts.append("")
parts.append(summary_block)

if savings_line:
parts.append("")
parts.append(f"**{savings_line}**")

if last_rollup:
when = last_rollup["ended_at"] or "in progress"
if recent_sessions:
parts.append("")
parts.append(f"**Previous session** ({when}):")
rollup = _grammar_expand((last_rollup["rollup_summary"] or "").strip())
for line in rollup.split("\n"):
line = line.strip()
if line:
parts.append(f" {line}")
if len(recent_sessions) == 1:
parts.append("**Previous session**:")
else:
parts.append(
f"**Previous {len(recent_sessions)} sessions** "
f"(most-recent first):"
)
for s in recent_sessions:
when = s["ended_at"] or "in progress"
sid = s["id"]
parts.append(f" - _session `{sid}` · {when}_")
rollup = _grammar_expand((s["rollup_summary"] or "").strip())
for line in rollup.split("\n"):
line = line.strip()
if line:
parts.append(f" {line}")
if decisions:
parts.append("")
parts.append("**Recent decisions** (most-recent first):")
Expand Down
Loading
Loading