opencodekit 0.20.2 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/template/.opencode/agent/build.md +4 -0
  3. package/dist/template/.opencode/agent/explore.md +4 -0
  4. package/dist/template/.opencode/agent/general.md +4 -0
  5. package/dist/template/.opencode/agent/plan.md +4 -0
  6. package/dist/template/.opencode/agent/review.md +4 -0
  7. package/dist/template/.opencode/agent/scout.md +4 -0
  8. package/dist/template/.opencode/command/create.md +119 -25
  9. package/dist/template/.opencode/command/design.md +1 -2
  10. package/dist/template/.opencode/command/health.md +234 -0
  11. package/dist/template/.opencode/command/init-user.md +15 -0
  12. package/dist/template/.opencode/command/plan.md +3 -4
  13. package/dist/template/.opencode/command/pr.md +13 -0
  14. package/dist/template/.opencode/command/research.md +15 -3
  15. package/dist/template/.opencode/command/review-codebase.md +11 -1
  16. package/dist/template/.opencode/command/ship.md +72 -8
  17. package/dist/template/.opencode/command/status.md +1 -1
  18. package/dist/template/.opencode/command/ui-review.md +0 -1
  19. package/dist/template/.opencode/command/ui-slop-check.md +1 -1
  20. package/dist/template/.opencode/command/verify.md +11 -1
  21. package/dist/template/.opencode/memory.db +0 -0
  22. package/dist/template/.opencode/memory.db-shm +0 -0
  23. package/dist/template/.opencode/memory.db-wal +0 -0
  24. package/dist/template/.opencode/opencode.json +1678 -1677
  25. package/dist/template/.opencode/plugin/README.md +1 -1
  26. package/dist/template/.opencode/plugin/lib/compact.ts +194 -0
  27. package/dist/template/.opencode/plugin/lib/compile.ts +253 -0
  28. package/dist/template/.opencode/plugin/lib/db/graph.ts +253 -0
  29. package/dist/template/.opencode/plugin/lib/db/observations.ts +8 -3
  30. package/dist/template/.opencode/plugin/lib/db/schema.ts +96 -5
  31. package/dist/template/.opencode/plugin/lib/db/types.ts +73 -0
  32. package/dist/template/.opencode/plugin/lib/index-generator.ts +170 -0
  33. package/dist/template/.opencode/plugin/lib/lint.ts +359 -0
  34. package/dist/template/.opencode/plugin/lib/memory-admin-tools.ts +78 -4
  35. package/dist/template/.opencode/plugin/lib/memory-db.ts +19 -1
  36. package/dist/template/.opencode/plugin/lib/memory-helpers.ts +30 -0
  37. package/dist/template/.opencode/plugin/lib/memory-hooks.ts +10 -0
  38. package/dist/template/.opencode/plugin/lib/memory-tools.ts +167 -2
  39. package/dist/template/.opencode/plugin/lib/operation-log.ts +109 -0
  40. package/dist/template/.opencode/plugin/lib/validate.ts +243 -0
  41. package/dist/template/.opencode/plugin/memory.ts +2 -1
  42. package/dist/template/.opencode/skill/design-taste-frontend/SKILL.md +13 -1
  43. package/dist/template/.opencode/skill/figma-go/SKILL.md +1 -1
  44. package/dist/template/.opencode/skill/full-output-enforcement/SKILL.md +13 -0
  45. package/dist/template/.opencode/skill/high-end-visual-design/SKILL.md +13 -0
  46. package/dist/template/.opencode/skill/industrial-brutalist-ui/SKILL.md +13 -0
  47. package/dist/template/.opencode/skill/memory-system/SKILL.md +65 -1
  48. package/dist/template/.opencode/skill/minimalist-ui/SKILL.md +13 -0
  49. package/dist/template/.opencode/skill/redesign-existing-projects/SKILL.md +13 -0
  50. package/dist/template/.opencode/skill/requesting-code-review/SKILL.md +48 -2
  51. package/dist/template/.opencode/skill/requesting-code-review/references/specialist-profiles.md +108 -0
  52. package/dist/template/.opencode/skill/skill-creator/SKILL.md +25 -0
  53. package/dist/template/.opencode/skill/stitch-design-taste/SKILL.md +13 -0
  54. package/dist/template/.opencode/skill/verification-before-completion/SKILL.md +46 -0
  55. package/package.json +1 -1
  56. package/dist/template/.opencode/agent/runner.md +0 -79
  57. package/dist/template/.opencode/command/start.md +0 -156
