Skip to content
Merged
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
31 changes: 31 additions & 0 deletions db/migrations/021_add_fact_lifecycle.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

# Migration v21: two-stage fact expiry lifecycle (#14).
#
# - reaffirmed_at: set ONLY by explicit ratification (CLI/MCP ratify
# surface) — never by passive recall. Passive recall touches
# last_recalled_at (v17), which self-defeats as a staleness signal:
# frequently-recalled-but-wrong facts never age out. Ratification is the
# distinct, intentional "still true" signal that returns an expiring
# fact to active and resets both clocks.
# - expiring_since: set when the sweeper moves an active fact to
# "expiring" (stale past threshold). Starts the ratification window;
# after ratify_window_days without ratification the fact becomes
# "expired" (excluded from default recall, never deleted, restorable).
Sequel.migration do
up do
alter_table(:facts) do
add_column :reaffirmed_at, String # ISO 8601, explicit ratification only
add_column :expiring_since, String # ISO 8601, entered expiring stage
Comment on lines +18 to +19

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these be datetimes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, following the schema-wide convention: every timestamp in this DB is an ISO-8601 UTC string — created_at (001), vec_indexed_at (012), last_recalled_at (017), promoted_at (020). SQLite has no native datetime type, and all the existing query logic compares these lexicographically (ISO-8601 UTC sorts chronologically), e.g. the sweeper's expiring_since < cutoff. Typing just these two as DateTime would make them the only columns Sequel round-trips as Time objects and break symmetry with every comparison in the codebase.

If you'd rather move the whole schema to typed timestamps, I'd do that as its own migration + issue rather than smuggling two odd columns in here. Happy to file it — say the word.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File it for investigation for performance. Not required if working well as-is

end

run "CREATE INDEX IF NOT EXISTS idx_facts_expiring_since ON facts(expiring_since)"
end

down do
alter_table(:facts) do
drop_column :reaffirmed_at
drop_column :expiring_since
end
end
end
1 change: 1 addition & 0 deletions lib/claude_memory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class Error < StandardError; end
require_relative "claude_memory/commands/completion_command"
require_relative "claude_memory/commands/embeddings_command"
require_relative "claude_memory/commands/reject_command"
require_relative "claude_memory/commands/ratify_command"
require_relative "claude_memory/commands/observations_command"
require_relative "claude_memory/commands/restore_command"
require_relative "claude_memory/commands/dedupe_conflicts_command"
Expand Down
65 changes: 65 additions & 0 deletions lib/claude_memory/commands/ratify_command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

require "optparse"

module ClaudeMemory
module Commands
# Reaffirm a fact as still true, resetting its decay clock.
# Restores expiring/expired facts to active (#14).
class RatifyCommand < BaseCommand
# @param args [Array<String>] command line arguments (fact_id_or_docid, --scope)
# @return [Integer] exit code (0 for success, 1 for failure)
def call(args)
opts = parse_options(args, {scope: "project"}) do |o|
OptionParser.new do |parser|
parser.banner = "Usage: claude-memory ratify <fact_id_or_docid> [options]"
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
end
end
return 1 if opts.nil?

identifier = args.first
return failure("Usage: claude-memory ratify <fact_id_or_docid> [options]") if identifier.nil? || identifier.empty?

manager = ClaudeMemory::Store::StoreManager.new
store = manager.store_for_scope(opts[:scope])

fact_id = resolve_fact_id(store, identifier)
unless fact_id
stderr.puts "Fact '#{identifier}' not found in #{opts[:scope]} database."
manager.close
return 1
end

result = store.ratify_fact(fact_id)
manager.close

if result.nil?
stderr.puts "Fact ##{fact_id} not found."
return 1
end

unless result[:ratified]
stderr.puts "Fact ##{fact_id} is #{result[:status]} — only active, expiring, or expired facts can be ratified."
return 1
end

stdout.puts "Ratified fact ##{fact_id} in #{opts[:scope]} database (was #{result[:previous_status]}) — decay clock reset."
0
end

private

# Accept either a numeric fact id or an 8-char docid hex string.
# @param store [Store::SQLiteStore] database to look up the fact in
# @param identifier [String] numeric fact id or hex docid
# @return [Integer, nil] resolved fact id, or nil if not found
def resolve_fact_id(store, identifier)
return identifier.to_i if identifier.match?(/\A\d+\z/)

