Skip to content

Commit 4888ad5

Browse files
feat(sessions): git-anchored session/LCM correlation
Query sessions and messages by branch, worktree, or commit: span-based attribution tables (session_git_spans, commit_sessions) in the per- project session store; live span recording from hook-route metadata (incl. linked-worktree resolution via git-common-dir identity); commit attribution on ingest; historical backfill CLI (sessions git-backfill, reflog-segmented, idempotent); tracedecay_sessions_for MCP tool plus branch/worktree/commit filters on message_search and lcm_grep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 940ad62 commit 4888ad5

29 files changed

Lines changed: 4433 additions & 33 deletions

plugin/README-cursor.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json`
132132
"tracedecay:tracedecay_retrieve",
133133
"tracedecay:tracedecay_runtime",
134134
"tracedecay:tracedecay_search",
135+
"tracedecay:tracedecay_sessions_for",
135136
"tracedecay:tracedecay_signature",
136137
"tracedecay:tracedecay_signature_search",
137138
"tracedecay:tracedecay_similar",

plugin/skills/recalling-session-context/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ This skill owns the **FTS → LCM** lane of `tracedecay_message_search`: `messag
1616
3. **Lossless replay → `tracedecay_lcm_load_session`** (`session_id`, `after_store_id` + `limit` for stable pagination, `roles`, `content_offset`/`content_limit`): ordered raw messages of one session; page with `next_cursor` instead of asking for everything at once.
1717
4. **Summary-DAG drill-down:** `tracedecay_lcm_describe` (`session_id`) for the session's raw/summary shape; `tracedecay_lcm_expand` (`target.kind`: `raw_message`|`summary_node`|`external_payload`) to open one node, paging sources via `source_offset`/`source_limit`; `tracedecay_lcm_expand_query` (`query`) to assemble bounded retrieval context for a prompt in one call.
1818
5. **Store inspection → `tracedecay_lcm_status`** (counts, token estimates, DAG depth/compression ratio) when you need to know what the store contains before searching it.
19+
6. **Git-scoped session lookup → `tracedecay_sessions_for`** (`git_ref`: `branch`|`worktree`|`commit`, `value`, optional `since`/`until`, `limit`): which sessions were active on a branch or in a worktree, or which conversations produced a commit; feed the returned session ids back into rungs 2–4.
1920

2021
## Guardrails
2122

22-
- Steps 1–5 are read-only. `tracedecay_lcm_compress`, `tracedecay_lcm_preflight`, and `tracedecay_lcm_session_boundary` are **lifecycle-integration tools for host agents** — never invoke them casually during recall.
23+
- Steps 1–6 are read-only. `tracedecay_lcm_compress`, `tracedecay_lcm_preflight`, and `tracedecay_lcm_session_boundary` are **lifecycle-integration tools for host agents** — never invoke them casually during recall.
2324
- For multi-step recall, dispatch scoped read-only subagents by session id, time window, provider, role, or query variant. Subagents must not call lifecycle or repair tools; the parent agent validates cited messages/summaries and produces the final timeline.
2425
- If the LCM store itself looks wrong (missing sessions, broken FTS, stale counts) → `tracedecay_lcm_doctor` (`mode: "diagnose"` first; `repair`/`clean` mutate and need explicit user intent).
2526
- All LCM tools default to `storage_scope: "project_local"`; only pass `hermes_profile` (with an absolute `hermes_home`) when the user asks about a Hermes profile store.

src/analytics_bridge.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ pub async fn run_analytics_diagnostics(
289289
project_id: project_filter.clone(),
290290
session_id: None,
291291
event_kind: None,
292+
since: None,
292293
limit: 10_000,
293294
})
294295
.await

