opencodekit 0.20.2 → 0.20.3

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 (50) 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/lib/compile.ts +253 -0
  26. package/dist/template/.opencode/plugin/lib/index-generator.ts +170 -0
  27. package/dist/template/.opencode/plugin/lib/lint.ts +359 -0
  28. package/dist/template/.opencode/plugin/lib/memory-admin-tools.ts +42 -1
  29. package/dist/template/.opencode/plugin/lib/memory-db.ts +7 -0
  30. package/dist/template/.opencode/plugin/lib/memory-helpers.ts +30 -0
  31. package/dist/template/.opencode/plugin/lib/memory-hooks.ts +10 -0
  32. package/dist/template/.opencode/plugin/lib/memory-tools.ts +30 -1
  33. package/dist/template/.opencode/plugin/lib/operation-log.ts +109 -0
  34. package/dist/template/.opencode/plugin/lib/validate.ts +243 -0
  35. package/dist/template/.opencode/skill/design-taste-frontend/SKILL.md +13 -1
  36. package/dist/template/.opencode/skill/figma-go/SKILL.md +1 -1
  37. package/dist/template/.opencode/skill/full-output-enforcement/SKILL.md +13 -0
  38. package/dist/template/.opencode/skill/high-end-visual-design/SKILL.md +13 -0
  39. package/dist/template/.opencode/skill/industrial-brutalist-ui/SKILL.md +13 -0
  40. package/dist/template/.opencode/skill/memory-system/SKILL.md +65 -1
  41. package/dist/template/.opencode/skill/minimalist-ui/SKILL.md +13 -0
  42. package/dist/template/.opencode/skill/redesign-existing-projects/SKILL.md +13 -0
  43. package/dist/template/.opencode/skill/requesting-code-review/SKILL.md +48 -2
  44. package/dist/template/.opencode/skill/requesting-code-review/references/specialist-profiles.md +108 -0
  45. package/dist/template/.opencode/skill/skill-creator/SKILL.md +25 -0
  46. package/dist/template/.opencode/skill/stitch-design-taste/SKILL.md +13 -0
  47. package/dist/template/.opencode/skill/verification-before-completion/SKILL.md +46 -0
  48. package/package.json +1 -1
  49. package/dist/template/.opencode/agent/runner.md +0 -79
  50. 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