row = store.find_fact_by_docid(identifier)
row ? row[:id] : nil
end
end
end
end
1 change: 1 addition & 0 deletions lib/claude_memory/commands/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Registry
"completion" => {class: CompletionCommand, description: "Generate shell completions"},
"embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
"reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
"ratify" => {class: RatifyCommand, description: "Reaffirm a fact, resetting its decay clock"},
"observations" => {class: ObservationsCommand, description: "Inspect, promote, or consolidate episodic observations"},
"restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"},
"dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"},
Expand Down
2 changes: 2 additions & 0 deletions lib/claude_memory/core/fact_query_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def self.build_facts_dataset(store)
Sequel[:facts][:valid_to],
Sequel[:facts][:created_at],
Sequel[:facts][:last_recalled_at],
Sequel[:facts][:reaffirmed_at],
Sequel[:facts][:expiring_since],
Sequel[:entities][:canonical_name].as(:subject_name),
Sequel[:facts][:scope],
Sequel[:facts][:project_path]
Expand Down
5 changes: 4 additions & 1 deletion lib/claude_memory/dashboard/health.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ def db_check(label, path)

store = @manager.store_for_scope(label)
version = store.schema_version
expiring = store.facts.where(status: "expiring").count
message = "Schema v#{version}, #{store.facts.where(status: "active").count} active facts"
message += ", #{expiring} expiring (awaiting ratification)" if expiring.positive?
{
name: "#{label}_database",
status: "healthy",
message: "Schema v#{version}, #{store.facts.where(status: "active").count} active facts"
message: message
}
rescue => e
{
Expand Down
10 changes: 7 additions & 3 deletions lib/claude_memory/dashboard/knowledge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,22 @@ def summary(params = {})
section: section_filter,
totals: {
project: count_for_scope("project"),
global: count_for_scope("global")
global: count_for_scope("global"),
expiring: {
project: count_for_scope("project", status: "expiring"),
global: count_for_scope("global", status: "expiring")
}
},
sections: sections
}
end

private

def count_for_scope(scope)
def count_for_scope(scope, status: "active")
store = @manager.store_if_exists(scope)
return 0 unless store
store.facts.where(status: "active").count
store.facts.where(status: status).count
rescue Sequel::DatabaseError
0
end
Expand Down
48 changes: 48 additions & 0 deletions lib/claude_memory/mcp/handlers/management_handlers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,54 @@ def reject_fact(args)
}
end

def list_expiring_facts(args)
scope = args["scope"] || "project"
store = get_store_for_scope(scope)
return {error: "Database not available"} unless store

rows = store.expiring_facts(limit: args["limit"] || 50)
{
scope: scope,
count: rows.size,
facts: rows.map do |r|
{
id: r[:id],
docid: r[:docid],
predicate: r[:predicate],
object: r[:object_literal],
expiring_since: r[:expiring_since]
}
end
}
end

def ratify_fact(args)
scope = args["scope"] || "project"
store = get_store_for_scope(scope)
return {error: "Database not available"} unless store

fact_id = args["fact_id"]
if fact_id.nil? && args["docid"]
row = store.find_fact_by_docid(args["docid"])
fact_id = row && row[:id]
end
return {error: "fact_id or docid required"} if fact_id.nil?

result = store.ratify_fact(fact_id)
return {error: "Fact #{fact_id} not found in #{scope} database"} if result.nil?
unless result[:ratified]
return {error: "Fact #{fact_id} is #{result[:status]} — only active, expiring, or expired facts can be ratified"}
end

{
success: true,
scope: scope,
fact_id: fact_id,
previous_status: result[:previous_status],
message: "Fact ratified — decay clock reset"
}
end