src/automation/runner.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ async fn build_session_reflector_evidence(
713713
role: role.clone(),
714714
start_time: options.start_time,
715715
end_time: options.end_time,
716+
git_filter: crate::sessions::git_correlation::GitScopeFilter::default(),
716717
})
717718
.await
718719
.map_err(|e| TraceDecayError::Config {
@@ -828,6 +829,7 @@ async fn build_skill_writer_evidence(
828829
role: None,
829830
start_time: None,
830831
end_time: None,
832+
git_filter: crate::sessions::git_correlation::GitScopeFilter::default(),
831833
})
832834
.await
833835
.map_err(|e| TraceDecayError::Config {

src/automation/skill_usage/analytics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub async fn ingest_project_analytics_events(
6868
project_id: Some(GlobalDb::canonical_project_key(project_root)),
6969
session_id: None,
7070
event_kind: None,
71+
since: None,
7172
limit,
7273
})
7374
.await

src/cli.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,35 @@ pub enum SessionsAction {
636636
/// Registered project root path or alias whose session store should be searched
637637
#[arg(long, conflicts_with = "project_id")]
638638
project_path: Option<String>,
639+
/// Only sessions correlated with this git branch
640+
#[arg(long)]
641+
branch: Option<String>,
642+
/// Only sessions correlated with this worktree path
643+
#[arg(long)]
644+
worktree: Option<String>,
645+
/// Only sessions that produced this commit (full or >=6-char prefix)
646+
#[arg(long)]
647+
commit: Option<String>,
648+
},
649+
/// Backfill the session↔git correlation index from historical session,
650+
/// analytics, and reflog signals
651+
GitBackfill {
652+
/// Registered project id whose session store should be backfilled
653+
#[arg(long)]
654+
project_id: Option<String>,
655+
/// Registered project root path or alias whose session store should be backfilled
656+
#[arg(long, conflicts_with = "project_id")]
657+
project_path: Option<String>,
658+
/// Lower bound on session activity and commit times (ISO-8601 or unix
659+
/// seconds); defaults to 90 days ago
660+
#[arg(long)]
661+
since: Option<String>,
662+
/// Maximum number of sessions to scan
663+
#[arg(long, default_value_t = 500)]
664+
limit_sessions: usize,
665+
/// Derive and report counts without writing to the session store
666+
#[arg(long)]
667+
dry_run: bool,
639668
},
640669
}
641670

src/cli/parse_tests.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,6 +1552,9 @@ fn parses_sessions_ingest_and_search_commands() {
15521552
project_path,
15531553
since,
15541554
until,
1555+
branch,
1556+
worktree,
1557+
commit,
15551558
},
15561559
}) => {
15571560
assert_eq!(query, "needle");
@@ -1561,6 +1564,9 @@ fn parses_sessions_ingest_and_search_commands() {
15611564
assert!(project_path.is_none());
15621565
assert!(since.is_none());
15631566
assert!(until.is_none());
1567+
assert!(branch.is_none());
1568+
assert!(worktree.is_none());
1569+
assert!(commit.is_none());
15641570
}
15651571
_ => panic!("expected sessions search command"),
15661572
}

src/dashboard/analytics_api.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async fn durable_analytics_rows(
123123
project_id: Some(project_id.to_string()),
124124
session_id: None,
125125
event_kind: None,
126+
since: None,
126127
limit: ANALYTICS_EVENT_LIMIT,
127128
})
128129
.await

src/global_db.rs

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ pub struct AnalyticsEventQuery {
9595
pub project_id: Option<String>,
9696
pub session_id: Option<String>,
9797
pub event_kind: Option<String>,
98+
/// Inclusive lower bound on `timestamp` (unix seconds). `None` = unbounded.
99+
pub since: Option<i64>,
98100
pub limit: usize,
99101
}
100102

