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.
- 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/lib/compile.ts +253 -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 +42 -1
- package/dist/template/.opencode/plugin/lib/memory-db.ts +7 -0
- 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 +30 -1
- 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/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
|
+
|
|
@@ -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
|
-
|
|
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
|
+
}
|