+
@@ -10,7 +10,11 @@ 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,
@@ -37,7 +41,7 @@ export function createAdminTools(deps: AdminToolDeps) {
37
41
 
38
42
  return {
39
43
  "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 })`,
44
+ 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
45
  args: {
42
46
  operation: tool.schema
43
47
  .string()
@@ -126,6 +130,43 @@ export function createAdminTools(deps: AdminToolDeps) {
126
130
  const r = curateFromDistillations();
127
131
  return `Created ${r.created}, skipped ${r.skipped}. Patterns: ${JSON.stringify(r.patterns)}`;
128
132
  }
133
+ case "lint": {
134
+ const result = lintMemory({ staleDays: olderThanDays });
135
+ if (result.issues.length === 0) {
136
+ return `Memory lint: clean (${result.stats.total_observations} observations, 0 issues).`;
137
+ }
138
+ const lines: string[] = [
139
+ `## Memory Lint Report\n`,
140
+ `**${result.issues.length} issues** found in ${result.stats.total_observations} observations:\n`,
141
+ `| Duplicates | Contradictions | Stale | Orphans | Missing Narrative |`,
142
+ `|---|---|---|---|---|`,
143
+ `| ${result.stats.duplicates} | ${result.stats.contradictions} | ${result.stats.stale} | ${result.stats.orphans} | ${result.stats.missing_narrative} |\n`,
144
+ ];
145
+ for (const issue of result.issues.slice(0, 15)) {
146
+ lines.push(`- **[${issue.severity}]** ${issue.title}`);
147
+ lines.push(` ${issue.detail}`);
148
+ lines.push(` _Suggestion: ${issue.suggestion}_\n`);
149
+ }
150
+ if (result.issues.length > 15) {
151
+ lines.push(`... and ${result.issues.length - 15} more issues.`);
152
+ }
153
+ return lines.join("\n");
154
+ }
155
+ case "index": {
156
+ const result = generateMemoryIndex();
157
+ return `Index generated: ${result.entryCount} observations, ${result.conceptCount} concepts. Read with \`memory-read({ file: "index" })\`.`;
158
+ }
159
+ case "compile": {
160
+ const result = compileObservations();
161
+ if (result.articles.length === 0) {
162
+ return `No concept clusters with 3+ observations found. Nothing to compile.`;
163
+ }
164
+ const articleList = result.articles.map(a => ` - ${a.concept} (${a.observationCount} obs)`).join("\n");
165
+ 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>" })\`.`;
166
+ }
167
+ case "log": {
168
+ return getLogContent();
169
+ }
129
170
  case "migrate": {
130
171
  const obsDir = path.join(
131
172
  directory,
@@ -56,3 +56,10 @@ export {
56
56
  export { closeMemoryDB, getMemoryDB } from "./db/schema.js";
57
57
  // Types & Configuration
58
58
  export * from "./db/types.js";
59
+
60
+ // New modules (v2.1: lint, compile, index, validate, operation log)
61
+ export { lintMemory, type LintResult, type LintIssue, type LintIssueType } from "./lint.js";
62
+ export { generateMemoryIndex, type IndexResult, type IndexEntry } from "./index-generator.js";
63
+ export { appendOperationLog, getRecentLogEntries, getLogContent, type LogEntry, type OperationType } from "./operation-log.js";
64
+ export { compileObservations, type CompileResult, type CompiledArticle, type ConceptCluster } from "./compile.js";
65
+ 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");
@@ -31,6 +31,7 @@ import {
31
31
  TYPE_ICONS,
32
32
  VALID_TYPES,
33
33
  } from "./memory-helpers.js";
34
+ import { validateObservation } from "./validate.js";
34
35
 
35
36
  /**
36
37
  * Wrap a memory tool execute function with DB error handling.
@@ -157,6 +158,30 @@ export function createCoreTools(deps: CoreToolDeps) {
157
158
 
158
159
  const source = (args.source ?? "manual") as ObservationSource;
159
160
 
161
+ // Validation gate: check for duplicates, contradictions, low quality
162
+ const validation = validateObservation({
163
+ type: obsType,
164
+ title: args.title,
165
+ subtitle: args.subtitle,
166
+ facts,
167
+ narrative,
168
+ concepts,
169
+ files_read: filesRead,
170
+ files_modified: filesModified,
171
+ confidence,
172
+ bead_id: args.bead_id,
173
+ supersedes,
174
+ source,
175
+ });
176
+
177
+ if (validation.verdict === "reject") {
178
+ const reasons = validation.issues.map(i => i.message).join("; ");
179
+ const dupHint = validation.duplicateOf
180
+ ? ` Use \`observation({ supersedes: "${validation.duplicateOf}", ... })\` to update it.`
181
+ : "";
182
+ return `Rejected: ${reasons}.${dupHint}`;
183
+ }
184
+
160
185
  const id = storeObservation({
161
186
  type: obsType,
162
187
  title: args.title,
@@ -172,7 +197,11 @@ export function createCoreTools(deps: CoreToolDeps) {
172
197
  source,
173
198
  });
174
199
 
175
- return `${TYPE_ICONS[obsType] ?? "\uD83D\uDCCC"} Observation #${id} stored [${obsType}] "${args.title}" (confidence: ${confidence}, source: ${source})`;
200
+ const warnings = validation.issues.length > 0
201
+ ? `\n⚠️ Warnings: ${validation.issues.map(i => i.message).join("; ")}`
202
+ : "";
203
+
204
+ return `${TYPE_ICONS[obsType] ?? "\uD83D\uDCCC"} Observation #${id} stored [${obsType}] "${args.title}" (confidence: ${confidence}, source: ${source})${warnings}`;
176
205
  }),
177
206
  }),
178
207
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Memory Operation Log — Append-Only Audit Trail
3
+ *
4
+ * Inspired by Karpathy's LLM Wiki log.md:
5
+ * chronological record of all memory operations for provenance tracking.
6
+ *
7
+ * Stored in memory_files as "log" — append-only, never overwritten.
8
+ */
9
+
10
+ import { getMemoryFile, upsertMemoryFile } from "./db/maintenance.js";
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export type OperationType =
17
+ | "observation-created"
18
+ | "observation-superseded"
19
+ | "observation-validated"
20
+ | "observation-rejected"
21
+ | "index-generated"
22
+ | "lint-run"
23
+ | "compile-run"
24
+ | "maintenance-run"
25
+ | "distillation-created"
26
+ | "curation-run";
27
+
28
+ export interface LogEntry {
29
+ timestamp: string;
30
+ operation: OperationType;
31
+ targets: string[];
32
+ summary: string;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Log Operations
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Append an entry to the operation log.
41
+ * The log is append-only and stored in memory_files.
42
+ */
43
+ export function appendOperationLog(entry: Omit<LogEntry, "timestamp">): void {
44
+ const timestamp = new Date().toISOString().slice(0, 19);
45
+ const targets = entry.targets.length > 0 ? entry.targets.join(", ") : "-";
46
+ const line = `[${timestamp}] ${entry.operation} | ${targets} | ${entry.summary}`;
47
+
48
+ // Check if log exists
49
+ const existing = getMemoryFile("log");
50
+ if (existing) {
51
+ // Append with newline
52
+ upsertMemoryFile("log", line, "append");
53
+ } else {
54
+ // Create with header
55
+ const header = [
56
+ "# Memory Operation Log",
57
+ "",
58
+ "> Append-only chronological record of all memory operations.",
59
+ "> Format: [timestamp] operation | targets | summary",
60
+ "",
61
+ "---",
62
+ "",
63
+ line,
64
+ ].join("\n");
65
+ upsertMemoryFile("log", header, "replace");
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Get recent log entries (parsed from the log file).
71
+ */
72
+ export function getRecentLogEntries(limit = 20): LogEntry[] {
73
+ const existing = getMemoryFile("log");
74
+ if (!existing) return [];
75
+
76
+ const lines = existing.content
77
+ .split("\n")
78
+ .filter((line) => line.startsWith("["))
79
+ .slice(-limit);
80
+
81
+ return lines.map((line) => {
82
+ const match = line.match(/^\[(.+?)\] (.+?) \| (.+?) \| (.+)$/);
83
+ if (!match) {
84
+ return {
85
+ timestamp: "",
86
+ operation: "observation-created" as OperationType,
87
+ targets: [],
88
+ summary: line,
89
+ };
90
+ }
91
+ return {
92
+ timestamp: match[1],
93
+ operation: match[2] as OperationType,
94
+ targets: match[3].split(",").map((t) => t.trim()),
95
+ summary: match[4],
96
+ };
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Get the full log content as a string.
102
+ */
103
+ export function getLogContent(): string {
104
+ const existing = getMemoryFile("log");
105
+ return (
106
+ existing?.content ??
107
+ "No operation log found. Run a memory operation to start logging."
108
+ );
109
+ }