def sweep_now(args)
scope = args["scope"] || "project"
store = get_store_for_scope(scope)
Expand Down
25 changes: 25 additions & 0 deletions lib/claude_memory/mcp/tool_definitions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,31 @@ def self.all
},
annotations: WRITE_IDEMPOTENT
},
{
name: "memory.list_expiring_facts",
description: "List facts in the expiring window — stale, awaiting ratification before they expire. Raise these with the user mid-session ('still true that X?'): user presence is the cheapest honest freshness signal.",
inputSchema: {
type: "object",
properties: {
scope: {type: "string", enum: ["project", "global"], description: "Database scope", default: "project"},
limit: {type: "integer", description: "Max facts to return", default: 50}
}
},
annotations: READ_ONLY
},
{
name: "memory.ratify_fact",
description: "Ratify a fact the user confirms is still true. Returns it to 'active' and resets the decay clock (reaffirmed_at) — the only signal that does; passive recall never resets expiry. Also restores 'expired' facts.",
inputSchema: {
type: "object",
properties: {
fact_id: {type: "integer", description: "Fact ID to ratify"},
docid: {type: "string", description: "8-char docid (alternative to fact_id)"},
scope: {type: "string", enum: ["project", "global"], description: "Database scope", default: "project"}
}
},
annotations: WRITE_IDEMPOTENT
},
{
name: "memory.store_extraction",
description: "Store extracted facts, entities, and decisions from a conversation. Call this to persist knowledge you've learned during the session.",
Expand Down
2 changes: 2 additions & 0 deletions lib/claude_memory/mcp/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def dispatch(name, arguments)
when "memory.stats" then stats(arguments)
when "memory.promote" then promote(arguments)
when "memory.reject_fact" then reject_fact(arguments)
when "memory.list_expiring_facts" then list_expiring_facts(arguments)
when "memory.ratify_fact" then ratify_fact(arguments)
when "memory.store_extraction" then store_extraction(arguments)
when "memory.decisions" then decisions(arguments)
when "memory.conventions" then conventions(arguments)
Expand Down
22 changes: 20 additions & 2 deletions lib/claude_memory/recall/staleness_annotator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,19 @@ module StalenessAnnotator

DEFAULT_THRESHOLD_DAYS = 180

# @param fact [Hash] needs :predicate; reads :valid_from, :created_at,
# :last_recalled_at when present
# @param fact [Hash] needs :predicate; reads :status, :valid_from,
# :created_at, :last_recalled_at, :expiring_since when present
# @param now [Time]
# @param threshold_days [Integer]
# @return [String, nil] marker text, or nil when not stale / not guarded
def marker_for(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS)
# Lifecycle state (#14) outranks the heuristic staleness guess: an
# expiring fact is one the sweeper has already judged unratified, so
# it's flagged on every predicate, not just single-value ones —
# this marker is the ratification prompt.
expiring = expiring_marker(fact, now: now)
return expiring if expiring

return nil unless Resolve::PredicatePolicy.single?(fact[:predicate].to_s)

established = parse_time(fact[:valid_from]) || parse_time(fact[:created_at])
Expand All @@ -61,6 +68,17 @@ def stale?(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS)
!marker_for(fact, now: now, threshold_days: threshold_days).nil?
end

# @return [String, nil] marker for facts in the expiring lifecycle
# state, or nil for any other status
def expiring_marker(fact, now: Time.now.utc)
return nil unless fact[:status].to_s == "expiring"

since = parse_time(fact[:expiring_since])
days = since ? ((now - since) / 86_400).round : nil
age = days ? " #{days}d ago" : ""
"⏳ expiring: unratified#{age} — reaffirm if still true, or let it expire"
end

def parse_time(value)
return nil if value.nil?
return value.utc if value.is_a?(Time)
Expand Down
2 changes: 1 addition & 1 deletion lib/claude_memory/shortcuts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def self.fetch_active_facts(store, predicates, limit)

Core::FactQueryBuilder.build_facts_dataset(store)
.where(Sequel[:facts][:predicate] => predicates,
Sequel[:facts][:status] => "active")
Sequel[:facts][:status] => %w[active expiring])
.reverse_order(Sequel[:facts][:id])
.limit(limit)
.all
Expand Down
2 changes: 1 addition & 1 deletion lib/claude_memory/store/schema_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Store
# Schema migration and version management for SQLiteStore.
# Handles Sequel migrations, legacy version syncing, and initial setup.
module SchemaManager
SCHEMA_VERSION = 20
SCHEMA_VERSION = 21

private

Expand Down
30 changes: 30 additions & 0 deletions lib/claude_memory/store/sqlite_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,36 @@ def reject_fact(fact_id, reason: nil)
{rejected: true, conflicts_resolved: resolved}
end

# Ratify a fact: the explicit freshness signal per #14. Returns the
# fact to "active" and resets the decay clock (reaffirmed_at). Passive
# recall never does this — ratification is the only clock reset.
# Expired facts are restorable through the same path; superseded,
# rejected, and disputed facts are not (they left the lifecycle for
# reasons ratification doesn't answer).
def ratify_fact(fact_id)
row = facts.where(id: fact_id).first
return nil unless row
unless %w[active expiring expired].include?(row[:status])
return {ratified: false, status: row[:status]}
end

facts.where(id: fact_id).update(
status: "active",
reaffirmed_at: Time.now.utc.iso8601,
expiring_since: nil
)
{ratified: true, previous_status: row[:status]}
end

# Facts awaiting ratification, oldest first (closest to expiry).
def expiring_facts(limit: 50)
facts
.where(status: "expiring")
.order(:expiring_since)
.limit(limit)
.all
end

# Retrieve active facts that have stored embeddings.
# @param limit [Integer] maximum rows to return
# @return [Array<Hash>] fact rows with :id, :subject_entity_id,
Expand Down
Loading
Loading