@jmtrin/kevin-core 1.4.0 → 1.5.0

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.
@@ -0,0 +1,286 @@
1
+ // K15-011/012/013 — Host importers (plan §4.5)
2
+ // Defensive markdown parsers, no YAML lib, same naive parser as validator.
3
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { resolveEnv } from "./env.js";
6
+ import { fingerprint as computeFingerprint } from "./fingerprint.js";
7
+ import { QualityGate } from "./QualityGate.js";
8
+ const MAX_FILE_BYTES = 1 * 1024 * 1024;
9
+ const MAX_CANDIDATES = 5000;
10
+ const CLAUDE_TYPE_MAP = {
11
+ user_preference: "context",
12
+ project_context: "context",
13
+ correction: "rule",
14
+ code_pattern: "pattern",
15
+ };
16
+ // naive frontmatter parse for Claude topic files: extract type field
17
+ function parseFrontmatterType(content) {
18
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
19
+ if (lines[0]?.trim() !== "---")
20
+ return null;
21
+ let type = null;
22
+ for (let i = 1; i < lines.length; i++) {
23
+ if (lines[i].trim() === "---")
24
+ break;
25
+ const m = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
26
+ if (m && m[1].toLowerCase() === "type") {
27
+ type = m[2].trim().replace(/^["']|["']$/g, "");
28
+ }
29
+ }
30
+ return type;
31
+ }
32
+ function extractBullets(content) {
33
+ // remove frontmatter block first
34
+ const withoutFm = content.replace(/^---\n[\s\S]*?\n---\n?/, "");
35
+ const lines = withoutFm.split("\n");
36
+ const out = [];
37
+ for (const line of lines) {
38
+ const m = line.match(/^\s*[-*]\s+(.*)$/);
39
+ if (m) {
40
+ const trimmed = m[1].trim();
41
+ if (trimmed !== "")
42
+ out.push(trimmed);
43
+ }
44
+ else {
45
+ // also handle heading/bullet? codex may have headings
46
+ const hm = line.match(/^\s*#{1,6}\s+(.*)$/);
47
+ if (hm && hm[1].trim() !== "") {
48
+ // treat heading text as candidate? For codex, headings+bulles both
49
+ // but we will extract bullets only for claude; codex extractor handles headings separately
50
+ }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function safeRead(filePath) {
56
+ try {
57
+ const st = lstatSync(filePath);
58
+ if (st.isSymbolicLink())
59
+ return null;
60
+ if (!st.isFile())
61
+ return null;
62
+ if (st.size > MAX_FILE_BYTES)
63
+ return null;
64
+ return readFileSync(filePath, "utf8");
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export function parseClaudeMemory(dataRoot, env) {
71
+ const root = join(resolveEnv(env).dataRoot ?? dataRoot, "claude", "projects");
72
+ // but dataRoot is already resolveEnv(...).dataRoot; we accept direct
73
+ const scanRoot = join(dataRoot, "claude", "projects");
74
+ const candidates = [];
75
+ let files_scanned = 0;
76
+ let skipped_files = 0;
77
+ if (!existsSync(scanRoot))
78
+ return { candidates, files_scanned, skipped_files, truncated: false };
79
+ let projectDirs = [];
80
+ try {
81
+ projectDirs = readdirSync(scanRoot);
82
+ }
83
+ catch {
84
+ return { candidates, files_scanned, skipped_files, truncated: false };
85
+ }
86
+ for (const proj of projectDirs) {
87
+ const memDir = join(scanRoot, proj, "memory");
88
+ if (!existsSync(memDir))
89
+ continue;
90
+ let files = [];
91
+ try {
92
+ files = readdirSync(memDir);
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ for (const f of files) {
98
+ if (!f.endsWith(".md"))
99
+ continue;
100
+ const full = join(memDir, f);
101
+ files_scanned++;
102
+ if (f === "MEMORY.md") {
103
+ // index only, skip harvesting
104
+ continue;
105
+ }
106
+ const content = safeRead(full);
107
+ if (content === null) {
108
+ skipped_files++;
109
+ continue;
110
+ }
111
+ // M-04: frontmatter without closing --- should skip
112
+ if (content.trimStart().startsWith("---")) {
113
+ const fmLines = content.replace(/\r\n/g, "\n").split("\n");
114
+ let hasClosing = false;
115
+ for (let i = 1; i < fmLines.length; i++) {
116
+ if (fmLines[i].trim() === "---") {
117
+ hasClosing = true;
118
+ break;
119
+ }
120
+ }
121
+ if (!hasClosing) {
122
+ skipped_files++;
123
+ continue;
124
+ }
125
+ }
126
+ const rawType = parseFrontmatterType(content);
127
+ if (rawType === null) {
128
+ // check if frontmatter missing or malformed: count as skipped but continue
129
+ // if file has no frontmatter, we still skip but not throw
130
+ // For test: one malformed topic file should be counted as skipped
131
+ if (!content.trim().startsWith("---")) {
132
+ skipped_files++;
133
+ continue;
134
+ }
135
+ // if frontmatter exists but type missing, map to context
136
+ }
137
+ const mapped = rawType ? (CLAUDE_TYPE_MAP[rawType.toLowerCase()] ?? "context") : "context";
138
+ // bullets
139
+ const bullets = extractBullets(content);
140
+ if (bullets.length === 0) {
141
+ // no bullets -> skip but not error
142
+ continue;
143
+ }
144
+ for (const b of bullets) {
145
+ if (candidates.length >= MAX_CANDIDATES)
146
+ break;
147
+ candidates.push({ content: b, type: mapped });
148
+ }
149
+ }
150
+ }
151
+ const truncated = candidates.length >= MAX_CANDIDATES;
152
+ return { candidates, files_scanned, skipped_files, truncated };
153
+ }
154
+ export function parseCodexMemories(dataRoot, env) {
155
+ const scanRoot = join(dataRoot, "codex", "memories");
156
+ const candidates = [];
157
+ let files_scanned = 0;
158
+ let skipped_files = 0;
159
+ if (!existsSync(scanRoot))
160
+ return { candidates, files_scanned, skipped_files, truncated: false };
161
+ let files = [];
162
+ try {
163
+ files = readdirSync(scanRoot);
164
+ }
165
+ catch {
166
+ return { candidates, files_scanned, skipped_files, truncated: false };
167
+ }
168
+ const targets = ["memory_summary.md", "MEMORY.md"];
169
+ for (const t of targets) {
170
+ if (!files.includes(t))
171
+ continue;
172
+ const full = join(scanRoot, t);
173
+ files_scanned++;
174
+ const content = safeRead(full);
175
+ if (content === null) {
176
+ skipped_files++;
177
+ continue;
178
+ }
179
+ // extract headings and bullets: both become candidates type context
180
+ const lines = content.split("\n");
181
+ for (const line of lines) {
182
+ const bullet = line.match(/^\s*[-*]\s+(.*)$/);
183
+ if (bullet && bullet[1].trim() !== "") {
184
+ if (candidates.length >= MAX_CANDIDATES)
185
+ break;
186
+ candidates.push({ content: bullet[1].trim(), type: "context" });
187
+ continue;
188
+ }
189
+ const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
190
+ if (heading && heading[1].trim() !== "") {
191
+ // heading text as candidate (avoid duplicates where heading is also bullet)
192
+ // we treat heading as candidate as well
193
+ if (candidates.length >= MAX_CANDIDATES)
194
+ break;
195
+ candidates.push({ content: heading[1].trim(), type: "context" });
196
+ }
197
+ }
198
+ }
199
+ const truncated2 = candidates.length >= MAX_CANDIDATES;
200
+ return { candidates, files_scanned, skipped_files, truncated: truncated2 };
201
+ }
202
+ export function importHostMemories(opts) {
203
+ const dataRoot = opts.dataRoot ?? resolveEnv(opts.env).dataRoot;
204
+ // gate
205
+ const gate = opts.memoryService.getSetting("import_host_memory", "0");
206
+ if (gate !== "1") {
207
+ return {
208
+ files_scanned: 0,
209
+ candidates: 0,
210
+ saved: 0,
211
+ duplicates: 0,
212
+ skipped_weak: 0,
213
+ error: "disabled",
214
+ hint: "Enable with kevin_config set import_host_memory 1",
215
+ };
216
+ }
217
+ let parsed;
218
+ if (opts.source === "claude-memory") {
219
+ parsed = parseClaudeMemory(dataRoot, opts.env);
220
+ }
221
+ else {
222
+ parsed = parseCodexMemories(dataRoot, opts.env);
223
+ }
224
+ let candidates = parsed.candidates;
225
+ // M-05: propagate truncated correctly from parsers
226
+ const truncated = (parsed.truncated ?? false) || candidates.length >= MAX_CANDIDATES;
227
+ if (candidates.length > MAX_CANDIDATES)
228
+ candidates = candidates.slice(0, MAX_CANDIDATES);
229
+ // pipeline per candidate: redact -> fingerprint dedup (existing rows) -> quality-gate classification -> save
230
+ let saved = 0;
231
+ let duplicates = 0;
232
+ let skipped_weak = 0;
233
+ // dedup intra-run via fingerprint set
234
+ const seenFingerprints = new Set();
235
+ // existing fingerprints from DB
236
+ const existingRows = opts.store.prepare("SELECT fingerprint FROM memories WHERE fingerprint IS NOT NULL").all();
237
+ for (const r of existingRows)
238
+ seenFingerprints.add(r.fingerprint);
239
+ for (const c of candidates) {
240
+ // redact (paths and private)
241
+ let content = c.content;
242
+ // simple redact paths: reuse import? For now strip private blocks
243
+ content = content.replace(/<private\b[^>]*>[\s\S]*?<\/private>/gi, (m) => `<private: redacted ${m.length} chars>`);
244
+ // fingerprint dedup
245
+ const fp = computeFingerprint(content);
246
+ if (seenFingerprints.has(fp)) {
247
+ duplicates++;
248
+ continue;
249
+ }
250
+ seenFingerprints.add(fp);
251
+ // quality-gate classification: weak stored-not-injected naturally
252
+ // We simulate: isActionable and strength via QualityGate.evaluate
253
+ // For host import, we consider type context is weak if generic? But we will use evaluate to decide
254
+ const q = QualityGate.evaluate({ errorType: "unknown", suggestion: content }, null, "unknown");
255
+ // If weak and not actionable, count as skipped_weak but still store? Plan says weak entries stored-but-NOT-injected
256
+ // So we still save but count as weak
257
+ const isWeak = q.strength === "weak" && !q.isActionable;
258
+ if (isWeak)
259
+ skipped_weak++;
260
+ // save
261
+ try {
262
+ opts.memoryService.save({
263
+ type: c.type,
264
+ content,
265
+ scope: "project",
266
+ origin: "imported",
267
+ fingerprint: fp,
268
+ metadata: { source: opts.source },
269
+ // evidence_count 0 => confidence low naturally, stored but not injected
270
+ });
271
+ saved++;
272
+ }
273
+ catch {
274
+ // skip on error
275
+ }
276
+ }
277
+ return {
278
+ files_scanned: parsed.files_scanned,
279
+ candidates: parsed.candidates.length,
280
+ saved,
281
+ duplicates,
282
+ skipped_weak,
283
+ truncated: truncated || undefined,
284
+ skipped_files: parsed.skipped_files || undefined,
285
+ };
286
+ }
package/dist/index.d.ts CHANGED
@@ -66,7 +66,11 @@ export * from "./kevin_propose.js";
66
66
  export { kevinPublish } from "./kevin_publish.js";
67
67
  export type { PublishResult } from "./kevin_publish.js";
68
68
  export * from "./kevin_why.js";
69
+ export * from "./skills-validate.js";
70
+ export * from "./skills-emit.js";
71
+ export * from "./mif.js";
72
+ export * from "./import-host.js";
69
73
  export * from "./env.js";
70
- export declare const KEVIN_CONFIG_KEYS: readonly ["quality_gate_enabled", "lesson_snippet_injection", "patternminer_enabled", "cross_project_enabled", "llm_reflection_enabled", "tool_calls_dedup_enabled", "deterministic_retrieval", "pre_prompt_budget_tokens", "archive_after_days", "curation_enabled", "agents_md_path", "skill_emission_enabled", "reference_emission_enabled", "injection_confidence_floor", "repo_truth_enabled", "convention_mining_enabled", "conflict_detection_enabled", "error_lesson_mode", "shared_layer_enabled", "okf_path", "share_requires_approval", "author_identity_mode", "shared_confidence_floor", "hook_liveness_enabled", "native_registration_enabled", "host_probe_history_enabled", "dead_hook_report_threshold", "perf_enabled", "perf_ring_capacity", "perf_flush_on_idle", "contract_report_enabled", "tui_snapshots_enabled", "mcp_write_enabled", "mcp_approve_enabled", "mcp_repo_override"];
74
+ export declare const KEVIN_CONFIG_KEYS: readonly ["quality_gate_enabled", "lesson_snippet_injection", "patternminer_enabled", "cross_project_enabled", "llm_reflection_enabled", "tool_calls_dedup_enabled", "deterministic_retrieval", "pre_prompt_budget_tokens", "archive_after_days", "curation_enabled", "agents_md_path", "skill_emission_enabled", "reference_emission_enabled", "injection_confidence_floor", "repo_truth_enabled", "convention_mining_enabled", "conflict_detection_enabled", "error_lesson_mode", "shared_layer_enabled", "okf_path", "share_requires_approval", "author_identity_mode", "shared_confidence_floor", "hook_liveness_enabled", "native_registration_enabled", "host_probe_history_enabled", "dead_hook_report_threshold", "perf_enabled", "perf_ring_capacity", "perf_flush_on_idle", "contract_report_enabled", "tui_snapshots_enabled", "mcp_write_enabled", "mcp_approve_enabled", "mcp_repo_override", "skills_canonical_dir", "skills_mirror_claude", "skills_mirror_cursor", "import_host_memory"];
71
75
  export declare const ERROR_LESSON_MODE_VALUES: readonly ["all", "triage_only"];
72
- export declare const KEVIN_VERSION = "1.4.0";
76
+ export declare const KEVIN_VERSION = "1.5.0";
package/dist/index.js CHANGED
@@ -68,6 +68,10 @@ export { handleNative } from "./kevin_native.js";
68
68
  export * from "./kevin_propose.js";
69
69
  export { kevinPublish } from "./kevin_publish.js";
70
70
  export * from "./kevin_why.js";
71
+ export * from "./skills-validate.js";
72
+ export * from "./skills-emit.js";
73
+ export * from "./mif.js";
74
+ export * from "./import-host.js";
71
75
  // KEVIN_CONFIG_KEYS and related constants — moved from adapter index so core owns the source of truth (C-04).
72
76
  // Duplicated here to allow adapter to import from core; adapter will re-export them.
73
77
  export * from "./env.js";
@@ -107,6 +111,10 @@ export const KEVIN_CONFIG_KEYS = [
107
111
  "mcp_write_enabled",
108
112
  "mcp_approve_enabled",
109
113
  "mcp_repo_override",
114
+ "skills_canonical_dir",
115
+ "skills_mirror_claude",
116
+ "skills_mirror_cursor",
117
+ "import_host_memory",
110
118
  ];
111
119
  export const ERROR_LESSON_MODE_VALUES = ["all", "triage_only"];
112
- export const KEVIN_VERSION = "1.4.0";
120
+ export const KEVIN_VERSION = "1.5.0";
@@ -185,6 +185,22 @@ export interface AuditReport {
185
185
  mcp: number;
186
186
  };
187
187
  };
188
+ channels_v2?: {
189
+ push: {
190
+ injections_total: number;
191
+ precision_rate: number;
192
+ coverage_rate: number;
193
+ };
194
+ mcp: {
195
+ injections_total: number;
196
+ precision_rate?: number;
197
+ coverage_rate?: number;
198
+ };
199
+ pull: {
200
+ registered_surfaces: number;
201
+ note: string;
202
+ };
203
+ };
188
204
  partial: boolean;
189
205
  }
190
206
  /**
@@ -665,6 +665,42 @@ export function buildAudit(store, metrics, capabilities = ALL_FALSE_CAPABILITIES
665
665
  partial = true;
666
666
  mcp = undefined;
667
667
  }
668
+ // v1.5.0 (K15-015) — channels_v2 honest scope (D15-06)
669
+ let channels_v2;
670
+ try {
671
+ const push_total = scalar(store, "SELECT COUNT(*) AS n FROM kevin_injections WHERE channel = 'plugin' OR channel IS NULL");
672
+ const mcp_total = (() => { try {
673
+ return scalar(store, "SELECT COUNT(*) AS n FROM kevin_injections WHERE channel = 'mcp'");
674
+ }
675
+ catch {
676
+ return 0;
677
+ } })();
678
+ const hasChannel = (() => { try {
679
+ store.prepare("SELECT channel FROM kevin_injections LIMIT 1").get();
680
+ return true;
681
+ }
682
+ catch {
683
+ return false;
684
+ } })();
685
+ const registered_surfaces = (() => {
686
+ try {
687
+ const r1 = store.prepare("SELECT value FROM kevin_metrics WHERE key = 'skills_registered'").get();
688
+ const r2 = store.prepare("SELECT value FROM kevin_metrics WHERE key = 'references_registered'").get();
689
+ return (r1?.value ?? 0) + (r2?.value ?? 0);
690
+ }
691
+ catch {
692
+ return 0;
693
+ }
694
+ })();
695
+ channels_v2 = {
696
+ push: { injections_total: push_total, precision_rate: metrics.precisionRate(), coverage_rate: metrics.coverageRate() },
697
+ mcp: { injections_total: hasChannel ? mcp_total : 0 },
698
+ pull: { registered_surfaces, note: "pull-effectiveness telemetry unavailable pre-contract-v2 — qualitative" },
699
+ };
700
+ }
701
+ catch {
702
+ channels_v2 = undefined;
703
+ }
668
704
  return {
669
705
  memories,
670
706
  injections,
@@ -683,6 +719,7 @@ export function buildAudit(store, metrics, capabilities = ALL_FALSE_CAPABILITIES
683
719
  contract,
684
720
  tui,
685
721
  mcp,
722
+ channels_v2,
686
723
  partial,
687
724
  };
688
725
  }
package/dist/metrics.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { Store } from "./Store.js";
6
6
  * underlying table is empty (e.g., before 003 is applied, on a fresh
7
7
  * :memory: test DB, or after a manual wipe).
8
8
  */
9
- export declare const METRIC_KEYS: readonly ["tokens_injected_pre_prompt", "tokens_injected_compacting", "reflections_throttled", "duplicate_suppressions", "tool_calls_deduped", "patterns_mined", "patterns_causal", "causal_links", "memories_superseded", "injections_total", "injections_effective", "injections_ineffective", "patterns_promoted_new", "injections_inconclusive", "injections_blocked_seen", "injections_blocked_weak", "injections_blocked_recurrence", "injections_blocked_stale", "injections_blocked_ignored", "feedback_positive_total", "feedback_negative_total", "memories_archived", "proposals_created", "proposals_approved", "proposals_rejected", "artifact_writes_total", "artifact_writes_noop", "injections_blocked_confidence", "repo_facts_scanned", "memories_contradicted", "conventions_mined", "conflicts_detected", "error_lessons_suppressed", "shared_entries_total", "shared_entries_imported", "shared_entries_exported", "okf_merge_folds", "rekey_events", "injections_from_shared", "bench_regression_failures", "forget_requests_total", "forget_tombstones_published", "tui_snapshots_flushed", "tui_actions_invoked", "mcp_requests_total", "mcp_reads_served", "mcp_writes_accepted", "mcp_writes_refused", "mcp_errors_total"];
9
+ export declare const METRIC_KEYS: readonly ["tokens_injected_pre_prompt", "tokens_injected_compacting", "reflections_throttled", "duplicate_suppressions", "tool_calls_deduped", "patterns_mined", "patterns_causal", "causal_links", "memories_superseded", "injections_total", "injections_effective", "injections_ineffective", "patterns_promoted_new", "injections_inconclusive", "injections_blocked_seen", "injections_blocked_weak", "injections_blocked_recurrence", "injections_blocked_stale", "injections_blocked_ignored", "feedback_positive_total", "feedback_negative_total", "memories_archived", "proposals_created", "proposals_approved", "proposals_rejected", "artifact_writes_total", "artifact_writes_noop", "injections_blocked_confidence", "repo_facts_scanned", "memories_contradicted", "conventions_mined", "conflicts_detected", "error_lessons_suppressed", "shared_entries_total", "shared_entries_imported", "shared_entries_exported", "okf_merge_folds", "rekey_events", "injections_from_shared", "bench_regression_failures", "forget_requests_total", "forget_tombstones_published", "tui_snapshots_flushed", "tui_actions_invoked", "mcp_requests_total", "mcp_reads_served", "mcp_writes_accepted", "mcp_writes_refused", "mcp_errors_total", "skills_emitted_total", "mif_exports_total", "mif_imports_total"];
10
10
  export type MetricKey = (typeof METRIC_KEYS)[number];
11
11
  /**
12
12
  * Cheap token estimate used when bumping the `tokens_injected_*` counters.
package/dist/metrics.js CHANGED
@@ -71,6 +71,10 @@ export const METRIC_KEYS = [
71
71
  "mcp_writes_accepted",
72
72
  "mcp_writes_refused",
73
73
  "mcp_errors_total",
74
+ // v1.5.0 (K15-001 / plan §4) — Diaspora metrics; lazy-incr, no migration.
75
+ "skills_emitted_total",
76
+ "mif_exports_total",
77
+ "mif_imports_total",
74
78
  ];
75
79
  const DEFAULT_FLUSH_MS = 1000;
76
80
  function zeroCache() {
package/dist/mif.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { Memory } from "./MemoryService.js";
2
+ export interface MifEnvelope {
3
+ format: "mif";
4
+ version: 1;
5
+ memories: MifMemory[];
6
+ vendorExtensions?: Record<string, unknown>;
7
+ }
8
+ export interface MifMemory {
9
+ id: string;
10
+ content: string;
11
+ type: string;
12
+ timestamp: string;
13
+ source: string;
14
+ metadata: Record<string, string>;
15
+ [k: string]: unknown;
16
+ }
17
+ export declare function toMif(rows: Memory[], opts: {
18
+ redactPii: boolean;
19
+ }): MifEnvelope;
20
+ export interface ImportCandidate {
21
+ id: string;
22
+ content: string;
23
+ type: string;
24
+ timestamp: string;
25
+ source: string;
26
+ metadata: Record<string, string>;
27
+ unknownFields: Record<string, unknown>;
28
+ }
29
+ export declare function fromMif(env: MifEnvelope): {
30
+ candidates: ImportCandidate[];
31
+ unknownFieldsPreserved: string[];
32
+ };
package/dist/mif.js ADDED
@@ -0,0 +1,112 @@
1
+ // K15-008 — MIF codec (plan §4.4)
2
+ // Envelope {id, content, type, timestamp, source, metadata} + vendor extensions preserved + PII redaction + content-hash dedup (import side)
3
+ import { fingerprint as computeFingerprint } from "./fingerprint.js";
4
+ const SECRET_PATTERNS = [
5
+ /\b(API_KEY|SECRET|PASSWORD|TOKEN)\b\s*[=:]\s*\S+/gi,
6
+ /\bBearer\s+\S+/gi,
7
+ /\b(access_?token|auth_?token|api_?token)\b\s*[=:]\s*\S+/gi,
8
+ /\btoken\s*[=:]\s*\S+/gi,
9
+ /\baws_secret_access_key\b\s*[=:]\s*\S+/gi,
10
+ /\bghp_[A-Za-z0-9_]+/g,
11
+ /\bsk-[A-Za-z0-9_\-]+/g,
12
+ /\bgithub_pat_[A-Za-z0-9_]+/g,
13
+ ];
14
+ function redactSecrets(text) {
15
+ let out = text;
16
+ for (const pat of SECRET_PATTERNS) {
17
+ out = out.replace(pat, (m) => {
18
+ const eq = m.indexOf("=");
19
+ const colon = m.indexOf(":");
20
+ const sep = eq !== -1 ? "=" : colon !== -1 ? ":" : " ";
21
+ const prefix = m.slice(0, m.indexOf(sep) + 1);
22
+ return `${prefix}<redacted>`;
23
+ });
24
+ }
25
+ // fallback: if pattern didn't match sep, replace whole token
26
+ return out;
27
+ }
28
+ function toIso(ts) {
29
+ try {
30
+ const iso = ts.includes("T") ? ts : `${ts.replace(" ", "T")}Z`;
31
+ return new Date(iso).toISOString();
32
+ }
33
+ catch {
34
+ return new Date().toISOString();
35
+ }
36
+ }
37
+ export function toMif(rows, opts) {
38
+ const memories = rows.map((r) => {
39
+ const originalContent = r.content;
40
+ let content = originalContent;
41
+ if (opts.redactPii) {
42
+ content = redactSecrets(content);
43
+ }
44
+ const meta = {
45
+ scope: String(r.scope ?? "project"),
46
+ fingerprint: String(r.fingerprint ?? computeFingerprint(originalContent)),
47
+ confidence: String(r.confidence ?? ""),
48
+ evidence_count: String(r.evidenceCount ?? 0),
49
+ };
50
+ const base = {
51
+ id: r.id,
52
+ content,
53
+ type: r.type,
54
+ timestamp: toIso(r.createdAt ?? new Date().toISOString()),
55
+ source: "opencode-kevin",
56
+ metadata: meta,
57
+ };
58
+ // preserve unknown fields from original row that are not part of standard mapping
59
+ // standard keys: id, content, type, createdAt, scope, fingerprint, confidence, evidenceCount, etc.
60
+ // unknown vendor extensions stored under `mif_vendor` in metadata if present
61
+ const mifVendor = r.mif_vendor;
62
+ if (mifVendor && typeof mifVendor === "object") {
63
+ for (const [k, v] of Object.entries(mifVendor)) {
64
+ if (!(k in base))
65
+ base[k] = v;
66
+ }
67
+ }
68
+ // also check if row has extra top-level keys beyond Memory standard (for codec-level preservation)
69
+ const extraKeys = Object.keys(r).filter((k) => !["id", "content", "type", "scope", "createdAt", "updatedAt", "fingerprint", "confidence", "evidenceCount", "recurrenceCount", "projectId", "repoId", "layer", "status", "metadata", "origin", "sourceTool", "sourceSession", "relevanceScore", "truthPenalty"].includes(k));
70
+ for (const k of extraKeys) {
71
+ if (k === "mif_vendor")
72
+ continue;
73
+ if (!(k in base))
74
+ base[k] = r[k];
75
+ }
76
+ return base;
77
+ });
78
+ return { format: "mif", version: 1, memories };
79
+ }
80
+ export function fromMif(env) {
81
+ if (!env || env.format !== "mif" || env.version !== 1 || !Array.isArray(env.memories)) {
82
+ throw new Error("invalid MIF envelope: expected {format:'mif', version:1, memories:[]}");
83
+ }
84
+ const candidates = [];
85
+ const preserved = new Set();
86
+ for (const m of env.memories) {
87
+ const known = new Set(["id", "content", "type", "timestamp", "source", "metadata", "format", "version"]);
88
+ const unknown = {};
89
+ for (const k of Object.keys(m)) {
90
+ if (!known.has(k)) {
91
+ unknown[k] = m[k];
92
+ preserved.add(k);
93
+ }
94
+ }
95
+ // also collect vendorExtensions top-level unknown?
96
+ candidates.push({
97
+ id: String(m.id),
98
+ content: String(m.content),
99
+ type: String(m.type),
100
+ timestamp: String(m.timestamp),
101
+ source: String(m.source ?? "opencode-kevin"),
102
+ metadata: { ...(m.metadata ?? {}) },
103
+ unknownFields: unknown,
104
+ });
105
+ }
106
+ // top-level vendorExtensions unknown
107
+ if (env.vendorExtensions) {
108
+ for (const k of Object.keys(env.vendorExtensions))
109
+ preserved.add(k);
110
+ }
111
+ return { candidates, unknownFieldsPreserved: [...preserved] };
112
+ }
@@ -0,0 +1,38 @@
1
+ import { type KevinEnv } from "./env.js";
2
+ export interface TopicBundle {
3
+ topic: string;
4
+ content: string;
5
+ /** optional precomputed summary; derived from content if omitted */
6
+ summary?: string;
7
+ }
8
+ export interface SkillEmitInput {
9
+ projectRoot: string;
10
+ canonicalDir: string;
11
+ mirrors: Array<"claude" | "cursor">;
12
+ topics: TopicBundle[];
13
+ repoId: string;
14
+ /** injectable for tests; defaults to ~/.opencode-kevin/skills-manifest.json */
15
+ manifestPath?: string;
16
+ env?: KevinEnv;
17
+ metrics?: {
18
+ incr: (key: string, by?: number) => void;
19
+ };
20
+ }
21
+ export interface EmitReport {
22
+ written: string[];
23
+ skipped_external: string[];
24
+ noop: string[];
25
+ removed_orphan_manifest: string[];
26
+ external_edits: string[];
27
+ }
28
+ declare function sha256Hex(s: string): string;
29
+ declare function escaped(text: string): string;
30
+ declare function buildSkillMd(repoId: string, bundles: TopicBundle[]): string;
31
+ export declare function emitSkillBundle(input: SkillEmitInput): EmitReport;
32
+ export declare function refreshSkillBundle(input: SkillEmitInput): EmitReport;
33
+ export declare const _internal: {
34
+ buildSkillMd: typeof buildSkillMd;
35
+ sha256Hex: typeof sha256Hex;
36
+ escaped: typeof escaped;
37
+ };
38
+ export {};