@jmtrin/kevin-core 1.3.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.
Files changed (145) hide show
  1. package/dist/Archiver.d.ts +42 -0
  2. package/dist/Archiver.js +83 -0
  3. package/dist/ArtifactWriter.d.ts +93 -0
  4. package/dist/ArtifactWriter.js +291 -0
  5. package/dist/CausalChain.d.ts +22 -0
  6. package/dist/CausalChain.js +197 -0
  7. package/dist/ChatBridge.d.ts +41 -0
  8. package/dist/ChatBridge.js +103 -0
  9. package/dist/ConflictDetector.d.ts +35 -0
  10. package/dist/ConflictDetector.js +260 -0
  11. package/dist/ContextInjector.d.ts +222 -0
  12. package/dist/ContextInjector.js +533 -0
  13. package/dist/ConventionMiner.d.ts +35 -0
  14. package/dist/ConventionMiner.js +242 -0
  15. package/dist/Curator.d.ts +118 -0
  16. package/dist/Curator.js +326 -0
  17. package/dist/DashboardHtml.d.ts +5 -0
  18. package/dist/DashboardHtml.js +180 -0
  19. package/dist/Feedback.d.ts +67 -0
  20. package/dist/Feedback.js +120 -0
  21. package/dist/HookLiveness.d.ts +88 -0
  22. package/dist/HookLiveness.js +322 -0
  23. package/dist/InjectionLedger.d.ts +98 -0
  24. package/dist/InjectionLedger.js +295 -0
  25. package/dist/LessonFixer.d.ts +44 -0
  26. package/dist/LessonFixer.js +45 -0
  27. package/dist/Materializer.d.ts +84 -0
  28. package/dist/Materializer.js +181 -0
  29. package/dist/MemoryService.d.ts +360 -0
  30. package/dist/MemoryService.js +1242 -0
  31. package/dist/Migrate.d.ts +24 -0
  32. package/dist/Migrate.js +285 -0
  33. package/dist/PatternMiner.d.ts +49 -0
  34. package/dist/PatternMiner.js +132 -0
  35. package/dist/QualityGate.d.ts +124 -0
  36. package/dist/QualityGate.js +119 -0
  37. package/dist/Reflector.d.ts +91 -0
  38. package/dist/Reflector.js +307 -0
  39. package/dist/RepoIdentity.d.ts +124 -0
  40. package/dist/RepoIdentity.js +300 -0
  41. package/dist/RepoTruth.d.ts +80 -0
  42. package/dist/RepoTruth.js +599 -0
  43. package/dist/Retrospective.d.ts +18 -0
  44. package/dist/Retrospective.js +216 -0
  45. package/dist/SharedLayer.d.ts +159 -0
  46. package/dist/SharedLayer.js +462 -0
  47. package/dist/Store.d.ts +29 -0
  48. package/dist/Store.js +50 -0
  49. package/dist/ToolCallObserver.d.ts +32 -0
  50. package/dist/ToolCallObserver.js +201 -0
  51. package/dist/TuiActions.d.ts +43 -0
  52. package/dist/TuiActions.js +181 -0
  53. package/dist/TuiSnapshots.d.ts +24 -0
  54. package/dist/TuiSnapshots.js +159 -0
  55. package/dist/capabilities.d.ts +6 -0
  56. package/dist/capabilities.js +1 -0
  57. package/dist/columns.d.ts +11 -0
  58. package/dist/columns.js +54 -0
  59. package/dist/confidence.d.ts +8 -0
  60. package/dist/confidence.js +34 -0
  61. package/dist/contract.d.ts +68 -0
  62. package/dist/contract.js +430 -0
  63. package/dist/diff.d.ts +8 -0
  64. package/dist/diff.js +182 -0
  65. package/dist/env.d.ts +5 -0
  66. package/dist/env.js +12 -0
  67. package/dist/escape.d.ts +39 -0
  68. package/dist/escape.js +60 -0
  69. package/dist/fingerprint.d.ts +27 -0
  70. package/dist/fingerprint.js +73 -0
  71. package/dist/host.d.ts +18 -0
  72. package/dist/host.js +7 -0
  73. package/dist/idle-pipeline.d.ts +9 -0
  74. package/dist/idle-pipeline.js +44 -0
  75. package/dist/index.d.ts +72 -0
  76. package/dist/index.js +109 -0
  77. package/dist/inferability.d.ts +32 -0
  78. package/dist/inferability.js +88 -0
  79. package/dist/kevin_approve.d.ts +34 -0
  80. package/dist/kevin_approve.js +52 -0
  81. package/dist/kevin_audit.d.ts +230 -0
  82. package/dist/kevin_audit.js +631 -0
  83. package/dist/kevin_bench.d.ts +11 -0
  84. package/dist/kevin_bench.js +83 -0
  85. package/dist/kevin_conflicts.d.ts +9 -0
  86. package/dist/kevin_conflicts.js +50 -0
  87. package/dist/kevin_contract.d.ts +8 -0
  88. package/dist/kevin_contract.js +59 -0
  89. package/dist/kevin_doctor.d.ts +63 -0
  90. package/dist/kevin_doctor.js +197 -0
  91. package/dist/kevin_facts.d.ts +42 -0
  92. package/dist/kevin_facts.js +36 -0
  93. package/dist/kevin_forget.d.ts +33 -0
  94. package/dist/kevin_forget.js +260 -0
  95. package/dist/kevin_native.d.ts +29 -0
  96. package/dist/kevin_native.js +79 -0
  97. package/dist/kevin_propose.d.ts +23 -0
  98. package/dist/kevin_propose.js +14 -0
  99. package/dist/kevin_publish.d.ts +38 -0
  100. package/dist/kevin_publish.js +18 -0
  101. package/dist/kevin_why.d.ts +23 -0
  102. package/dist/kevin_why.js +111 -0
  103. package/dist/memory-format.d.ts +20 -0
  104. package/dist/memory-format.js +59 -0
  105. package/dist/metrics.d.ts +97 -0
  106. package/dist/metrics.js +288 -0
  107. package/dist/migrations/001_initial.sql +92 -0
  108. package/dist/migrations/002_indexes.sql +14 -0
  109. package/dist/migrations/003_v02_signal.sql +58 -0
  110. package/dist/migrations/004_v03_knowledge.sql +138 -0
  111. package/dist/migrations/005_v04_signal.sql +57 -0
  112. package/dist/migrations/006_v05_glassbox.sql +118 -0
  113. package/dist/migrations/007_v06_pull.sql +145 -0
  114. package/dist/migrations/008_v07_truth.sql +124 -0
  115. package/dist/migrations/009_v08_team.sql +100 -0
  116. package/dist/migrations/010_v09_native.sql +78 -0
  117. package/dist/migrations/011_v10_proven.sql +69 -0
  118. package/dist/migrations/012_v11_drift.sql +24 -0
  119. package/dist/native.d.ts +23 -0
  120. package/dist/native.js +1 -0
  121. package/dist/okf-export.d.ts +3 -0
  122. package/dist/okf-export.js +134 -0
  123. package/dist/okf-import.d.ts +76 -0
  124. package/dist/okf-import.js +271 -0
  125. package/dist/okf.d.ts +107 -0
  126. package/dist/okf.js +303 -0
  127. package/dist/perf.d.ts +35 -0
  128. package/dist/perf.js +174 -0
  129. package/dist/query-tokenizer.d.ts +13 -0
  130. package/dist/query-tokenizer.js +133 -0
  131. package/dist/redact.d.ts +2 -0
  132. package/dist/redact.js +18 -0
  133. package/dist/replay-types.d.ts +69 -0
  134. package/dist/replay-types.js +156 -0
  135. package/dist/replay.d.ts +44 -0
  136. package/dist/replay.js +215 -0
  137. package/dist/sqlite-adapter.d.ts +12 -0
  138. package/dist/sqlite-adapter.js +78 -0
  139. package/dist/time-ms.d.ts +1 -0
  140. package/dist/time-ms.js +16 -0
  141. package/dist/tui-types.d.ts +59 -0
  142. package/dist/tui-types.js +4 -0
  143. package/dist/uuid.d.ts +1 -0
  144. package/dist/uuid.js +50 -0
  145. package/package.json +28 -0
