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
78 changes: 78 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))

# Capture a baseline work_profile so the very first SessionStart
# already has something to inject. Best-effort: a partially-migrated
# db or missing v4 table must not break `cce init`.
try:
_refresh_work_profile(config, project_dir)
except Exception: # pragma: no cover - defensive
# `cce init` runs before there's a meaningful work history, so a
# failure here is almost never user-visible. Swallow silently.
pass

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


def _refresh_work_profile(config, project_dir: Path) -> dict | None:
"""Rebuild the work_profile row for `project_dir` if missing or stale.

Returns the persisted profile dict, or None if memory.db is
unavailable. Safe to call from `cce init` and `cce profile`.
"""
from context_engine.memory import db as memory_db
from context_engine.memory.work_profile import refresh_work_profile

project_name = project_dir.name
storage_base = Path(config.storage_path) / project_name
db_path = memory_db.memory_db_path(storage_base)
if not db_path.exists() and not db_path.parent.exists():
return None
conn = memory_db.connect(db_path)
try:
return refresh_work_profile(conn, project_name)
finally:
conn.close()


@main.command()
@click.option(
"--force", is_flag=True,
help="Regenerate even if the cached profile is fresh.",
)
@click.pass_context
def profile(ctx: click.Context, force: bool) -> None:
"""Show (and refresh) the user work-profile injected at SessionStart.

Extractive — pulled from session cadence, code_areas, rollup
summaries, and decisions — so there's no LLM or network call.
The block is regenerated automatically every 3 days, on
`cce init`, and any time you pass --force.
"""
from context_engine.memory import db as memory_db
from context_engine.memory.work_profile import (
format_profile_block, refresh_work_profile,
)
config = ctx.obj["config"]
project_dir = _safe_cwd()
project_name = project_dir.name
storage_base = Path(config.storage_path) / project_name
db_path = memory_db.memory_db_path(storage_base)
if not db_path.exists():
_warn("No memory.db yet — run `cce serve` for a session first.")
return
conn = memory_db.connect(db_path)
try:
profile_data = refresh_work_profile(
conn, project_name, force=force,
)
finally:
conn.close()
block = format_profile_block(profile_data)
if not block:
_warn(
"Not enough session history yet — let Claude Code run for a "
"few sessions, then retry."
)
return
click.echo("")
click.echo(block)
click.echo("")
_ok(f"Work profile 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
31 changes: 29 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,27 @@
]


# v4: work_profile — extractive summary of how the user works on this
# project, derived from their accumulated session history (cadence,
# top-touched files, recurring rollup keywords, decision volume). One
# row per project, refreshed by `cce summarize` and on `cce init`. Used
# alongside any per-session resume so each new Claude/Codex session
# opens with both "what is this project" and "how does this user
# typically work on it" context.
_SCHEMA_V4 = [
"""
CREATE TABLE IF NOT EXISTS work_profile (
project TEXT PRIMARY KEY,
cadence TEXT NOT NULL DEFAULT '',
top_files TEXT NOT NULL DEFAULT '',
recurring_themes TEXT NOT NULL DEFAULT '',
open_decisions 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 +345,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 +361,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 work_profile (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 +378,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
18 changes: 17 additions & 1 deletion src/context_engine/memory/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ def build_session_resume(conn: sqlite3.Connection, project: str) -> str:
"""
parts: list[str] = []

# Extractive user work-profile (v4): cadence, top files, recurring
# rollup keywords, decision volume. Tolerates a missing v4 table on
# partially-migrated dbs so the rest of the resume still renders.
from context_engine.memory.work_profile import (
format_profile_block, load_work_profile,
)
try:
profile = load_work_profile(conn, project)
except sqlite3.Error:
profile = None
work_profile_block = format_profile_block(profile) if profile else ""

last_rollup = conn.execute(
"SELECT id, rollup_summary, ended_at "
"FROM sessions "
Expand All @@ -118,14 +130,18 @@ 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 (last_rollup or decisions or savings_line or work_profile_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 work_profile_block:
parts.append("")
parts.append(work_profile_block)

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