opencodekit 0.23.1 → 0.23.2

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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ObservationRow } from "./db/types.js";
12
- import { getMemoryDB } from "./db.js";
12
+ import { getMemoryDB } from "./db/schema.js";
13
13
  import { hasWord, parseConcepts } from "./helpers.js";
14
14
 
15
15
  // ============================================================================
@@ -17,31 +17,31 @@ import { hasWord, parseConcepts } from "./helpers.js";
17
17
  // ============================================================================
18
18
 
19
19
  export type LintIssueType =
20
- | "duplicate"
21
- | "contradiction"
22
- | "stale"
23
- | "orphan"
24
- | "missing-narrative";
20
+ | "duplicate"
21
+ | "contradiction"
22
+ | "stale"
23
+ | "orphan"
24
+ | "missing-narrative";
25
25
 
26
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;
27
+ type: LintIssueType;
28
+ severity: "high" | "medium" | "low";
29
+ observation_ids: number[];
30
+ title: string;
31
+ detail: string;
32
+ suggestion: string;
33
33
  }
34
34
 
35
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
- };
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
45
  }
46
46
 
47
47
  // ============================================================================
@@ -52,42 +52,34 @@ export interface LintResult {
52
52
  * Run all lint checks and return a consolidated report.
53
53
  */
54
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
- };
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(...duplicates, ...contradictions, ...stale, ...orphans, ...missing);
65
+
66
+ // Count total active observations
67
+ const db = getMemoryDB();
68
+ const row = db
69
+ .query("SELECT COUNT(*) as count FROM observations WHERE superseded_by IS NULL")
70
+ .get() as { count: number };
71
+
72
+ return {
73
+ issues,
74
+ stats: {
75
+ total_observations: row.count,
76
+ duplicates: duplicates.length,
77
+ contradictions: contradictions.length,
78
+ stale: stale.length,
79
+ orphans: orphans.length,
80
+ missing_narrative: missing.length,
81
+ },
82
+ };
91
83
  }
92
84
 
93
85
  /**
@@ -95,77 +87,74 @@ export function lintMemory(options: { staleDays?: number } = {}): LintResult {
95
87
  * Uses normalized title comparison + concept overlap.
96
88
  */
97
89
  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;
