Files
ai-workflow-skill/packages/repo-memory-runtime/internal/store/store.go
T

940 lines
25 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"ai-workflow-skill/packages/repo-memory-runtime/internal/documents"
_ "github.com/mattn/go-sqlite3"
)
const schema = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
root_path TEXT NOT NULL UNIQUE,
vcs TEXT NOT NULL DEFAULT 'git',
default_branch TEXT,
last_seen_branch TEXT,
last_seen_commit TEXT,
last_sync_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS knowledge_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
key TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
detail_md TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.7,
scope_branch TEXT,
scope_module TEXT,
scope_path_prefix TEXT,
source_path TEXT,
source_line INTEGER,
verified_at TEXT,
verified_on_commit TEXT,
last_checked_at TEXT,
last_checked_commit TEXT,
stale_reason TEXT,
superseded_by INTEGER REFERENCES knowledge_entries(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(repo_id, kind, key, scope_branch, scope_module, scope_path_prefix)
);
CREATE TABLE IF NOT EXISTS knowledge_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
dep_type TEXT NOT NULL,
locator TEXT NOT NULL,
is_hard INTEGER NOT NULL DEFAULT 1,
baseline_hash TEXT,
note TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_entry_id
ON knowledge_dependencies(entry_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_locator
ON knowledge_dependencies(dep_type, locator);
CREATE TABLE IF NOT EXISTS knowledge_aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
normalized_alias TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(entry_id, normalized_alias)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_aliases_normalized
ON knowledge_aliases(normalized_alias);
CREATE TABLE IF NOT EXISTS knowledge_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
to_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
relation TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_links_from
ON knowledge_links(from_entry_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_links_to
ON knowledge_links(to_entry_id);
CREATE TABLE IF NOT EXISTS knowledge_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
from_status TEXT,
to_status TEXT,
reason TEXT,
evidence TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_events_entry_id_created_at
ON knowledge_events(entry_id, created_at);
`
type Store struct {
db *sql.DB
}
type RepoState struct {
RootPath string
Name string
VCS string
DefaultBranch string
LastSeenBranch string
LastSeenCommit string
}
type DependencyInput struct {
Type string
Locator string
IsHard bool
BaselineHash string
Note string
}
type EntryInput struct {
RepoRoot string
Kind string
Key string
Title string
Summary string
DetailMD string
Status string
Confidence float64
ScopeBranch string
ScopeModule string
ScopePathPrefix string
SourcePath string
SourceLine int
VerifiedAt string
VerifiedOnCommit string
LastCheckedAt string
LastCheckedCommit string
StaleReason string
Aliases []string
Dependencies []DependencyInput
}
type SearchParams struct {
Query string
RepoFilter string
Limit int
}
type SearchResult struct {
ID int64
RepoPath string
Kind string
Key string
Title string
Status string
Summary string
Snippet string
}
type ListParams struct {
RepoFilter string
Kind string
Status string
Limit int
}
type EntryListItem struct {
ID int64
RepoPath string
Kind string
Key string
Title string
Status string
Summary string
VerifiedOnCommit string
UpdatedAt time.Time
}
type EntryRef struct {
ID int64
RepoRoot string
Kind string
Key string
}
type EventRecord struct {
ID int64
EventType string
FromStatus string
ToStatus string
Reason string
Evidence string
CreatedAt time.Time
}
type VerifyCandidate struct {
ID int64
RepoPath string
Kind string
Key string
Title string
Status string
VerifiedOnCommit string
Dependencies []DependencyInput
}
type RepoInfo struct {
Path string
Name string
EntryCount int
UpdatedAt time.Time
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Init(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, schema)
return err
}
func (s *Store) UpsertRepo(ctx context.Context, state RepoState) (int64, error) {
now := nowUTC()
if strings.TrimSpace(state.RootPath) == "" {
return 0, fmt.Errorf("repo root path is required")
}
if strings.TrimSpace(state.Name) == "" {
state.Name = filepath.Base(state.RootPath)
}
if strings.TrimSpace(state.VCS) == "" {
state.VCS = "git"
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO repos(name, root_path, vcs, default_branch, last_seen_branch, last_seen_commit, last_sync_at, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(root_path) DO UPDATE SET
name = excluded.name,
vcs = excluded.vcs,
default_branch = excluded.default_branch,
last_seen_branch = excluded.last_seen_branch,
last_seen_commit = excluded.last_seen_commit,
last_sync_at = excluded.last_sync_at,
updated_at = excluded.updated_at
`, state.Name, state.RootPath, state.VCS, nullable(state.DefaultBranch), nullable(state.LastSeenBranch), nullable(state.LastSeenCommit), now, now, now)
if err != nil {
return 0, err
}
var repoID int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, state.RootPath).Scan(&repoID); err != nil {
return 0, err
}
return repoID, nil
}
func (s *Store) UpsertEntry(ctx context.Context, in EntryInput) (int64, error) {
if strings.TrimSpace(in.RepoRoot) == "" {
return 0, fmt.Errorf("repo root is required")
}
if strings.TrimSpace(in.Kind) == "" {
return 0, fmt.Errorf("kind is required")
}
if strings.TrimSpace(in.Key) == "" {
return 0, fmt.Errorf("key is required")
}
if strings.TrimSpace(in.Summary) == "" {
return 0, fmt.Errorf("summary is required")
}
if in.Confidence <= 0 {
in.Confidence = 0.7
}
if strings.TrimSpace(in.Status) == "" {
in.Status = "draft"
}
if strings.TrimSpace(in.Title) == "" {
in.Title = in.Key
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var repoID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, in.RepoRoot).Scan(&repoID); err != nil {
if err == sql.ErrNoRows {
return 0, fmt.Errorf("repo %s is not registered", in.RepoRoot)
}
return 0, err
}
now := nowUTC()
var entryID int64
var oldStatus sql.NullString
err = tx.QueryRowContext(ctx, `
SELECT id, status
FROM knowledge_entries
WHERE repo_id = ?
AND kind = ?
AND key = ?
AND scope_branch IS ?
AND scope_module IS ?
AND scope_path_prefix IS ?
`, repoID, in.Kind, in.Key, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix)).Scan(&entryID, &oldStatus)
if err != nil && err != sql.ErrNoRows {
return 0, err
}
eventType := "created"
fromStatus := ""
if err == sql.ErrNoRows {
res, execErr := tx.ExecContext(ctx, `
INSERT INTO knowledge_entries(
repo_id, kind, key, title, summary, detail_md, status, confidence,
scope_branch, scope_module, scope_path_prefix, source_path, source_line,
verified_at, verified_on_commit, last_checked_at, last_checked_commit,
stale_reason, created_at, updated_at
)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, repoID, in.Kind, in.Key, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine),
nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit),
nullable(in.StaleReason), now, now)
if execErr != nil {
return 0, execErr
}
entryID, err = res.LastInsertId()
if err != nil {
return 0, err
}
} else {
eventType = "updated"
fromStatus = oldStatus.String
if _, err := tx.ExecContext(ctx, `
UPDATE knowledge_entries
SET title = ?, summary = ?, detail_md = ?, status = ?, confidence = ?,
scope_branch = ?, scope_module = ?, scope_path_prefix = ?,
source_path = ?, source_line = ?, verified_at = ?, verified_on_commit = ?,
last_checked_at = ?, last_checked_commit = ?, stale_reason = ?, updated_at = ?
WHERE id = ?
`, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix),
nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit),
nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, entryID); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_aliases WHERE entry_id = ?`, entryID); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_dependencies WHERE entry_id = ?`, entryID); err != nil {
return 0, err
}
}
for _, alias := range dedupeStrings(in.Aliases) {
if strings.TrimSpace(alias) == "" {
continue
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO knowledge_aliases(entry_id, alias, normalized_alias, created_at)
VALUES(?, ?, ?, ?)
`, entryID, alias, normalizeAlias(alias), now); err != nil {
return 0, err
}
}
for _, dep := range in.Dependencies {
if strings.TrimSpace(dep.Type) == "" || strings.TrimSpace(dep.Locator) == "" {
continue
}
isHard := 0
if dep.IsHard {
isHard = 1
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO knowledge_dependencies(entry_id, dep_type, locator, is_hard, baseline_hash, note, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, entryID, dep.Type, dep.Locator, isHard, nullable(dep.BaselineHash), nullable(dep.Note), now); err != nil {
return 0, err
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, entryID, eventType, nullable(fromStatus), nullable(in.Status), nullable(in.StaleReason), nullable(eventEvidence(in)), now); err != nil {
return 0, err
}
return entryID, tx.Commit()
}
func (s *Store) ImportDocument(ctx context.Context, doc documents.Document, repoState RepoState) error {
repoState.RootPath = doc.RepoPath
if _, err := s.UpsertRepo(ctx, repoState); err != nil {
return err
}
for _, sec := range doc.Sections {
key := buildImportedKey(doc.DocPath, sec.Heading, sec.Ordinal)
entry := EntryInput{
RepoRoot: doc.RepoPath,
Kind: classifyImportedKind(doc.Kind, sec.Heading),
Key: key,
Title: sec.Heading,
Summary: sectionSummary(sec.Body),
DetailMD: sec.Body,
Status: "confirmed",
Confidence: 0.8,
SourcePath: doc.DocPath,
VerifiedAt: nowUTC(),
VerifiedOnCommit: repoState.LastSeenCommit,
Dependencies: []DependencyInput{{
Type: "file",
Locator: doc.DocPath,
IsHard: true,
Note: "Imported from markdown knowledge source",
}},
}
if _, err := s.UpsertEntry(ctx, entry); err != nil {
return fmt.Errorf("import section %s: %w", sec.Heading, err)
}
}
return nil
}
func (s *Store) Search(ctx context.Context, params SearchParams) ([]SearchResult, error) {
limit := params.Limit
if limit <= 0 {
limit = 10
}
terms := dedupeStrings(strings.Fields(strings.ToLower(params.Query)))
if len(terms) == 0 {
return nil, nil
}
query := `
SELECT DISTINCT
e.id,
r.root_path,
e.kind,
e.key,
e.title,
e.status,
e.summary,
e.detail_md
FROM knowledge_entries e
JOIN repos r ON r.id = e.repo_id
LEFT JOIN knowledge_aliases a ON a.entry_id = e.id
WHERE 1 = 1
`
args := make([]any, 0, len(terms)*5+2)
if params.RepoFilter != "" {
query += ` AND r.root_path LIKE ?`
args = append(args, "%"+params.RepoFilter+"%")
}
for _, term := range terms {
query += ` AND (
LOWER(e.key) LIKE ?
OR LOWER(e.title) LIKE ?
OR LOWER(e.summary) LIKE ?
OR LOWER(e.detail_md) LIKE ?
OR LOWER(COALESCE(a.normalized_alias, '')) LIKE ?
)`
like := "%" + term + "%"
args = append(args, like, like, like, like, like)
}
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var res SearchResult
var detail string
if err := rows.Scan(&res.ID, &res.RepoPath, &res.Kind, &res.Key, &res.Title, &res.Status, &res.Summary, &detail); err != nil {
return nil, err
}
res.Snippet = makeSnippet(res.Summary+"\n"+detail, terms)
results = append(results, res)
}
return results, rows.Err()
}
func (s *Store) ListRepos(ctx context.Context) ([]RepoInfo, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.root_path, r.name, COUNT(e.id) AS entry_count, r.updated_at
FROM repos r
LEFT JOIN knowledge_entries e ON e.repo_id = r.id
GROUP BY r.id
ORDER BY r.root_path
`)
if err != nil {
return nil, err
}
defer rows.Close()
var repos []RepoInfo
for rows.Next() {
var repo RepoInfo
var updated string
if err := rows.Scan(&repo.Path, &repo.Name, &repo.EntryCount, &updated); err != nil {
return nil, err
}
repo.UpdatedAt, err = time.Parse(time.RFC3339, updated)
if err != nil {
return nil, fmt.Errorf("parse repo updated_at: %w", err)
}
repos = append(repos, repo)
}
return repos, rows.Err()
}
func (s *Store) ListEntries(ctx context.Context, params ListParams) ([]EntryListItem, error) {
limit := params.Limit
if limit <= 0 {
limit = 20
}
query := `
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
FROM knowledge_entries e
JOIN repos r ON r.id = e.repo_id
WHERE 1 = 1
`
args := make([]any, 0, 4)
if params.RepoFilter != "" {
query += ` AND r.root_path LIKE ?`
args = append(args, "%"+params.RepoFilter+"%")
}
if params.Kind != "" {
query += ` AND e.kind = ?`
args = append(args, params.Kind)
}
if params.Status != "" {
query += ` AND e.status = ?`
args = append(args, params.Status)
}
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []EntryListItem
for rows.Next() {
var item EntryListItem
var updated string
if err := rows.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated); err != nil {
return nil, err
}
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ResolveEntry(ctx context.Context, ref EntryRef) (EntryListItem, error) {
if ref.ID > 0 {
return s.getEntryByID(ctx, ref.ID)
}
if strings.TrimSpace(ref.RepoRoot) == "" || strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Key) == "" {
return EntryListItem{}, fmt.Errorf("either id or repo+kind+key is required")
}
row := s.db.QueryRowContext(ctx, `
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
FROM knowledge_entries e
JOIN repos r ON r.id = e.repo_id
WHERE r.root_path = ? AND e.kind = ? AND e.key = ?
ORDER BY e.updated_at DESC
LIMIT 1
`, ref.RepoRoot, ref.Kind, ref.Key)
return scanEntryListItem(row)
}
func (s *Store) ListEvents(ctx context.Context, entryID int64, limit int) ([]EventRecord, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, event_type, COALESCE(from_status, ''), COALESCE(to_status, ''), COALESCE(reason, ''), COALESCE(evidence, ''), created_at
FROM knowledge_events
WHERE entry_id = ?
ORDER BY created_at DESC
LIMIT ?
`, entryID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var events []EventRecord
for rows.Next() {
var ev EventRecord
var created string
if err := rows.Scan(&ev.ID, &ev.EventType, &ev.FromStatus, &ev.ToStatus, &ev.Reason, &ev.Evidence, &created); err != nil {
return nil, err
}
ev.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, err
}
events = append(events, ev)
}
return events, rows.Err()
}
func (s *Store) AddLink(ctx context.Context, fromEntryID, toEntryID int64, relation string) error {
if fromEntryID <= 0 || toEntryID <= 0 {
return fmt.Errorf("both entry ids are required")
}
if strings.TrimSpace(relation) == "" {
return fmt.Errorf("relation is required")
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO knowledge_links(from_entry_id, to_entry_id, relation, created_at)
VALUES(?, ?, ?, ?)
`, fromEntryID, toEntryID, relation, nowUTC())
return err
}
func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]VerifyCandidate, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, COALESCE(e.verified_on_commit, '')
FROM knowledge_entries e
JOIN repos r ON r.id = e.repo_id
WHERE r.root_path = ? AND e.status IN ('confirmed', 'needs_review')
ORDER BY e.kind, e.key
`, repoRoot)
if err != nil {
return nil, err
}
defer rows.Close()
var candidates []VerifyCandidate
for rows.Next() {
var c VerifyCandidate
if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil {
return nil, err
}
deps, err := s.listDependencies(ctx, c.ID)
if err != nil {
return nil, err
}
c.Dependencies = deps
candidates = append(candidates, c)
}
return candidates, rows.Err()
}
func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {
item, err := s.getEntryByID(ctx, entryID)
if err != nil {
return err
}
now := nowUTC()
eventType := ""
switch {
case item.Status != nextStatus && nextStatus == "needs_review":
eventType = "downgraded"
case item.Status != nextStatus && nextStatus == "stale":
eventType = "marked_stale"
case item.Status != nextStatus:
eventType = "updated"
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE knowledge_entries
SET status = ?, stale_reason = ?, last_checked_at = ?, last_checked_commit = ?, updated_at = ?
WHERE id = ?
`, nextStatus, nullable(reason), now, nullable(currentCommit), now, entryID); err != nil {
return err
}
if eventType != "" {
if _, err := tx.ExecContext(ctx, `
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, entryID, eventType, item.Status, nextStatus, nullable(reason), nil, now); err != nil {
return err
}
}
return tx.Commit()
}
func normalizeAlias(alias string) string {
alias = strings.ToLower(strings.TrimSpace(alias))
alias = strings.ReplaceAll(alias, "_", " ")
alias = strings.ReplaceAll(alias, "-", " ")
return strings.Join(strings.Fields(alias), " ")
}
func buildImportedKey(docPath, heading string, ordinal int) string {
base := strings.TrimSuffix(filepath.Base(docPath), filepath.Ext(docPath))
if normalized := slug(heading); normalized != "" {
return base + ":" + normalized
}
return fmt.Sprintf("%s:%d", base, ordinal)
}
func classifyImportedKind(docKind, heading string) string {
lowerDoc := strings.ToLower(docKind)
lowerHeading := strings.ToLower(heading)
switch lowerDoc {
case "glossary":
return "term"
case "playbooks":
return "entry"
case "repo-memory", "repo-brief":
switch {
case strings.Contains(lowerHeading, "module"):
return "module"
case strings.Contains(lowerHeading, "chain"):
return "chain"
case strings.Contains(lowerHeading, "danger"):
return "danger"
case strings.Contains(lowerHeading, "anchor"), strings.Contains(lowerHeading, "entry"):
return "entry"
case strings.Contains(lowerHeading, "command"), strings.Contains(lowerHeading, "verify"):
return "command"
default:
return "decision"
}
default:
return "decision"
}
}
func sectionSummary(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return "Imported knowledge section"
}
body = strings.ReplaceAll(body, "\n", " ")
body = strings.Join(strings.Fields(body), " ")
runes := []rune(body)
if len(runes) > 160 {
return string(runes[:160]) + " …"
}
return body
}
func makeSnippet(body string, terms []string) string {
body = strings.TrimSpace(strings.ReplaceAll(body, "\n", " "))
if body == "" {
return ""
}
lower := strings.ToLower(body)
pos := -1
match := ""
for _, term := range terms {
idx := strings.Index(lower, term)
if idx >= 0 && (pos == -1 || idx < pos) {
pos = idx
match = term
}
}
if pos == -1 {
if len(body) > 160 {
return body[:160] + " …"
}
return body
}
start := pos - 50
if start < 0 {
start = 0
}
end := pos + len(match) + 80
if end > len(body) {
end = len(body)
}
snippet := strings.TrimSpace(body[start:end])
if start > 0 {
snippet = "… " + snippet
}
if end < len(body) {
snippet += " …"
}
return snippet
}
func eventEvidence(in EntryInput) string {
payload := map[string]any{
"source_path": in.SourcePath,
"source_line": in.SourceLine,
"dependencies": in.Dependencies,
}
encoded, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(encoded)
}
func nowUTC() string {
return time.Now().UTC().Format(time.RFC3339)
}
func nullable(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func nullableInt(value int) any {
if value <= 0 {
return nil
}
return value
}
func slug(value string) string {
value = normalizeAlias(value)
return strings.ReplaceAll(value, " ", "-")
}
func dedupeStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
var out []string
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := trimmed
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
return out
}
func (s *Store) getEntryByID(ctx context.Context, id int64) (EntryListItem, error) {
row := s.db.QueryRowContext(ctx, `
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
FROM knowledge_entries e
JOIN repos r ON r.id = e.repo_id
WHERE e.id = ?
`, id)
return scanEntryListItem(row)
}
func scanEntryListItem(row *sql.Row) (EntryListItem, error) {
var item EntryListItem
var updated string
err := row.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated)
if err != nil {
return EntryListItem{}, err
}
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
if err != nil {
return EntryListItem{}, err
}
return item, nil
}
func (s *Store) listDependencies(ctx context.Context, entryID int64) ([]DependencyInput, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT dep_type, locator, is_hard, COALESCE(baseline_hash, ''), COALESCE(note, '')
FROM knowledge_dependencies
WHERE entry_id = ?
ORDER BY id
`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var deps []DependencyInput
for rows.Next() {
var dep DependencyInput
var hard int
if err := rows.Scan(&dep.Type, &dep.Locator, &hard, &dep.BaselineHash, &dep.Note); err != nil {
return nil, err
}
dep.IsHard = hard == 1
deps = append(deps, dep)
}
return deps, rows.Err()
}