@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.
- package/dist/Archiver.d.ts +42 -0
- package/dist/Archiver.js +83 -0
- package/dist/ArtifactWriter.d.ts +93 -0
- package/dist/ArtifactWriter.js +291 -0
- package/dist/CausalChain.d.ts +22 -0
- package/dist/CausalChain.js +197 -0
- package/dist/ChatBridge.d.ts +41 -0
- package/dist/ChatBridge.js +103 -0
- package/dist/ConflictDetector.d.ts +35 -0
- package/dist/ConflictDetector.js +260 -0
- package/dist/ContextInjector.d.ts +222 -0
- package/dist/ContextInjector.js +533 -0
- package/dist/ConventionMiner.d.ts +35 -0
- package/dist/ConventionMiner.js +242 -0
- package/dist/Curator.d.ts +118 -0
- package/dist/Curator.js +326 -0
- package/dist/DashboardHtml.d.ts +5 -0
- package/dist/DashboardHtml.js +180 -0
- package/dist/Feedback.d.ts +67 -0
- package/dist/Feedback.js +120 -0
- package/dist/HookLiveness.d.ts +88 -0
- package/dist/HookLiveness.js +322 -0
- package/dist/InjectionLedger.d.ts +98 -0
- package/dist/InjectionLedger.js +295 -0
- package/dist/LessonFixer.d.ts +44 -0
- package/dist/LessonFixer.js +45 -0
- package/dist/Materializer.d.ts +84 -0
- package/dist/Materializer.js +181 -0
- package/dist/MemoryService.d.ts +360 -0
- package/dist/MemoryService.js +1242 -0
- package/dist/Migrate.d.ts +24 -0
- package/dist/Migrate.js +285 -0
- package/dist/PatternMiner.d.ts +49 -0
- package/dist/PatternMiner.js +132 -0
- package/dist/QualityGate.d.ts +124 -0
- package/dist/QualityGate.js +119 -0
- package/dist/Reflector.d.ts +91 -0
- package/dist/Reflector.js +307 -0
- package/dist/RepoIdentity.d.ts +124 -0
- package/dist/RepoIdentity.js +300 -0
- package/dist/RepoTruth.d.ts +80 -0
- package/dist/RepoTruth.js +599 -0
- package/dist/Retrospective.d.ts +18 -0
- package/dist/Retrospective.js +216 -0
- package/dist/SharedLayer.d.ts +159 -0
- package/dist/SharedLayer.js +462 -0
- package/dist/Store.d.ts +29 -0
- package/dist/Store.js +50 -0
- package/dist/ToolCallObserver.d.ts +32 -0
- package/dist/ToolCallObserver.js +201 -0
- package/dist/TuiActions.d.ts +43 -0
- package/dist/TuiActions.js +181 -0
- package/dist/TuiSnapshots.d.ts +24 -0
- package/dist/TuiSnapshots.js +159 -0
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.js +1 -0
- package/dist/columns.d.ts +11 -0
- package/dist/columns.js +54 -0
- package/dist/confidence.d.ts +8 -0
- package/dist/confidence.js +34 -0
- package/dist/contract.d.ts +68 -0
- package/dist/contract.js +430 -0
- package/dist/diff.d.ts +8 -0
- package/dist/diff.js +182 -0
- package/dist/env.d.ts +5 -0
- package/dist/env.js +12 -0
- package/dist/escape.d.ts +39 -0
- package/dist/escape.js +60 -0
- package/dist/fingerprint.d.ts +27 -0
- package/dist/fingerprint.js +73 -0
- package/dist/host.d.ts +18 -0
- package/dist/host.js +7 -0
- package/dist/idle-pipeline.d.ts +9 -0
- package/dist/idle-pipeline.js +44 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +109 -0
- package/dist/inferability.d.ts +32 -0
- package/dist/inferability.js +88 -0
- package/dist/kevin_approve.d.ts +34 -0
- package/dist/kevin_approve.js +52 -0
- package/dist/kevin_audit.d.ts +230 -0
- package/dist/kevin_audit.js +631 -0
- package/dist/kevin_bench.d.ts +11 -0
- package/dist/kevin_bench.js +83 -0
- package/dist/kevin_conflicts.d.ts +9 -0
- package/dist/kevin_conflicts.js +50 -0
- package/dist/kevin_contract.d.ts +8 -0
- package/dist/kevin_contract.js +59 -0
- package/dist/kevin_doctor.d.ts +63 -0
- package/dist/kevin_doctor.js +197 -0
- package/dist/kevin_facts.d.ts +42 -0
- package/dist/kevin_facts.js +36 -0
- package/dist/kevin_forget.d.ts +33 -0
- package/dist/kevin_forget.js +260 -0
- package/dist/kevin_native.d.ts +29 -0
- package/dist/kevin_native.js +79 -0
- package/dist/kevin_propose.d.ts +23 -0
- package/dist/kevin_propose.js +14 -0
- package/dist/kevin_publish.d.ts +38 -0
- package/dist/kevin_publish.js +18 -0
- package/dist/kevin_why.d.ts +23 -0
- package/dist/kevin_why.js +111 -0
- package/dist/memory-format.d.ts +20 -0
- package/dist/memory-format.js +59 -0
- package/dist/metrics.d.ts +97 -0
- package/dist/metrics.js +288 -0
- package/dist/migrations/001_initial.sql +92 -0
- package/dist/migrations/002_indexes.sql +14 -0
- package/dist/migrations/003_v02_signal.sql +58 -0
- package/dist/migrations/004_v03_knowledge.sql +138 -0
- package/dist/migrations/005_v04_signal.sql +57 -0
- package/dist/migrations/006_v05_glassbox.sql +118 -0
- package/dist/migrations/007_v06_pull.sql +145 -0
- package/dist/migrations/008_v07_truth.sql +124 -0
- package/dist/migrations/009_v08_team.sql +100 -0
- package/dist/migrations/010_v09_native.sql +78 -0
- package/dist/migrations/011_v10_proven.sql +69 -0
- package/dist/migrations/012_v11_drift.sql +24 -0
- package/dist/native.d.ts +23 -0
- package/dist/native.js +1 -0
- package/dist/okf-export.d.ts +3 -0
- package/dist/okf-export.js +134 -0
- package/dist/okf-import.d.ts +76 -0
- package/dist/okf-import.js +271 -0
- package/dist/okf.d.ts +107 -0
- package/dist/okf.js +303 -0
- package/dist/perf.d.ts +35 -0
- package/dist/perf.js +174 -0
- package/dist/query-tokenizer.d.ts +13 -0
- package/dist/query-tokenizer.js +133 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.js +18 -0
- package/dist/replay-types.d.ts +69 -0
- package/dist/replay-types.js +156 -0
- package/dist/replay.d.ts +44 -0
- package/dist/replay.js +215 -0
- package/dist/sqlite-adapter.d.ts +12 -0
- package/dist/sqlite-adapter.js +78 -0
- package/dist/time-ms.d.ts +1 -0
- package/dist/time-ms.js +16 -0
- package/dist/tui-types.d.ts +59 -0
- package/dist/tui-types.js +4 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +50 -0
- package/package.json +28 -0
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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;
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MemoryService } from "./MemoryService.js";
|
|
2
|
+
import type { RepoTruth } from "./RepoTruth.js";
|
|
3
|
+
import type { Store } from "./Store.js";
|
|
4
|
+
import type { Metrics } from "./metrics.js";
|
|
5
|
+
export type ConflictKind = "repo_truth" | "decision_pair" | "temporal";
|
|
6
|
+
export interface Conflict {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly kind: ConflictKind;
|
|
9
|
+
readonly memoryA: string;
|
|
10
|
+
readonly memoryB?: string;
|
|
11
|
+
readonly factId?: string;
|
|
12
|
+
readonly detail: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RepoTruthConflictInput {
|
|
15
|
+
readonly memoryId: string;
|
|
16
|
+
readonly factId?: string | null;
|
|
17
|
+
readonly reasons: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export declare class ConflictDetector {
|
|
20
|
+
private readonly store;
|
|
21
|
+
private readonly projectId;
|
|
22
|
+
private readonly repoTruth?;
|
|
23
|
+
private readonly memoryService?;
|
|
24
|
+
private readonly metrics;
|
|
25
|
+
constructor(store: Store, projectId: string, metrics?: Metrics | null, repoTruth?: RepoTruth | undefined, memoryService?: MemoryService | undefined);
|
|
26
|
+
detect(repoTruthInput?: readonly RepoTruthConflictInput[]): Conflict[];
|
|
27
|
+
acknowledge(id: string): void;
|
|
28
|
+
/** Human-only operation. It resolves the conflict row, never a memory. */
|
|
29
|
+
resolve(id: string, keepMemoryId: string): void;
|
|
30
|
+
private detectRepoTruth;
|
|
31
|
+
private repoTruthInputs;
|
|
32
|
+
private detectDecisionPairs;
|
|
33
|
+
private detectTemporal;
|
|
34
|
+
private insertIfNew;
|
|
35
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { mapRow } from "./MemoryService.js";
|
|
2
|
+
import { normalize } from "./fingerprint.js";
|
|
3
|
+
import { STOP_WORDS } from "./query-tokenizer.js";
|
|
4
|
+
import { uuidv7 } from "./uuid.js";
|
|
5
|
+
const POLARITY_RULES = [
|
|
6
|
+
{
|
|
7
|
+
positive: ["use"],
|
|
8
|
+
negative: [
|
|
9
|
+
["do", "not", "use"],
|
|
10
|
+
["don't", "use"],
|
|
11
|
+
["never", "use"],
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{ positive: ["always"], negative: [["never"]] },
|
|
15
|
+
{ positive: ["required"], negative: [["forbidden"], ["not", "required"]] },
|
|
16
|
+
{ positive: ["enable"], negative: [["disable"]] },
|
|
17
|
+
{ positive: ["prefer"], negative: [["avoid"]] },
|
|
18
|
+
];
|
|
19
|
+
const POLARITY_WORDS = new Set([
|
|
20
|
+
"use",
|
|
21
|
+
"do",
|
|
22
|
+
"not",
|
|
23
|
+
// "don't" tokenizes to ["don", "t"] (see tokens()); the lexicon phrase
|
|
24
|
+
// ["don't","use"] is matched by tokenizing the phrase the same way, so
|
|
25
|
+
// both fragments must be excluded from subjects() too (K7-014).
|
|
26
|
+
"don",
|
|
27
|
+
"t",
|
|
28
|
+
"never",
|
|
29
|
+
"always",
|
|
30
|
+
"required",
|
|
31
|
+
"forbidden",
|
|
32
|
+
"enable",
|
|
33
|
+
"disable",
|
|
34
|
+
"prefer",
|
|
35
|
+
"avoid",
|
|
36
|
+
]);
|
|
37
|
+
export class ConflictDetector {
|
|
38
|
+
store;
|
|
39
|
+
projectId;
|
|
40
|
+
repoTruth;
|
|
41
|
+
memoryService;
|
|
42
|
+
metrics;
|
|
43
|
+
constructor(store, projectId, metrics, repoTruth, memoryService) {
|
|
44
|
+
this.store = store;
|
|
45
|
+
this.projectId = projectId;
|
|
46
|
+
this.repoTruth = repoTruth;
|
|
47
|
+
this.memoryService = memoryService;
|
|
48
|
+
this.metrics = metrics ?? null;
|
|
49
|
+
}
|
|
50
|
+
detect(repoTruthInput) {
|
|
51
|
+
const detected = [];
|
|
52
|
+
detected.push(...this.detectRepoTruth(repoTruthInput));
|
|
53
|
+
detected.push(...this.detectDecisionPairs());
|
|
54
|
+
detected.push(...this.detectTemporal());
|
|
55
|
+
return detected;
|
|
56
|
+
}
|
|
57
|
+
acknowledge(id) {
|
|
58
|
+
this.store
|
|
59
|
+
.prepare("UPDATE memory_conflicts SET status = 'acknowledged' WHERE id = ? AND project_id = ? AND status = 'open'")
|
|
60
|
+
.run(id, this.projectId);
|
|
61
|
+
}
|
|
62
|
+
/** Human-only operation. It resolves the conflict row, never a memory. */
|
|
63
|
+
resolve(id, keepMemoryId) {
|
|
64
|
+
const row = this.store
|
|
65
|
+
.prepare(`SELECT memory_a, memory_b FROM memory_conflicts
|
|
66
|
+
WHERE id = ? AND project_id = ? AND status <> 'resolved'`)
|
|
67
|
+
.get(id, this.projectId);
|
|
68
|
+
if (!row)
|
|
69
|
+
throw new Error(`conflict not found: ${id}`);
|
|
70
|
+
if (keepMemoryId !== row.memory_a && keepMemoryId !== row.memory_b) {
|
|
71
|
+
throw new Error("keep must identify one of the conflict memories");
|
|
72
|
+
}
|
|
73
|
+
this.store
|
|
74
|
+
.prepare("UPDATE memory_conflicts SET status = 'resolved', resolved_at = datetime('now') WHERE id = ? AND project_id = ?")
|
|
75
|
+
.run(id, this.projectId);
|
|
76
|
+
}
|
|
77
|
+
detectRepoTruth(input) {
|
|
78
|
+
const items = input ?? this.repoTruthInputs();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
for (const reason of item.reasons) {
|
|
82
|
+
this.memoryService?.applyTruthPenalty(item.memoryId, 0.5, reason);
|
|
83
|
+
const id = this.insertIfNew({
|
|
84
|
+
kind: "repo_truth",
|
|
85
|
+
memoryA: item.memoryId,
|
|
86
|
+
memoryB: null,
|
|
87
|
+
factId: item.factId ?? null,
|
|
88
|
+
detail: reason,
|
|
89
|
+
});
|
|
90
|
+
if (id)
|
|
91
|
+
out.push({
|
|
92
|
+
id,
|
|
93
|
+
kind: "repo_truth",
|
|
94
|
+
memoryA: item.memoryId,
|
|
95
|
+
factId: item.factId ?? undefined,
|
|
96
|
+
detail: reason,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// v0.7.0 (D7-03) — "recoverable by re-scanning": a memory whose
|
|
101
|
+
// contradiction has lapsed (the repo no longer contradicts it) is
|
|
102
|
+
// de-ranked no longer. The conflict row stays open — resolution is
|
|
103
|
+
// human-initiated only (D7-06) — but the score penalty lifts, so
|
|
104
|
+
// fixing the repo genuinely recovers the memory. Runs only on the
|
|
105
|
+
// production path (no explicit input): explicit-input callers control
|
|
106
|
+
// every write themselves.
|
|
107
|
+
if (!input && this.repoTruth && this.memoryService) {
|
|
108
|
+
const penalized = this.store
|
|
109
|
+
.prepare("SELECT id FROM memories WHERE project_id = ? AND truth_penalty > 0 AND status = 'active'")
|
|
110
|
+
.all(this.projectId);
|
|
111
|
+
for (const row of penalized) {
|
|
112
|
+
const memory = this.memoryService.getById(row.id);
|
|
113
|
+
if (!memory)
|
|
114
|
+
continue;
|
|
115
|
+
if ((this.repoTruth.contradictions(memory) ?? []).length === 0) {
|
|
116
|
+
this.memoryService.applyTruthPenalty(row.id, 0, "");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
repoTruthInputs() {
|
|
123
|
+
if (!this.repoTruth)
|
|
124
|
+
return [];
|
|
125
|
+
const rows = this.store
|
|
126
|
+
.prepare(`SELECT id, type, content, scope, relevance_score, source_tool,
|
|
127
|
+
source_session, metadata, created_at, updated_at, expires_at,
|
|
128
|
+
project_id, fingerprint, origin, evidence_count, last_verified_at,
|
|
129
|
+
status, fix_args, ignored, superseded_by, feedback_positive,
|
|
130
|
+
feedback_negative, curated, curated_at, inferable,
|
|
131
|
+
truth_penalty, contradicted_at
|
|
132
|
+
FROM memories WHERE project_id = ? AND status = 'active'`)
|
|
133
|
+
.all(this.projectId);
|
|
134
|
+
return rows.flatMap((row) => {
|
|
135
|
+
// v1.1.0 (K11-014) — route through mapRow so metadata is parsed and confidence derived
|
|
136
|
+
const memory = mapRow(row);
|
|
137
|
+
const reasons = this.repoTruth?.contradictions(memory) ?? [];
|
|
138
|
+
return reasons.length > 0
|
|
139
|
+
? [{ memoryId: row.id, reasons }]
|
|
140
|
+
: [];
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
detectDecisionPairs() {
|
|
144
|
+
const rows = this.store
|
|
145
|
+
.prepare(`SELECT id, type, content, fingerprint FROM memories
|
|
146
|
+
WHERE project_id = ? AND status = 'active'
|
|
147
|
+
AND type IN ('decision', 'rule') ORDER BY id`)
|
|
148
|
+
.all(this.projectId);
|
|
149
|
+
const out = [];
|
|
150
|
+
for (let i = 0; i < rows.length; i++) {
|
|
151
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
152
|
+
const a = rows[i];
|
|
153
|
+
const b = rows[j];
|
|
154
|
+
if (!a || !b || a.fingerprint === b.fingerprint)
|
|
155
|
+
continue;
|
|
156
|
+
const pair = oppositePair(a.content, b.content);
|
|
157
|
+
if (!pair)
|
|
158
|
+
continue;
|
|
159
|
+
const id = this.insertIfNew({
|
|
160
|
+
kind: "decision_pair",
|
|
161
|
+
memoryA: a.id,
|
|
162
|
+
memoryB: b.id,
|
|
163
|
+
factId: null,
|
|
164
|
+
detail: pair,
|
|
165
|
+
});
|
|
166
|
+
if (id)
|
|
167
|
+
out.push({
|
|
168
|
+
id,
|
|
169
|
+
kind: "decision_pair",
|
|
170
|
+
memoryA: a.id,
|
|
171
|
+
memoryB: b.id,
|
|
172
|
+
detail: pair,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
detectTemporal() {
|
|
179
|
+
const rows = this.store
|
|
180
|
+
.prepare(`SELECT m.id,
|
|
181
|
+
MAX(CASE WHEN i.outcome = 'effective' THEN i.injected_at END) AS effective_at,
|
|
182
|
+
MAX(CASE WHEN i.outcome = 'ineffective' THEN i.injected_at END) AS ineffective_at
|
|
183
|
+
FROM memories m JOIN kevin_injections i ON i.memory_id = m.id
|
|
184
|
+
WHERE m.project_id = ? AND m.status = 'active'
|
|
185
|
+
GROUP BY m.id
|
|
186
|
+
HAVING effective_at IS NOT NULL AND ineffective_at IS NOT NULL
|
|
187
|
+
AND ineffective_at > effective_at`)
|
|
188
|
+
.all(this.projectId);
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
const detail = `memory was effective at ${row.effective_at} and became ineffective at ${row.ineffective_at}`;
|
|
192
|
+
const id = this.insertIfNew({
|
|
193
|
+
kind: "temporal",
|
|
194
|
+
memoryA: row.id,
|
|
195
|
+
memoryB: null,
|
|
196
|
+
factId: null,
|
|
197
|
+
detail,
|
|
198
|
+
});
|
|
199
|
+
if (id)
|
|
200
|
+
out.push({ id, kind: "temporal", memoryA: row.id, detail });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
insertIfNew(input) {
|
|
205
|
+
const existing = this.store
|
|
206
|
+
.prepare(`SELECT id FROM memory_conflicts
|
|
207
|
+
WHERE project_id = ? AND kind = ? AND memory_a = ?
|
|
208
|
+
AND (memory_b IS ? OR (memory_b IS NULL AND ? IS NULL))
|
|
209
|
+
AND (fact_id IS ? OR (fact_id IS NULL AND ? IS NULL))
|
|
210
|
+
AND status = 'open' LIMIT 1`)
|
|
211
|
+
.get(this.projectId, input.kind, input.memoryA, input.memoryB, input.memoryB, input.factId, input.factId);
|
|
212
|
+
if (existing)
|
|
213
|
+
return null;
|
|
214
|
+
const id = uuidv7();
|
|
215
|
+
this.store
|
|
216
|
+
.prepare(`INSERT INTO memory_conflicts
|
|
217
|
+
(id, project_id, memory_a, memory_b, fact_id, kind, detail)
|
|
218
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
219
|
+
.run(id, this.projectId, input.memoryA, input.memoryB, input.factId, input.kind, input.detail);
|
|
220
|
+
this.metrics?.incr("conflicts_detected", 1);
|
|
221
|
+
return id;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function tokens(text) {
|
|
225
|
+
return normalize(text).match(/[a-z0-9@._/-]+/g) ?? [];
|
|
226
|
+
}
|
|
227
|
+
function hasPhrase(words, phrase) {
|
|
228
|
+
if (phrase.length === 1)
|
|
229
|
+
return words.includes(phrase[0] ?? "");
|
|
230
|
+
for (let i = 0; i <= words.length - phrase.length; i++) {
|
|
231
|
+
if (phrase.every((word, offset) => words[i + offset] === word))
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
function polarity(text, rule) {
|
|
237
|
+
const words = tokens(text);
|
|
238
|
+
// Lexicon phrases are run through the same tokenizer as the text: "don't
|
|
239
|
+
// use" → ["don","t","use"], which is exactly how the text's own apostrophe
|
|
240
|
+
// is split, so a phrase never becomes unreachable (K7-014).
|
|
241
|
+
const match = (phrase) => hasPhrase(words, tokens(phrase.join(" ")));
|
|
242
|
+
const negative = rule.negative.some(match);
|
|
243
|
+
return { positive: !negative && match(rule.positive), negative };
|
|
244
|
+
}
|
|
245
|
+
function subjects(text) {
|
|
246
|
+
return new Set(tokens(text).filter((token) => !STOP_WORDS.has(token) && !POLARITY_WORDS.has(token)));
|
|
247
|
+
}
|
|
248
|
+
function oppositePair(a, b) {
|
|
249
|
+
const shared = [...subjects(a)].some((token) => subjects(b).has(token));
|
|
250
|
+
if (!shared)
|
|
251
|
+
return null;
|
|
252
|
+
for (const rule of POLARITY_RULES) {
|
|
253
|
+
const pa = polarity(a, rule);
|
|
254
|
+
const pb = polarity(b, rule);
|
|
255
|
+
if ((pa.positive && pb.negative) || (pa.negative && pb.positive)) {
|
|
256
|
+
return `statements carry opposite ${rule.positive.join(" ")} polarity for shared subject ${shared}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|