@jmtrin/opencode-kevin 1.0.0 → 1.2.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/LICENSE +21 -0
- package/README.md +54 -8
- package/dist/migrations/001_initial.sql +91 -91
- package/dist/migrations/002_indexes.sql +13 -13
- package/dist/migrations/003_v02_signal.sql +57 -57
- package/dist/migrations/004_v03_knowledge.sql +138 -138
- package/dist/migrations/005_v04_signal.sql +57 -57
- package/dist/migrations/006_v05_glassbox.sql +118 -118
- package/dist/migrations/007_v06_pull.sql +144 -144
- package/dist/migrations/012_v11_drift.sql +24 -0
- package/dist/plugin/Archiver.js +2 -17
- package/dist/plugin/CausalChain.js +32 -13
- package/dist/plugin/ChatBridge.d.ts +41 -0
- package/dist/plugin/ChatBridge.js +103 -0
- package/dist/plugin/ConflictDetector.js +7 -29
- package/dist/plugin/DashboardHtml.d.ts +5 -0
- package/dist/plugin/DashboardHtml.js +180 -0
- package/dist/plugin/Feedback.js +2 -19
- package/dist/plugin/HookLiveness.d.ts +1 -0
- package/dist/plugin/HookLiveness.js +11 -27
- package/dist/plugin/InjectionLedger.js +104 -57
- package/dist/plugin/Materializer.js +1 -88
- package/dist/plugin/MemoryService.d.ts +59 -1
- package/dist/plugin/MemoryService.js +13 -106
- package/dist/plugin/Migrate.js +5 -0
- package/dist/plugin/Retrospective.js +7 -0
- package/dist/plugin/ToolCallObserver.js +18 -5
- package/dist/plugin/TuiActions.d.ts +43 -0
- package/dist/plugin/TuiActions.js +181 -0
- package/dist/plugin/TuiSnapshots.d.ts +24 -0
- package/dist/plugin/TuiSnapshots.js +158 -0
- package/dist/plugin/capabilities.d.ts +2 -0
- package/dist/plugin/capabilities.js +3 -0
- package/dist/plugin/columns.d.ts +11 -0
- package/dist/plugin/columns.js +54 -0
- package/dist/plugin/contract.d.ts +8 -0
- package/dist/plugin/contract.js +23 -5
- package/dist/plugin/index.d.ts +2 -2
- package/dist/plugin/index.js +315 -10
- package/dist/plugin/kevin_audit.d.ts +17 -1
- package/dist/plugin/kevin_audit.js +69 -1
- package/dist/plugin/kevin_forget.d.ts +33 -0
- package/dist/plugin/kevin_forget.js +260 -0
- package/dist/plugin/kevin_why.js +1 -18
- package/dist/plugin/metrics.d.ts +1 -1
- package/dist/plugin/metrics.js +8 -0
- package/dist/plugin/query-tokenizer.js +56 -8
- package/dist/plugin/time-ms.d.ts +1 -0
- package/dist/plugin/time-ms.js +16 -0
- package/dist/plugin/tui-types.d.ts +59 -0
- package/dist/plugin/tui-types.js +4 -0
- package/dist/plugin/tui.d.ts +18 -0
- package/dist/plugin/tui.js +198 -0
- package/package.json +8 -2
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// v1.2.0 (K12-017 / plan §4.4 R2, D12-10) — static dashboard generator.
|
|
2
|
+
// Single self-contained file: inline CSS/JS, snapshot data embedded as const DATA.
|
|
3
|
+
// Zero network: no fetch, no XHR, no WebSocket, no external asset.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const CAP_BYTES = 512 * 1024;
|
|
8
|
+
function byteLen(s) {
|
|
9
|
+
return Buffer.byteLength(s, "utf8");
|
|
10
|
+
}
|
|
11
|
+
// HTML escaping — mirrors escapeInjectedText discipline (C-09 family).
|
|
12
|
+
export function escapeHtml(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """);
|
|
18
|
+
}
|
|
19
|
+
export function proposalToken(proposalId, proposedText) {
|
|
20
|
+
return createHash("sha256")
|
|
21
|
+
.update(`${proposalId}\0${proposedText}`, "utf8")
|
|
22
|
+
.digest("hex")
|
|
23
|
+
.slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
function truncateForHtml(views, cap) {
|
|
26
|
+
// Estimate html size; if over cap, truncate diffs progressively.
|
|
27
|
+
let html = renderDashboardInner(views, false);
|
|
28
|
+
if (byteLen(html) <= cap)
|
|
29
|
+
return views;
|
|
30
|
+
// First pass: cap diffs to 2000 chars
|
|
31
|
+
let truncated = {
|
|
32
|
+
...views,
|
|
33
|
+
proposals: views.proposals.map((p) => {
|
|
34
|
+
if (p.diff.length <= 2000)
|
|
35
|
+
return p;
|
|
36
|
+
return {
|
|
37
|
+
...p,
|
|
38
|
+
diff: `${p.diff.slice(0, 2000)}\n…[truncated]`,
|
|
39
|
+
truncated: true,
|
|
40
|
+
};
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
html = renderDashboardInner(truncated, false);
|
|
44
|
+
if (byteLen(html) <= cap)
|
|
45
|
+
return truncated;
|
|
46
|
+
// Second: 800 chars
|
|
47
|
+
truncated = {
|
|
48
|
+
...views,
|
|
49
|
+
proposals: views.proposals.map((p) => ({
|
|
50
|
+
...p,
|
|
51
|
+
diff: p.diff.length > 800 ? `${p.diff.slice(0, 800)}\n…[truncated]` : p.diff,
|
|
52
|
+
truncated: p.diff.length > 800 ? true : p.truncated,
|
|
53
|
+
})),
|
|
54
|
+
conflicts: views.conflicts.map((c) => ({
|
|
55
|
+
...c,
|
|
56
|
+
a_summary: c.a_summary.slice(0, 200),
|
|
57
|
+
b_summary: c.b_summary.slice(0, 200),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
html = renderDashboardInner(truncated, false);
|
|
61
|
+
if (byteLen(html) <= cap)
|
|
62
|
+
return truncated;
|
|
63
|
+
// Third: drop diffs to 400 chars
|
|
64
|
+
truncated = {
|
|
65
|
+
...views,
|
|
66
|
+
proposals: views.proposals.map((p) => ({
|
|
67
|
+
...p,
|
|
68
|
+
diff: `${p.diff.slice(0, 400)}\n…[truncated]`,
|
|
69
|
+
truncated: true,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
return truncated;
|
|
73
|
+
}
|
|
74
|
+
function renderDashboardInner(views, _withTruncation) {
|
|
75
|
+
const css = "*{box-sizing:border-box}body{font-family:ui-monospace,monospace;margin:0;padding:16px;background:#0f1115;color:#e6e6e6}a{color:#8ab4ff}header{border-bottom:1px solid #2a2e39;padding-bottom:12px;margin-bottom:16px}h1{margin:0;font-size:20px}h2{font-size:16px;margin:24px 0 8px;border-bottom:1px solid #222;padding-bottom:4px}.card{border:1px solid #2a2e39;border-radius:8px;padding:12px;margin:8px 0;background:#151821}.muted{color:#9aa0b2;font-size:12px}.badge{display:inline-block;padding:2px 6px;border-radius:999px;font-size:11px;border:1px solid #2a2e39}.badge-healthy{background:#12331a;color:#8ef0a0}.badge-degraded{background:#331a1a;color:#f0a0a0}.badge-unknown{background:#2a2a33;color:#c0c0d0}pre{white-space:pre-wrap;word-break:break-word;background:#0b0d12;padding:8px;border-radius:6px;overflow:auto;max-height:320px;font-size:12px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #2a2e39;padding:4px 6px;text-align:left}button{cursor:pointer;background:#1f2330;color:#e6e6e6;border:1px solid #2a2e39;border-radius:6px;padding:4px 8px;font-size:12px}button:hover{background:#2a3045}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}.cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}.count{font-size:11px;color:#9aa0b2}";
|
|
76
|
+
// Build proposals HTML server-side
|
|
77
|
+
const proposalsHtml = views.proposals.length === 0
|
|
78
|
+
? `<div class="muted">No pending proposals.</div>`
|
|
79
|
+
: views.proposals
|
|
80
|
+
.map((p) => {
|
|
81
|
+
const token = p.token ?? proposalToken(p.id, p.diff);
|
|
82
|
+
const approveCmd = `/kevin-approve ${p.id} ${token}`;
|
|
83
|
+
const rejectCmd = `/kevin-reject ${p.id} ${token}`;
|
|
84
|
+
return `<div class="card">
|
|
85
|
+
<div><strong>${escapeHtml(p.kind)}</strong> <span class="muted">${escapeHtml(p.id)}</span> <span class="badge">${escapeHtml(p.target_path)}</span> <span class="muted">${escapeHtml(p.created_at)}</span>${p.truncated ? ` <span class="badge">truncated</span>` : ""}</div>
|
|
86
|
+
<div class="muted">memories: ${escapeHtml(p.memory_ids.join(", "))}</div>
|
|
87
|
+
<pre>${escapeHtml(p.diff)}</pre>
|
|
88
|
+
<div style="display:flex;gap:8px;margin-top:8px">
|
|
89
|
+
<button data-copy="${escapeHtml(approveCmd)}" onclick="copyCmd(this)">Copy approve</button>
|
|
90
|
+
<button data-copy="${escapeHtml(rejectCmd)}" onclick="copyCmd(this)">Copy reject</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="muted copy-hint"></div>
|
|
93
|
+
</div>`;
|
|
94
|
+
})
|
|
95
|
+
.join("\n");
|
|
96
|
+
const conflictsHtml = views.conflicts.length === 0
|
|
97
|
+
? `<div class="muted">No open conflicts.</div>`
|
|
98
|
+
: views.conflicts
|
|
99
|
+
.map((c) => {
|
|
100
|
+
const ackCmd = `/kevin-ack ${c.id}`;
|
|
101
|
+
return `<div class="card">
|
|
102
|
+
<div><strong>${escapeHtml(c.kind)}</strong> <span class="muted">${escapeHtml(c.id)}</span> <span class="muted">${escapeHtml(c.opened_at)}</span></div>
|
|
103
|
+
<div class="cols"><div><div class="muted">A</div><pre>${escapeHtml(c.a_summary)}</pre></div><div><div class="muted">B</div><pre>${escapeHtml(c.b_summary)}</pre></div></div>
|
|
104
|
+
<div style="margin-top:8px"><button data-copy="${escapeHtml(ackCmd)}" onclick="copyCmd(this)">Copy ack</button> <span class="muted copy-hint"></span></div>
|
|
105
|
+
</div>`;
|
|
106
|
+
})
|
|
107
|
+
.join("\n");
|
|
108
|
+
const verdictClass = views.health.verdict === "healthy"
|
|
109
|
+
? "badge-healthy"
|
|
110
|
+
: views.health.verdict === "degraded"
|
|
111
|
+
? "badge-degraded"
|
|
112
|
+
: "badge-unknown";
|
|
113
|
+
const hooksRows = views.health.hooks.length === 0
|
|
114
|
+
? `<tr><td colspan="4" class="muted">No hooks</td></tr>`
|
|
115
|
+
: views.health.hooks
|
|
116
|
+
.map((h) => `<tr><td>${escapeHtml(h.hook)}</td><td>${escapeHtml(h.state)}</td><td>${h.fire_count}</td><td>${h.expected_count}</td></tr>`)
|
|
117
|
+
.join("\n");
|
|
118
|
+
const perfRows = views.health.perf.length === 0
|
|
119
|
+
? `<tr><td colspan="4" class="muted">No perf data</td></tr>`
|
|
120
|
+
: views.health.perf
|
|
121
|
+
.map((p) => `<tr><td>${escapeHtml(p.scope)}</td><td>${p.p95}</td><td>${p.budget_p95}</td><td>${p.within_budget ? "yes" : "no"}</td></tr>`)
|
|
122
|
+
.join("\n");
|
|
123
|
+
const countersHtml = Object.keys(views.health.counters).length === 0
|
|
124
|
+
? `<div class="muted">No counters</div>`
|
|
125
|
+
: `<div class="grid">${Object.entries(views.health.counters)
|
|
126
|
+
.map(([k, v]) => `<div class="card"><div class="muted">${escapeHtml(k)}</div><div><strong>${v}</strong></div></div>`)
|
|
127
|
+
.join("\n")}</div>`;
|
|
128
|
+
// Embedded DATA — escape every "<" to \u003c so hostile "<script>" can never appear verbatim inside the <script> block.
|
|
129
|
+
const dataJson = JSON.stringify(views).replace(/</g, "\\u003c");
|
|
130
|
+
const js = `function copyCmd(btn){var cmd=btn.getAttribute('data-copy');var hint=btn.parentElement.nextElementSibling;function done(t){if(hint)hint.textContent=t;setTimeout(function(){if(hint)hint.textContent='';},2500);}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(cmd).then(function(){done('Copied: '+cmd+' — paste into your opencode session');},function(){fallback(cmd,done);});}else{fallback(cmd,done);}}function fallback(cmd,done){var ta=document.createElement('textarea');ta.value=cmd;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');done('Copied: '+cmd+' — paste into your opencode session');}catch(e){done('Copy failed — manually copy: '+cmd);}document.body.removeChild(ta);}`;
|
|
131
|
+
return `<!doctype html>
|
|
132
|
+
<html lang="en">
|
|
133
|
+
<head>
|
|
134
|
+
<meta charset="utf-8">
|
|
135
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
136
|
+
<title>Kevin — Dashboard</title>
|
|
137
|
+
<style>${css}</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<header>
|
|
141
|
+
<h1>Kevin — Surface Dashboard</h1>
|
|
142
|
+
<div class="muted">Generated at ${escapeHtml(views.generatedAt)} · <span class="badge ${verdictClass}">${escapeHtml(views.health.verdict)}</span> ${escapeHtml(views.health.reason)} · contract ${escapeHtml(views.health.contract_digest)}</div>
|
|
143
|
+
<div class="muted">Proposals ${views.proposals.length} · Conflicts ${views.conflicts.length} · Paste copied <code>/kevin-*</code> commands into your opencode session (Desktop or CLI).</div>
|
|
144
|
+
</header>
|
|
145
|
+
|
|
146
|
+
<section>
|
|
147
|
+
<h2>Proposals</h2>
|
|
148
|
+
${proposalsHtml}
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<section>
|
|
152
|
+
<h2>Conflicts</h2>
|
|
153
|
+
${conflictsHtml}
|
|
154
|
+
</section>
|
|
155
|
+
|
|
156
|
+
<section>
|
|
157
|
+
<h2>Health</h2>
|
|
158
|
+
<table><thead><tr><th>hook</th><th>state</th><th>fires</th><th>expected</th></tr></thead><tbody>${hooksRows}</tbody></table>
|
|
159
|
+
<table style="margin-top:12px"><thead><tr><th>scope</th><th>p95 ms</th><th>budget p95</th><th>within</th></tr></thead><tbody>${perfRows}</tbody></table>
|
|
160
|
+
<div style="margin-top:12px">${countersHtml}</div>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
<script>const DATA=${dataJson};${js}</script>
|
|
164
|
+
</body>
|
|
165
|
+
</html>`;
|
|
166
|
+
}
|
|
167
|
+
export function renderDashboard(views) {
|
|
168
|
+
const capped = truncateForHtml(views, CAP_BYTES);
|
|
169
|
+
return renderDashboardInner(capped, true);
|
|
170
|
+
}
|
|
171
|
+
export function writeDashboard(root, views) {
|
|
172
|
+
const dir = join(root, "tui");
|
|
173
|
+
mkdirSync(dir, { recursive: true });
|
|
174
|
+
const html = renderDashboard(views);
|
|
175
|
+
const target = join(dir, "dashboard.html");
|
|
176
|
+
const tmp = `${target}.tmp`;
|
|
177
|
+
writeFileSync(tmp, html, "utf8");
|
|
178
|
+
renameSync(tmp, target);
|
|
179
|
+
return target;
|
|
180
|
+
}
|
package/dist/plugin/Feedback.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFeedbackTable } from "./columns.js";
|
|
1
2
|
import { uuidv7 } from "./uuid.js";
|
|
2
3
|
const POSITIVE_VERDICTS = ["useful"];
|
|
3
4
|
const NEGATIVE_VERDICTS = [
|
|
@@ -5,25 +6,7 @@ const NEGATIVE_VERDICTS = [
|
|
|
5
6
|
"outdated",
|
|
6
7
|
"ignore",
|
|
7
8
|
];
|
|
8
|
-
//
|
|
9
|
-
// descriptive error instead of a bare "no such table" (the tool layer turns
|
|
10
|
-
// it into a graceful message).
|
|
11
|
-
// v0.6.0 (K6-001a) — positive-only caching: a successful probe is cached,
|
|
12
|
-
// a failed probe is NOT. A Store migrated in place heals on the next call.
|
|
13
|
-
const feedbackTableCache = new WeakMap();
|
|
14
|
-
function hasFeedbackTable(store) {
|
|
15
|
-
const cached = feedbackTableCache.get(store);
|
|
16
|
-
if (cached === true)
|
|
17
|
-
return true;
|
|
18
|
-
try {
|
|
19
|
-
store.prepare("SELECT COUNT(*) FROM memory_feedback").get();
|
|
20
|
-
feedbackTableCache.set(store, true);
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
9
|
+
// v1.1.0 (K11-011) — table probe delegates to columns registry
|
|
27
10
|
export class Feedback {
|
|
28
11
|
store;
|
|
29
12
|
metrics;
|
|
@@ -25,6 +25,8 @@ export function parseThreshold(text) {
|
|
|
25
25
|
export class HookLiveness {
|
|
26
26
|
store;
|
|
27
27
|
options;
|
|
28
|
+
// v1.1.0 (K11-015) — debug counter for excess arity; never logged on hot path
|
|
29
|
+
excessArityCount = 0;
|
|
28
30
|
counters;
|
|
29
31
|
seenSessions;
|
|
30
32
|
suppressedSessions = new Set();
|
|
@@ -228,34 +230,16 @@ export class HookLiveness {
|
|
|
228
230
|
}
|
|
229
231
|
throw e;
|
|
230
232
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
catch (e) {
|
|
240
|
-
return recordError(e);
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
if (n === 1) {
|
|
245
|
-
return async (a) => {
|
|
246
|
-
try {
|
|
247
|
-
const result = await delegate(a);
|
|
248
|
-
record();
|
|
249
|
-
return result;
|
|
250
|
-
}
|
|
251
|
-
catch (e) {
|
|
252
|
-
return recordError(e);
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
return async (a, b) => {
|
|
233
|
+
// v1.1.0 (K11-015) — arity guard: maximum supported arity is 2 (plan §5.5).
|
|
234
|
+
// Excess args are sliced and counted via excessArityCount; never logged.
|
|
235
|
+
return async (...args) => {
|
|
236
|
+
let callArgs = args;
|
|
237
|
+
if (args.length > 2) {
|
|
238
|
+
this.excessArityCount++;
|
|
239
|
+
callArgs = args.slice(0, 2);
|
|
240
|
+
}
|
|
257
241
|
try {
|
|
258
|
-
const result = await delegate(
|
|
242
|
+
const result = await delegate(...callArgs);
|
|
259
243
|
record();
|
|
260
244
|
return result;
|
|
261
245
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readOriginCallId } from "./MemoryService.js";
|
|
2
|
+
import { hasColumn } from "./columns.js";
|
|
3
|
+
import { toMs } from "./time-ms.js";
|
|
1
4
|
import { uuidv7 } from "./uuid.js";
|
|
2
5
|
export class InjectionLedger {
|
|
3
6
|
store;
|
|
@@ -11,11 +14,21 @@ export class InjectionLedger {
|
|
|
11
14
|
* duplicates are expected only via the caller's per-session seen-set.
|
|
12
15
|
*/
|
|
13
16
|
record(input) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// v1.1.0 (K11-003 / plan §5.2, D11-01) — dual-write injected_at_ms when column exists
|
|
18
|
+
if (hasColumn(this.store, "kevin_injections", "injected_at_ms")) {
|
|
19
|
+
this.store
|
|
20
|
+
.prepare(`INSERT INTO kevin_injections
|
|
21
|
+
(id, memory_id, fingerprint, session_id, hook, tokens, outcome, injected_at_ms)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, 'unmeasured', ?)`)
|
|
23
|
+
.run(uuidv7(), input.memoryId, input.fingerprint, input.sessionId, input.hook, input.tokens, Date.now());
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
this.store
|
|
27
|
+
.prepare(`INSERT INTO kevin_injections
|
|
28
|
+
(id, memory_id, fingerprint, session_id, hook, tokens, outcome)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?, 'unmeasured')`)
|
|
30
|
+
.run(uuidv7(), input.memoryId, input.fingerprint, input.sessionId, input.hook, input.tokens);
|
|
31
|
+
}
|
|
19
32
|
this.metrics?.incr("injections_total", 1);
|
|
20
33
|
// v0.8.0 (K8-024 / plan §5.7) — shared-layer consumption is counted
|
|
21
34
|
// separately so the audit can tell how much of the push channel
|
|
@@ -40,20 +53,24 @@ export class InjectionLedger {
|
|
|
40
53
|
* recent injection").
|
|
41
54
|
*/
|
|
42
55
|
settle(sessionId) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.
|
|
56
|
+
// v1.1.0 (K11-003 / plan §5.2, D11-01/D11-07) — ms-aware settle: readers
|
|
57
|
+
// prefer _ms and fall back to legacy string. Column probes are cached.
|
|
58
|
+
const hasInjMs = hasColumn(this.store, "kevin_injections", "injected_at_ms");
|
|
59
|
+
const hasToolMs = hasColumn(this.store, "tool_calls", "ts_ms");
|
|
60
|
+
const injections = (hasInjMs
|
|
61
|
+
? this.store.prepare(`SELECT id, memory_id, fingerprint, injected_at, injected_at_ms, outcome
|
|
62
|
+
FROM kevin_injections
|
|
63
|
+
WHERE session_id = ?
|
|
64
|
+
ORDER BY injected_at ASC, id ASC`)
|
|
65
|
+
: this.store.prepare(`SELECT id, memory_id, fingerprint, injected_at, outcome
|
|
66
|
+
FROM kevin_injections
|
|
67
|
+
WHERE session_id = ?
|
|
68
|
+
ORDER BY injected_at ASC, id ASC`)).all(sessionId);
|
|
49
69
|
for (const inj of injections) {
|
|
50
70
|
// Same identity dimension CausalChain uses: the failing call's
|
|
51
71
|
// `error_fingerprint` (stamped by Reflector) or the legacy
|
|
52
|
-
// `fingerprint` hash.
|
|
53
|
-
//
|
|
54
|
-
// COUNT (not LIMIT 1): every failing call after the injection
|
|
55
|
-
// is a recurrence — the charge must reach 3 so D4-06 expels
|
|
56
|
-
// the lesson.
|
|
72
|
+
// `fingerprint` hash.
|
|
73
|
+
// v1.1.0 — time comparison uses toMs helper (prefers _ms).
|
|
57
74
|
//
|
|
58
75
|
// BUG-003 — the exemption is now bounded to the lesson's OWN
|
|
59
76
|
// creating call (memories.metadata.origin_call_id, stamped by
|
|
@@ -65,17 +82,50 @@ export class InjectionLedger {
|
|
|
65
82
|
// precision. Memories without a tracked creating call (agent-
|
|
66
83
|
// saved, test fixtures) get no exemption: only the `ts >=
|
|
67
84
|
// injected_at` bound applies.
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
const metaRow = this.store
|
|
86
|
+
.prepare("SELECT metadata FROM memories WHERE id = ?")
|
|
87
|
+
.get(inj.memory_id);
|
|
88
|
+
const originCallId = readOriginCallId(metaRow?.metadata ?? null);
|
|
89
|
+
// v1.1.0 — heuristic: when legacy string and _ms diverge by >2s
|
|
90
|
+
// (manual UPDATE of injected_at in tests), trust the string
|
|
91
|
+
// because the ms reflects wall time at record, not the pinned
|
|
92
|
+
// fixture time. Real rows differ by <1s (second truncation).
|
|
93
|
+
const rawInjMs = inj.injected_at_ms ?? null;
|
|
94
|
+
const stringMs = inj.injected_at
|
|
95
|
+
? Date.parse(`${inj.injected_at.replace(" ", "T")}Z`)
|
|
96
|
+
: null;
|
|
97
|
+
const injectedMs = rawInjMs !== null &&
|
|
98
|
+
stringMs !== null &&
|
|
99
|
+
!Number.isNaN(stringMs) &&
|
|
100
|
+
Math.abs(rawInjMs - stringMs) > 2000
|
|
101
|
+
? stringMs
|
|
102
|
+
: toMs(inj.injected_at, rawInjMs);
|
|
103
|
+
// Fetch candidate failing calls for this fingerprint and filter by ms
|
|
104
|
+
const failRows = (hasToolMs
|
|
105
|
+
? this.store.prepare(`SELECT id, ts, ts_ms FROM tool_calls
|
|
106
|
+
WHERE session_id = ?
|
|
107
|
+
AND success = 0
|
|
108
|
+
AND COALESCE(error_fingerprint, fingerprint) = ?`)
|
|
109
|
+
: this.store.prepare(`SELECT id, ts FROM tool_calls
|
|
110
|
+
WHERE session_id = ?
|
|
111
|
+
AND success = 0
|
|
112
|
+
AND COALESCE(error_fingerprint, fingerprint) = ?`)).all(sessionId, inj.fingerprint);
|
|
113
|
+
let n = 0;
|
|
114
|
+
for (const r of failRows) {
|
|
115
|
+
if (originCallId !== null && r.id === originCallId)
|
|
116
|
+
continue;
|
|
117
|
+
const tsMs = toMs(r.ts, r.ts_ms ?? null);
|
|
118
|
+
if (injectedMs !== null && tsMs !== null) {
|
|
119
|
+
if (tsMs < injectedMs)
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// fallback to string comparison when either side missing (legacy)
|
|
124
|
+
if (r.ts < inj.injected_at)
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
n++;
|
|
128
|
+
}
|
|
79
129
|
// v0.5.0 (K5-005 / plan §5.1, D5-01) — three-way settlement:
|
|
80
130
|
// recurrences >= 1 → ineffective (existing side effects unchanged)
|
|
81
131
|
// else fixes >= 1 → effective (a linked fix was OBSERVED)
|
|
@@ -97,7 +147,7 @@ export class InjectionLedger {
|
|
|
97
147
|
OR ? > last_injected_at
|
|
98
148
|
THEN ? ELSE last_injected_at END
|
|
99
149
|
WHERE fingerprint = ? AND id = ?`)
|
|
100
|
-
.run(
|
|
150
|
+
.run(n, inj.injected_at, inj.injected_at, inj.fingerprint, inj.memory_id);
|
|
101
151
|
// v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — recurrence
|
|
102
152
|
// expels: a fingerprint at `recurrence_count >= 3` is
|
|
103
153
|
// demoted to `status='stale'` and never injected again
|
|
@@ -116,15 +166,31 @@ export class InjectionLedger {
|
|
|
116
166
|
// `ts >= injected_at` bound and `session_id = ?` filter are
|
|
117
167
|
// kept; there is no `origin_call_id` exemption for fixes —
|
|
118
168
|
// a fix is not the creating call.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
169
|
+
// v1.1.0 — ms-aware: fetch and filter via toMs.
|
|
170
|
+
const fixCandidates = (hasToolMs
|
|
171
|
+
? this.store.prepare(`SELECT ts, ts_ms FROM tool_calls
|
|
172
|
+
WHERE session_id = ?
|
|
173
|
+
AND success = 1
|
|
174
|
+
AND fix_for_fingerprint = ?`)
|
|
175
|
+
: this.store.prepare(`SELECT ts FROM tool_calls
|
|
176
|
+
WHERE session_id = ?
|
|
177
|
+
AND success = 1
|
|
178
|
+
AND fix_for_fingerprint = ?`)).all(sessionId, inj.fingerprint);
|
|
179
|
+
let hasFix = false;
|
|
180
|
+
for (const fr of fixCandidates) {
|
|
181
|
+
const tsMs = toMs(fr.ts, fr.ts_ms ?? null);
|
|
182
|
+
if (injectedMs !== null && tsMs !== null) {
|
|
183
|
+
if (tsMs >= injectedMs) {
|
|
184
|
+
hasFix = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (fr.ts >= inj.injected_at) {
|
|
189
|
+
hasFix = true;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (hasFix) {
|
|
128
194
|
this.store
|
|
129
195
|
.prepare(`UPDATE kevin_injections SET outcome = 'effective'
|
|
130
196
|
WHERE id = ?`)
|
|
@@ -226,23 +292,4 @@ export class InjectionLedger {
|
|
|
226
292
|
return out;
|
|
227
293
|
}
|
|
228
294
|
}
|
|
229
|
-
|
|
230
|
-
* BUG-003 — read `origin_call_id` (the failing tool_call that CREATED the
|
|
231
|
-
* memory) from memories.metadata, mirroring the feedback loop's
|
|
232
|
-
* `readOriginCallId` in MemoryService. Returns null when absent/malformed.
|
|
233
|
-
*/
|
|
234
|
-
function readOriginCallId(store, memoryId) {
|
|
235
|
-
const row = store
|
|
236
|
-
.prepare("SELECT metadata FROM memories WHERE id = ?")
|
|
237
|
-
.get(memoryId);
|
|
238
|
-
if (!row?.metadata)
|
|
239
|
-
return null;
|
|
240
|
-
try {
|
|
241
|
-
const parsed = JSON.parse(row.metadata);
|
|
242
|
-
const id = parsed?.origin_call_id;
|
|
243
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
295
|
+
// v1.1.0 — readOriginCallId deduplicated: imported from MemoryService (K11-003/K11-013)
|
|
@@ -2,100 +2,13 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { firstSentence } from "./Curator.js";
|
|
4
4
|
import { normalize } from "./fingerprint.js";
|
|
5
|
+
import { STOP_WORDS } from "./query-tokenizer.js";
|
|
5
6
|
export const SKILL_TOPIC = "project-knowledge";
|
|
6
7
|
/** Hash-like noise: FNV-1a output is 16 lowercase hex chars; an 8+ hex
|
|
7
8
|
* token is indistinguishable from a fingerprint prefix and must never
|
|
8
9
|
* become a topic (D6-14). */
|
|
9
10
|
const HEX_LIKE_RE = /^[0-9a-f]{8,}$/;
|
|
10
11
|
const TOKEN_RE = /[^a-z0-9]+/;
|
|
11
|
-
/** Conservative English function words; code-adjacent meaning-bearing
|
|
12
|
-
* words (npm, ts2304, cargo...) are deliberately NOT stop-words. */
|
|
13
|
-
const STOP_WORDS = new Set([
|
|
14
|
-
"a",
|
|
15
|
-
"about",
|
|
16
|
-
"after",
|
|
17
|
-
"again",
|
|
18
|
-
"all",
|
|
19
|
-
"also",
|
|
20
|
-
"an",
|
|
21
|
-
"and",
|
|
22
|
-
"any",
|
|
23
|
-
"are",
|
|
24
|
-
"as",
|
|
25
|
-
"at",
|
|
26
|
-
"be",
|
|
27
|
-
"been",
|
|
28
|
-
"before",
|
|
29
|
-
"being",
|
|
30
|
-
"but",
|
|
31
|
-
"by",
|
|
32
|
-
"can",
|
|
33
|
-
"could",
|
|
34
|
-
"did",
|
|
35
|
-
"do",
|
|
36
|
-
"does",
|
|
37
|
-
"for",
|
|
38
|
-
"from",
|
|
39
|
-
"had",
|
|
40
|
-
"has",
|
|
41
|
-
"have",
|
|
42
|
-
"he",
|
|
43
|
-
"her",
|
|
44
|
-
"his",
|
|
45
|
-
"if",
|
|
46
|
-
"in",
|
|
47
|
-
"into",
|
|
48
|
-
"is",
|
|
49
|
-
"it",
|
|
50
|
-
"its",
|
|
51
|
-
"may",
|
|
52
|
-
"might",
|
|
53
|
-
"more",
|
|
54
|
-
"most",
|
|
55
|
-
"must",
|
|
56
|
-
"not",
|
|
57
|
-
"of",
|
|
58
|
-
"on",
|
|
59
|
-
"one",
|
|
60
|
-
"or",
|
|
61
|
-
"our",
|
|
62
|
-
"per",
|
|
63
|
-
"shall",
|
|
64
|
-
"she",
|
|
65
|
-
"so",
|
|
66
|
-
"than",
|
|
67
|
-
"that",
|
|
68
|
-
"the",
|
|
69
|
-
"their",
|
|
70
|
-
"them",
|
|
71
|
-
"then",
|
|
72
|
-
"there",
|
|
73
|
-
"these",
|
|
74
|
-
"they",
|
|
75
|
-
"this",
|
|
76
|
-
"those",
|
|
77
|
-
"through",
|
|
78
|
-
"to",
|
|
79
|
-
"too",
|
|
80
|
-
"under",
|
|
81
|
-
"up",
|
|
82
|
-
"us",
|
|
83
|
-
"via",
|
|
84
|
-
"was",
|
|
85
|
-
"we",
|
|
86
|
-
"were",
|
|
87
|
-
"what",
|
|
88
|
-
"when",
|
|
89
|
-
"where",
|
|
90
|
-
"which",
|
|
91
|
-
"while",
|
|
92
|
-
"who",
|
|
93
|
-
"will",
|
|
94
|
-
"with",
|
|
95
|
-
"would",
|
|
96
|
-
"you",
|
|
97
|
-
"your",
|
|
98
|
-
]);
|
|
99
12
|
/** Sanitize a memory type into a filesystem-safe topic prefix. */
|
|
100
13
|
function sanitizeType(type) {
|
|
101
14
|
const cleaned = type
|
|
@@ -160,8 +160,55 @@ export interface GetRelevantInput {
|
|
|
160
160
|
*/
|
|
161
161
|
now?: Date;
|
|
162
162
|
}
|
|
163
|
+
interface MemoryRow {
|
|
164
|
+
id: string;
|
|
165
|
+
type: MemoryType;
|
|
166
|
+
content: string;
|
|
167
|
+
scope: MemoryScope;
|
|
168
|
+
relevance_score: number;
|
|
169
|
+
source_tool: string | null;
|
|
170
|
+
source_session: string | null;
|
|
171
|
+
metadata: string | null;
|
|
172
|
+
created_at: string;
|
|
173
|
+
updated_at: string;
|
|
174
|
+
expires_at: string | null;
|
|
175
|
+
/** v0.2.0 columns — nullable for rows from pre-003 DBs. */
|
|
176
|
+
project_id?: string | null;
|
|
177
|
+
fingerprint?: string | null;
|
|
178
|
+
origin?: MemoryOrigin | null;
|
|
179
|
+
/** v0.3.0 */
|
|
180
|
+
evidence_count?: number;
|
|
181
|
+
last_verified_at?: string | null;
|
|
182
|
+
status?: string;
|
|
183
|
+
/** v0.4.0 */
|
|
184
|
+
recurrence_count?: number;
|
|
185
|
+
/** v0.4.0 */
|
|
186
|
+
fix_args?: string | null;
|
|
187
|
+
/** v0.5.0 (K5-009) */
|
|
188
|
+
ignored?: number;
|
|
189
|
+
/** v0.5.0 (K5-009) */
|
|
190
|
+
superseded_by?: string | null;
|
|
191
|
+
/** v0.5.0 (K5-010) */
|
|
192
|
+
feedback_positive?: number;
|
|
193
|
+
/** v0.5.0 (K5-010) */
|
|
194
|
+
feedback_negative?: number;
|
|
195
|
+
/** v0.6.0 (K6-011 / migration 007) */
|
|
196
|
+
curated?: number;
|
|
197
|
+
/** v0.6.0 (K6-011 / migration 007) */
|
|
198
|
+
curated_at?: string | null;
|
|
199
|
+
/** v0.6.0 (K6-011 / migration 007) — 1 = inferable, 0 = non_inferable,
|
|
200
|
+
* NULL = unknown. */
|
|
201
|
+
inferable?: number | null;
|
|
202
|
+
/** v0.7.0 (K7-008 / migration 008) — de-ranking penalty in [0, 0.5]. */
|
|
203
|
+
truth_penalty?: number | null;
|
|
204
|
+
/** v0.7.0 (K7-008 / migration 008) — first contradiction timestamp. */
|
|
205
|
+
contradicted_at?: string | null;
|
|
206
|
+
/** v0.8.0 (K8-018 / migration 009) — layer marker on the row. */
|
|
207
|
+
layer?: string | null;
|
|
208
|
+
}
|
|
163
209
|
export declare const DATE_NOW = "2099-01-01T00:00:00.000Z";
|
|
164
210
|
export declare function hasRepoIdColumn(store: Store): boolean;
|
|
211
|
+
export declare function mapRow(row: MemoryRow, score?: number): Memory;
|
|
165
212
|
export declare class MemoryService {
|
|
166
213
|
private readonly metrics;
|
|
167
214
|
constructor(store: Store, metrics?: Metrics | null, repoId?: string | null);
|
|
@@ -169,7 +216,6 @@ export declare class MemoryService {
|
|
|
169
216
|
private repoId;
|
|
170
217
|
setRepoId(repoId: string | null): void;
|
|
171
218
|
private hasRecurrenceColumn;
|
|
172
|
-
private _hasRecurrenceColumn;
|
|
173
219
|
private hasIgnoredColumn;
|
|
174
220
|
private hasCuratedColumn;
|
|
175
221
|
private hasTruthColumns;
|
|
@@ -290,6 +336,17 @@ export declare class MemoryService {
|
|
|
290
336
|
countSupersedeCandidates(type: MemoryType, fingerprint: string | null | undefined, projectId: string | null): number;
|
|
291
337
|
penalizeRecurringReflectors(sessionId: string): number;
|
|
292
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* v0.3.0 fix — Extract `origin_call_id` from the memory metadata blob.
|
|
341
|
+
*
|
|
342
|
+
* Reflector stores the failing tool_call id in metadata.origin_call_id
|
|
343
|
+
* (when available) so the feedback loop can exclude the original call
|
|
344
|
+
* from the recurrence count. Returns null when metadata is absent,
|
|
345
|
+
* malformed, or lacks the field.
|
|
346
|
+
* // v1.1.0 (K11-003 / plan §5.5, D11-05) — single source for origin lookup;
|
|
347
|
+
* // InjectionLedger reuses this implementation (K11-013).
|
|
348
|
+
*/
|
|
349
|
+
export declare function readOriginCallId(metadata: string | null): string | null;
|
|
293
350
|
/**
|
|
294
351
|
* v0.3.0 fix — Count active memories that would be superseded by a new
|
|
295
352
|
* row with the given (type, fingerprint, projectId) tuple. Used by
|
|
@@ -300,3 +357,4 @@ export declare class MemoryService {
|
|
|
300
357
|
* any other type.
|
|
301
358
|
*/
|
|
302
359
|
export declare function countSupersedeCandidates(store: Store, type: MemoryType, fingerprint: string | null | undefined, projectId: string | null): number;
|
|
360
|
+
export {};
|