@@ -1018,6 +1020,9 @@ impl GlobalDb {
10181020
crate::sessions::lcm::schema::ensure_lcm_schema(&db.conn)
10191021
.await
10201022
.ok()?;
1023+
crate::sessions::git_correlation::ensure_git_correlation_schema(&db.conn)
1024+
.await
1025+
.ok()?;
10211026
// One-off self-heal: re-derive timestamps and token-usage counters
10221027
// for legacy messages ingested before extraction existed.
10231028
// Marker-guarded (runs once per store) and fail-open, like the LCM
@@ -1880,11 +1885,9 @@ impl GlobalDb {
18801885
/// filter by canonical project path. Returns zeros on any DB error.
18811886
pub async fn sum_savings(&self, project: Option<&str>, since: i64) -> SavingsTotal {
18821887
let project = project.map(|p| Self::canonical_project_key(Path::new(p)));
1883-
let sql_with_project =
1884-
"SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
1888+
let sql_with_project = "SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
18851889
FROM savings_ledger WHERE project_path = ?1 AND ts >= ?2";
1886-
let sql_all =
1887-
"SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
1890+
let sql_all = "SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
18881891
FROM savings_ledger WHERE ts >= ?1";
18891892

18901893
let rows = match project.as_deref() {
@@ -1912,14 +1915,12 @@ impl GlobalDb {
19121915
/// Group ledger entries by UTC calendar day. Newest-first.
19131916
pub async fn savings_history(&self, project: Option<&str>, since: i64) -> Vec<SavingsDay> {
19141917
let project = project.map(|p| Self::canonical_project_key(Path::new(p)));
1915-
let sql_with_project =
1916-
"SELECT (ts/86400)*86400 AS day, \
1918+
let sql_with_project = "SELECT (ts/86400)*86400 AS day, \
19171919
COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), \
19181920
COUNT(*) \
19191921
FROM savings_ledger WHERE project_path = ?1 AND ts >= ?2 \
19201922
GROUP BY day ORDER BY day DESC";
1921-
let sql_all =
1922-
"SELECT (ts/86400)*86400 AS day, \
1923+
let sql_all = "SELECT (ts/86400)*86400 AS day, \
19231924
COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), \
19241925
COUNT(*) \
19251926
FROM savings_ledger WHERE ts >= ?1 \
@@ -2241,6 +2242,10 @@ impl GlobalDb {
22412242
] {
22422243
push_optional_analytics_filter(&mut clauses, &mut values, column, value);
22432244
}
2245+
if let Some(since) = query.since {
2246+
values.push(Value::Integer(since));
2247+
clauses.push(format!("timestamp >= ?{}", values.len()));
2248+
}
22442249
if !clauses.is_empty() {
22452250
sql.push_str(" WHERE ");
22462251
sql.push_str(&clauses.join(" AND "));
@@ -3165,6 +3170,83 @@ impl GlobalDb {
31653170
.await
31663171
}
31673172

3173+
// ── Session ↔ git correlation ────────────────────────────────────
3174+
3175+
/// Folds one live/backfilled git observation into the span table.
3176+
/// See [`crate::sessions::git_correlation::record_span_observation`].
3177+
pub async fn git_record_span_observation(
3178+
&self,
3179+
observation: &crate::sessions::git_correlation::SpanObservation,
3180+
merge_gap_secs: i64,
3181+
) -> Result<i64, crate::sessions::git_correlation::GitCorrelationError> {
3182+
crate::sessions::git_correlation::record_span_observation(
3183+
&self.conn,
3184+
observation,
3185+
merge_gap_secs,
3186+
)
3187+
.await
3188+
}
3189+
3190+
/// Attributes one commit to one session (idempotent).
3191+
/// See [`crate::sessions::git_correlation::upsert_commit_session`].
3192+
pub async fn git_upsert_commit_session(
3193+
&self,
3194+
record: &crate::sessions::git_correlation::CommitSessionRecord,
3195+
) -> Result<bool, crate::sessions::git_correlation::GitCorrelationError> {
3196+
crate::sessions::git_correlation::upsert_commit_session(&self.conn, record).await
3197+
}
3198+
3199+
/// Runs the commit-attribution sweep, delegating branch-scoped git log
3200+
/// reads to `scan`. See
3201+
/// [`crate::sessions::git_correlation::run_commit_attribution_sweep`].
3202+
pub async fn git_run_commit_attribution_sweep<F>(
3203+
&self,
3204+
gap_secs: i64,
3205+
scan: F,
3206+
) -> Result<usize, crate::sessions::git_correlation::GitCorrelationError>
3207+
where
3208+
F: FnMut(
3209+
&crate::sessions::git_correlation::SpanScanTarget,
3210+
) -> Vec<crate::sessions::git_correlation::ScannedCommit>,
3211+
{
3212+
crate::sessions::git_correlation::run_commit_attribution_sweep(&self.conn, gap_secs, scan)
3213+
.await
3214+
}
3215+
3216+
/// Returns sessions correlated with a branch, worktree, or commit.
3217+
/// See [`crate::sessions::git_correlation::sessions_for`].
3218+
pub async fn git_sessions_for(
3219+
&self,
3220+
query: &crate::sessions::git_correlation::SessionsForQuery,
3221+
) -> Result<
3222+
Vec<crate::sessions::git_correlation::SessionGitCorrelationHit>,
3223+
crate::sessions::git_correlation::GitCorrelationError,
3224+
> {
3225+
crate::sessions::git_correlation::sessions_for(&self.conn, query).await
3226+
}
3227+
3228+
/// Resolves the `(provider, session_id)` pairs matching a git-scope filter.
3229+
/// See [`crate::sessions::git_correlation::session_ids_for_scope`].
3230+
pub async fn git_session_ids_for_scope(
3231+
&self,
3232+
filter: &crate::sessions::git_correlation::GitScopeFilter,
3233+
) -> Result<Option<Vec<(String, String)>>, crate::sessions::git_correlation::GitCorrelationError>
3234+
{
3235+
crate::sessions::git_correlation::session_ids_for_scope(&self.conn, filter).await
3236+
}
3237+
3238+
/// Lists per-session activity windows for the historical git-correlation
3239+
/// backfill: each row carries the session's declared `started_at`/`ended_at`
3240+
/// plus the min/max `session_messages.timestamp`, so the caller can derive
3241+
/// coarse activity windows without a second query per session. Ordered
3242+
/// newest-first (by the latest known activity), capped at `limit`.
3243+
pub async fn session_activity_rows(
3244+
&self,
3245+
limit: usize,
3246+
) -> Result<Vec<crate::sessions::git_correlation::SessionActivityRow>, String> {
3247+
crate::sessions::git_correlation::session_activity_rows(&self.conn, limit).await
3248+
}
3249+
31683250
/// Searches message text for a provider, optionally constrained to one project.
31693251
pub async fn search_session_messages(
31703252
&self,
@@ -3198,6 +3280,32 @@ impl GlobalDb {
31983280
query,
31993281
limit,
32003282
filters,
3283+
None,
3284+
)
3285+
.await
3286+
}
3287+
3288+
/// Like [`Self::search_session_messages_filtered`], additionally scoping
3289+
/// hits to sessions correlated with a git branch/worktree/commit via
3290+
/// EXISTS pushdown against the git-correlation tables. Pass `provider =
3291+
/// None` to search all providers. A git-scoped call against a store
3292+
/// predating the correlation schema returns no hits.
3293+
pub async fn search_session_messages_git_scoped(
3294+
&self,
3295+
provider: Option<&str>,
3296+
project_key: Option<&str>,
3297+
query: &str,
3298+
limit: usize,
3299+
filters: SessionSearchFilters<'_>,
3300+
git_filter: &crate::sessions::git_correlation::GitScopeFilter,
3301+
) -> Vec<SessionMessageSearchResult> {
3302+
self.search_session_messages_filtered_inner(
3303+
provider,
3304+
project_key,
3305+
query,
3306+
limit,
3307+
filters,
3308+
Some(git_filter),
32013309
)
32023310
.await
32033311
}
@@ -3210,7 +3318,7 @@ impl GlobalDb {
32103318
limit: usize,
32113319
filters: SessionSearchFilters<'_>,
32123320
) -> Vec<SessionMessageSearchResult> {
3213-
self.search_session_messages_filtered_inner(None, project_key, query, limit, filters)
3321+
self.search_session_messages_filtered_inner(None, project_key, query, limit, filters, None)
32143322
.await
32153323
}
32163324

@@ -3221,7 +3329,20 @@ impl GlobalDb {
32213329
query: &str,
32223330
limit: usize,
32233331
filters: SessionSearchFilters<'_>,
3332+
git_filter: Option<&crate::sessions::git_correlation::GitScopeFilter>,
32243333
) -> Vec<SessionMessageSearchResult> {
3334+
// A git-scoped search against a store written before the correlation
3335+
// schema existed can never match; report empty rather than issuing a
3336+
// `no such table` EXISTS subquery.
3337+
if let Some(filter) = git_filter {
3338+
if !filter.is_empty()
3339+
&& !crate::sessions::git_correlation::tables_present(&self.conn)
3340+
.await
3341+
.unwrap_or(false)
3342+
{
3343+
return Vec::new();
3344+
}
3345+
}
32253346
let fts_query = session_fts_query(query);
32263347
if fts_query.is_empty() || limit == 0 {
32273348
return Vec::new();
@@ -3285,6 +3406,19 @@ impl GlobalDb {
32853406
) {
32863407
sql.push_str(" AND s.is_subagent = 1");
32873408
}
3409+
// Reuse the shared scoping SQL (also used by the lcm/grep path) so the
3410+
// branch/worktree/commit EXISTS semantics stay in one place. Its
3411+
// anonymous `?` placeholders bind to the next sequential positions,
3412+
// which — since the predicate and its values are appended together in
3413+
// order — line up with the numbered placeholders that follow.
3414+
if let Some(filter) = git_filter {
3415+
if let Some((predicate, predicate_values)) =
3416+
crate::sessions::git_correlation::git_scope_exists_predicate(filter, "m.session_id")
3417+
{
3418+
let _ = write!(sql, " AND {predicate}");
3419+
query_params.extend(predicate_values);
3420+
}
3421+
}
32883422
for term in &literal_terms {
32893423
query_params.push(Value::Text(term.clone()));
32903424
let _ = write!(

0 commit comments

Comments
 (0)