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
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Memory Lint — Self-Healing Knowledge Base
3
+ *
4
+ * Inspired by Karpathy's LLM Wiki "lint" operation:
5
+ * scans observations for duplicates, contradictions, stale claims,
6
+ * orphan concepts, and missing cross-references.
7
+ *
8
+ * Returns structured issues for human or automated resolution.
9
+ */
10
+
11
+ import type { ObservationRow } from "./db/types.js";
12
+ import { getMemoryDB } from "./memory-db.js";
13
+ import { hasWord, parseConcepts } from "./memory-helpers.js";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export type LintIssueType =
20
+ | "duplicate"
21
+ | "contradiction"
22
+ | "stale"
23
+ | "orphan"
24
+ | "missing-narrative";
25
+
26
+ export interface LintIssue {
27
+ type: LintIssueType;
28
+ severity: "high" | "medium" | "low";
29
+ observation_ids: number[];
30
+ title: string;
31
+ detail: string;
32
+ suggestion: string;
33
+ }
34
+
35
+ export interface LintResult {
36
+ issues: LintIssue[];
37
+ stats: {
38
+ total_observations: number;
39
+ duplicates: number;
40
+ contradictions: number;
41
+ stale: number;
42
+ orphans: number;
43
+ missing_narrative: number;
44
+ };
45
+ }
46
+
47
+ // ============================================================================
48
+ // Core Lint Operations
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Run all lint checks and return a consolidated report.
53
+ */
54
+ export function lintMemory(options: { staleDays?: number } = {}): LintResult {
55
+ const staleDays = options.staleDays ?? 90;
56
+ const issues: LintIssue[] = [];
57
+
58
+ const duplicates = findDuplicates();
59
+ const contradictions = findContradictions();
60
+ const stale = findStaleObservations(staleDays);
61
+ const orphans = findOrphanObservations();
62
+ const missing = findMissingNarratives();
63
+
64
+ issues.push(
65
+ ...duplicates,
66
+ ...contradictions,
67
+ ...stale,
68
+ ...orphans,
69
+ ...missing,
70
+ );
71
+
72
+ // Count total active observations
73
+ const db = getMemoryDB();
74
+ const row = db
75
+ .query(
76
+ "SELECT COUNT(*) as count FROM observations WHERE superseded_by IS NULL",
77
+ )
78
+ .get() as { count: number };
79
+
80
+ return {
81
+ issues,
82
+ stats: {
83
+ total_observations: row.count,
84
+ duplicates: duplicates.length,
85
+ contradictions: contradictions.length,
86
+ stale: stale.length,
87
+ orphans: orphans.length,
88
+ missing_narrative: missing.length,
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Find observations with very similar titles (potential duplicates).
95
+ * Uses normalized title comparison + concept overlap.
96
+ */
97
+ function findDuplicates(): LintIssue[] {
98
+ const db = getMemoryDB();
99
+ const issues: LintIssue[] = [];
100
+
101
+ const observations = db
102
+ .query(
103
+ "SELECT id, type, title, concepts, narrative FROM observations WHERE superseded_by IS NULL ORDER BY created_at_epoch DESC",
104
+ )
105
+ .all() as Pick<
106
+ ObservationRow,
107
+ "id" | "type" | "title" | "concepts" | "narrative"
108
+ >[];
109
+
110
+ // Group by normalized title
111
+ const titleMap = new Map<string, typeof observations>();
112
+ for (const obs of observations) {
113
+ const normalized = normalizeTitle(obs.title);
114
+ const group = titleMap.get(normalized) ?? [];
115
+ group.push(obs);
116
+ titleMap.set(normalized, group);
117
+ }
118
+
119
+ for (const [normalized, group] of titleMap) {
120
+ if (group.length > 1) {
121
+ issues.push({
122
+ type: "duplicate",
123
+ severity: "medium",
124
+ observation_ids: group.map((o) => o.id),
125
+ title: `Duplicate: "${group[0].title}"`,
126
+ detail: `${group.length} observations with similar title "${normalized}": IDs ${group.map((o) => `#${o.id}`).join(", ")}`,
127
+ suggestion: `Use \`observation({ supersedes: "${group[group.length - 1].id}" })\` to merge, keeping the most recent.`,
128
+ });
129
+ }
130
+ }
131
+
132
+ // Also check concept overlap for same-type observations
133
+ const byType = new Map<string, typeof observations>();
134
+ for (const obs of observations) {
135
+ if (!obs.concepts) continue;
136
+ const group = byType.get(obs.type) ?? [];
137
+ group.push(obs);
138
+ byType.set(obs.type, group);
139
+ }
140
+
141
+ for (const [, group] of byType) {
142
+ for (let i = 0; i < group.length; i++) {
143
+ for (let j = i + 1; j < group.length; j++) {
144
+ const overlap = conceptOverlap(group[i].concepts, group[j].concepts);
145
+ if (overlap > 0.8 && group[i].id !== group[j].id) {
146
+ // Check not already flagged by title
147
+ const alreadyFlagged = issues.some(
148
+ (iss) =>
149
+ iss.type === "duplicate" &&
150
+ iss.observation_ids.includes(group[i].id) &&
151
+ iss.observation_ids.includes(group[j].id),
152
+ );
153
+ if (!alreadyFlagged) {
154
+ issues.push({
155
+ type: "duplicate",
156
+ severity: "low",
157
+ observation_ids: [group[i].id, group[j].id],
158
+ title: `High concept overlap: #${group[i].id} ↔ #${group[j].id}`,
159
+ detail: `"${group[i].title}" and "${group[j].title}" share ${(overlap * 100).toFixed(0)}% concepts`,
160
+ suggestion: `Review if these should be merged with \`supersedes\`.`,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ return issues;
169
+ }
170
+
171
+ /**
172
+ * Find observations of the same type/concepts that may contradict each other.
173
+ * Looks for opposing signal words in narratives.
174
+ */
175
+ function findContradictions(): LintIssue[] {
176
+ const db = getMemoryDB();
177
+ const issues: LintIssue[] = [];
178
+
179
+ // Get decision-type observations that share concepts
180
+ const decisions = db
181
+ .query(
182
+ `SELECT id, title, concepts, narrative FROM observations
183
+ WHERE type = 'decision' AND superseded_by IS NULL AND concepts IS NOT NULL`,
184
+ )
185
+ .all() as Pick<ObservationRow, "id" | "title" | "concepts" | "narrative">[];
186
+
187
+ // Check pairs for contradictory language
188
+ const contradictionPairs = [
189
+ ["use", "don't use"],
190
+ ["enable", "disable"],
191
+ ["add", "remove"],
192
+ ["prefer", "avoid"],
193
+ ["always", "never"],
194
+ ["yes", "no"],
195
+ ];
196
+
197
+ for (let i = 0; i < decisions.length; i++) {
198
+ for (let j = i + 1; j < decisions.length; j++) {
199
+ const overlap = conceptOverlap(
200
+ decisions[i].concepts,
201
+ decisions[j].concepts,
202
+ );
203
+ if (overlap < 0.3) continue; // Unrelated decisions
204
+
205
+ const textA =
206
+ `${decisions[i].title} ${decisions[i].narrative ?? ""}`.toLowerCase();
207
+ const textB =
208
+ `${decisions[j].title} ${decisions[j].narrative ?? ""}`.toLowerCase();
209
+
210
+ for (const [wordA, wordB] of contradictionPairs) {
211
+ if (
212
+ (hasWord(textA, wordA) && hasWord(textB, wordB)) ||
213
+ (hasWord(textA, wordB) && hasWord(textB, wordA))
214
+ ) {
215
+ issues.push({
216
+ type: "contradiction",
217
+ severity: "high",
218
+ observation_ids: [decisions[i].id, decisions[j].id],
219
+ title: `Potential contradiction: #${decisions[i].id} vs #${decisions[j].id}`,
220
+ detail: `"${decisions[i].title}" and "${decisions[j].title}" share concepts but contain opposing signals ("${wordA}" vs "${wordB}")`,
221
+ suggestion: `Review both and supersede the outdated one.`,
222
+ });
223
+ break; // One contradiction signal per pair is enough
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ return issues;
230
+ }
231
+
232
+ /**
233
+ * Find observations older than N days with no references in recent distillations.
234
+ */
235
+ function findStaleObservations(staleDays: number): LintIssue[] {
236
+ const db = getMemoryDB();
237
+ const cutoffEpoch = Date.now() - staleDays * 24 * 60 * 60 * 1000;
238
+
239
+ const stale = db
240
+ .query(
241
+ `SELECT id, type, title, created_at, created_at_epoch FROM observations
242
+ WHERE superseded_by IS NULL AND created_at_epoch < ? AND valid_until IS NULL
243
+ ORDER BY created_at_epoch ASC`,
244
+ )
245
+ .all(cutoffEpoch) as Pick<
246
+ ObservationRow,
247
+ "id" | "type" | "title" | "created_at" | "created_at_epoch"
248
+ >[];
249
+
250
+ return stale.map((obs) => {
251
+ const ageDays = Math.floor(
252
+ (Date.now() - obs.created_at_epoch) / (1000 * 60 * 60 * 24),
253
+ );
254
+ return {
255
+ type: "stale" as const,
256
+ severity:
257
+ ageDays > staleDays * 2 ? ("high" as const) : ("medium" as const),
258
+ observation_ids: [obs.id],
259
+ title: `Stale (${ageDays}d): #${obs.id} "${obs.title}"`,
260
+ detail: `[${obs.type}] created ${obs.created_at.slice(0, 10)}, ${ageDays} days old with no valid_until set`,
261
+ suggestion: `Review: still relevant? If yes, update it. If no, supersede or set valid_until.`,
262
+ };
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Find observations with concepts that appear in only one observation.
268
+ * These are "orphan concepts" — knowledge islands with no connections.
269
+ */
270
+ function findOrphanObservations(): LintIssue[] {
271
+ const db = getMemoryDB();
272
+ const issues: LintIssue[] = [];
273
+
274
+ const observations = db
275
+ .query(
276
+ "SELECT id, title, concepts FROM observations WHERE superseded_by IS NULL AND concepts IS NOT NULL",
277
+ )
278
+ .all() as Pick<ObservationRow, "id" | "title" | "concepts">[];
279
+
280
+ // Build concept → observation IDs map
281
+ const conceptMap = new Map<string, number[]>();
282
+ for (const obs of observations) {
283
+ const concepts = parseConcepts(obs.concepts);
284
+ for (const concept of concepts) {
285
+ const ids = conceptMap.get(concept) ?? [];
286
+ ids.push(obs.id);
287
+ conceptMap.set(concept, ids);
288
+ }
289
+ }
290
+
291
+ // Find observations where ALL concepts are orphans (only appear once)
292
+ for (const obs of observations) {
293
+ const concepts = parseConcepts(obs.concepts);
294
+ if (concepts.length === 0) continue;
295
+ const allOrphan = concepts.every(
296
+ (c) => (conceptMap.get(c)?.length ?? 0) <= 1,
297
+ );
298
+ if (allOrphan && concepts.length >= 2) {
299
+ issues.push({
300
+ type: "orphan",
301
+ severity: "low",
302
+ observation_ids: [obs.id],
303
+ title: `Isolated: #${obs.id} "${obs.title}"`,
304
+ detail: `All concepts [${concepts.join(", ")}] appear in no other observation — this knowledge is disconnected`,
305
+ suggestion: `Consider adding cross-references or broadening concept tags.`,
306
+ });
307
+ }
308
+ }
309
+
310
+ return issues;
311
+ }
312
+
313
+ /**
314
+ * Find observations with no narrative (title-only, low value).
315
+ */
316
+ function findMissingNarratives(): LintIssue[] {
317
+ const db = getMemoryDB();
318
+
319
+ const missing = db
320
+ .query(
321
+ `SELECT id, type, title FROM observations
322
+ WHERE superseded_by IS NULL AND (narrative IS NULL OR narrative = '')`,
323
+ )
324
+ .all() as Pick<ObservationRow, "id" | "type" | "title">[];
325
+
326
+ return missing.map((obs) => ({
327
+ type: "missing-narrative" as const,
328
+ severity: "low" as const,
329
+ observation_ids: [obs.id],
330
+ title: `No narrative: #${obs.id} "${obs.title}"`,
331
+ detail: `[${obs.type}] has title but no narrative — low-value observation`,
332
+ suggestion: `Add narrative context or remove if the title alone is not useful.`,
333
+ }));
334
+ }
335
+
336
+ // ============================================================================
337
+ // Helpers
338
+ // ============================================================================
339
+
340
+ function normalizeTitle(title: string): string {
341
+ return title
342
+ .toLowerCase()
343
+ .replace(/[^a-z0-9\s]/g, "")
344
+ .replace(/\s+/g, " ")
345
+ .trim();
346
+ }
347
+
348
+ function conceptOverlap(a: string | null, b: string | null): number {
349
+ const conceptsA = new Set(parseConcepts(a));
350
+ const conceptsB = new Set(parseConcepts(b));
351
+ if (conceptsA.size === 0 || conceptsB.size === 0) return 0;
352
+
353
+ let overlap = 0;
354
+ for (const c of conceptsA) {
355
+ if (conceptsB.has(c)) overlap++;
356
+ }
357
+ return overlap / Math.min(conceptsA.size, conceptsB.size);
358
+ }
359
+
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Memory Plugin — Admin Tools
3
3
  *
4
- * memory-admin (9 operations).
4
+ * memory-admin (12 operations).
5
5
  *
6
6
  * Uses factory pattern: createAdminTools(deps) returns tool definitions.
7
7
  */
@@ -10,16 +10,23 @@ import { readdir, readFile } from "node:fs/promises";
10
10
  import path from "node:path";
11
11
  import { tool } from "@opencode-ai/plugin/tool";
12
12
  import { curateFromDistillations } from "./curator.js";
13
+ import { compileObservations } from "./compile.js";
13
14
  import { distillSession } from "./distill.js";
15
+ import { generateMemoryIndex } from "./index-generator.js";
16
+ import { lintMemory } from "./lint.js";
17
+ import { getLogContent } from "./operation-log.js";
14
18
  import {
15
19
  archiveOldObservations,
16
20
  type ConfidenceLevel,
17
21
  checkFTS5Available,
18
22
  checkpointWAL,
23
+ findGraphContradictions,
19
24
  getCaptureStats,
20
25
  getDatabaseSizes,
21
26
  getDistillationStats,
27
+ getEntityGraphStats,
22
28
  getMarkdownFilesInSqlite,
29
+ getMemoryDB,
23
30
  getObservationStats,
24
31
  type ObservationType,
25
32
  rebuildFTS5,
@@ -37,7 +44,7 @@ export function createAdminTools(deps: AdminToolDeps) {
37
44
 
38
45
  return {
39
46
  "memory-admin": tool({
40
- description: `Memory system administration: maintenance and migration.\n\nOperations:\n- "status": Storage stats and recommendations\n- "full": Full maintenance cycle (archive + checkpoint + vacuum)\n- "archive": Archive old observations (>90 days default)\n- "checkpoint": Checkpoint WAL file\n- "vacuum": Vacuum database\n- "migrate": Import .opencode/memory/observations/*.md into SQLite\n- "capture-stats": Temporal message capture statistics\n- "distill-now": Force distillation for current session\n- "curate-now": Force curator run\n\nExample:\nmemory-admin({ operation: "status" })\nmemory-admin({ operation: "migrate", dry_run: true })`,
47
+ description: `Memory system administration: maintenance and migration.\n\nOperations:\n- "status": Storage stats and recommendations\n- "full": Full maintenance cycle (archive + checkpoint + vacuum)\n- "archive": Archive old observations (>90 days default)\n- "checkpoint": Checkpoint WAL file\n- "vacuum": Vacuum database\n- "migrate": Import .opencode/memory/observations/*.md into SQLite\n- "capture-stats": Temporal message capture statistics\n- "distill-now": Force distillation for current session\n- "curate-now": Force curator run\n- "lint": Run lint checks (duplicates, contradictions, stale, orphans)\n- "index": Generate memory index catalog\n- "compile": Compile observations into structured articles\n- "log": View operation log\n\nExample:\nmemory-admin({ operation: "status" })\nmemory-admin({ operation: "migrate", dry_run: true })\nmemory-admin({ operation: "lint" })\nmemory-admin({ operation: "compile" })`,
41
48
  args: {
42
49
  operation: tool.schema
43
50
  .string()
@@ -69,11 +76,12 @@ export function createAdminTools(deps: AdminToolDeps) {
69
76
  });
70
77
  const captureStats = getCaptureStats();
71
78
  const distillStats = getDistillationStats();
79
+ const graphStats = getEntityGraphStats();
72
80
  return [
73
81
  "## Memory System Status\n",
74
82
  `**Database**: ${(sizes.total / 1024).toFixed(1)} KB`,
75
83
  `**FTS5**: ${checkFTS5Available() ? "Available (porter stemming)" : "Unavailable"}`,
76
- `**Schema**: v2 (4-tier storage)\n`,
84
+ `**Schema**: v3 (4-tier + entity graph)\n`,
77
85
  "### Observations",
78
86
  ...Object.entries(stats).map(([k, v]) => ` ${k}: ${v}`),
79
87
  ` Archivable (>${olderThanDays}d): ${archivable}\n`,
@@ -82,7 +90,11 @@ export function createAdminTools(deps: AdminToolDeps) {
82
90
  ` Sessions: ${captureStats.sessions}\n`,
83
91
  "### Distillations",
84
92
  ` Total: ${distillStats.total} (${distillStats.sessions} sessions)`,
85
- ` Avg compression: ${(distillStats.avgCompression * 100).toFixed(1)}%`,
93
+ ` Avg compression: ${(distillStats.avgCompression * 100).toFixed(1)}%\n`,
94
+ "### Entity Graph",
95
+ ` Triples: ${graphStats.total_triples} (active: ${graphStats.active_triples})`,
96
+ ` Entities: ${graphStats.unique_entities}`,
97
+ ` Predicates: ${graphStats.unique_predicates}`,
86
98
  ].join("\n");
87
99
  }
88
100
  case "full": {
@@ -126,6 +138,68 @@ export function createAdminTools(deps: AdminToolDeps) {
126
138
  const r = curateFromDistillations();
127
139
  return `Created ${r.created}, skipped ${r.skipped}. Patterns: ${JSON.stringify(r.patterns)}`;
128
140
  }
141
+ case "lint": {
142
+ const result = lintMemory({ staleDays: olderThanDays });
143
+
144
+ // Entity graph contradiction scan
145
+ const graphStats = getEntityGraphStats();
146
+ if (graphStats.total_triples > 0) {
147
+ try {
148
+ // Check each active triple for contradictions
149
+ const db = getMemoryDB();
150
+ const activeTriples = db.query(
151
+ "SELECT DISTINCT subject, predicate, object FROM entity_triples WHERE valid_to IS NULL LIMIT 200"
152
+ ).all() as { subject: string; predicate: string; object: string }[];
153
+ for (const t of activeTriples) {
154
+ const contradictions = findGraphContradictions(t.subject, t.predicate, t.object);
155
+ if (contradictions.length > 0) {
156
+ result.issues.push({
157
+ severity: "medium" as const,
158
+ title: `Graph contradiction: ${t.subject} ↔ ${t.object}`,
159
+ detail: `Active triple "${t.subject} —[${t.predicate}]→ ${t.object}" has ${contradictions.length} conflicting predicate(s): ${contradictions.map(c => c.predicate).join(", ")}`,
160
+ suggestion: `Use memory-graph-invalidate to close outdated triples`,
161
+ type: "contradiction" as const,
162
+ observation_ids: contradictions.map(c => c.source_observation_id).filter((id): id is number => id != null),
163
+ });
164
+ }
165
+ }
166
+ } catch { /* Graph table may not exist yet */ }
167
+ }
168
+ if (result.issues.length === 0) {
169
+ return `Memory lint: clean (${result.stats.total_observations} observations, 0 issues).`;
170
+ }
171
+ const lines: string[] = [
172
+ `## Memory Lint Report\n`,
173
+ `**${result.issues.length} issues** found in ${result.stats.total_observations} observations:\n`,
174
+ `| Duplicates | Contradictions | Stale | Orphans | Missing Narrative |`,
175
+ `|---|---|---|---|---|`,
176
+ `| ${result.stats.duplicates} | ${result.stats.contradictions} | ${result.stats.stale} | ${result.stats.orphans} | ${result.stats.missing_narrative} |\n`,
177
+ ];
178
+ for (const issue of result.issues.slice(0, 15)) {
179
+ lines.push(`- **[${issue.severity}]** ${issue.title}`);
180
+ lines.push(` ${issue.detail}`);
181
+ lines.push(` _Suggestion: ${issue.suggestion}_\n`);
182
+ }
183
+ if (result.issues.length > 15) {
184
+ lines.push(`... and ${result.issues.length - 15} more issues.`);
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+ case "index": {
189
+ const result = generateMemoryIndex();
190
+ return `Index generated: ${result.entryCount} observations, ${result.conceptCount} concepts. Read with \`memory-read({ file: "index" })\`.`;
191
+ }
192
+ case "compile": {
193
+ const result = compileObservations();
194
+ if (result.articles.length === 0) {
195
+ return `No concept clusters with 3+ observations found. Nothing to compile.`;
196
+ }
197
+ const articleList = result.articles.map(a => ` - ${a.concept} (${a.observationCount} obs)`).join("\n");
198
+ return `Compiled ${result.articles.length} articles from ${result.totalObservations} observations (${result.skippedClusters} skipped).\n\nArticles:\n${articleList}\n\nRead with \`memory-read({ file: "compiled/<concept>" })\`.`;
199
+ }
200
+ case "log": {
201
+ return getLogContent();
202
+ }
129
203
  case "migrate": {
130
204
  const obsDir = path.join(
131
205
  directory,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Memory Database Module v2 — Barrel Export
2
+ * Memory Database Module v3 — Barrel Export
3
3
  *
4
4
  * Re-exports all functions and types from sub-modules in ./db/.
5
5
  * This preserves backward compatibility for existing imports from "./lib/memory-db.js".
@@ -10,6 +10,7 @@
10
10
  * db/observations.ts — Observation CRUD, search, timeline, stats
11
11
  * db/pipeline.ts — Temporal messages, distillations, relevance scoring
12
12
  * db/maintenance.ts — Memory files, FTS5, DB maintenance
13
+ * db/graph.ts — Entity graph: temporal triples, queries, stats
13
14
  */
14
15
 
15
16
  // Memory Files, FTS5, and Maintenance
@@ -52,7 +53,24 @@ export {
52
53
  storeDistillation,
53
54
  storeTemporalMessage,
54
55
  } from "./db/pipeline.js";
56
+ // Entity Graph Operations (v3)
57
+ export {
58
+ addEntityTriple,
59
+ findContradictions as findGraphContradictions,
60
+ getEntityGraphStats,
61
+ getEntityTimeline,
62
+ getTripleById,
63
+ invalidateTriple,
64
+ queryEntity,
65
+ } from "./db/graph.js";
55
66
  // Database Manager
56
67
  export { closeMemoryDB, getMemoryDB } from "./db/schema.js";
57
68
  // Types & Configuration
58
69
  export * from "./db/types.js";
70
+
71
+ // New modules (v2.1: lint, compile, index, validate, operation log)
72
+ export { lintMemory, type LintResult, type LintIssue, type LintIssueType } from "./lint.js";
73
+ export { generateMemoryIndex, type IndexResult, type IndexEntry } from "./index-generator.js";
74
+ export { appendOperationLog, getRecentLogEntries, getLogContent, type LogEntry, type OperationType } from "./operation-log.js";
75
+ export { compileObservations, type CompileResult, type CompiledArticle, type ConceptCluster } from "./compile.js";
76
+ export { validateObservation, type ValidationResult, type ValidationVerdict, type ValidationIssue } from "./validate.js";
@@ -75,6 +75,36 @@ export function parseCSV(value: string | undefined): string[] | undefined {
75
75
  .filter((s) => s.length > 0);
76
76
  }
77
77
 
78
+ /**
79
+ * Parse a concepts field (JSON array or CSV string) into normalized strings.
80
+ * Handles both `["a","b"]` JSON and `"a, b"` CSV formats.
81
+ */
82
+ export function parseConcepts(raw: string | null): string[] {
83
+ if (!raw) return [];
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ if (Array.isArray(parsed))
87
+ return parsed.map((c: string) => c.toLowerCase().trim());
88
+ // JSON.parse succeeded but not an array (e.g., a plain string) — fall through to CSV
89
+ } catch {
90
+ // Not JSON — fall through to CSV
91
+ }
92
+ return raw
93
+ .split(",")
94
+ .map((c) => c.toLowerCase().trim())
95
+ .filter((c) => c.length > 0);
96
+ }
97
+
98
+ /**
99
+ * Check if a word appears in text with word-boundary matching.
100
+ * Prevents false positives from substring matches (e.g., "use" in "because").
101
+ */
102
+ export function hasWord(text: string, word: string): boolean {
103
+ return new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(
104
+ text,
105
+ );
106
+ }
107
+
78
108
  export function formatObservation(obs: {
79
109
  id: number;
80
110
  type: string;
@@ -15,9 +15,11 @@
15
15
  */
16
16
 
17
17
  import { captureMessageMeta, captureMessagePart } from "./capture.js";
18
+ import { compileObservations } from "./compile.js";
18
19
  import { manageContext } from "./context.js";
19
20
  import { curateFromDistillations } from "./curator.js";
20
21
  import { distillSession } from "./distill.js";
22
+ import { generateMemoryIndex } from "./index-generator.js";
21
23
  import { buildInjection } from "./inject.js";
22
24
  import {
23
25
  checkFTS5Available,
@@ -128,6 +130,14 @@ export function createHooks(deps: HookDeps) {
128
130
  if (checkFTS5Available()) optimizeFTS5();
129
131
  const sizes = getDatabaseSizes();
130
132
  if (sizes.wal > 1024 * 1024) checkpointWAL();
133
+
134
+ // Compile & index on idle (lightweight, after curation)
135
+ try {
136
+ compileObservations({ minObservations: 3, maxArticles: 10 });
137
+ generateMemoryIndex();
138
+ } catch {
139
+ /* Non-fatal: compile/index are nice-to-have */
140
+ }
131
141
  } catch (err) {
132
142
  const msg = err instanceof Error ? err.message : String(err);
133
143
  await log(`Idle maintenance failed: ${msg}`, "warn");