@@ -0,0 +1,242 @@
1
+ import { fingerprint } from "./fingerprint.js";
2
+ const DEFAULT_MIN_SUPPORT = 5;
3
+ export class ConventionMiner {
4
+ store;
5
+ memoryService;
6
+ projectId;
7
+ metrics;
8
+ constructor(store, memoryService, projectId, metrics) {
9
+ this.store = store;
10
+ this.memoryService = memoryService;
11
+ this.projectId = projectId;
12
+ this.metrics = metrics ?? null;
13
+ // `memoryService` is retained for emission (K7-012); mining itself is
14
+ // store-only.
15
+ void this.memoryService;
16
+ }
17
+ /** v0.7.0 (K7-010) — the `sequence` miner over successful tool_calls. */
18
+ mineSequence(minSupport = DEFAULT_MIN_SUPPORT) {
19
+ const rows = this.fetchToolCalls();
20
+ if (rows.length === 0)
21
+ return [];
22
+ // Group successful calls by session in execution order (ts ASC, then id
23
+ // as a deterministic tie-break).
24
+ const bySession = new Map();
25
+ const sorted = [...rows]
26
+ .filter((r) => r.success === 1)
27
+ .sort((a, b) => a.session_id === b.session_id
28
+ ? compareTs(a.ts, b.ts) || a.id.localeCompare(b.id)
29
+ : a.session_id.localeCompare(b.session_id));
30
+ for (const r of sorted) {
31
+ const list = bySession.get(r.session_id) ?? [];
32
+ list.push(r);
33
+ bySession.set(r.session_id, list);
34
+ }
35
+ const map = new Map();
36
+ const record = (key, statement, sessionId) => {
37
+ let cand = map.get(key);
38
+ if (!cand) {
39
+ cand = { key, statement, sessions: new Set() };
40
+ map.set(key, cand);
41
+ }
42
+ cand.sessions.add(sessionId);
43
+ };
44
+ for (const [sessionId, list] of bySession) {
45
+ if (list.length < 2)
46
+ continue;
47
+ for (let i = 0; i < list.length - 1; i++) {
48
+ const a = list[i];
49
+ const b = list[i + 1];
50
+ const aTok = tokenOf(a);
51
+ const bTok = tokenOf(b);
52
+ const key2 = `seq::${aTok}::${bTok}`;
53
+ const stmt2 = statementForSequence(aTok, bTok);
54
+ record(key2, stmt2, sessionId);
55
+ if (i + 2 < list.length) {
56
+ const c = list[i + 2];
57
+ const cTok = tokenOf(c);
58
+ const key3 = `seq::${aTok}::${bTok}::${cTok}`;
59
+ const stmt3 = statementForSequence3(aTok, bTok, cTok);
60
+ record(key3, stmt3, sessionId);
61
+ }
62
+ }
63
+ }
64
+ return this.materialize(map, minSupport, "sequence");
65
+ }
66
+ /** v0.7.0 (K7-011) — the `co_edit` miner over same-session file writes. */
67
+ mineCoEdit(minSupport = DEFAULT_MIN_SUPPORT) {
68
+ const rows = this.fetchToolCalls();
69
+ if (rows.length === 0)
70
+ return [];
71
+ // Successful file writes (broad: any success call mentioning a path) per
72
+ // session. Pairs span two different directory prefixes.
73
+ const bySession = new Map();
74
+ const sorted = [...rows]
75
+ .filter((r) => r.success === 1 && pathOf(r) !== null)
76
+ .sort((a, b) => a.session_id === b.session_id
77
+ ? compareTs(a.ts, b.ts) || a.id.localeCompare(b.id)
78
+ : a.session_id.localeCompare(b.session_id));
79
+ for (const r of sorted) {
80
+ const list = bySession.get(r.session_id) ?? [];
81
+ list.push(pathOf(r));
82
+ bySession.set(r.session_id, list);
83
+ }
84
+ const map = new Map();
85
+ const record = (key, statement, sessionId) => {
86
+ let cand = map.get(key);
87
+ if (!cand) {
88
+ cand = { key, statement, sessions: new Set() };
89
+ map.set(key, cand);
90
+ }
91
+ cand.sessions.add(sessionId);
92
+ };
93
+ for (const [sessionId, paths] of bySession) {
94
+ const unique = [...new Set(paths)];
95
+ if (unique.length < 2)
96
+ continue;
97
+ // Bounded enumeration: a large session must not explode into an
98
+ // O(n²) pair set. Cap distinct prefixes per session at 40.
99
+ const limit = Math.min(unique.length, 40);
100
+ for (let i = 0; i < limit; i++) {
101
+ const pA = unique[i];
102
+ for (let j = i + 1; j < limit; j++) {
103
+ const pB = unique[j];
104
+ const prefixA = dirOf(pA);
105
+ const prefixB = dirOf(pB);
106
+ if (prefixA === prefixB)
107
+ continue;
108
+ const [lo, hi] = prefixA < prefixB ? [prefixA, prefixB] : [prefixB, prefixA];
109
+ const key = `coedit::${lo}::${hi}`;
110
+ const stmt = `every new file under ${lo}/ is accompanied by a change under ${hi}/`;
111
+ record(key, stmt, sessionId);
112
+ }
113
+ }
114
+ }
115
+ return this.materialize(map, minSupport, "co_edit");
116
+ }
117
+ /** v0.7.0 (K7-012) — mine both kinds in one call. */
118
+ mine(minSupport = DEFAULT_MIN_SUPPORT) {
119
+ return [
120
+ ...this.mineSequence(minSupport),
121
+ ...this.mineCoEdit(minSupport),
122
+ ].sort((a, b) => a.statement.localeCompare(b.statement));
123
+ }
124
+ /**
125
+ * v0.7.0 (K7-012 / plan §5.4, D7-11) — emit mined(conventions) as `rule`
126
+ * memories. Returns the number of memories created or refreshed and
127
+ * increments `conventions_mined`. Runs on session.idle only, behind
128
+ * convention_mining_enabled. The fingerprint derives from the statement,
129
+ * so a re-mine of an unchanged convention collides and supersedes.
130
+ */
131
+ emit(conventions) {
132
+ let emitted = 0;
133
+ for (const c of conventions) {
134
+ const derivedFingerprint = fingerprint(c.statement, this.projectId);
135
+ // save() derives the fingerprint-based supersede/collision path for
136
+ // `rule` types automatically, so re-emitting an unchanged convention
137
+ // refreshes rather than duplicates.
138
+ this.memoryService.save({
139
+ type: "rule",
140
+ origin: "pattern",
141
+ scope: "project",
142
+ projectId: this.projectId,
143
+ fingerprint: derivedFingerprint,
144
+ content: c.statement,
145
+ relevanceScore: 0.5,
146
+ sourceTool: "ConventionMiner",
147
+ });
148
+ this.metrics?.incr("conventions_mined", 1);
149
+ emitted += 1;
150
+ }
151
+ return emitted;
152
+ }
153
+ materialize(map, minSupport, kind) {
154
+ const out = [];
155
+ for (const cand of map.values()) {
156
+ if (cand.sessions.size < minSupport)
157
+ continue;
158
+ out.push({
159
+ fingerprint: fingerprint(cand.statement, this.projectId),
160
+ statement: cand.statement,
161
+ support: cand.sessions.size,
162
+ kind,
163
+ });
164
+ }
165
+ // Deterministic order: lexicographic by statement (ties are resolved).
166
+ return out.sort((a, b) => a.statement.localeCompare(b.statement));
167
+ }
168
+ fetchToolCalls() {
169
+ const rows = this.store
170
+ .prepare(`SELECT id, session_id, ts, tool, success, args_summary
171
+ FROM tool_calls
172
+ WHERE project_id = ?
173
+ ORDER BY session_id, ts`)
174
+ .all(this.projectId);
175
+ return rows;
176
+ }
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Helpers — deterministic token/path extraction. Every value is normalized so
180
+ // the same underlying fact never collides via a JavaScript type difference.
181
+ // ---------------------------------------------------------------------------
182
+ /** `<tool>:<first path segment>` token for sequence mining. */
183
+ function tokenOf(r) {
184
+ const seg = firstPathSegment(r.args_summary);
185
+ return `${r.tool}:${seg}`;
186
+ }
187
+ function compareTs(a, b) {
188
+ return a.localeCompare(b);
189
+ }
190
+ /** First path segment of the first path-like argument, else "*". */
191
+ function firstPathSegment(argsSummary) {
192
+ const paths = pathSegments(argsSummary);
193
+ const first = paths[0];
194
+ if (!first)
195
+ return "*";
196
+ return first.split("/").filter(Boolean)[0] ?? "*";
197
+ }
198
+ /** All path segments found in args_summary, in appearance order. */
199
+ function pathSegments(argsSummary) {
200
+ const text = argsSummary ?? "";
201
+ const found = [];
202
+ // Try JSON object string values first.
203
+ try {
204
+ const parsed = JSON.parse(text);
205
+ if (typeof parsed === "object" &&
206
+ parsed !== null &&
207
+ !Array.isArray(parsed)) {
208
+ for (const v of Object.values(parsed)) {
209
+ if (typeof v === "string" && /\//.test(v) && !v.includes(" ")) {
210
+ found.push(v.replace(/\\/g, "/").toLowerCase().trim());
211
+ }
212
+ }
213
+ }
214
+ }
215
+ catch {
216
+ /* not JSON */
217
+ }
218
+ if (found.length === 0) {
219
+ const m = text.match(/[^\s"':,=]*\/[^\s"':,=]*/g);
220
+ for (const tok of m ?? []) {
221
+ found.push(tok.replace(/\\/g, "/").toLowerCase().trim());
222
+ }
223
+ }
224
+ return found;
225
+ }
226
+ /** Directory of a path (drop the filename). `src/routes/user.ts` → `src/routes`. */
227
+ function dirOf(p) {
228
+ const segs = p.split("/").filter(Boolean);
229
+ segs.pop();
230
+ return segs.join("/") || "*";
231
+ }
232
+ /** A deterministic path for a call if it is a file write/read on disk. */
233
+ function pathOf(r) {
234
+ const segs = pathSegments(r.args_summary);
235
+ return segs[0] ?? null;
236
+ }
237
+ function statementForSequence(a, b) {
238
+ return `every ${a} is immediately followed by ${b}`;
239
+ }
240
+ function statementForSequence3(a, b, c) {
241
+ return `every ${a} is followed by ${b} and then ${c}`;
242
+ }
@@ -0,0 +1,118 @@
1
+ import { type KevinEnv } from "./env.js";
2
+ import { type ArtifactWriter } from "./ArtifactWriter.js";
3
+ import type { MemoryService } from "./MemoryService.js";
4
+ import type { Store } from "./Store.js";
5
+ import type { Metrics } from "./metrics.js";
6
+ /**
7
+ * K6-012/013 — v0.6.0 pull — candidate selection, line rendering and the
8
+ * proposal lifecycle (plan §5.4/§5.5).
9
+ *
10
+ * Deliberately fs-free: this module holds NO filesystem capability — reads
11
+ * and writes are delegated to the `ArtifactWriter` at the call site (D6-01).
12
+ */
13
+ export interface CurationCandidate {
14
+ readonly memoryId: string;
15
+ /** The single AGENTS.md bullet. */
16
+ readonly line: string;
17
+ readonly confidence: number;
18
+ /** e.g. "verified 3×, last 2026-08-04". */
19
+ readonly evidence: string;
20
+ }
21
+ export type ProposalKind = "agents_md" | "skill" | "reference";
22
+ export type ProposalStatus = "pending" | "approved" | "rejected" | "applied" | "superseded";
23
+ export type ProposalTransition = "approve" | "reject" | "apply" | "supersede";
24
+ /** Plan §5.4 — the row shape of §6's `curation_proposals` table, surfaced. */
25
+ export interface CurationProposal {
26
+ readonly id: string;
27
+ readonly kind: ProposalKind;
28
+ readonly targetPath: string;
29
+ readonly memoryIds: readonly string[];
30
+ readonly proposedText: string;
31
+ readonly diff: string;
32
+ readonly status: ProposalStatus;
33
+ readonly createdAt: string;
34
+ }
35
+ /** v0.8.0 (K8-023 / plan §5.7) — the candidate source. */
36
+ export type CandidateSource = "memories" | "shared";
37
+ export declare function firstSentence(content: string): string;
38
+ export declare class Curator {
39
+ private readonly store;
40
+ private readonly memoryService;
41
+ private readonly projectId;
42
+ private readonly repoId?;
43
+ private readonly env?;
44
+ private readonly metrics;
45
+ constructor(store: Store, memoryService: MemoryService, projectId: string, metrics?: Metrics | null, repoId?: string | null | undefined, env?: KevinEnv | undefined);
46
+ /**
47
+ * v0.8.0 (K8-023 / plan §5.7) — the source the flag selects. When
48
+ * `shared_layer_enabled` is on, curation reads the committed OKF file's
49
+ * projection (`shared_entries`) instead of the local `memories` table.
50
+ * The routing lives here, next to the predicate it switches, so a
51
+ * reviewer sees substrate and rendering change together (D8-11).
52
+ */
53
+ private sourceFromFlag;
54
+ /** v0.8.0 (K8-023) — the shared-layer candidate rows. */
55
+ private sharedRows;
56
+ /**
57
+ * Plan §5.4 selection predicate — all clauses must hold, evaluated
58
+ * verbatim. The confidence floor cannot be expressed in SQL (it is the
59
+ * two-sided formula of `computeConfidence`), so the cheap clauses run
60
+ * in SQL and confidence is computed and filtered per row in JS.
61
+ *
62
+ * The floor is `confidence >= 0.6` where the feedback arm of D6-09 also
63
+ * clears it: `computeConfidence` credits one positive human verdict at
64
+ * 0.05 (K5-010), so a row with `feedback_positive = 1` and no causal
65
+ * evidence scores 0.55 — yet D6-09's warrant is "either the world
66
+ * verified it twice, or a human verified it once". The disjunction
67
+ * (K6-012 acceptance) is the floor's complement for the feedback arm.
68
+ *
69
+ * Ordered by `confidence DESC, updated_at DESC`, capped at 20 lines and
70
+ * 4000 characters of content, whichever binds first. The char budget
71
+ * counts the raw content length: rendered lines are truncated to 160
72
+ * chars, so a rendered-line budget could never bind before the line cap.
73
+ *
74
+ * v0.8.0 (K8-023 / plan §5.7) — `source` selects the substrate: the
75
+ * v0.6.0 path over `memories` ("local") or the shared layer. The
76
+ * predicate, the caps and the deterministic sort are unchanged in both
77
+ * cases — only the FROM changes. The shared schema has no feedback
78
+ * columns, so the feedback disjunct of the predicate can never fire
79
+ * there; `evidence >= 2` is its SQL half, evaluated in the query.
80
+ */
81
+ candidates(limit?: number, source?: CandidateSource): CurationCandidate[];
82
+ /**
83
+ * Renders the block sorted by memory id (D6-10): confidence orders
84
+ * selection, id orders output. Adding one candidate to a set of ten
85
+ * must change exactly one line of the rendered block.
86
+ */
87
+ renderBlock(candidates: CurationCandidate[]): string;
88
+ /**
89
+ * Plan §5.4 — strict dry run (D5-08): plan only, never write. Returns a
90
+ * pending proposal per call, or [] when no candidate clears the floor.
91
+ *
92
+ * The proposal is a whole-block proposal: the persisted `proposed_text`
93
+ * is the merged block (current block in the file + new candidate lines),
94
+ * so approving it replaces the file block with exactly what was reviewed.
95
+ * The current block is learned from `plan().before` — the Curator has no
96
+ * fs capability, the writer reads (D6-01). Both plan calls are
97
+ * deterministic, so a second propose() with unchanged inputs reproduces
98
+ * the persisted diff byte-identically.
99
+ *
100
+ * A new proposal supersedes prior pending (or rejected) rows for the
101
+ * same (project_id, kind, target_path) triple — §5.5's regeneration
102
+ * arrow. Rows are never deleted (the audit trail is append-only).
103
+ *
104
+ * The schema's `memory_id` column is singular while a whole-block
105
+ * proposal carries several ids; the contributing ids are stored joined
106
+ * by "," (uuidv7 ids contain no comma) and split on read.
107
+ */
108
+ propose(kind: ProposalKind, writer: ArtifactWriter): CurationProposal[];
109
+ /**
110
+ * Plan §5.5 state machine, as explicit transitions with an exhaustive
111
+ * switch — an unknown transition or an illegal source state throws
112
+ * rather than silently no-oping. `decided_at` stamps approve/reject/
113
+ * supersede, `applied_at` stamps apply.
114
+ */
115
+ transition(proposalId: string, transition: ProposalTransition): ProposalStatus;
116
+ /** Plan §5.6 target paths. agents_md is a setting; skill/reference live under ~/.opencode-kevin. */
117
+ private targetPathFor;
118
+ }
@@ -0,0 +1,326 @@
1
+ import { join } from "node:path";
2
+ import { resolveEnv } from "./env.js";
3
+ import { MARKER_BEGIN, MARKER_END, } from "./ArtifactWriter.js";
4
+ import { computeConfidence } from "./confidence.js";
5
+ import { normalize } from "./fingerprint.js";
6
+ import { uuidv7 } from "./uuid.js";
7
+ const MAX_CANDIDATE_LINES = 20;
8
+ const MAX_CANDIDATE_CHARS = 4000;
9
+ const CONFIDENCE_FLOOR = 0.6;
10
+ const MAX_SENTENCE_CHARS = 160;
11
+ export function firstSentence(content) {
12
+ const match = content.match(/^[\s\S]*?(?=[.!?](?:\s|$)|\r?\n|$)/);
13
+ const sentence = (match ? match[0] : content).trim();
14
+ return sentence.slice(0, MAX_SENTENCE_CHARS);
15
+ }
16
+ function evidenceString(row) {
17
+ const parts = [];
18
+ if (row.evidence_count >= 2) {
19
+ const last = row.last_verified_at
20
+ ? `, last ${row.last_verified_at.slice(0, 10)}`
21
+ : "";
22
+ parts.push(`verified ${row.evidence_count}×${last}`);
23
+ }
24
+ if (row.feedback_positive >= 1) {
25
+ parts.push(`feedback ${row.feedback_positive}×`);
26
+ }
27
+ return parts.join(", ");
28
+ }
29
+ export class Curator {
30
+ store;
31
+ memoryService;
32
+ projectId;
33
+ repoId;
34
+ env;
35
+ metrics;
36
+ constructor(store, memoryService, projectId, metrics, repoId, env) {
37
+ this.store = store;
38
+ this.memoryService = memoryService;
39
+ this.projectId = projectId;
40
+ this.repoId = repoId;
41
+ this.env = env;
42
+ this.metrics = metrics ?? null;
43
+ }
44
+ /**
45
+ * v0.8.0 (K8-023 / plan §5.7) — the source the flag selects. When
46
+ * `shared_layer_enabled` is on, curation reads the committed OKF file's
47
+ * projection (`shared_entries`) instead of the local `memories` table.
48
+ * The routing lives here, next to the predicate it switches, so a
49
+ * reviewer sees substrate and rendering change together (D8-11).
50
+ */
51
+ sourceFromFlag() {
52
+ return this.memoryService.getSetting("shared_layer_enabled", "0") === "1"
53
+ ? "shared"
54
+ : "memories";
55
+ }
56
+ /** v0.8.0 (K8-023) — the shared-layer candidate rows. */
57
+ sharedRows() {
58
+ if (!this.repoId) {
59
+ throw new Error("shared source requires a repoId");
60
+ }
61
+ return this.store
62
+ .prepare(`SELECT entry_id AS id, statement AS content,
63
+ evidence AS evidence_count,
64
+ 0 AS recurrence_count,
65
+ 0 AS feedback_positive,
66
+ 0 AS feedback_negative,
67
+ NULL AS last_verified_at,
68
+ created_at AS updated_at
69
+ FROM shared_entries
70
+ WHERE repo_id = ?
71
+ AND op = 'assert'
72
+ AND evidence >= 2`)
73
+ .all(this.repoId);
74
+ }
75
+ /**
76
+ * Plan §5.4 selection predicate — all clauses must hold, evaluated
77
+ * verbatim. The confidence floor cannot be expressed in SQL (it is the
78
+ * two-sided formula of `computeConfidence`), so the cheap clauses run
79
+ * in SQL and confidence is computed and filtered per row in JS.
80
+ *
81
+ * The floor is `confidence >= 0.6` where the feedback arm of D6-09 also
82
+ * clears it: `computeConfidence` credits one positive human verdict at
83
+ * 0.05 (K5-010), so a row with `feedback_positive = 1` and no causal
84
+ * evidence scores 0.55 — yet D6-09's warrant is "either the world
85
+ * verified it twice, or a human verified it once". The disjunction
86
+ * (K6-012 acceptance) is the floor's complement for the feedback arm.
87
+ *
88
+ * Ordered by `confidence DESC, updated_at DESC`, capped at 20 lines and
89
+ * 4000 characters of content, whichever binds first. The char budget
90
+ * counts the raw content length: rendered lines are truncated to 160
91
+ * chars, so a rendered-line budget could never bind before the line cap.
92
+ *
93
+ * v0.8.0 (K8-023 / plan §5.7) — `source` selects the substrate: the
94
+ * v0.6.0 path over `memories` ("local") or the shared layer. The
95
+ * predicate, the caps and the deterministic sort are unchanged in both
96
+ * cases — only the FROM changes. The shared schema has no feedback
97
+ * columns, so the feedback disjunct of the predicate can never fire
98
+ * there; `evidence >= 2` is its SQL half, evaluated in the query.
99
+ */
100
+ candidates(limit, source = "memories") {
101
+ const rows = source === "shared"
102
+ ? this.sharedRows()
103
+ : this.store
104
+ .prepare(`SELECT id, content, evidence_count, recurrence_count,
105
+ feedback_positive, feedback_negative,
106
+ last_verified_at, updated_at
107
+ FROM memories
108
+ WHERE status = 'active'
109
+ AND ignored = 0
110
+ AND curated = 0
111
+ AND (inferable IS NULL OR inferable != 1)
112
+ AND (evidence_count >= 2 OR feedback_positive >= 1)`)
113
+ .all();
114
+ const scored = rows
115
+ .map((row) => ({
116
+ row,
117
+ confidence: computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0, row.feedback_positive ?? 0, row.feedback_negative ?? 0),
118
+ }))
119
+ .filter((s) => s.confidence >= CONFIDENCE_FLOOR || s.row.feedback_positive >= 1)
120
+ .sort((a, b) => b.confidence - a.confidence ||
121
+ b.row.updated_at.localeCompare(a.row.updated_at));
122
+ const maxLines = limit ?? MAX_CANDIDATE_LINES;
123
+ const result = [];
124
+ let totalChars = 0;
125
+ for (const s of scored) {
126
+ if (result.length >= maxLines)
127
+ break;
128
+ if (totalChars + s.row.content.length > MAX_CANDIDATE_CHARS)
129
+ break;
130
+ const evidence = evidenceString(s.row);
131
+ result.push({
132
+ memoryId: s.row.id,
133
+ line: `- ${firstSentence(s.row.content)} (${evidence})`,
134
+ confidence: s.confidence,
135
+ evidence,
136
+ });
137
+ totalChars += s.row.content.length;
138
+ }
139
+ return result;
140
+ }
141
+ /**
142
+ * Renders the block sorted by memory id (D6-10): confidence orders
143
+ * selection, id orders output. Adding one candidate to a set of ten
144
+ * must change exactly one line of the rendered block.
145
+ */
146
+ renderBlock(candidates) {
147
+ const sorted = [...candidates].sort((a, b) => a.memoryId.localeCompare(b.memoryId));
148
+ return `${sorted.map((c) => c.line).join("\n")}\n`;
149
+ }
150
+ /**
151
+ * Plan §5.4 — strict dry run (D5-08): plan only, never write. Returns a
152
+ * pending proposal per call, or [] when no candidate clears the floor.
153
+ *
154
+ * The proposal is a whole-block proposal: the persisted `proposed_text`
155
+ * is the merged block (current block in the file + new candidate lines),
156
+ * so approving it replaces the file block with exactly what was reviewed.
157
+ * The current block is learned from `plan().before` — the Curator has no
158
+ * fs capability, the writer reads (D6-01). Both plan calls are
159
+ * deterministic, so a second propose() with unchanged inputs reproduces
160
+ * the persisted diff byte-identically.
161
+ *
162
+ * A new proposal supersedes prior pending (or rejected) rows for the
163
+ * same (project_id, kind, target_path) triple — §5.5's regeneration
164
+ * arrow. Rows are never deleted (the audit trail is append-only).
165
+ *
166
+ * The schema's `memory_id` column is singular while a whole-block
167
+ * proposal carries several ids; the contributing ids are stored joined
168
+ * by "," (uuidv7 ids contain no comma) and split on read.
169
+ */
170
+ propose(kind, writer) {
171
+ const targetPath = this.targetPathFor(kind);
172
+ const candidates = this.candidates(undefined, this.sourceFromFlag());
173
+ if (candidates.length === 0)
174
+ return [];
175
+ const readPlan = writer.plan(targetPath, this.renderBlock(candidates));
176
+ const currentBlock = extractBlock(readPlan.before);
177
+ // v0.7.0 (K7-013 / plan §4, D6-09) — de-duplicate against the WHOLE
178
+ // file, not just the region between Kevin's markers. A convention the
179
+ // user already wrote in their own words in their own section must not
180
+ // be proposed back to them. The comparison is over the same normalized
181
+ // tokens the fingerprint uses — no new similarity metric (D7-11).
182
+ const entireFile = wholeFileLines(readPlan.before);
183
+ const freshCandidates = candidates.filter((candidate) => !entireFile.has(normalizeBullet(candidate.line)));
184
+ if (freshCandidates.length === 0 && currentBlock === "")
185
+ return [];
186
+ const newBlock = this.renderBlock(freshCandidates);
187
+ const newLines = newBlock.split("\n").filter((l) => l !== "");
188
+ const mergedBlock = newLines.length === 0
189
+ ? currentBlock
190
+ : currentBlock === ""
191
+ ? `${newLines.join("\n")}\n`
192
+ : `${currentBlock}${currentBlock.endsWith("\n") ? "" : "\n"}${newLines.join("\n")}\n`;
193
+ // v0.7.0 (K7-013) — nothing new to propose: every candidate is already
194
+ // in the file (inside or outside the markers). A vacuous proposal would
195
+ // be noise; return none.
196
+ const plan = writer.plan(targetPath, mergedBlock);
197
+ const prior = this.store
198
+ .prepare(`SELECT id FROM curation_proposals
199
+ WHERE project_id = ? AND kind = ? AND target_path = ?
200
+ AND status IN ('pending', 'rejected')`)
201
+ .all(this.projectId, kind, targetPath);
202
+ for (const row of prior) {
203
+ this.transition(row.id, "supersede");
204
+ }
205
+ const memoryIds = freshCandidates.map((c) => c.memoryId);
206
+ const id = uuidv7();
207
+ this.store
208
+ .prepare(`INSERT INTO curation_proposals
209
+ (id, project_id, memory_id, kind, target_path,
210
+ proposed_text, diff, status)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')`)
212
+ .run(id, this.projectId, memoryIds.join(","), kind, targetPath, mergedBlock, plan.diff);
213
+ this.metrics?.incr("proposals_created", 1);
214
+ const row = this.store
215
+ .prepare("SELECT created_at FROM curation_proposals WHERE id = ?")
216
+ .get(id);
217
+ return [
218
+ {
219
+ id,
220
+ kind,
221
+ targetPath,
222
+ memoryIds,
223
+ proposedText: mergedBlock,
224
+ diff: plan.diff,
225
+ status: "pending",
226
+ createdAt: row.created_at,
227
+ },
228
+ ];
229
+ }
230
+ /**
231
+ * Plan §5.5 state machine, as explicit transitions with an exhaustive
232
+ * switch — an unknown transition or an illegal source state throws
233
+ * rather than silently no-oping. `decided_at` stamps approve/reject/
234
+ * supersede, `applied_at` stamps apply.
235
+ */
236
+ transition(proposalId, transition) {
237
+ const row = this.store
238
+ .prepare("SELECT status FROM curation_proposals WHERE id = ?")
239
+ .get(proposalId);
240
+ if (!row) {
241
+ throw new Error(`proposal not found: ${proposalId}`);
242
+ }
243
+ const status = row.status;
244
+ let next = null;
245
+ switch (transition) {
246
+ case "approve":
247
+ if (status === "pending")
248
+ next = "approved";
249
+ break;
250
+ case "reject":
251
+ if (status === "pending")
252
+ next = "rejected";
253
+ break;
254
+ case "apply":
255
+ if (status === "approved")
256
+ next = "applied";
257
+ break;
258
+ case "supersede":
259
+ if (status === "pending" || status === "rejected") {
260
+ next = "superseded";
261
+ }
262
+ break;
263
+ default:
264
+ throw new Error(`unknown transition: ${transition}`);
265
+ }
266
+ if (next === null) {
267
+ throw new Error(`illegal transition: ${status} -> ${transition}`);
268
+ }
269
+ if (next === "applied") {
270
+ this.store
271
+ .prepare("UPDATE curation_proposals SET status = 'applied', applied_at = datetime('now') WHERE id = ?")
272
+ .run(proposalId);
273
+ }
274
+ else {
275
+ this.store
276
+ .prepare("UPDATE curation_proposals SET status = ?, decided_at = datetime('now') WHERE id = ?")
277
+ .run(next, proposalId);
278
+ }
279
+ return next;
280
+ }
281
+ /** Plan §5.6 target paths. agents_md is a setting; skill/reference live under ~/.opencode-kevin. */
282
+ targetPathFor(kind) {
283
+ switch (kind) {
284
+ case "agents_md":
285
+ return this.memoryService.getSetting("agents_md_path", "AGENTS.md");
286
+ case "skill":
287
+ return join(resolveEnv(this.env).dataRoot, "skills", "project-knowledge.md");
288
+ case "reference":
289
+ return join(resolveEnv(this.env).dataRoot, "refs", "project-knowledge.md");
290
+ }
291
+ }
292
+ }
293
+ /** The block content currently between the markers, "" when absent or malformed. */
294
+ function extractBlock(before) {
295
+ const begin = before.indexOf(MARKER_BEGIN);
296
+ const end = before.indexOf(MARKER_END);
297
+ if (begin === -1 || end === -1 || end < begin)
298
+ return "";
299
+ return before
300
+ .slice(begin + MARKER_BEGIN.length, end)
301
+ .replace(/^\r?\n/, "")
302
+ .replace(/\r?\n$/, "");
303
+ }
304
+ /**
305
+ * v0.7.0 (K7-013 / plan §4) — the normalized set of bullet statements across
306
+ * the WHOLE file, including any the user wrote outside Kevin's markers. Only
307
+ * markdown bullets (`- ...`) are considered: prose and the marker lines are
308
+ * never a candidate to propose back.
309
+ */
310
+ function wholeFileLines(before) {
311
+ const set = new Set();
312
+ for (const line of before.split(/\r?\n/)) {
313
+ if (line.trim().startsWith("-")) {
314
+ set.add(normalizeBullet(line));
315
+ }
316
+ }
317
+ return set;
318
+ }
319
+ /** Normalize a rendered bullet for de-duplication — strip the marker prefix. */
320
+ function normalizeBullet(line) {
321
+ const statement = line
322
+ .replace(/^\s*-\s+/, "")
323
+ .trim()
324
+ .replace(/\s+\((?:verified|feedback)[^)]*\)\s*$/i, "");
325
+ return normalize(statement);
326
+ }