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.
- package/dist/index.js +1 -1
- package/dist/template/.opencode/agent/build.md +4 -0
- package/dist/template/.opencode/agent/explore.md +4 -0
- package/dist/template/.opencode/agent/general.md +4 -0
- package/dist/template/.opencode/agent/plan.md +4 -0
- package/dist/template/.opencode/agent/review.md +4 -0
- package/dist/template/.opencode/agent/scout.md +4 -0
- package/dist/template/.opencode/command/create.md +119 -25
- package/dist/template/.opencode/command/design.md +1 -2
- package/dist/template/.opencode/command/health.md +234 -0
- package/dist/template/.opencode/command/init-user.md +15 -0
- package/dist/template/.opencode/command/plan.md +3 -4
- package/dist/template/.opencode/command/pr.md +13 -0
- package/dist/template/.opencode/command/research.md +15 -3
- package/dist/template/.opencode/command/review-codebase.md +11 -1
- package/dist/template/.opencode/command/ship.md +72 -8
- package/dist/template/.opencode/command/status.md +1 -1
- package/dist/template/.opencode/command/ui-review.md +0 -1
- package/dist/template/.opencode/command/ui-slop-check.md +1 -1
- package/dist/template/.opencode/command/verify.md +11 -1
- package/dist/template/.opencode/memory.db +0 -0
- package/dist/template/.opencode/memory.db-shm +0 -0
- package/dist/template/.opencode/memory.db-wal +0 -0
- package/dist/template/.opencode/opencode.json +1678 -1677
- package/dist/template/.opencode/plugin/README.md +1 -1
- package/dist/template/.opencode/plugin/lib/compact.ts +194 -0
- package/dist/template/.opencode/plugin/lib/compile.ts +253 -0
- package/dist/template/.opencode/plugin/lib/db/graph.ts +253 -0
- package/dist/template/.opencode/plugin/lib/db/observations.ts +8 -3
- package/dist/template/.opencode/plugin/lib/db/schema.ts +96 -5
- package/dist/template/.opencode/plugin/lib/db/types.ts +73 -0
- package/dist/template/.opencode/plugin/lib/index-generator.ts +170 -0
- package/dist/template/.opencode/plugin/lib/lint.ts +359 -0
- package/dist/template/.opencode/plugin/lib/memory-admin-tools.ts +78 -4
- package/dist/template/.opencode/plugin/lib/memory-db.ts +19 -1
- package/dist/template/.opencode/plugin/lib/memory-helpers.ts +30 -0
- package/dist/template/.opencode/plugin/lib/memory-hooks.ts +10 -0
- package/dist/template/.opencode/plugin/lib/memory-tools.ts +167 -2
- package/dist/template/.opencode/plugin/lib/operation-log.ts +109 -0
- package/dist/template/.opencode/plugin/lib/validate.ts +243 -0
- package/dist/template/.opencode/plugin/memory.ts +2 -1
- package/dist/template/.opencode/skill/design-taste-frontend/SKILL.md +13 -1
- package/dist/template/.opencode/skill/figma-go/SKILL.md +1 -1
- package/dist/template/.opencode/skill/full-output-enforcement/SKILL.md +13 -0
- package/dist/template/.opencode/skill/high-end-visual-design/SKILL.md +13 -0
- package/dist/template/.opencode/skill/industrial-brutalist-ui/SKILL.md +13 -0
- package/dist/template/.opencode/skill/memory-system/SKILL.md +65 -1
- package/dist/template/.opencode/skill/minimalist-ui/SKILL.md +13 -0
- package/dist/template/.opencode/skill/redesign-existing-projects/SKILL.md +13 -0
- package/dist/template/.opencode/skill/requesting-code-review/SKILL.md +48 -2
- package/dist/template/.opencode/skill/requesting-code-review/references/specialist-profiles.md +108 -0
- package/dist/template/.opencode/skill/skill-creator/SKILL.md +25 -0
- package/dist/template/.opencode/skill/stitch-design-taste/SKILL.md +13 -0
- package/dist/template/.opencode/skill/verification-before-completion/SKILL.md +46 -0
- package/package.json +1 -1
- package/dist/template/.opencode/agent/runner.md +0 -79
- 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 (
|
|
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**:
|
|
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
|
|
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");
|