@@ -41,7 +41,7 @@ plugin/
41
41
  - Injects relevant knowledge into system prompt (BM25 _ recency _ confidence scoring)
42
42
  - Manages context window via messages.transform (token budget enforcement)
43
43
  - Merges compaction logic (beads, handoffs, project memory, knowledge)
44
- - Provides 7 tools: observation, memory-search, memory-get, memory-read, memory-update, memory-timeline, memory-admin
44
+ - Provides 11 tools: observation, memory-search, memory-get, memory-read, memory-update, memory-timeline, memory-graph-add, memory-graph-query, memory-graph-invalidate, memory-compact, memory-admin
45
45
 
46
46
  - `sessions.ts`
47
47
  - Provides tools: `find_sessions`, `read_session`
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Compact Format — AAAK-Inspired Memory Compression (v3)
3
+ *
4
+ * Inspired by MemPalace's AAAK dialect: a symbolic, pipe-separated format
5
+ * achieving ~3-5x compression while remaining readable by any LLM.
6
+ *
7
+ * Used for L1 wake-up context: compress top observations into a dense
8
+ * format that fits in ~200-300 tokens instead of 800+.
9
+ *
10
+ * Format rules:
11
+ * - Entities: 3-letter uppercase codes (ALC=Alice, KAI=Kai)
12
+ * - Categories: UPPERCASE labels (DECISION, PATTERN, WARNING)
13
+ * - Relationships: arrow notation (→, ←, ↔)
14
+ * - Importance: 1-5 stars (★ to ★★★★★)
15
+ * - Dates: ISO short (2026-03-31)
16
+ * - Pipe-separated fields within lines
17
+ */
18
+
19
+ import type { CompactResult } from "./db/types.js";
20
+
21
+ // ============================================================================
22
+ // Entity Code Generation
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Generate a 3-letter uppercase code from a name.
27
+ * Prioritizes: first 3 consonants, then first 3 chars.
28
+ */
29
+ function generateCode(name: string): string {
30
+ const cleaned = name.replace(/[^a-zA-Z]/g, "").toUpperCase();
31
+ const consonants = cleaned.replace(/[AEIOU]/g, "");
32
+ if (consonants.length >= 3) return consonants.slice(0, 3);
33
+ return cleaned.slice(0, 3).padEnd(3, "X");
34
+ }
35
+
36
+ /**
37
+ * Build entity code map from observation data.
38
+ * Deduplicates codes by appending numbers.
39
+ */
40
+ function buildCodeMap(names: string[]): Map<string, string> {
41
+ const codeMap = new Map<string, string>();
42
+ const usedCodes = new Set<string>();
43
+
44
+ for (const name of names) {
45
+ let code = generateCode(name);
46
+ if (usedCodes.has(code)) {
47
+ // Append incrementing suffix
48
+ let i = 2;
49
+ while (usedCodes.has(`${code.slice(0, 2)}${i}`)) i++;
50
+ code = `${code.slice(0, 2)}${i}`;
51
+ }
52
+ usedCodes.add(code);
53
+ codeMap.set(name.toLowerCase(), code);
54
+ }
55
+
56
+ return codeMap;
57
+ }
58
+
59
+ // ============================================================================
60
+ // Observation Type Mapping
61
+ // ============================================================================
62
+
63
+ const TYPE_LABELS: Record<string, string> = {
64
+ decision: "DECISION",
65
+ bugfix: "FIX",
66
+ feature: "FEAT",
67
+ pattern: "PATTERN",
68
+ discovery: "DISC",
69
+ learning: "LEARN",
70
+ warning: "WARN",
71
+ };
72
+
73
+ const CONFIDENCE_STARS: Record<string, string> = {
74
+ high: "★★★",
75
+ medium: "★★",
76
+ low: "★",
77
+ };
78
+
79
+ // ============================================================================
80
+ // Compact Compression
81
+ // ============================================================================
82
+
83
+ interface ObservationSummary {
84
+ id: number;
85
+ type: string;
86
+ title: string;
87
+ narrative?: string | null;
88
+ concepts?: string | null;
89
+ wing?: string | null;
90
+ hall?: string | null;
91
+ room?: string | null;
92
+ confidence?: string | null;
93
+ created_at?: string | null;
94
+ }
95
+
96
+ /**
97
+ * Compress an array of observations into compact AAAK-inspired format.
98
+ * Returns compressed text + compression metrics.
99
+ */
100
+ export function compactObservations(
101
+ observations: ObservationSummary[],
102
+ ): CompactResult {
103
+ if (observations.length === 0) {
104
+ return {
105
+ compressed: "",
106
+ token_estimate: 0,
107
+ original_tokens: 0,
108
+ compression_ratio: 0,
109
+ };
110
+ }
111
+
112
+ // Estimate original size
113
+ const originalText = observations
114
+ .map(
115
+ (o) =>
116
+ `[${o.type}] ${o.title}: ${o.narrative ?? ""} (${o.concepts ?? ""})`,
117
+ )
118
+ .join("\n");
119
+ const originalTokens = Math.ceil(originalText.length / 4);
120
+
121
+ // Collect entities for code generation
122
+ const entities: string[] = [];
123
+ for (const obs of observations) {
124
+ if (obs.wing) entities.push(obs.wing);
125
+ if (obs.room) entities.push(obs.room);
126
+ }
127
+ const codeMap = buildCodeMap([...new Set(entities)]);
128
+
129
+ // Group by type
130
+ const grouped = new Map<string, ObservationSummary[]>();
131
+ for (const obs of observations) {
132
+ const key = obs.type;
133
+ const existing = grouped.get(key) ?? [];
134
+ existing.push(obs);
135
+ grouped.set(key, existing);
136
+ }
137
+
138
+ // Build compact lines
139
+ const lines: string[] = [];
140
+
141
+ // Header: entity legend (if entities exist)
142
+ if (codeMap.size > 0) {
143
+ const legend = [...codeMap.entries()]
144
+ .map(([name, code]) => `${code}=${name}`)
145
+ .join(" ");
146
+ lines.push(`ENTITIES: ${legend}`);
147
+ }
148
+
149
+ // Observations grouped by type
150
+ for (const [type, obs] of grouped) {
151
+ const label = TYPE_LABELS[type] ?? type.toUpperCase();
152
+ const entries = obs.map((o) => {
153
+ const parts: string[] = [];
154
+
155
+ // Title (abbreviated)
156
+ const shortTitle =
157
+ o.title.length > 60 ? `${o.title.slice(0, 57)}...` : o.title;
158
+ parts.push(shortTitle);
159
+
160
+ // Navigation context
161
+ if (o.wing || o.room) {
162
+ const nav: string[] = [];
163
+ if (o.wing) nav.push(codeMap.get(o.wing.toLowerCase()) ?? o.wing);
164
+ if (o.room) nav.push(codeMap.get(o.room.toLowerCase()) ?? o.room);
165
+ parts.push(`@${nav.join("/")}`);
166
+ }
167
+
168
+ // Confidence
169
+ if (o.confidence) {
170
+ parts.push(CONFIDENCE_STARS[o.confidence] ?? "★★");
171
+ }
172
+
173
+ // Date
174
+ if (o.created_at) {
175
+ parts.push(o.created_at.slice(0, 10));
176
+ }
177
+
178
+ return parts.join(" | ");
179
+ });
180
+
181
+ lines.push(`${label}: ${entries.join(" // ")}`);
182
+ }
183
+
184
+ const compressed = lines.join("\n");
185
+ const compressedTokens = Math.ceil(compressed.length / 4);
186
+
187
+ return {
188
+ compressed,
189
+ token_estimate: compressedTokens,
190
+ original_tokens: originalTokens,
191
+ compression_ratio:
192
+ originalTokens > 0 ? compressedTokens / originalTokens : 0,
193
+ };
194
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Memory Compilation — Observations → Structured Articles
3
+ *
4
+ * Inspired by Karpathy's LLM Wiki "compilation" layer:
5
+ * reads clusters of related observations and produces structured
6
+ * summary articles with cross-references. This is the missing 5th stage:
7
+ *
8
+ * capture → distill → curate → **compile** → inject
9
+ *
10
+ * Unlike Karpathy's approach (which uses LLM for compilation), this
11
+ * implementation uses pure heuristics — grouping by concept clusters,
12
+ * ranking by confidence/recency, and templated article generation.
13
+ *
14
+ * For LLM-powered compilation, use the compile admin operation which
15
+ * generates articles the agent can review and edit.
16
+ */
17
+
18
+ import { upsertMemoryFile } from "./db/maintenance.js";
19
+ import type { ObservationRow } from "./db/types.js";
20
+ import { getMemoryDB } from "./memory-db.js";
21
+ import { TYPE_ICONS, parseConcepts } from "./memory-helpers.js";
22
+ import { appendOperationLog } from "./operation-log.js";
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ export interface ConceptCluster {
29
+ concept: string;
30
+ observations: Pick<
31
+ ObservationRow,
32
+ "id" | "type" | "title" | "narrative" | "confidence" | "created_at"
33
+ >[];
34
+ relatedConcepts: string[];
35
+ }
36
+
37
+ export interface CompiledArticle {
38
+ concept: string;
39
+ content: string;
40
+ observationCount: number;
41
+ relatedConcepts: string[];
42
+ }
43
+
44
+ export interface CompileResult {
45
+ articles: CompiledArticle[];
46
+ totalObservations: number;
47
+ skippedClusters: number;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Compilation Operations
52
+ // ============================================================================
53
+
54
+ /**
55
+ * Compile observations into structured articles grouped by concept.
56
+ * Only generates articles for concepts with `minObservations` or more observations.
57
+ * Stores articles in memory_files as "compiled/{concept}".
58
+ */
59
+ export function compileObservations(
60
+ options: { minObservations?: number; maxArticles?: number } = {},
61
+ ): CompileResult {
62
+ const minObs = options.minObservations ?? 3;
63
+ const maxArticles = options.maxArticles ?? 20;
64
+
65
+ const clusters = buildConceptClusters(minObs);
66
+ const articles: CompiledArticle[] = [];
67
+ let skipped = 0;
68
+
69
+ // Sort clusters by observation count (most connected first)
70
+ const sortedClusters = clusters
71
+ .sort((a, b) => b.observations.length - a.observations.length)
72
+ .slice(0, maxArticles);
73
+
74
+ let totalObservations = 0;
75
+
76
+ for (const cluster of sortedClusters) {
77
+ const article = compileCluster(cluster);
78
+ if (article) {
79
+ articles.push(article);
80
+ totalObservations += article.observationCount;
81
+ // Store in memory_files
82
+ const safeName = cluster.concept.replace(/[^a-z0-9-]/g, "-");
83
+ upsertMemoryFile(`compiled/${safeName}`, article.content, "replace");
84
+ } else {
85
+ skipped++;
86
+ }
87
+ }
88
+
89
+ // Log the operation
90
+ appendOperationLog({
91
+ operation: "compile-run",
92
+ targets: articles.map((a) => a.concept),
93
+ summary: `Compiled ${articles.length} articles from ${totalObservations} observations (${skipped} clusters skipped)`,
94
+ });
95
+
96
+ return { articles, totalObservations, skippedClusters: skipped };
97
+ }
98
+
99
+ // ============================================================================
100
+ // Internal
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Build concept clusters from active observations.
105
+ */
106
+ function buildConceptClusters(minObservations: number): ConceptCluster[] {
107
+ const db = getMemoryDB();
108
+
109
+ const observations = db
110
+ .query(
111
+ `SELECT id, type, title, narrative, concepts, confidence, created_at
112
+ FROM observations
113
+ WHERE superseded_by IS NULL AND concepts IS NOT NULL
114
+ ORDER BY created_at_epoch DESC`,
115
+ )
116
+ .all() as Pick<
117
+ ObservationRow,
118
+ | "id"
119
+ | "type"
120
+ | "title"
121
+ | "narrative"
122
+ | "concepts"
123
+ | "confidence"
124
+ | "created_at"
125
+ >[];
126
+
127
+ // Build concept → observations map
128
+ const conceptMap = new Map<string, typeof observations>();
129
+
130
+ for (const obs of observations) {
131
+ const concepts = parseConcepts(obs.concepts);
132
+ for (const concept of concepts) {
133
+ const group = conceptMap.get(concept) ?? [];
134
+ group.push(obs);
135
+ conceptMap.set(concept, group);
136
+ }
137
+ }
138
+
139
+ // Filter to clusters with enough observations
140
+ const clusters: ConceptCluster[] = [];
141
+ for (const [concept, obsGroup] of conceptMap) {
142
+ if (obsGroup.length < minObservations) continue;
143
+
144
+ // Find related concepts (co-occurring)
145
+ const relatedCounts = new Map<string, number>();
146
+ for (const obs of obsGroup) {
147
+ const concepts = parseConcepts(obs.concepts);
148
+ for (const c of concepts) {
149
+ if (c === concept) continue;
150
+ relatedCounts.set(c, (relatedCounts.get(c) ?? 0) + 1);
151
+ }
152
+ }
153
+ const relatedConcepts = [...relatedCounts.entries()]
154
+ .filter(([, count]) => count >= 2)
155
+ .sort((a, b) => b[1] - a[1])
156
+ .map(([c]) => c);
157
+
158
+ clusters.push({
159
+ concept,
160
+ observations: obsGroup,
161
+ relatedConcepts,
162
+ });
163
+ }
164
+
165
+ return clusters;
166
+ }
167
+
168
+ /**
169
+ * Compile a single concept cluster into a markdown article.
170
+ */
171
+ function compileCluster(cluster: ConceptCluster): CompiledArticle | null {
172
+ if (cluster.observations.length === 0) return null;
173
+
174
+ const lines: string[] = [];
175
+
176
+ // Header
177
+ lines.push(`# ${cluster.concept}`);
178
+ lines.push("");
179
+ lines.push(`> Compiled from ${cluster.observations.length} observations.`);
180
+ lines.push(`> Last compiled: ${new Date().toISOString().slice(0, 19)}`);
181
+
182
+ // Related concepts
183
+ if (cluster.relatedConcepts.length > 0) {
184
+ lines.push(`> Related: ${cluster.relatedConcepts.join(", ")}`);
185
+ }
186
+ lines.push("");
187
+
188
+ // Group observations by type for structure
189
+ const byType = new Map<string, typeof cluster.observations>();
190
+ for (const obs of cluster.observations) {
191
+ const group = byType.get(obs.type) ?? [];
192
+ group.push(obs);
193
+ byType.set(obs.type, group);
194
+ }
195
+
196
+ // Decisions first (most important)
197
+ const typeOrder = [
198
+ "decision",
199
+ "pattern",
200
+ "warning",
201
+ "bugfix",
202
+ "discovery",
203
+ "feature",
204
+ "learning",
205
+ ];
206
+ for (const type of typeOrder) {
207
+ const group = byType.get(type);
208
+ if (!group || group.length === 0) continue;
209
+
210
+ const icon = TYPE_ICONS[type] ?? "📌";
211
+ const heading = type.charAt(0).toUpperCase() + type.slice(1);
212
+ lines.push(`## ${icon} ${heading}s`);
213
+ lines.push("");
214
+
215
+ for (const obs of group) {
216
+ lines.push(`### #${obs.id}: ${obs.title}`);
217
+ if (obs.narrative) {
218
+ // Truncate long narratives for the compiled view
219
+ const narrative =
220
+ obs.narrative.length > 500
221
+ ? `${obs.narrative.slice(0, 500)}...`
222
+ : obs.narrative;
223
+ lines.push("");
224
+ lines.push(narrative);
225
+ }
226
+ lines.push("");
227
+ lines.push(
228
+ `_Confidence: ${obs.confidence} | Created: ${obs.created_at.slice(0, 10)}_`,
229
+ );
230
+ lines.push("");
231
+ }
232
+ }
233
+
234
+ // Cross-reference footer
235
+ lines.push("---");
236
+ lines.push("");
237
+ lines.push(
238
+ `**Source observations:** ${cluster.observations.map((o) => `#${o.id}`).join(", ")}`,
239
+ );
240
+ if (cluster.relatedConcepts.length > 0) {
241
+ lines.push(
242
+ `**See also:** ${cluster.relatedConcepts.map((c) => `[[${c}]]`).join(", ")}`,
243
+ );
244
+ }
245
+
246
+ return {
247
+ concept: cluster.concept,
248
+ content: lines.join("\n"),
249
+ observationCount: cluster.observations.length,
250
+ relatedConcepts: cluster.relatedConcepts,
251
+ };
252
+ }
253
+
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Entity Graph Operations (v3)
3
+ *
4
+ * Temporal knowledge graph backed by SQLite entity_triples table.
5
+ * Supports time-aware queries (as_of), invalidation, and entity timeline.
6
+ *
7
+ * Inspired by MemPalace's temporal KG pattern: facts have valid_from/valid_to
8
+ * dates, enabling "what was true on date X?" queries.
9
+ */
10
+
11
+ import { getMemoryDB } from "./schema.js";
12
+ import type {
13
+ EntityQueryResult,
14
+ EntityTripleInput,
15
+ EntityTripleRow,
16
+ } from "./types.js";
17
+
18
+ // ============================================================================
19
+ // CRUD
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Add a new entity triple to the knowledge graph.
24
+ */
25
+ export function addEntityTriple(input: EntityTripleInput): number {
26
+ const db = getMemoryDB();
27
+ const now = new Date();
28
+
29
+ const validFrom = input.valid_from ?? now.toISOString().slice(0, 10);
30
+
31
+ const result = db
32
+ .query(
33
+ `INSERT INTO entity_triples
34
+ (subject, predicate, object, valid_from, valid_to, confidence, source_observation_id, created_at, created_at_epoch)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
36
+ )
37
+ .run(
38
+ input.subject.toLowerCase().trim(),
39
+ input.predicate.toLowerCase().trim(),
40
+ input.object.toLowerCase().trim(),
41
+ validFrom,
42
+ input.valid_to ?? null,
43
+ input.confidence ?? 1.0,
44
+ input.source_observation_id ?? null,
45
+ now.toISOString(),
46
+ now.getTime(),
47
+ );
48
+
49
+ return Number(result.lastInsertRowid);
50
+ }
51
+
52
+ /**
53
+ * Invalidate a triple by setting its valid_to date.
54
+ * Finds active triples matching subject+predicate+object and closes them.
55
+ */
56
+ export function invalidateTriple(
57
+ subject: string,
58
+ predicate: string,
59
+ object: string,
60
+ endDate?: string,
61
+ ): number {
62
+ const db = getMemoryDB();
63
+ const validTo = endDate ?? new Date().toISOString().slice(0, 10);
64
+
65
+ const result = db.run(
66
+ `UPDATE entity_triples
67
+ SET valid_to = ?
68
+ WHERE LOWER(subject) = LOWER(?) AND LOWER(predicate) = LOWER(?) AND LOWER(object) = LOWER(?)
69
+ AND valid_to IS NULL`,
70
+ [validTo, subject.trim(), predicate.trim(), object.trim()],
71
+ );
72
+
73
+ return result.changes;
74
+ }
75
+
76
+ /**
77
+ * Get a triple by ID.
78
+ */
79
+ export function getTripleById(id: number): EntityTripleRow | null {
80
+ const db = getMemoryDB();
81
+ return db
82
+ .query("SELECT * FROM entity_triples WHERE id = ?")
83
+ .get(id) as EntityTripleRow | null;
84
+ }
85
+
86
+ // ============================================================================
87
+ // Queries
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Query entity relationships with optional time filtering.
92
+ * Returns triples where entity appears as subject or object.
93
+ */
94
+ export function queryEntity(
95
+ entity: string,
96
+ options: {
97
+ as_of?: string; // ISO date — filter to triples valid at this time
98
+ direction?: "out" | "in" | "both"; // out = subject, in = object, both = either
99
+ predicate?: string; // filter by predicate
100
+ activeOnly?: boolean; // only return currently active triples
101
+ limit?: number;
102
+ } = {},
103
+ ): EntityQueryResult[] {
104
+ const db = getMemoryDB();
105
+ const direction = options.direction ?? "both";
106
+ const limit = options.limit ?? 50;
107
+ const entityLower = entity.toLowerCase().trim();
108
+
109
+ const conditions: string[] = [];
110
+ const params: (string | number)[] = [];
111
+
112
+ // Direction filter
113
+ if (direction === "out") {
114
+ conditions.push("LOWER(subject) = ?");
115
+ params.push(entityLower);
116
+ } else if (direction === "in") {
117
+ conditions.push("LOWER(object) = ?");
118
+ params.push(entityLower);
119
+ } else {
120
+ conditions.push("(LOWER(subject) = ? OR LOWER(object) = ?)");
121
+ params.push(entityLower, entityLower);
122
+ }
123
+
124
+ // Predicate filter
125
+ if (options.predicate) {
126
+ conditions.push("LOWER(predicate) = ?");
127
+ params.push(options.predicate.toLowerCase().trim());
128
+ }
129
+
130
+ // Time filter
131
+ if (options.as_of) {
132
+ conditions.push("valid_from <= ?");
133
+ params.push(options.as_of);
134
+ conditions.push("(valid_to IS NULL OR valid_to >= ?)");
135
+ params.push(options.as_of);
136
+ } else if (options.activeOnly) {
137
+ conditions.push("valid_to IS NULL");
138
+ }
139
+
140
+ params.push(limit);
141
+
142
+ const sql = `
143
+ SELECT id, subject, predicate, object, valid_from, valid_to, confidence,
144
+ CASE WHEN valid_to IS NULL THEN 1 ELSE 0 END as is_active
145
+ FROM entity_triples
146
+ WHERE ${conditions.join(" AND ")}
147
+ ORDER BY valid_from DESC, created_at_epoch DESC
148
+ LIMIT ?
149
+ `;
150
+
151
+ return db.query(sql).all(...params) as EntityQueryResult[];
152
+ }
153
+
154
+ /**
155
+ * Get entity timeline — all triples involving an entity, sorted chronologically.
156
+ */
157
+ export function getEntityTimeline(
158
+ entity: string,
159
+ options: { limit?: number } = {},
160
+ ): EntityTripleRow[] {
161
+ const db = getMemoryDB();
162
+ const limit = options.limit ?? 100;
163
+ const entityLower = entity.toLowerCase().trim();
164
+
165
+ return db
166
+ .query(
167
+ `SELECT * FROM entity_triples
168
+ WHERE LOWER(subject) = ? OR LOWER(object) = ?
169
+ ORDER BY valid_from ASC, created_at_epoch ASC
170
+ LIMIT ?`,
171
+ )
172
+ .all(entityLower, entityLower, limit) as EntityTripleRow[];
173
+ }
174
+
175
+ /**
176
+ * Find contradictions: active triples with opposing predicates for same subject-object pair.
177
+ */
178
+ export function findContradictions(
179
+ subject: string,
180
+ predicate: string,
181
+ object: string,
182
+ ): EntityTripleRow[] {
183
+ const db = getMemoryDB();
184
+
185
+ return db
186
+ .query(
187
+ `SELECT * FROM entity_triples
188
+ WHERE LOWER(subject) = LOWER(?) AND LOWER(object) = LOWER(?)
189
+ AND LOWER(predicate) != LOWER(?)
190
+ AND valid_to IS NULL
191
+ ORDER BY created_at_epoch DESC`,
192
+ )
193
+ .all(subject.trim(), object.trim(), predicate.trim()) as EntityTripleRow[];
194
+ }
195
+
196
+ // ============================================================================
197
+ // Stats
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Get entity graph statistics.
202
+ */
203
+ export function getEntityGraphStats(): {
204
+ total_triples: number;
205
+ active_triples: number;
206
+ unique_entities: number;
207
+ unique_predicates: number;
208
+ } {
209
+ const db = getMemoryDB();
210
+
211
+ const total = (
212
+ db.query("SELECT COUNT(*) as count FROM entity_triples").get() as {
213
+ count: number;
214
+ }
215
+ ).count;
216
+
217
+ const active = (
218
+ db
219
+ .query(
220
+ "SELECT COUNT(*) as count FROM entity_triples WHERE valid_to IS NULL",
221
+ )
222
+ .get() as {
223
+ count: number;
224
+ }
225
+ ).count;
226
+
227
+ const entities = (
228
+ db
229
+ .query(
230
+ `SELECT COUNT(DISTINCT entity) as count FROM (
231
+ SELECT subject as entity FROM entity_triples
232
+ UNION ALL
233
+ SELECT object as entity FROM entity_triples
234
+ )`,
235
+ )
236
+ .get() as { count: number }
237
+ ).count;
238
+
239
+ const predicates = (
240
+ db
241
+ .query("SELECT COUNT(DISTINCT predicate) as count FROM entity_triples")
242
+ .get() as {
243
+ count: number;
244
+ }
245
+ ).count;
246
+
247
+ return {
248
+ total_triples: total,
249
+ active_triples: active,
250
+ unique_entities: entities,
251
+ unique_predicates: predicates,
252
+ };
253
+ }