90
+ const db = getMemoryDB();
91
+ const issues: LintIssue[] = [];
92
+
93
+ const observations = db
94
+ .query(
95
+ "SELECT id, type, title, concepts, narrative FROM observations WHERE superseded_by IS NULL ORDER BY created_at_epoch DESC",
96
+ )
97
+ .all() as Pick<ObservationRow, "id" | "type" | "title" | "concepts" | "narrative">[];
98
+
99
+ // Group by normalized title
100
+ const titleMap = new Map<string, typeof observations>();
101
+ for (const obs of observations) {
102
+ const normalized = normalizeTitle(obs.title);
103
+ const group = titleMap.get(normalized) ?? [];
104
+ group.push(obs);
105
+ titleMap.set(normalized, group);
106
+ }
107
+
108
+ for (const [normalized, group] of titleMap) {
109
+ if (group.length > 1) {
110
+ issues.push({
111
+ type: "duplicate",
112
+ severity: "medium",
113
+ observation_ids: group.map((o) => o.id),
114
+ title: `Duplicate: "${group[0].title}"`,
115
+ detail: `${group.length} observations with similar title "${normalized}": IDs ${group.map((o) => `#${o.id}`).join(", ")}`,
116
+ suggestion: `Use \`observation({ supersedes: "${group[group.length - 1].id}" })\` to merge, keeping the most recent.`,
117
+ });
118
+ }
119
+ }
120
+
121
+ // Also check concept overlap for same-type observations
122
+ const byType = new Map<string, typeof observations>();
123
+ for (const obs of observations) {
124
+ if (!obs.concepts) continue;
125
+ const group = byType.get(obs.type) ?? [];
126
+ group.push(obs);
127
+ byType.set(obs.type, group);
128
+ }
129
+
130
+ for (const [, group] of byType) {
131
+ for (let i = 0; i < group.length; i++) {
132
+ for (let j = i + 1; j < group.length; j++) {
133
+ const overlap = conceptOverlap(group[i].concepts, group[j].concepts);
134
+ if (overlap > 0.8 && group[i].id !== group[j].id) {
135
+ // Check not already flagged by title
136
+ const alreadyFlagged = issues.some(
137
+ (iss) =>
138
+ iss.type === "duplicate" &&
139
+ iss.observation_ids.includes(group[i].id) &&
140
+ iss.observation_ids.includes(group[j].id),
141
+ );
142
+ if (!alreadyFlagged) {
143
+ issues.push({
144
+ type: "duplicate",
145
+ severity: "low",
146
+ observation_ids: [group[i].id, group[j].id],
147
+ title: `High concept overlap: #${group[i].id} ↔ #${group[j].id}`,
148
+ detail: `"${group[i].title}" and "${group[j].title}" share ${(overlap * 100).toFixed(0)}% concepts`,
149
+ suggestion: `Review if these should be merged with \`supersedes\`.`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ return issues;
169
158
  }
170
159
 
171
160
  /**
@@ -173,94 +162,86 @@ function findDuplicates(): LintIssue[] {
173
162
  * Looks for opposing signal words in narratives.
174
163
  */
175
164
  function findContradictions(): LintIssue[] {
176
- const db = getMemoryDB();
177
- const issues: LintIssue[] = [];
165
+ const db = getMemoryDB();
166
+ const issues: LintIssue[] = [];
178
167
 
179
- // Get decision-type observations that share concepts
180
- const decisions = db
181
- .query(
182
- `SELECT id, title, concepts, narrative FROM observations
168
+ // Get decision-type observations that share concepts
169
+ const decisions = db
170
+ .query(
171
+ `SELECT id, title, concepts, narrative FROM observations
183
172
  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;
173
+ )
174
+ .all() as Pick<ObservationRow, "id" | "title" | "concepts" | "narrative">[];
175
+
176
+ // Check pairs for contradictory language
177
+ const contradictionPairs = [
178
+ ["use", "don't use"],
179
+ ["enable", "disable"],
180
+ ["add", "remove"],
181
+ ["prefer", "avoid"],
182
+ ["always", "never"],
183
+ ["yes", "no"],
184
+ ];
185
+
186
+ for (let i = 0; i < decisions.length; i++) {
187
+ for (let j = i + 1; j < decisions.length; j++) {
188
+ const overlap = conceptOverlap(decisions[i].concepts, decisions[j].concepts);
189
+ if (overlap < 0.3) continue; // Unrelated decisions
190
+
191
+ const textA = `${decisions[i].title} ${decisions[i].narrative ?? ""}`.toLowerCase();
192
+ const textB = `${decisions[j].title} ${decisions[j].narrative ?? ""}`.toLowerCase();
193
+
194
+ for (const [wordA, wordB] of contradictionPairs) {
195
+ if (
196
+ (hasWord(textA, wordA) && hasWord(textB, wordB)) ||
197
+ (hasWord(textA, wordB) && hasWord(textB, wordA))
198
+ ) {
199
+ issues.push({
200
+ type: "contradiction",
201
+ severity: "high",
202
+ observation_ids: [decisions[i].id, decisions[j].id],
203
+ title: `Potential contradiction: #${decisions[i].id} vs #${decisions[j].id}`,
204
+ detail: `"${decisions[i].title}" and "${decisions[j].title}" share concepts but contain opposing signals ("${wordA}" vs "${wordB}")`,
205
+ suggestion: `Review both and supersede the outdated one.`,
206
+ });
207
+ break; // One contradiction signal per pair is enough
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return issues;
230
214
  }
231
215
 
232
216
  /**
233
217
  * Find observations older than N days with no references in recent distillations.
234
218
  */
235
219
  function findStaleObservations(staleDays: number): LintIssue[] {
236
- const db = getMemoryDB();
237
- const cutoffEpoch = Date.now() - staleDays * 24 * 60 * 60 * 1000;
220
+ const db = getMemoryDB();
221
+ const cutoffEpoch = Date.now() - staleDays * 24 * 60 * 60 * 1000;
238
222
 
239
- const stale = db
240
- .query(
241
- `SELECT id, type, title, created_at, created_at_epoch FROM observations
223
+ const stale = db
224
+ .query(
225
+ `SELECT id, type, title, created_at, created_at_epoch FROM observations
242
226
  WHERE superseded_by IS NULL AND created_at_epoch < ? AND valid_until IS NULL
243
227
  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
- });
228
+ )
229
+ .all(cutoffEpoch) as Pick<
230
+ ObservationRow,
231
+ "id" | "type" | "title" | "created_at" | "created_at_epoch"
232
+ >[];
233
+
234
+ return stale.map((obs) => {
235
+ const ageDays = Math.floor((Date.now() - obs.created_at_epoch) / (1000 * 60 * 60 * 24));
236
+ return {
237
+ type: "stale" as const,
238
+ severity: ageDays > staleDays * 2 ? ("high" as const) : ("medium" as const),
239
+ observation_ids: [obs.id],
240
+ title: `Stale (${ageDays}d): #${obs.id} "${obs.title}"`,
241
+ detail: `[${obs.type}] created ${obs.created_at.slice(0, 10)}, ${ageDays} days old with no valid_until set`,
242
+ suggestion: `Review: still relevant? If yes, update it. If no, supersede or set valid_until.`,
243
+ };
244
+ });
264
245
  }
265
246
 
266
247
  /**
@@ -268,69 +249,67 @@ function findStaleObservations(staleDays: number): LintIssue[] {
268
249
  * These are "orphan concepts" — knowledge islands with no connections.
269
250
  */
270
251
  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;
252
+ const db = getMemoryDB();
253
+ const issues: LintIssue[] = [];
254
+
255
+ const observations = db
256
+ .query(
257
+ "SELECT id, title, concepts FROM observations WHERE superseded_by IS NULL AND concepts IS NOT NULL",
258
+ )
259
+ .all() as Pick<ObservationRow, "id" | "title" | "concepts">[];
260
+
261
+ // Build concept → observation IDs map
262
+ const conceptMap = new Map<string, number[]>();
263
+ for (const obs of observations) {
264
+ const concepts = parseConcepts(obs.concepts);
265
+ for (const concept of concepts) {
266
+ const ids = conceptMap.get(concept) ?? [];
267
+ ids.push(obs.id);
268
+ conceptMap.set(concept, ids);
269
+ }
270
+ }
271
+
272
+ // Find observations where ALL concepts are orphans (only appear once)
273
+ for (const obs of observations) {
274
+ const concepts = parseConcepts(obs.concepts);
275
+ if (concepts.length === 0) continue;
276
+ const allOrphan = concepts.every((c) => (conceptMap.get(c)?.length ?? 0) <= 1);
277
+ if (allOrphan && concepts.length >= 2) {
278
+ issues.push({
279
+ type: "orphan",
280
+ severity: "low",
281
+ observation_ids: [obs.id],
282
+ title: `Isolated: #${obs.id} "${obs.title}"`,
283
+ detail: `All concepts [${concepts.join(", ")}] appear in no other observation — this knowledge is disconnected`,
284
+ suggestion: `Consider adding cross-references or broadening concept tags.`,
285
+ });
286
+ }
287
+ }
288
+
289
+ return issues;
311
290
  }
312
291
 
313
292
  /**
314
293
  * Find observations with no narrative (title-only, low value).
315
294
  */
316
295
  function findMissingNarratives(): LintIssue[] {
317
- const db = getMemoryDB();
296
+ const db = getMemoryDB();
318
297
 
319
- const missing = db
320
- .query(
321
- `SELECT id, type, title FROM observations
298
+ const missing = db
299
+ .query(
300
+ `SELECT id, type, title FROM observations
322
301
  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
- }));
302
+ )
303
+ .all() as Pick<ObservationRow, "id" | "type" | "title">[];
304
+
305
+ return missing.map((obs) => ({
306
+ type: "missing-narrative" as const,
307
+ severity: "low" as const,
308
+ observation_ids: [obs.id],
309
+ title: `No narrative: #${obs.id} "${obs.title}"`,
310
+ detail: `[${obs.type}] has title but no narrative — low-value observation`,
311
+ suggestion: `Add narrative context or remove if the title alone is not useful.`,
312
+ }));
334
313
  }
335
314
 
336
315
  // ============================================================================
@@ -338,22 +317,21 @@ function findMissingNarratives(): LintIssue[] {
338
317
  // ============================================================================
339
318
 
340
319
  function normalizeTitle(title: string): string {
341
- return title
342
- .toLowerCase()
343
- .replace(/[^a-z0-9\s]/g, "")
344
- .replace(/\s+/g, " ")
345
- .trim();
320
+ return title
321
+ .toLowerCase()
322
+ .replace(/[^a-z0-9\s]/g, "")
323
+ .replace(/\s+/g, " ")
324
+ .trim();
346
325
  }
347
326
 
348
327
  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);
328
+ const conceptsA = new Set(parseConcepts(a));
329
+ const conceptsB = new Set(parseConcepts(b));
330
+ if (conceptsA.size === 0 || conceptsB.size === 0) return 0;
331
+
332
+ let overlap = 0;
333
+ for (const c of conceptsA) {
334
+ if (conceptsB.has(c)) overlap++;
335
+ }
336
+ return overlap / Math.min(conceptsA.size, conceptsB.size);
358
337
  }
359
-