@jmtrin/opencode-kevin 1.2.0 → 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.
- package/dist/plugin/index.d.ts +4 -3
- package/dist/plugin/index.js +122 -113
- package/dist/plugin/native.d.ts +2 -2
- package/dist/plugin/native.js +2 -2
- package/package.json +56 -68
- package/LICENSE +0 -21
- package/README.md +0 -605
- package/dist/migrations/001_initial.sql +0 -92
- package/dist/migrations/002_indexes.sql +0 -14
- package/dist/migrations/003_v02_signal.sql +0 -58
- package/dist/migrations/004_v03_knowledge.sql +0 -138
- package/dist/migrations/005_v04_signal.sql +0 -57
- package/dist/migrations/006_v05_glassbox.sql +0 -118
- package/dist/migrations/007_v06_pull.sql +0 -145
- package/dist/migrations/008_v07_truth.sql +0 -124
- package/dist/migrations/009_v08_team.sql +0 -100
- package/dist/migrations/010_v09_native.sql +0 -78
- package/dist/migrations/011_v10_proven.sql +0 -69
- package/dist/migrations/012_v11_drift.sql +0 -24
- package/dist/plugin/Archiver.d.ts +0 -42
- package/dist/plugin/Archiver.js +0 -83
- package/dist/plugin/ArtifactWriter.d.ts +0 -93
- package/dist/plugin/ArtifactWriter.js +0 -291
- package/dist/plugin/CausalChain.d.ts +0 -22
- package/dist/plugin/CausalChain.js +0 -197
- package/dist/plugin/ChatBridge.d.ts +0 -41
- package/dist/plugin/ChatBridge.js +0 -103
- package/dist/plugin/ConflictDetector.d.ts +0 -35
- package/dist/plugin/ConflictDetector.js +0 -260
- package/dist/plugin/ContextInjector.d.ts +0 -222
- package/dist/plugin/ContextInjector.js +0 -533
- package/dist/plugin/ConventionMiner.d.ts +0 -35
- package/dist/plugin/ConventionMiner.js +0 -242
- package/dist/plugin/Curator.d.ts +0 -116
- package/dist/plugin/Curator.js +0 -324
- package/dist/plugin/DashboardHtml.d.ts +0 -5
- package/dist/plugin/DashboardHtml.js +0 -180
- package/dist/plugin/Feedback.d.ts +0 -67
- package/dist/plugin/Feedback.js +0 -120
- package/dist/plugin/HookLiveness.d.ts +0 -88
- package/dist/plugin/HookLiveness.js +0 -322
- package/dist/plugin/InjectionLedger.d.ts +0 -98
- package/dist/plugin/InjectionLedger.js +0 -295
- package/dist/plugin/LessonFixer.d.ts +0 -44
- package/dist/plugin/LessonFixer.js +0 -45
- package/dist/plugin/Materializer.d.ts +0 -83
- package/dist/plugin/Materializer.js +0 -181
- package/dist/plugin/MemoryService.d.ts +0 -360
- package/dist/plugin/MemoryService.js +0 -1242
- package/dist/plugin/Migrate.d.ts +0 -17
- package/dist/plugin/Migrate.js +0 -271
- package/dist/plugin/PatternMiner.d.ts +0 -49
- package/dist/plugin/PatternMiner.js +0 -132
- package/dist/plugin/QualityGate.d.ts +0 -124
- package/dist/plugin/QualityGate.js +0 -119
- package/dist/plugin/Reflector.d.ts +0 -91
- package/dist/plugin/Reflector.js +0 -307
- package/dist/plugin/RepoIdentity.d.ts +0 -124
- package/dist/plugin/RepoIdentity.js +0 -300
- package/dist/plugin/RepoTruth.d.ts +0 -80
- package/dist/plugin/RepoTruth.js +0 -599
- package/dist/plugin/Retrospective.d.ts +0 -17
- package/dist/plugin/Retrospective.js +0 -216
- package/dist/plugin/SharedLayer.d.ts +0 -159
- package/dist/plugin/SharedLayer.js +0 -462
- package/dist/plugin/Store.d.ts +0 -29
- package/dist/plugin/Store.js +0 -50
- package/dist/plugin/ToolCallObserver.d.ts +0 -32
- package/dist/plugin/ToolCallObserver.js +0 -201
- package/dist/plugin/TuiActions.d.ts +0 -43
- package/dist/plugin/TuiActions.js +0 -181
- package/dist/plugin/TuiSnapshots.d.ts +0 -24
- package/dist/plugin/TuiSnapshots.js +0 -158
- package/dist/plugin/columns.d.ts +0 -11
- package/dist/plugin/columns.js +0 -54
- package/dist/plugin/confidence.d.ts +0 -8
- package/dist/plugin/confidence.js +0 -34
- package/dist/plugin/contract.d.ts +0 -54
- package/dist/plugin/contract.js +0 -386
- package/dist/plugin/diff.d.ts +0 -8
- package/dist/plugin/diff.js +0 -182
- package/dist/plugin/escape.d.ts +0 -39
- package/dist/plugin/escape.js +0 -60
- package/dist/plugin/fingerprint.d.ts +0 -27
- package/dist/plugin/fingerprint.js +0 -73
- package/dist/plugin/inferability.d.ts +0 -32
- package/dist/plugin/inferability.js +0 -88
- package/dist/plugin/kevin_approve.d.ts +0 -34
- package/dist/plugin/kevin_approve.js +0 -52
- package/dist/plugin/kevin_audit.d.ts +0 -229
- package/dist/plugin/kevin_audit.js +0 -631
- package/dist/plugin/kevin_bench.d.ts +0 -10
- package/dist/plugin/kevin_bench.js +0 -82
- package/dist/plugin/kevin_conflicts.d.ts +0 -9
- package/dist/plugin/kevin_conflicts.js +0 -50
- package/dist/plugin/kevin_contract.d.ts +0 -8
- package/dist/plugin/kevin_contract.js +0 -59
- package/dist/plugin/kevin_doctor.d.ts +0 -62
- package/dist/plugin/kevin_doctor.js +0 -196
- package/dist/plugin/kevin_facts.d.ts +0 -42
- package/dist/plugin/kevin_facts.js +0 -36
- package/dist/plugin/kevin_forget.d.ts +0 -33
- package/dist/plugin/kevin_forget.js +0 -260
- package/dist/plugin/kevin_native.d.ts +0 -29
- package/dist/plugin/kevin_native.js +0 -79
- package/dist/plugin/kevin_propose.d.ts +0 -23
- package/dist/plugin/kevin_propose.js +0 -14
- package/dist/plugin/kevin_publish.d.ts +0 -38
- package/dist/plugin/kevin_publish.js +0 -18
- package/dist/plugin/kevin_why.d.ts +0 -23
- package/dist/plugin/kevin_why.js +0 -111
- package/dist/plugin/memory-format.d.ts +0 -20
- package/dist/plugin/memory-format.js +0 -59
- package/dist/plugin/metrics.d.ts +0 -97
- package/dist/plugin/metrics.js +0 -288
- package/dist/plugin/okf-export.d.ts +0 -3
- package/dist/plugin/okf-export.js +0 -134
- package/dist/plugin/okf-import.d.ts +0 -76
- package/dist/plugin/okf-import.js +0 -271
- package/dist/plugin/okf.d.ts +0 -107
- package/dist/plugin/okf.js +0 -303
- package/dist/plugin/perf.d.ts +0 -35
- package/dist/plugin/perf.js +0 -174
- package/dist/plugin/query-tokenizer.d.ts +0 -13
- package/dist/plugin/query-tokenizer.js +0 -133
- package/dist/plugin/redact.d.ts +0 -2
- package/dist/plugin/redact.js +0 -18
- package/dist/plugin/replay-types.d.ts +0 -69
- package/dist/plugin/replay-types.js +0 -156
- package/dist/plugin/replay.d.ts +0 -36
- package/dist/plugin/replay.js +0 -202
- package/dist/plugin/sqlite-adapter.d.ts +0 -12
- package/dist/plugin/sqlite-adapter.js +0 -81
- package/dist/plugin/time-ms.d.ts +0 -1
- package/dist/plugin/time-ms.js +0 -16
- package/dist/plugin/tui-types.d.ts +0 -59
- package/dist/plugin/tui-types.js +0 -4
- package/dist/plugin/tui.d.ts +0 -18
- package/dist/plugin/tui.js +0 -198
- package/dist/plugin/uuid.d.ts +0 -1
- package/dist/plugin/uuid.js +0 -50
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
3
|
-
import { unifiedDiff } from "./diff.js";
|
|
4
|
-
import { escapeForFence, escapeForMarkerBlock, escapeForOkfLine, } from "./escape.js";
|
|
5
|
-
import { uuidv7 } from "./uuid.js";
|
|
6
|
-
// v0.6.0 (K6-005 / plan §5.1, D6-02) — the frozen marker contract. The exact
|
|
7
|
-
// byte sequences are load-bearing: README, v1.0.0 plan C-01 and the round-trip
|
|
8
|
-
// test all depend on them.
|
|
9
|
-
export const MARKER_BEGIN = "<!-- kevin:begin — curated by opencode-kevin, safe to edit -->";
|
|
10
|
-
export const MARKER_END = "<!-- kevin:end -->";
|
|
11
|
-
function sha256(text) {
|
|
12
|
-
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Rule 5 (K6-008 / plan §5.1, D6-02) — the line-ending style comes from the
|
|
16
|
-
* FIRST line ending in the existing file: CRLF if it is CRLF, otherwise LF.
|
|
17
|
-
* A CRLF file whose last line lacks a terminator, and a mixed-ending file,
|
|
18
|
-
* are both resolved by the same first-ending rule, deterministically.
|
|
19
|
-
*/
|
|
20
|
-
function detectEol(text) {
|
|
21
|
-
const nl = text.indexOf("\n");
|
|
22
|
-
if (nl === -1)
|
|
23
|
-
return "\n";
|
|
24
|
-
return nl > 0 && text[nl - 1] === "\r" ? "\r\n" : "\n";
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Rule 5 (K6-008) — normalize the generated body into the file's line-ending
|
|
28
|
-
* style so a CRLF file stays CRLF everywhere, including inside the block.
|
|
29
|
-
*/
|
|
30
|
-
function normalizeEol(body, eol) {
|
|
31
|
-
return body.replace(/\r\n/g, "\n").replace(/\n/g, eol);
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Rule 9, layer (a) — superseded in v1.0.0 by `escapeForMarkerBlock`
|
|
35
|
-
* in `plugin/escape.ts` (K10-027), which carries the same idempotent
|
|
36
|
-
* entity discipline.
|
|
37
|
-
*/
|
|
38
|
-
/**
|
|
39
|
-
* v1.0.0 (K10-027 / plan §5.7) — the boundary functions now live in
|
|
40
|
-
* `plugin/escape.ts`; this composer applies them in fence-then-marker
|
|
41
|
-
* order. Layer (b) — strip any line containing `kevin:begin` or
|
|
42
|
-
* `kevin:end`, in any casing — is a filter rather than an escape and
|
|
43
|
-
* stays here. Without layer (b), a memory containing a literal
|
|
44
|
-
* `<!-- kevin:end -->` line would close the marker comment early and
|
|
45
|
-
* let subsequent content escape the curated region on the next
|
|
46
|
-
* regeneration — a marker-injection variant of the v0.1.5
|
|
47
|
-
* prompt-injection defect (plan §3.5). The trailing `-->` strip is kept
|
|
48
|
-
* as defence in depth although `escapeForMarkerBlock` already escapes
|
|
49
|
-
* every `>` that could form one.
|
|
50
|
-
*/
|
|
51
|
-
export function sanitizeArtifactBody(body) {
|
|
52
|
-
const escaped = escapeForMarkerBlock(escapeForFence(body));
|
|
53
|
-
const kept = escaped
|
|
54
|
-
.split("\n")
|
|
55
|
-
.filter((line) => !/kevin:begin|kevin:end/i.test(line))
|
|
56
|
-
.join("\n");
|
|
57
|
-
return kept.replace(/-->/g, "");
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* v1.0.0 (K10-027 / plan §5.7, rule 2) — whole-file writes escape by
|
|
61
|
-
* container. Only `.okf` files are line-oriented JSON: each line gets
|
|
62
|
-
* `escapeForOkfLine`, which is the identity on a well-formed OKF line
|
|
63
|
-
* (canonical JSON never contains raw control characters) and therefore
|
|
64
|
-
* preserves the re-render `noop`. Kevin-owned markdown paths keep their
|
|
65
|
-
* bytes — fences there are legitimate content.
|
|
66
|
-
*/
|
|
67
|
-
export function escapeForContainer(path, content) {
|
|
68
|
-
if (!path.endsWith(".okf"))
|
|
69
|
-
return content;
|
|
70
|
-
return content
|
|
71
|
-
.split("\n")
|
|
72
|
-
.map((line) => {
|
|
73
|
-
// A CRLF file (healHeader preserves the original EOL) leaves a
|
|
74
|
-
// trailing \r on each line after the split — it is line-ending
|
|
75
|
-
// bytes, not statement content, so it must survive untouched.
|
|
76
|
-
if (line.endsWith("\r")) {
|
|
77
|
-
return `${escapeForOkfLine(line.slice(0, -1))}\r`;
|
|
78
|
-
}
|
|
79
|
-
return escapeForOkfLine(line);
|
|
80
|
-
})
|
|
81
|
-
.join("\n");
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* v0.6.0 (K6-005 / plan §5.1) — the single write path to disk (D6-01).
|
|
85
|
-
*
|
|
86
|
-
* `plan()` is pure: it reads the target file, locates the marker pair, splices
|
|
87
|
-
* the body between the markers and returns a {@link WritePlan}. It performs no
|
|
88
|
-
* writes — rule 1. `apply()` is implemented by K6-007; until then it is a stub
|
|
89
|
-
* that throws, so no caller can accidentally write before the audit trail
|
|
90
|
-
* exists.
|
|
91
|
-
*
|
|
92
|
-
* `projectId` is a constructor argument rather than a per-call argument so that
|
|
93
|
-
* every audit row is attributed without the call site having to remember.
|
|
94
|
-
*/
|
|
95
|
-
export class ArtifactWriter {
|
|
96
|
-
store;
|
|
97
|
-
projectId;
|
|
98
|
-
metrics;
|
|
99
|
-
constructor(store, projectId, metrics) {
|
|
100
|
-
this.store = store;
|
|
101
|
-
this.projectId = projectId;
|
|
102
|
-
this.metrics = metrics ?? null;
|
|
103
|
-
}
|
|
104
|
-
plan(pathOrRequest, body) {
|
|
105
|
-
const request = typeof pathOrRequest === "string"
|
|
106
|
-
? { path: pathOrRequest, mode: "markers", content: body ?? "" }
|
|
107
|
-
: pathOrRequest;
|
|
108
|
-
const { path, mode, content } = request;
|
|
109
|
-
let before;
|
|
110
|
-
try {
|
|
111
|
-
// Read as Buffer, not as utf8 text: readFileSync's text decoding
|
|
112
|
-
// strips a leading BOM, which would silently drop it on the next
|
|
113
|
-
// write. Buffer.toString keeps \uFEFF as a character of `before`.
|
|
114
|
-
before = readFileSync(path).toString("utf8");
|
|
115
|
-
}
|
|
116
|
-
catch (err) {
|
|
117
|
-
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
118
|
-
before = "";
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
throw err;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (mode === "whole") {
|
|
125
|
-
// K8-019 (D8-08) — the whole-file path. The file is Kevin-owned,
|
|
126
|
-
// so there are no markers, no sanitization and no EOL
|
|
127
|
-
// normalization: the rendered bytes are written as-is, which is
|
|
128
|
-
// what makes a re-render of the same content a `noop`. A
|
|
129
|
-
// caller-side refusal leaves the file untouched and is audited
|
|
130
|
-
// like any other refusal: after = before, both hashes recorded.
|
|
131
|
-
const refusal = request.refusal;
|
|
132
|
-
const refused = refusal !== undefined;
|
|
133
|
-
// K10-027 — the container boundary: OKF lines are escaped at the
|
|
134
|
-
// single write path, never by callers.
|
|
135
|
-
const after = refused ? before : escapeForContainer(path, content);
|
|
136
|
-
return {
|
|
137
|
-
path,
|
|
138
|
-
before,
|
|
139
|
-
after,
|
|
140
|
-
diff: unifiedDiff(path, before, after),
|
|
141
|
-
outcome: refused ? "refused" : after === before ? "noop" : "written",
|
|
142
|
-
...(refusal !== undefined ? { reason: refusal } : {}),
|
|
143
|
-
hashBefore: sha256(before),
|
|
144
|
-
hashAfter: sha256(after),
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
const eol = detectEol(before);
|
|
148
|
-
// Rule 9 — sanitation happens in plan(), before hashing, so the hashes
|
|
149
|
-
// describe what was actually written.
|
|
150
|
-
const bodyEol = normalizeEol(sanitizeArtifactBody(content), eol);
|
|
151
|
-
const firstBegin = before.indexOf(MARKER_BEGIN);
|
|
152
|
-
const firstEnd = before.indexOf(MARKER_END);
|
|
153
|
-
let after;
|
|
154
|
-
let outcome = "written";
|
|
155
|
-
let reason;
|
|
156
|
-
if (firstBegin === -1 && firstEnd === -1) {
|
|
157
|
-
// Rule 2 — create: the block is appended at the end of the content,
|
|
158
|
-
// preceded by a blank line. For an empty file (missing file treated
|
|
159
|
-
// as "") the result is exactly: blank line, MARKER_BEGIN, body,
|
|
160
|
-
// MARKER_END, trailing newline.
|
|
161
|
-
const separator = before === "" ? eol : before.endsWith(eol) ? eol : eol + eol;
|
|
162
|
-
after =
|
|
163
|
-
before +
|
|
164
|
-
separator +
|
|
165
|
-
MARKER_BEGIN +
|
|
166
|
-
eol +
|
|
167
|
-
bodyEol +
|
|
168
|
-
eol +
|
|
169
|
-
MARKER_END +
|
|
170
|
-
eol;
|
|
171
|
-
}
|
|
172
|
-
else if (firstBegin === -1 || firstEnd === -1) {
|
|
173
|
-
// Rule 3 — exactly one marker present.
|
|
174
|
-
after = before;
|
|
175
|
-
outcome = "refused";
|
|
176
|
-
reason =
|
|
177
|
-
firstBegin === -1
|
|
178
|
-
? "kevin:end marker present without kevin:begin"
|
|
179
|
-
: "kevin:begin marker present without kevin:end";
|
|
180
|
-
}
|
|
181
|
-
else if (firstEnd < firstBegin) {
|
|
182
|
-
// Rule 3 — MARKER_END precedes MARKER_BEGIN.
|
|
183
|
-
after = before;
|
|
184
|
-
outcome = "refused";
|
|
185
|
-
reason = "kevin:end appears before kevin:begin";
|
|
186
|
-
}
|
|
187
|
-
else if (before.indexOf(MARKER_BEGIN, firstBegin + MARKER_BEGIN.length) !== -1 ||
|
|
188
|
-
before.indexOf(MARKER_END, firstEnd + MARKER_END.length) !== -1) {
|
|
189
|
-
// Rule 3 — more than one pair (any additional marker occurrence).
|
|
190
|
-
after = before;
|
|
191
|
-
outcome = "refused";
|
|
192
|
-
reason = "more than one marker pair present";
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
// Rule 4 — bytes outside the marker pair are byte-identical. The
|
|
196
|
-
// block between the markers (begin marker, old block, end marker)
|
|
197
|
-
// is replaced by the regenerated block.
|
|
198
|
-
const blockEnd = firstEnd + MARKER_END.length;
|
|
199
|
-
after =
|
|
200
|
-
before.slice(0, firstBegin) +
|
|
201
|
-
MARKER_BEGIN +
|
|
202
|
-
eol +
|
|
203
|
-
bodyEol +
|
|
204
|
-
eol +
|
|
205
|
-
MARKER_END +
|
|
206
|
-
before.slice(blockEnd);
|
|
207
|
-
// Rule 6 — unchanged content is a noop, never a write.
|
|
208
|
-
outcome = after === before ? "noop" : "written";
|
|
209
|
-
}
|
|
210
|
-
return {
|
|
211
|
-
path,
|
|
212
|
-
before,
|
|
213
|
-
after,
|
|
214
|
-
// v0.6.0 (K6-006 / plan §5.2, D6-05) — approval prompts show bytes,
|
|
215
|
-
// never prose. Identical inputs yield "".
|
|
216
|
-
diff: unifiedDiff(path, before, after),
|
|
217
|
-
outcome,
|
|
218
|
-
...(reason !== undefined ? { reason } : {}),
|
|
219
|
-
hashBefore: sha256(before),
|
|
220
|
-
hashAfter: sha256(after),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* K8-019 (D8-08) — the single write funnel: every file Kevin writes
|
|
225
|
-
* goes through this method, which is the ONLY call site of `apply()`
|
|
226
|
-
* in the plugin (asserted by tests/unit/single_write_path.test.ts).
|
|
227
|
-
*/
|
|
228
|
-
write(request, proposalId) {
|
|
229
|
-
return this.apply(this.plan(request), proposalId);
|
|
230
|
-
}
|
|
231
|
-
// v0.6.0 (K6-007 / plan §5.1, rules 7–8) — atomic write + audit row.
|
|
232
|
-
apply(plan, proposalId) {
|
|
233
|
-
// Rule 8 — refusals and noops still leave an audit trail; a refusal
|
|
234
|
-
// that leaves no trace is indistinguishable from a write that never
|
|
235
|
-
// happened. Rule 6 — a noop creates no temp file and writes nothing.
|
|
236
|
-
if (plan.outcome === "noop" || plan.outcome === "refused") {
|
|
237
|
-
if (plan.outcome === "noop") {
|
|
238
|
-
this.metrics?.incr("artifact_writes_noop", 1);
|
|
239
|
-
}
|
|
240
|
-
this.audit(plan, proposalId);
|
|
241
|
-
return plan.outcome;
|
|
242
|
-
}
|
|
243
|
-
// Rule 7 — write to `<path>.kevin.tmp` in the same directory (same
|
|
244
|
-
// filesystem, so rename is atomic), fsync, close, then rename over the
|
|
245
|
-
// target. Never write the target path directly, never truncate-then-write.
|
|
246
|
-
const tmpPath = `${plan.path}.kevin.tmp`;
|
|
247
|
-
let fd;
|
|
248
|
-
try {
|
|
249
|
-
fd = openSync(tmpPath, "w");
|
|
250
|
-
writeSync(fd, plan.after, null, "utf8");
|
|
251
|
-
fsyncSync(fd);
|
|
252
|
-
closeSync(fd);
|
|
253
|
-
fd = undefined;
|
|
254
|
-
this.renameTemp(tmpPath, plan.path);
|
|
255
|
-
}
|
|
256
|
-
catch (err) {
|
|
257
|
-
// Never leave .kevin.tmp litter next to the user's file.
|
|
258
|
-
if (fd !== undefined) {
|
|
259
|
-
try {
|
|
260
|
-
closeSync(fd);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
// already closed or unusable; unlink below is the cleanup
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
unlinkSync(tmpPath);
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
// nothing to clean up
|
|
271
|
-
}
|
|
272
|
-
throw err;
|
|
273
|
-
}
|
|
274
|
-
this.metrics?.incr("artifact_writes_total", 1);
|
|
275
|
-
this.audit(plan, proposalId);
|
|
276
|
-
return "written";
|
|
277
|
-
}
|
|
278
|
-
// Fault-injection seam for the atomicity test: the rename is the point
|
|
279
|
-
// where a failure must leave the target untouched and the temp file gone.
|
|
280
|
-
renameTemp(tmpPath, target) {
|
|
281
|
-
renameSync(tmpPath, target);
|
|
282
|
-
}
|
|
283
|
-
audit(plan, proposalId) {
|
|
284
|
-
this.store
|
|
285
|
-
.prepare(`INSERT INTO artifact_writes
|
|
286
|
-
(id, proposal_id, project_id, path, bytes_before, bytes_after,
|
|
287
|
-
hash_before, hash_after, outcome, reason)
|
|
288
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
289
|
-
.run(uuidv7(), proposalId ?? null, this.projectId, plan.path, Buffer.byteLength(plan.before, "utf8"), Buffer.byteLength(plan.after, "utf8"), plan.hashBefore, plan.hashAfter, plan.outcome, plan.reason ?? null);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { type EnrichFn } from "./LessonFixer.js";
|
|
2
|
-
import type { MemoryService } from "./MemoryService.js";
|
|
3
|
-
import type { Store } from "./Store.js";
|
|
4
|
-
import type { Metrics } from "./metrics.js";
|
|
5
|
-
export declare class CausalChain {
|
|
6
|
-
private store;
|
|
7
|
-
private memoryService;
|
|
8
|
-
private metrics;
|
|
9
|
-
private enrichFn?;
|
|
10
|
-
constructor(store: Store, memoryService: MemoryService, metrics: Metrics | null, enrichFn?: EnrichFn | undefined);
|
|
11
|
-
onSuccess(_tool: string, _args: Record<string, unknown>, _projectId: string | null, sessionId: string): void;
|
|
12
|
-
onSessionIdle(sessionId: string): Promise<number>;
|
|
13
|
-
/**
|
|
14
|
-
* v0.4.0 (K4-015) — fire the opt-in enrich hook at most once per
|
|
15
|
-
* promoted pattern. The hook's one-line phrase replaces the
|
|
16
|
-
* deterministic `Fixed by:` line; null keeps it. A call (phrase or
|
|
17
|
-
* not) stamps `metadata.enriched` so repeated idle cycles stay at
|
|
18
|
-
* one LLM call per pattern.
|
|
19
|
-
*/
|
|
20
|
-
private enrichIfEnabled;
|
|
21
|
-
private isLlmReflectionEnabled;
|
|
22
|
-
}
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import { extractFixArgs, } from "./LessonFixer.js";
|
|
2
|
-
import { hasColumn } from "./columns.js";
|
|
3
|
-
import { toMs } from "./time-ms.js";
|
|
4
|
-
/** K3-007 — a success only links to a failure within this many calls. */
|
|
5
|
-
const MAX_LINK_DISTANCE = 10;
|
|
6
|
-
export class CausalChain {
|
|
7
|
-
store;
|
|
8
|
-
memoryService;
|
|
9
|
-
metrics;
|
|
10
|
-
enrichFn;
|
|
11
|
-
constructor(store, memoryService, metrics,
|
|
12
|
-
// v0.4.0 (K4-015) — opt-in promotion-time LLM phrasing. Absent in
|
|
13
|
-
// production (zero network calls by default); injected by tests or
|
|
14
|
-
// by a future settings-driven wiring.
|
|
15
|
-
enrichFn) {
|
|
16
|
-
this.store = store;
|
|
17
|
-
this.memoryService = memoryService;
|
|
18
|
-
this.metrics = metrics;
|
|
19
|
-
this.enrichFn = enrichFn;
|
|
20
|
-
}
|
|
21
|
-
// K3-007: link a success to the failing fingerprint only when it
|
|
22
|
-
// occurred within MAX_LINK_DISTANCE tool calls of the failure (plan
|
|
23
|
-
// §K3-007 acceptance: "within 24h and within 10 tool calls").
|
|
24
|
-
// v0.3.0 fix (bug #3) — the old code linked the most recent session
|
|
25
|
-
// success to the most recent unlinked error memory regardless of
|
|
26
|
-
// distance, so an unrelated success (e.g. an `ls` run after a
|
|
27
|
-
// typecheck failure) was stamped as the fix for that error.
|
|
28
|
-
onSuccess(_tool, _args, _projectId, sessionId) {
|
|
29
|
-
// v1.1.0 (K11-004 / plan §5.2, D11-01/D11-07) — ms-aware window: prefer _ms
|
|
30
|
-
const hasTsMs = hasColumn(this.store, "tool_calls", "ts_ms");
|
|
31
|
-
const successRow = (hasTsMs
|
|
32
|
-
? this.store.prepare(`SELECT rowid, tool, args_summary, ts, ts_ms FROM tool_calls
|
|
33
|
-
WHERE session_id = ? AND success = 1
|
|
34
|
-
ORDER BY rowid DESC LIMIT 1`)
|
|
35
|
-
: this.store.prepare(`SELECT rowid, tool, args_summary, ts FROM tool_calls
|
|
36
|
-
WHERE session_id = ? AND success = 1
|
|
37
|
-
ORDER BY rowid DESC LIMIT 1`)).get(sessionId);
|
|
38
|
-
if (!successRow)
|
|
39
|
-
return;
|
|
40
|
-
const linkedFps = new Set(this.store
|
|
41
|
-
.prepare(`SELECT DISTINCT fix_for_fingerprint FROM tool_calls
|
|
42
|
-
WHERE session_id = ? AND fix_for_fingerprint IS NOT NULL`)
|
|
43
|
-
.all(sessionId).map((r) => r.fix_for_fingerprint));
|
|
44
|
-
// Most recent failing calls in this session, newest first. The
|
|
45
|
-
// failing call's `error_fingerprint` (stamped by Reflector via
|
|
46
|
-
// onLinkError) is the SAME identity dimension the error memory
|
|
47
|
-
// uses; `fingerprint` is the legacy tool|args|success hash and
|
|
48
|
-
// simply never matches a reflector error memory.
|
|
49
|
-
const failRows = (hasTsMs
|
|
50
|
-
? this.store.prepare(`SELECT rowid, COALESCE(error_fingerprint, fingerprint) AS fp, ts, ts_ms
|
|
51
|
-
FROM tool_calls
|
|
52
|
-
WHERE session_id = ?
|
|
53
|
-
AND success = 0
|
|
54
|
-
AND (error_fingerprint IS NOT NULL OR fingerprint IS NOT NULL)
|
|
55
|
-
ORDER BY rowid DESC LIMIT ?`)
|
|
56
|
-
: this.store.prepare(`SELECT rowid, COALESCE(error_fingerprint, fingerprint) AS fp, ts
|
|
57
|
-
FROM tool_calls
|
|
58
|
-
WHERE session_id = ?
|
|
59
|
-
AND success = 0
|
|
60
|
-
AND (error_fingerprint IS NOT NULL OR fingerprint IS NOT NULL)
|
|
61
|
-
ORDER BY rowid DESC LIMIT ?`)).all(sessionId, MAX_LINK_DISTANCE);
|
|
62
|
-
for (const fail of failRows) {
|
|
63
|
-
if (!fail.fp || linkedFps.has(fail.fp))
|
|
64
|
-
continue;
|
|
65
|
-
const dist = successRow.rowid - fail.rowid;
|
|
66
|
-
if (dist <= 0 || dist > MAX_LINK_DISTANCE)
|
|
67
|
-
continue;
|
|
68
|
-
// v1.1.0 — ≤24h window uses ms when available
|
|
69
|
-
const successMs = toMs(successRow.ts, successRow.ts_ms ?? null);
|
|
70
|
-
const failMs = toMs(fail.ts, fail.ts_ms ?? null);
|
|
71
|
-
if (successMs !== null && failMs !== null) {
|
|
72
|
-
const diff = successMs - failMs;
|
|
73
|
-
if (diff < 0 || diff > 86_400_000)
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
const mem = this.store
|
|
77
|
-
.prepare(`SELECT 1 FROM memories
|
|
78
|
-
WHERE fingerprint = ? AND type = 'error'
|
|
79
|
-
AND origin = 'reflector' AND status IN ('active', 'stale')
|
|
80
|
-
AND created_at > datetime('now', '-24 hours')
|
|
81
|
-
LIMIT 1`)
|
|
82
|
-
.get(fail.fp);
|
|
83
|
-
if (!mem)
|
|
84
|
-
continue;
|
|
85
|
-
this.store
|
|
86
|
-
.prepare("UPDATE tool_calls SET fix_for_fingerprint = ? WHERE rowid = ?")
|
|
87
|
-
.run(fail.fp, successRow.rowid);
|
|
88
|
-
// v0.4.0 (K4-014) — deterministic "Fixed by:" raw material
|
|
89
|
-
// (plan §5.4 / D4-07): copy the linked success call's
|
|
90
|
-
// args_summary into memories.fix_args for every active row of
|
|
91
|
-
// that fingerprint (error + pattern), zero LLM cost.
|
|
92
|
-
const fixArgs = extractFixArgs({
|
|
93
|
-
tool: successRow.tool,
|
|
94
|
-
args_summary: successRow.args_summary ?? null,
|
|
95
|
-
});
|
|
96
|
-
if (fixArgs) {
|
|
97
|
-
this.store
|
|
98
|
-
.prepare(`UPDATE memories SET fix_args = ?
|
|
99
|
-
WHERE fingerprint = ? AND status IN ('active', 'stale')`)
|
|
100
|
-
.run(fixArgs, fail.fp);
|
|
101
|
-
}
|
|
102
|
-
this.metrics?.incr("causal_links", 1);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
async onSessionIdle(sessionId) {
|
|
107
|
-
const linkedErrors = this.store
|
|
108
|
-
.prepare(`SELECT m.id, m.fingerprint, m.recurrence_count,
|
|
109
|
-
(SELECT COUNT(*)
|
|
110
|
-
FROM tool_calls tc_all
|
|
111
|
-
WHERE tc_all.fix_for_fingerprint = m.fingerprint) as evidence_count
|
|
112
|
-
FROM memories m
|
|
113
|
-
WHERE m.type = 'error'
|
|
114
|
-
AND m.origin = 'reflector'
|
|
115
|
-
AND m.fingerprint IN (
|
|
116
|
-
SELECT DISTINCT fix_for_fingerprint
|
|
117
|
-
FROM tool_calls
|
|
118
|
-
WHERE session_id = ? AND fix_for_fingerprint IS NOT NULL
|
|
119
|
-
)
|
|
120
|
-
AND m.status IN ('active', 'stale')
|
|
121
|
-
GROUP BY m.fingerprint
|
|
122
|
-
HAVING (
|
|
123
|
-
SELECT MAX(tc.ts) FROM tool_calls tc
|
|
124
|
-
WHERE tc.fix_for_fingerprint = m.fingerprint
|
|
125
|
-
) >= COALESCE(
|
|
126
|
-
(SELECT MAX(m2.updated_at) FROM memories m2
|
|
127
|
-
WHERE m2.fingerprint = m.fingerprint
|
|
128
|
-
AND m2.type = 'pattern'
|
|
129
|
-
AND m2.origin = 'causal'),
|
|
130
|
-
'1970-01-01'
|
|
131
|
-
)`)
|
|
132
|
-
.all(sessionId);
|
|
133
|
-
let promoted = 0;
|
|
134
|
-
for (const err of linkedErrors) {
|
|
135
|
-
try {
|
|
136
|
-
const result = this.memoryService.promoteToPattern(err.id, err.evidence_count, err.recurrence_count ?? 0);
|
|
137
|
-
if (result) {
|
|
138
|
-
promoted++;
|
|
139
|
-
// v0.4.0 (K4-009) — only a NEW pattern row counts as a
|
|
140
|
-
// promotion; the idempotent refresh path no longer
|
|
141
|
-
// inflates the metric. `patterns_causal` is deprecated
|
|
142
|
-
// (key kept for compat, never incremented).
|
|
143
|
-
if (result.created) {
|
|
144
|
-
this.metrics?.incr("patterns_promoted_new", 1);
|
|
145
|
-
// v0.4.0 (K4-015) — promotion-time LLM enrichment:
|
|
146
|
-
// at most one call per NEW pattern, gated on
|
|
147
|
-
// `kevin_settings.llm_reflection_enabled` and the
|
|
148
|
-
// per-pattern `metadata.enriched` marker. Never on
|
|
149
|
-
// the failure hot path.
|
|
150
|
-
await this.enrichIfEnabled(err.id, result.id);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// promoteToPattern may fail if the error memory was removed
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return promoted;
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* v0.4.0 (K4-015) — fire the opt-in enrich hook at most once per
|
|
162
|
-
* promoted pattern. The hook's one-line phrase replaces the
|
|
163
|
-
* deterministic `Fixed by:` line; null keeps it. A call (phrase or
|
|
164
|
-
* not) stamps `metadata.enriched` so repeated idle cycles stay at
|
|
165
|
-
* one LLM call per pattern.
|
|
166
|
-
*/
|
|
167
|
-
async enrichIfEnabled(errorId, patternId) {
|
|
168
|
-
if (!this.enrichFn || !this.isLlmReflectionEnabled())
|
|
169
|
-
return;
|
|
170
|
-
const pattern = this.memoryService.getById(patternId);
|
|
171
|
-
if (!pattern)
|
|
172
|
-
return;
|
|
173
|
-
const meta = (pattern.metadata ?? {});
|
|
174
|
-
if (meta.enriched === true)
|
|
175
|
-
return;
|
|
176
|
-
const phrase = await this.enrichFn({
|
|
177
|
-
lesson: pattern.content,
|
|
178
|
-
fixArgs: pattern.fixArgs ?? null,
|
|
179
|
-
originalError: this.memoryService.getById(errorId)?.content ?? null,
|
|
180
|
-
});
|
|
181
|
-
const content = phrase
|
|
182
|
-
? pattern.content.includes("\nFixed by: ")
|
|
183
|
-
? pattern.content.replace(/\nFixed by: .+$/s, `\n${phrase}`)
|
|
184
|
-
: `${pattern.content}\n${phrase}`
|
|
185
|
-
: pattern.content;
|
|
186
|
-
this.memoryService.update(patternId, {
|
|
187
|
-
content,
|
|
188
|
-
metadata: { ...meta, enriched: true },
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
isLlmReflectionEnabled() {
|
|
192
|
-
const row = this.store
|
|
193
|
-
.prepare("SELECT value FROM kevin_settings WHERE key = ?")
|
|
194
|
-
.get("llm_reflection_enabled");
|
|
195
|
-
return row?.value === "1";
|
|
196
|
-
}
|
|
197
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { type PendingProposal } from "./TuiActions.js";
|
|
2
|
-
export type BridgeDeps = {
|
|
3
|
-
readonly getPending: () => readonly PendingProposal[];
|
|
4
|
-
readonly approve: (proposalId: string) => unknown;
|
|
5
|
-
readonly reject: (proposalId: string, note?: string) => unknown;
|
|
6
|
-
readonly acknowledge: (conflictId: string) => unknown;
|
|
7
|
-
readonly metrics?: {
|
|
8
|
-
incr: (key: "tui_actions_invoked", by?: number) => void;
|
|
9
|
-
} | null;
|
|
10
|
-
};
|
|
11
|
-
export type ParsedBridgeCommand = {
|
|
12
|
-
readonly type: "approve";
|
|
13
|
-
readonly proposalId: string;
|
|
14
|
-
readonly token: string;
|
|
15
|
-
readonly note?: string;
|
|
16
|
-
} | {
|
|
17
|
-
readonly type: "reject";
|
|
18
|
-
readonly proposalId: string;
|
|
19
|
-
readonly token: string;
|
|
20
|
-
readonly note?: string;
|
|
21
|
-
} | {
|
|
22
|
-
readonly type: "ack";
|
|
23
|
-
readonly conflictId: string;
|
|
24
|
-
};
|
|
25
|
-
export declare function parseBridgeCommand(text: string): ParsedBridgeCommand | null;
|
|
26
|
-
export declare const KEVIN_COMMAND_RE: RegExp;
|
|
27
|
-
export interface BridgeResult {
|
|
28
|
-
readonly handled: boolean;
|
|
29
|
-
readonly status?: "applied" | "rejected" | "stale_skipped" | "error";
|
|
30
|
-
readonly detail?: string;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Execute a chat message through the bridge.
|
|
34
|
-
* - Non-matching → {handled:false} (byte-identical pass-through)
|
|
35
|
-
* - Matching but stale/invalid → {handled:false, status:"stale_skipped"} (pass-through + counter)
|
|
36
|
-
* - Valid → executes via deps handlers, {handled:true}
|
|
37
|
-
*
|
|
38
|
-
* Valid commands are SWALLOWED — caller must not forward to model.
|
|
39
|
-
* Invalid/stale commands pass through untouched (D12-09).
|
|
40
|
-
*/
|
|
41
|
-
export declare function handleBridgeCommand(text: string, deps: BridgeDeps): BridgeResult;
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
// v1.2.0 (K12-018 / plan §4.4 R3, D12-09) — chat-command bridge (universal actions).
|
|
2
|
-
// Hot path: ONE regex test on non-match, allocation-free.
|
|
3
|
-
import { verifyFresh, } from "./TuiActions.js";
|
|
4
|
-
// Exact regex for approve/reject (require 16-hex token, optional note capture).
|
|
5
|
-
// Ack variant is token-free (acknowledge is non-destructive, matches mailbox).
|
|
6
|
-
const APPROVE_REJECT_RE = /^\/kevin-(approve|reject)\s+(\S+)\s+([0-9a-f]{16})(?:\s+([\s\S]+))?$/;
|
|
7
|
-
const ACK_RE = /^\/kevin-ack\s+(\S+)\s*$/;
|
|
8
|
-
// Combined pattern for documentation / static analysis (covers both forms).
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
10
|
-
const _DOC_RE = /^\/kevin-(approve|reject|ack)\s+(\S+)\s+([0-9a-f]{16})(?:\s+([\s\S]+))?$/;
|
|
11
|
-
export function parseBridgeCommand(text) {
|
|
12
|
-
// Approve / reject — require token
|
|
13
|
-
const m = APPROVE_REJECT_RE.exec(text);
|
|
14
|
-
if (m) {
|
|
15
|
-
const type = m[1];
|
|
16
|
-
const proposalId = m[2];
|
|
17
|
-
const token = m[3];
|
|
18
|
-
const note = m[4] !== undefined ? m[4] : undefined;
|
|
19
|
-
if (type === "approve")
|
|
20
|
-
return {
|
|
21
|
-
type: "approve",
|
|
22
|
-
proposalId,
|
|
23
|
-
token,
|
|
24
|
-
...(note !== undefined ? { note } : {}),
|
|
25
|
-
};
|
|
26
|
-
return {
|
|
27
|
-
type: "reject",
|
|
28
|
-
proposalId,
|
|
29
|
-
token,
|
|
30
|
-
...(note !== undefined ? { note } : {}),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
const ack = ACK_RE.exec(text);
|
|
34
|
-
if (ack) {
|
|
35
|
-
return { type: "ack", conflictId: ack[1] };
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
// Backward alias for tests that import by plan name
|
|
40
|
-
export const KEVIN_COMMAND_RE = APPROVE_REJECT_RE;
|
|
41
|
-
/**
|
|
42
|
-
* Execute a chat message through the bridge.
|
|
43
|
-
* - Non-matching → {handled:false} (byte-identical pass-through)
|
|
44
|
-
* - Matching but stale/invalid → {handled:false, status:"stale_skipped"} (pass-through + counter)
|
|
45
|
-
* - Valid → executes via deps handlers, {handled:true}
|
|
46
|
-
*
|
|
47
|
-
* Valid commands are SWALLOWED — caller must not forward to model.
|
|
48
|
-
* Invalid/stale commands pass through untouched (D12-09).
|
|
49
|
-
*/
|
|
50
|
-
export function handleBridgeCommand(text, deps) {
|
|
51
|
-
const parsed = parseBridgeCommand(text);
|
|
52
|
-
if (!parsed)
|
|
53
|
-
return { handled: false };
|
|
54
|
-
if (parsed.type === "ack") {
|
|
55
|
-
// Ack is non-destructive, no token verification — matches mailbox acknowledge semantics.
|
|
56
|
-
try {
|
|
57
|
-
deps.acknowledge(parsed.conflictId);
|
|
58
|
-
try {
|
|
59
|
-
deps.metrics?.incr("tui_actions_invoked", 1);
|
|
60
|
-
}
|
|
61
|
-
catch { }
|
|
62
|
-
return { handled: true, status: "applied" };
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
66
|
-
return { handled: true, status: "error", detail: msg };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// approve / reject — verify token against CURRENT pending state (same as mailbox)
|
|
70
|
-
const pending = deps.getPending();
|
|
71
|
-
const action = {
|
|
72
|
-
type: parsed.type,
|
|
73
|
-
proposalId: parsed.proposalId,
|
|
74
|
-
token: parsed.token,
|
|
75
|
-
...(parsed.note !== undefined ? { note: parsed.note } : {}),
|
|
76
|
-
};
|
|
77
|
-
const fresh = verifyFresh(action, pending);
|
|
78
|
-
if (!fresh.ok) {
|
|
79
|
-
// Invalid/stale → pass-through untouched (do NOT swallow). Caller should forward byte-identically.
|
|
80
|
-
// Audit counter could be incremented here, but not as contract metric (blockedSnapshot-style internal).
|
|
81
|
-
return { handled: false, status: "stale_skipped", detail: fresh.reason };
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
if (parsed.type === "approve") {
|
|
85
|
-
deps.approve(parsed.proposalId);
|
|
86
|
-
try {
|
|
87
|
-
deps.metrics?.incr("tui_actions_invoked", 1);
|
|
88
|
-
}
|
|
89
|
-
catch { }
|
|
90
|
-
return { handled: true, status: "applied" };
|
|
91
|
-
}
|
|
92
|
-
deps.reject(parsed.proposalId, parsed.note);
|
|
93
|
-
try {
|
|
94
|
-
deps.metrics?.incr("tui_actions_invoked", 1);
|
|
95
|
-
}
|
|
96
|
-
catch { }
|
|
97
|
-
return { handled: true, status: "rejected" };
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
-
return { handled: true, status: "error", detail: msg };
|
|
102
|
-
}
|
|
103
|
-
}
|