mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
f79782a008
* Resolves issue #13 - Switched model to bge-small-en-v1.5 - Added lazy re-embedding - Added model version tracking per vector - Added conversion of vectors to the new model * Add lfs support to the workflow. * Implements importance scoring with decay + voting #6 * Resolves issue #5 by marking observations as superseeded and scheduled for deletion * Implement pattern detection #7 * Improve injections and observations accuracy - Session start: Recent observations for project context (recency-based) - User prompt: Semantically relevant observations (similarity-based with threshold) * Added two stage retrieval with bi and cross encoder #8 * Implement query expansion and reformulation #9 * Knowledge graph and relationships ( resolves #4 ) - File Overlap Detection: Detects relationships when observations modify/read the same files - Concept Overlap Detection: Detects relationships based on shared semantic concepts - Type Progression Detection: Infers relationships from natural observation type progressions (e.g., discovery → bugfix = "fixes") - Temporal Proximity Detection: Detects relationships between observations in the same session within 5 minutes - Narrative Mention Detection: Detects explicit relationship language in narratives (e.g., "fixes", "depends on", "supersedes") * Add visualisation of the relations to the dashboard. * fixup! Add visualisation of the relations to the dashboard. * Update documentation with new settings and screenshots.
277 lines
8.6 KiB
Go
277 lines
8.6 KiB
Go
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
|
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
)
|
|
|
|
// SupersededRetentionDays is the number of days to keep superseded observations before deletion.
|
|
const SupersededRetentionDays = 3
|
|
|
|
// ConflictStore provides conflict-related database operations.
|
|
type ConflictStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// NewConflictStore creates a new conflict store.
|
|
func NewConflictStore(store *Store) *ConflictStore {
|
|
return &ConflictStore{store: store}
|
|
}
|
|
|
|
// StoreConflict stores a new observation conflict.
|
|
func (s *ConflictStore) StoreConflict(ctx context.Context, conflict *models.ObservationConflict) (int64, error) {
|
|
const query = `
|
|
INSERT INTO observation_conflicts
|
|
(newer_obs_id, older_obs_id, conflict_type, resolution, reason, detected_at, detected_at_epoch, resolved, resolved_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
|
|
result, err := s.store.ExecContext(ctx, query,
|
|
conflict.NewerObsID, conflict.OlderObsID,
|
|
string(conflict.ConflictType), string(conflict.Resolution),
|
|
conflict.Reason, conflict.DetectedAt, conflict.DetectedAtEpoch,
|
|
conflict.Resolved, conflict.ResolvedAt,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return result.LastInsertId()
|
|
}
|
|
|
|
// MarkObservationSuperseded marks an observation as superseded.
|
|
func (s *ConflictStore) MarkObservationSuperseded(ctx context.Context, obsID int64) error {
|
|
const query = `UPDATE observations SET is_superseded = 1 WHERE id = ?`
|
|
_, err := s.store.ExecContext(ctx, query, obsID)
|
|
return err
|
|
}
|
|
|
|
// MarkObservationsSuperseded marks multiple observations as superseded.
|
|
func (s *ConflictStore) MarkObservationsSuperseded(ctx context.Context, obsIDs []int64) error {
|
|
if len(obsIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
query := `UPDATE observations SET is_superseded = 1 WHERE id IN (?` + repeatPlaceholders(len(obsIDs)-1) + `)` // #nosec G202 -- uses parameterized placeholders
|
|
args := int64SliceToInterface(obsIDs)
|
|
_, err := s.store.db.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// GetConflictsByObservationID retrieves all conflicts involving an observation.
|
|
func (s *ConflictStore) GetConflictsByObservationID(ctx context.Context, obsID int64) ([]*models.ObservationConflict, error) {
|
|
const query = `
|
|
SELECT id, newer_obs_id, older_obs_id, conflict_type, resolution, reason,
|
|
detected_at, detected_at_epoch, resolved, resolved_at
|
|
FROM observation_conflicts
|
|
WHERE newer_obs_id = ? OR older_obs_id = ?
|
|
ORDER BY detected_at_epoch DESC
|
|
`
|
|
|
|
rows, err := s.store.QueryContext(ctx, query, obsID, obsID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return s.scanConflictRows(rows)
|
|
}
|
|
|
|
// GetUnresolvedConflicts retrieves all unresolved conflicts.
|
|
func (s *ConflictStore) GetUnresolvedConflicts(ctx context.Context, limit int) ([]*models.ObservationConflict, error) {
|
|
const query = `
|
|
SELECT id, newer_obs_id, older_obs_id, conflict_type, resolution, reason,
|
|
detected_at, detected_at_epoch, resolved, resolved_at
|
|
FROM observation_conflicts
|
|
WHERE resolved = 0
|
|
ORDER BY detected_at_epoch DESC
|
|
LIMIT ?
|
|
`
|
|
|
|
rows, err := s.store.QueryContext(ctx, query, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return s.scanConflictRows(rows)
|
|
}
|
|
|
|
// GetSupersededObservationIDs returns IDs of all observations that have been superseded.
|
|
func (s *ConflictStore) GetSupersededObservationIDs(ctx context.Context, project string) ([]int64, error) {
|
|
const query = `
|
|
SELECT DISTINCT older_obs_id
|
|
FROM observation_conflicts oc
|
|
JOIN observations o ON o.id = oc.older_obs_id
|
|
WHERE oc.resolution = 'prefer_newer'
|
|
AND (o.project = ? OR o.scope = 'global')
|
|
`
|
|
|
|
rows, err := s.store.QueryContext(ctx, query, project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
// ResolveConflict marks a conflict as resolved.
|
|
func (s *ConflictStore) ResolveConflict(ctx context.Context, conflictID int64, resolution models.ConflictResolution) error {
|
|
now := time.Now().Format(time.RFC3339)
|
|
const query = `
|
|
UPDATE observation_conflicts
|
|
SET resolved = 1, resolved_at = ?, resolution = ?
|
|
WHERE id = ?
|
|
`
|
|
_, err := s.store.ExecContext(ctx, query, now, string(resolution), conflictID)
|
|
return err
|
|
}
|
|
|
|
// DeleteConflictsByObservationID deletes all conflicts involving an observation.
|
|
// Called when an observation is deleted.
|
|
func (s *ConflictStore) DeleteConflictsByObservationID(ctx context.Context, obsID int64) error {
|
|
const query = `DELETE FROM observation_conflicts WHERE newer_obs_id = ? OR older_obs_id = ?`
|
|
_, err := s.store.ExecContext(ctx, query, obsID, obsID)
|
|
return err
|
|
}
|
|
|
|
// ConflictWithDetails contains a conflict with its observation details.
|
|
type ConflictWithDetails struct {
|
|
Conflict *models.ObservationConflict
|
|
NewerObsTitle string
|
|
OlderObsTitle string
|
|
}
|
|
|
|
// CleanupSupersededObservations deletes observations that have been superseded for longer than
|
|
// SupersededRetentionDays. Returns the IDs of deleted observations for downstream cleanup (e.g., vector DB).
|
|
func (s *ConflictStore) CleanupSupersededObservations(ctx context.Context, project string) ([]int64, error) {
|
|
// Calculate cutoff time (3 days ago in milliseconds)
|
|
cutoffEpoch := time.Now().AddDate(0, 0, -SupersededRetentionDays).UnixMilli()
|
|
|
|
// First, find the IDs that will be deleted
|
|
// We delete observations that:
|
|
// 1. Are marked as superseded
|
|
// 2. Have a conflict record where they are the older observation
|
|
// 3. The conflict was detected more than 3 days ago
|
|
const selectQuery = `
|
|
SELECT DISTINCT o.id FROM observations o
|
|
JOIN observation_conflicts oc ON o.id = oc.older_obs_id
|
|
WHERE o.is_superseded = 1
|
|
AND o.project = ?
|
|
AND oc.detected_at_epoch < ?
|
|
`
|
|
|
|
rows, err := s.store.QueryContext(ctx, selectQuery, project, cutoffEpoch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var toDelete []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
toDelete = append(toDelete, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(toDelete) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Delete the conflict records first (due to foreign key constraints)
|
|
for _, obsID := range toDelete {
|
|
if err := s.DeleteConflictsByObservationID(ctx, obsID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Delete the observations
|
|
deleteQuery := `DELETE FROM observations WHERE id IN (?` + repeatPlaceholders(len(toDelete)-1) + `)` // #nosec G202 -- uses parameterized placeholders
|
|
args := int64SliceToInterface(toDelete)
|
|
_, err = s.store.db.ExecContext(ctx, deleteQuery, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return toDelete, nil
|
|
}
|
|
|
|
// GetConflictsWithDetails retrieves all conflicts with observation titles for display.
|
|
func (s *ConflictStore) GetConflictsWithDetails(ctx context.Context, project string, limit int) ([]*ConflictWithDetails, error) {
|
|
const query = `
|
|
SELECT oc.id, oc.newer_obs_id, oc.older_obs_id, oc.conflict_type, oc.resolution, oc.reason,
|
|
oc.detected_at, oc.detected_at_epoch, oc.resolved, oc.resolved_at,
|
|
COALESCE(newer.title, '') as newer_title,
|
|
COALESCE(older.title, '') as older_title
|
|
FROM observation_conflicts oc
|
|
JOIN observations newer ON newer.id = oc.newer_obs_id
|
|
JOIN observations older ON older.id = oc.older_obs_id
|
|
WHERE newer.project = ? OR older.project = ?
|
|
ORDER BY oc.detected_at_epoch DESC
|
|
LIMIT ?
|
|
`
|
|
|
|
rows, err := s.store.QueryContext(ctx, query, project, project, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []*ConflictWithDetails
|
|
for rows.Next() {
|
|
var c models.ObservationConflict
|
|
var cwd ConflictWithDetails
|
|
if err := rows.Scan(
|
|
&c.ID, &c.NewerObsID, &c.OlderObsID,
|
|
&c.ConflictType, &c.Resolution, &c.Reason,
|
|
&c.DetectedAt, &c.DetectedAtEpoch,
|
|
&c.Resolved, &c.ResolvedAt,
|
|
&cwd.NewerObsTitle, &cwd.OlderObsTitle,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
cwd.Conflict = &c
|
|
results = append(results, &cwd)
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// scanConflictRows scans multiple conflicts from rows.
|
|
func (s *ConflictStore) scanConflictRows(rows interface {
|
|
Next() bool
|
|
Scan(...interface{}) error
|
|
Err() error
|
|
}) ([]*models.ObservationConflict, error) {
|
|
var conflicts []*models.ObservationConflict
|
|
for rows.Next() {
|
|
var c models.ObservationConflict
|
|
if err := rows.Scan(
|
|
&c.ID, &c.NewerObsID, &c.OlderObsID,
|
|
&c.ConflictType, &c.Resolution, &c.Reason,
|
|
&c.DetectedAt, &c.DetectedAtEpoch,
|
|
&c.Resolved, &c.ResolvedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
conflicts = append(conflicts, &c)
|
|
}
|
|
return conflicts, rows.Err()
|
|
}
|