@jmtrin/opencode-kevin 1.1.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/capabilities.d.ts +2 -0
- package/dist/plugin/capabilities.js +3 -0
- package/dist/plugin/index.d.ts +5 -4
- package/dist/plugin/index.js +401 -108
- package/dist/plugin/native.d.ts +2 -2
- package/dist/plugin/native.js +2 -2
- package/package.json +56 -63
- package/LICENSE +0 -21
- package/README.md +0 -576
- 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/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/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 -213
- 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/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 -383
- 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 -213
- package/dist/plugin/kevin_audit.js +0 -563
- 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 -284
- 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/uuid.d.ts +0 -1
- package/dist/plugin/uuid.js +0 -50
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { Store } from "./Store.js";
|
|
2
|
-
import type { Metrics } from "./metrics.js";
|
|
3
|
-
/**
|
|
4
|
-
* v0.5.0 Feedback (K5-009 / plan §5.3, D5-02).
|
|
5
|
-
*
|
|
6
|
-
* The human-judgement half of the glassbox: the agent can report whether a
|
|
7
|
-
* memory was useful, wrong, outdated, or should be ignored. Verdicts are
|
|
8
|
-
* stored in `memory_feedback` (migration 006_v05_glassbox.sql) and folded
|
|
9
|
-
* into the memory's `feedback_positive` / `feedback_negative` counters —
|
|
10
|
-
* kept SEPARATE from `evidence_count` / `recurrence_count` by design: human
|
|
11
|
-
* judgement is evidence about the memory, causal counters are evidence
|
|
12
|
-
* about the world (the confidence-poisoning defect closed in v0.4.0).
|
|
13
|
-
*
|
|
14
|
-
* D5-07 — the `ignore` verdict is a hard lifecycle action, not a soft
|
|
15
|
-
* signal: the memory is stamped `ignored = 1`, which excludes it from
|
|
16
|
-
* retrieval (K5-008) and from the quality gate (K5-007).
|
|
17
|
-
*
|
|
18
|
-
* Schema:
|
|
19
|
-
* memory_feedback(id PK, memory_id, verdict, session_id, note, created_at)
|
|
20
|
-
* memories(feedback_positive, feedback_negative, ignored)
|
|
21
|
-
*/
|
|
22
|
-
export type FeedbackVerdict = "useful" | "wrong" | "outdated" | "ignore";
|
|
23
|
-
export interface FeedbackRecordInput {
|
|
24
|
-
memoryId: string;
|
|
25
|
-
verdict: FeedbackVerdict;
|
|
26
|
-
sessionId?: string | null;
|
|
27
|
-
note?: string | null;
|
|
28
|
-
}
|
|
29
|
-
export interface FeedbackRow {
|
|
30
|
-
id: string;
|
|
31
|
-
memoryId: string;
|
|
32
|
-
verdict: FeedbackVerdict;
|
|
33
|
-
sessionId: string | null;
|
|
34
|
-
note: string | null;
|
|
35
|
-
createdAt: string;
|
|
36
|
-
}
|
|
37
|
-
export interface FeedbackCounts {
|
|
38
|
-
positive: number;
|
|
39
|
-
negative: number;
|
|
40
|
-
}
|
|
41
|
-
export declare class Feedback {
|
|
42
|
-
private readonly store;
|
|
43
|
-
private readonly metrics;
|
|
44
|
-
private readonly now;
|
|
45
|
-
constructor(store: Store, metrics?: Metrics | null, now?: () => Date);
|
|
46
|
-
/**
|
|
47
|
-
* Records one human verdict and folds it into the memory's counters.
|
|
48
|
-
* Counters are RECOMPUTED from the table (never incremented) so rows
|
|
49
|
-
* deleted in tests or by hand cannot drift them.
|
|
50
|
-
*
|
|
51
|
-
* The `ignore` verdict also stamps `memories.ignored = 1` (D5-07).
|
|
52
|
-
* Returns the feedback row id.
|
|
53
|
-
*/
|
|
54
|
-
record(input: FeedbackRecordInput): string;
|
|
55
|
-
/** Human-judgement counters for one memory (from the row, not the table). */
|
|
56
|
-
countsFor(memoryId: string): FeedbackCounts;
|
|
57
|
-
/**
|
|
58
|
-
* Raw verdict history, newest first. Used by `kevin_feedback` (K5-011)
|
|
59
|
-
* and by the audit tool (K5-016).
|
|
60
|
-
*/
|
|
61
|
-
list(memoryId?: string, limit?: number): FeedbackRow[];
|
|
62
|
-
/**
|
|
63
|
-
* Recompute a memory's positive/negative counters straight from the
|
|
64
|
-
* verdict table. Kept private — `record` is the only mutation path.
|
|
65
|
-
*/
|
|
66
|
-
private recomputeCounters;
|
|
67
|
-
}
|
package/dist/plugin/Feedback.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { hasFeedbackTable } from "./columns.js";
|
|
2
|
-
import { uuidv7 } from "./uuid.js";
|
|
3
|
-
const POSITIVE_VERDICTS = ["useful"];
|
|
4
|
-
const NEGATIVE_VERDICTS = [
|
|
5
|
-
"wrong",
|
|
6
|
-
"outdated",
|
|
7
|
-
"ignore",
|
|
8
|
-
];
|
|
9
|
-
// v1.1.0 (K11-011) — table probe delegates to columns registry
|
|
10
|
-
export class Feedback {
|
|
11
|
-
store;
|
|
12
|
-
metrics;
|
|
13
|
-
now;
|
|
14
|
-
constructor(store, metrics, now = () => new Date()) {
|
|
15
|
-
this.store = store;
|
|
16
|
-
this.metrics = metrics ?? null;
|
|
17
|
-
this.now = now;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Records one human verdict and folds it into the memory's counters.
|
|
21
|
-
* Counters are RECOMPUTED from the table (never incremented) so rows
|
|
22
|
-
* deleted in tests or by hand cannot drift them.
|
|
23
|
-
*
|
|
24
|
-
* The `ignore` verdict also stamps `memories.ignored = 1` (D5-07).
|
|
25
|
-
* Returns the feedback row id.
|
|
26
|
-
*/
|
|
27
|
-
record(input) {
|
|
28
|
-
if (!hasFeedbackTable(this.store)) {
|
|
29
|
-
throw new Error("kevin_feedback requires migration 006_v05_glassbox.sql (missing table memory_feedback)");
|
|
30
|
-
}
|
|
31
|
-
const id = uuidv7();
|
|
32
|
-
this.store
|
|
33
|
-
.prepare(`INSERT INTO memory_feedback
|
|
34
|
-
(id, memory_id, verdict, session_id, note)
|
|
35
|
-
VALUES (?, ?, ?, ?, ?)`)
|
|
36
|
-
.run(id, input.memoryId, input.verdict, input.sessionId ?? null, input.note ?? null);
|
|
37
|
-
this.recomputeCounters(input.memoryId);
|
|
38
|
-
if (input.verdict === "ignore") {
|
|
39
|
-
this.store
|
|
40
|
-
.prepare("UPDATE memories SET ignored = 1 WHERE id = ?")
|
|
41
|
-
.run(input.memoryId);
|
|
42
|
-
}
|
|
43
|
-
// v0.5.0 (K5-023 / plan §5.3, D5-06) — lifecycle action of a
|
|
44
|
-
// negative verdict. `wrong` is an opinion and deserves a second
|
|
45
|
-
// opinion: it demotes only at feedback_negative >= 2. `outdated`
|
|
46
|
-
// is a self-verifying claim about the world and acts at once.
|
|
47
|
-
// `useful` confirms the memory and refreshes its verification.
|
|
48
|
-
if (input.verdict === "outdated") {
|
|
49
|
-
this.store
|
|
50
|
-
.prepare("UPDATE memories SET status = 'stale' WHERE id = ?")
|
|
51
|
-
.run(input.memoryId);
|
|
52
|
-
}
|
|
53
|
-
else if (input.verdict === "wrong") {
|
|
54
|
-
const counts = this.countsFor(input.memoryId);
|
|
55
|
-
if (counts.negative >= 2) {
|
|
56
|
-
this.store
|
|
57
|
-
.prepare("UPDATE memories SET status = 'stale' WHERE id = ?")
|
|
58
|
-
.run(input.memoryId);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
else if (input.verdict === "useful") {
|
|
62
|
-
this.store
|
|
63
|
-
.prepare("UPDATE memories SET last_verified_at = datetime('now') WHERE id = ?")
|
|
64
|
-
.run(input.memoryId);
|
|
65
|
-
}
|
|
66
|
-
if (POSITIVE_VERDICTS.includes(input.verdict)) {
|
|
67
|
-
this.metrics?.incr("feedback_positive_total", 1);
|
|
68
|
-
}
|
|
69
|
-
else if (NEGATIVE_VERDICTS.includes(input.verdict)) {
|
|
70
|
-
this.metrics?.incr("feedback_negative_total", 1);
|
|
71
|
-
}
|
|
72
|
-
return id;
|
|
73
|
-
}
|
|
74
|
-
/** Human-judgement counters for one memory (from the row, not the table). */
|
|
75
|
-
countsFor(memoryId) {
|
|
76
|
-
const row = this.store
|
|
77
|
-
.prepare("SELECT feedback_positive, feedback_negative FROM memories WHERE id = ?")
|
|
78
|
-
.get(memoryId);
|
|
79
|
-
if (!row)
|
|
80
|
-
return { positive: 0, negative: 0 };
|
|
81
|
-
return { positive: row.feedback_positive, negative: row.feedback_negative };
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Raw verdict history, newest first. Used by `kevin_feedback` (K5-011)
|
|
85
|
-
* and by the audit tool (K5-016).
|
|
86
|
-
*/
|
|
87
|
-
list(memoryId, limit = 50) {
|
|
88
|
-
const rows = this.store
|
|
89
|
-
.prepare(`SELECT id, memory_id, verdict, session_id, note, created_at
|
|
90
|
-
FROM memory_feedback
|
|
91
|
-
${memoryId ? "WHERE memory_id = ?" : ""}
|
|
92
|
-
ORDER BY created_at DESC, rowid DESC
|
|
93
|
-
LIMIT ?`)
|
|
94
|
-
.all(...(memoryId ? [memoryId, limit] : [limit]));
|
|
95
|
-
return rows.map((r) => ({
|
|
96
|
-
id: r.id,
|
|
97
|
-
memoryId: r.memory_id,
|
|
98
|
-
verdict: r.verdict,
|
|
99
|
-
sessionId: r.session_id,
|
|
100
|
-
note: r.note,
|
|
101
|
-
createdAt: r.created_at,
|
|
102
|
-
}));
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Recompute a memory's positive/negative counters straight from the
|
|
106
|
-
* verdict table. Kept private — `record` is the only mutation path.
|
|
107
|
-
*/
|
|
108
|
-
recomputeCounters(memoryId) {
|
|
109
|
-
this.store
|
|
110
|
-
.prepare(`UPDATE memories SET
|
|
111
|
-
feedback_positive = (
|
|
112
|
-
SELECT COUNT(*) FROM memory_feedback
|
|
113
|
-
WHERE memory_id = ? AND verdict = 'useful'),
|
|
114
|
-
feedback_negative = (
|
|
115
|
-
SELECT COUNT(*) FROM memory_feedback
|
|
116
|
-
WHERE memory_id = ? AND verdict IN ('wrong','outdated','ignore'))
|
|
117
|
-
WHERE id = ?`)
|
|
118
|
-
.run(memoryId, memoryId, memoryId);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { HOOK_NAMES } from "./Migrate.js";
|
|
2
|
-
import type { Store } from "./Store.js";
|
|
3
|
-
export type HookName = (typeof HOOK_NAMES)[number];
|
|
4
|
-
export type LivenessState = "live" | "dead" | "unknown";
|
|
5
|
-
export interface HookReport {
|
|
6
|
-
hook: HookName;
|
|
7
|
-
experimental: boolean;
|
|
8
|
-
state: LivenessState;
|
|
9
|
-
firstSeenAt: string | null;
|
|
10
|
-
lastSeenAt: string | null;
|
|
11
|
-
fireCount: number;
|
|
12
|
-
expectedCount: number;
|
|
13
|
-
/** v0.9.0 (K9-012) — set once when the hook went dead; never cleared. */
|
|
14
|
-
deadSince: string | null;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* v0.9.0 (K9-010 / plan §5.3, D9-09) — parse the TEXT setting
|
|
18
|
-
* `dead_hook_report_threshold` into the [1, 1000] clamp. NaN and empty
|
|
19
|
-
* fall back to 3 (never 0: a zero threshold would report every hook dead
|
|
20
|
-
* before a single session completes a checkpoint).
|
|
21
|
-
*/
|
|
22
|
-
export declare function parseThreshold(text: string | null | undefined): number;
|
|
23
|
-
interface HookLivenessOptions {
|
|
24
|
-
/** `hook_liveness_enabled === "1"` (TEXT comparison, decided at construction). */
|
|
25
|
-
enabled: boolean;
|
|
26
|
-
/** Raw TEXT of `dead_hook_report_threshold`; parsed+clamped in report(). */
|
|
27
|
-
thresholdText: string | null | undefined;
|
|
28
|
-
/** `host.pluginVersion` at construction, stored on every flushed row. */
|
|
29
|
-
pluginVersion: string | null;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* v0.9.0 (K9-009/K9-010 / plan §5.3) — wraps the plugin's hooks with a
|
|
33
|
-
* success-path recorder, dedups per-session checkpoints, and reports each
|
|
34
|
-
* hook's liveness state. Persistence is machine-scoped: the hook_liveness
|
|
35
|
-
* table carries no project_id or repo_id (D9-08).
|
|
36
|
-
*/
|
|
37
|
-
export declare class HookLiveness {
|
|
38
|
-
private readonly store;
|
|
39
|
-
private readonly options;
|
|
40
|
-
excessArityCount: number;
|
|
41
|
-
private readonly counters;
|
|
42
|
-
private readonly seenSessions;
|
|
43
|
-
private readonly suppressedSessions;
|
|
44
|
-
private readonly threshold;
|
|
45
|
-
private readonly pluginVersion;
|
|
46
|
-
constructor(store: Store, options: HookLivenessOptions);
|
|
47
|
-
/**
|
|
48
|
-
* Returns a new object with the same keys; function values are replaced
|
|
49
|
-
* by delegating wrappers, non-function values (the tool map) pass by
|
|
50
|
-
* reference. When liveness is disabled, returns the argument unchanged —
|
|
51
|
-
* the same object, not a copy.
|
|
52
|
-
*/
|
|
53
|
-
wrap<T>(hooks: T): T;
|
|
54
|
-
/**
|
|
55
|
-
* v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
|
|
56
|
-
* model turn (tool.execute.after fired), so the system prompt was
|
|
57
|
-
* assembled and `experimental.chat.system.transform` MUST have been
|
|
58
|
-
* offered. Deduped per session: 20 tool calls in one session count
|
|
59
|
-
* exactly one expectation.
|
|
60
|
-
*/
|
|
61
|
-
expect(hook: HookName, sessionID: string): void;
|
|
62
|
-
/**
|
|
63
|
-
* Per-hook liveness verdicts in canonical HOOK_NAMES order.
|
|
64
|
-
* A hook that fired once is live forever; dead requires both zero fires
|
|
65
|
-
* and `expectedCount >= threshold`; everything else is unknown — and
|
|
66
|
-
* unknown is never rounded to healthy (D9-09).
|
|
67
|
-
*/
|
|
68
|
-
report(): HookReport[];
|
|
69
|
-
/** Persists every counter row. Called on the metrics.flush() cadence. */
|
|
70
|
-
flush(): void;
|
|
71
|
-
private makeWrapper;
|
|
72
|
-
/**
|
|
73
|
-
* v1.0.0 (K10-013 / plan §5.3) — records the `dispose` fire and
|
|
74
|
-
* flushes immediately, from inside the dispose delegate: this is the
|
|
75
|
-
* last write of the process, and nothing after it can persist.
|
|
76
|
-
*/
|
|
77
|
-
recordDispose(): void;
|
|
78
|
-
private recordSuccess;
|
|
79
|
-
private recordError;
|
|
80
|
-
private loadFromDb;
|
|
81
|
-
}
|
|
82
|
-
export type HostVerdict = "healthy" | "degraded" | "unknown";
|
|
83
|
-
export interface HostVerdictResult {
|
|
84
|
-
verdict: HostVerdict;
|
|
85
|
-
reason: string;
|
|
86
|
-
}
|
|
87
|
-
export declare function reduceVerdict(reports: readonly HookReport[]): HostVerdictResult;
|
|
88
|
-
export {};
|
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
import { HOOK_NAMES } from "./Migrate.js";
|
|
2
|
-
const DEFAULT_THRESHOLD = 3;
|
|
3
|
-
/**
|
|
4
|
-
* v0.9.0 (K9-010 / plan §5.3, D9-09) — parse the TEXT setting
|
|
5
|
-
* `dead_hook_report_threshold` into the [1, 1000] clamp. NaN and empty
|
|
6
|
-
* fall back to 3 (never 0: a zero threshold would report every hook dead
|
|
7
|
-
* before a single session completes a checkpoint).
|
|
8
|
-
*/
|
|
9
|
-
export function parseThreshold(text) {
|
|
10
|
-
if (text === null || text === undefined || text === "")
|
|
11
|
-
return DEFAULT_THRESHOLD;
|
|
12
|
-
const n = Number.parseInt(text, 10);
|
|
13
|
-
// NaN → default; 0 is not a valid threshold (K9-010 AC: 'abc'/''/'0'
|
|
14
|
-
// all clamp to the default of three — never zero).
|
|
15
|
-
if (Number.isNaN(n) || n === 0)
|
|
16
|
-
return DEFAULT_THRESHOLD;
|
|
17
|
-
return Math.min(1000, Math.max(1, n));
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* v0.9.0 (K9-009/K9-010 / plan §5.3) — wraps the plugin's hooks with a
|
|
21
|
-
* success-path recorder, dedups per-session checkpoints, and reports each
|
|
22
|
-
* hook's liveness state. Persistence is machine-scoped: the hook_liveness
|
|
23
|
-
* table carries no project_id or repo_id (D9-08).
|
|
24
|
-
*/
|
|
25
|
-
export class HookLiveness {
|
|
26
|
-
store;
|
|
27
|
-
options;
|
|
28
|
-
// v1.1.0 (K11-015) — debug counter for excess arity; never logged on hot path
|
|
29
|
-
excessArityCount = 0;
|
|
30
|
-
counters;
|
|
31
|
-
seenSessions;
|
|
32
|
-
suppressedSessions = new Set();
|
|
33
|
-
threshold;
|
|
34
|
-
pluginVersion;
|
|
35
|
-
constructor(store, options) {
|
|
36
|
-
this.store = store;
|
|
37
|
-
this.options = options;
|
|
38
|
-
this.threshold = parseThreshold(options.thresholdText);
|
|
39
|
-
this.pluginVersion = options.pluginVersion;
|
|
40
|
-
this.counters = new Map();
|
|
41
|
-
this.seenSessions = new Map();
|
|
42
|
-
for (const hook of HOOK_NAMES) {
|
|
43
|
-
this.counters.set(hook, {
|
|
44
|
-
fireCount: 0,
|
|
45
|
-
errorCount: 0,
|
|
46
|
-
expectedCount: 0,
|
|
47
|
-
firstSeenAt: null,
|
|
48
|
-
lastSeenAt: null,
|
|
49
|
-
deadSince: null,
|
|
50
|
-
});
|
|
51
|
-
this.seenSessions.set(hook, new Set());
|
|
52
|
-
}
|
|
53
|
-
this.loadFromDb();
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Returns a new object with the same keys; function values are replaced
|
|
57
|
-
* by delegating wrappers, non-function values (the tool map) pass by
|
|
58
|
-
* reference. When liveness is disabled, returns the argument unchanged —
|
|
59
|
-
* the same object, not a copy.
|
|
60
|
-
*/
|
|
61
|
-
wrap(hooks) {
|
|
62
|
-
if (!this.options.enabled)
|
|
63
|
-
return hooks;
|
|
64
|
-
const out = {};
|
|
65
|
-
for (const [key, value] of Object.entries(hooks)) {
|
|
66
|
-
if (typeof value === "function") {
|
|
67
|
-
out[key] = this.makeWrapper(key, value);
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
out[key] = value;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
|
|
77
|
-
* model turn (tool.execute.after fired), so the system prompt was
|
|
78
|
-
* assembled and `experimental.chat.system.transform` MUST have been
|
|
79
|
-
* offered. Deduped per session: 20 tool calls in one session count
|
|
80
|
-
* exactly one expectation.
|
|
81
|
-
*/
|
|
82
|
-
expect(hook, sessionID) {
|
|
83
|
-
const seen = this.seenSessions.get(hook);
|
|
84
|
-
if (!seen || seen.has(sessionID))
|
|
85
|
-
return;
|
|
86
|
-
seen.add(sessionID);
|
|
87
|
-
const counters = this.counters.get(hook);
|
|
88
|
-
if (!counters)
|
|
89
|
-
return;
|
|
90
|
-
counters.expectedCount += 1;
|
|
91
|
-
// The dead flag is materialized here (not only in report()) so that
|
|
92
|
-
// flush() persists dead_since even when no report() was ever called:
|
|
93
|
-
// kevin_doctor's PURE-SQL blocks read dead_since from the table.
|
|
94
|
-
if (counters.fireCount === 0 &&
|
|
95
|
-
counters.expectedCount >= this.threshold &&
|
|
96
|
-
counters.deadSince === null) {
|
|
97
|
-
counters.deadSince = counters.lastSeenAt ?? new Date().toISOString();
|
|
98
|
-
}
|
|
99
|
-
// v0.9.0 (K9-011 / plan §5.3) — when the injection hook is dead, the
|
|
100
|
-
// checkpointed session was suppressed: the host stopped offering the
|
|
101
|
-
// transform, so no injection could happen. Counted once per session
|
|
102
|
-
// (same dedup as expected_count) and persisted by flush(). This is
|
|
103
|
-
// the counter that turns "zero injections" from an ambiguous number
|
|
104
|
-
// into a diagnosis.
|
|
105
|
-
if (hook === "experimental.chat.system.transform" &&
|
|
106
|
-
counters.deadSince !== null &&
|
|
107
|
-
!this.suppressedSessions.has(sessionID)) {
|
|
108
|
-
this.suppressedSessions.add(sessionID);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Per-hook liveness verdicts in canonical HOOK_NAMES order.
|
|
113
|
-
* A hook that fired once is live forever; dead requires both zero fires
|
|
114
|
-
* and `expectedCount >= threshold`; everything else is unknown — and
|
|
115
|
-
* unknown is never rounded to healthy (D9-09).
|
|
116
|
-
*/
|
|
117
|
-
report() {
|
|
118
|
-
const out = [];
|
|
119
|
-
for (const hook of HOOK_NAMES) {
|
|
120
|
-
const c = this.counters.get(hook);
|
|
121
|
-
if (!c)
|
|
122
|
-
continue;
|
|
123
|
-
let state;
|
|
124
|
-
if (c.fireCount > 0) {
|
|
125
|
-
state = "live";
|
|
126
|
-
}
|
|
127
|
-
else if (c.expectedCount >= this.threshold) {
|
|
128
|
-
// dead_since is set ONCE and never cleared: it documents the
|
|
129
|
-
// historical death even if the hook later recovers.
|
|
130
|
-
if (c.deadSince === null) {
|
|
131
|
-
c.deadSince = c.lastSeenAt ?? new Date().toISOString();
|
|
132
|
-
}
|
|
133
|
-
state = "dead";
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
state = "unknown";
|
|
137
|
-
}
|
|
138
|
-
out.push({
|
|
139
|
-
hook,
|
|
140
|
-
experimental: hook.startsWith("experimental."),
|
|
141
|
-
state,
|
|
142
|
-
firstSeenAt: c.firstSeenAt,
|
|
143
|
-
lastSeenAt: c.lastSeenAt,
|
|
144
|
-
fireCount: c.fireCount,
|
|
145
|
-
expectedCount: c.expectedCount,
|
|
146
|
-
deadSince: c.deadSince,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
return out;
|
|
150
|
-
}
|
|
151
|
-
/** Persists every counter row. Called on the metrics.flush() cadence. */
|
|
152
|
-
flush() {
|
|
153
|
-
const store = this.store;
|
|
154
|
-
store.transaction(() => {
|
|
155
|
-
// Created lazily on first flush, mirroring Metrics.flush(): the
|
|
156
|
-
// migration guarantees the table in production, but unit tests
|
|
157
|
-
// against pre-010 DBs must not explode at dispose.
|
|
158
|
-
store.exec(`CREATE TABLE IF NOT EXISTS hook_liveness (
|
|
159
|
-
hook TEXT PRIMARY KEY,
|
|
160
|
-
experimental INTEGER NOT NULL DEFAULT 0,
|
|
161
|
-
fire_count INTEGER NOT NULL DEFAULT 0,
|
|
162
|
-
error_count INTEGER NOT NULL DEFAULT 0,
|
|
163
|
-
expected_count INTEGER NOT NULL DEFAULT 0,
|
|
164
|
-
first_seen_at TEXT,
|
|
165
|
-
last_seen_at TEXT,
|
|
166
|
-
dead_since TEXT,
|
|
167
|
-
plugin_version TEXT
|
|
168
|
-
)`);
|
|
169
|
-
const upsert = store.prepare(`INSERT INTO hook_liveness (hook, experimental, fire_count, error_count,
|
|
170
|
-
expected_count, first_seen_at, last_seen_at, dead_since, plugin_version)
|
|
171
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
172
|
-
ON CONFLICT(hook) DO UPDATE SET
|
|
173
|
-
experimental = excluded.experimental,
|
|
174
|
-
fire_count = excluded.fire_count,
|
|
175
|
-
error_count = excluded.error_count,
|
|
176
|
-
expected_count = excluded.expected_count,
|
|
177
|
-
first_seen_at = excluded.first_seen_at,
|
|
178
|
-
last_seen_at = excluded.last_seen_at,
|
|
179
|
-
dead_since = excluded.dead_since,
|
|
180
|
-
plugin_version = excluded.plugin_version`);
|
|
181
|
-
for (const [hook, c] of this.counters) {
|
|
182
|
-
upsert.run(hook, hook.startsWith("experimental.") ? 1 : 0, c.fireCount, c.errorCount, c.expectedCount, c.firstSeenAt, c.lastSeenAt, c.deadSince, this.pluginVersion);
|
|
183
|
-
}
|
|
184
|
-
// v0.9.0 (K9-011 / plan §5.3) — the four v0.9.0 counters are
|
|
185
|
-
// re-derived here, on the metrics cadence, exactly like the 009
|
|
186
|
-
// post-apply hook re-derives hooks_dead_total: SUM(fire_count),
|
|
187
|
-
// SUM(error_count), COUNT(dead_since) and the per-session
|
|
188
|
-
// suppression count. No Metrics dependency, no writes in the hot
|
|
189
|
-
// path — flush() is the single persistence point.
|
|
190
|
-
let fireSum = 0;
|
|
191
|
-
let errorSum = 0;
|
|
192
|
-
let deadCount = 0;
|
|
193
|
-
for (const c of this.counters.values()) {
|
|
194
|
-
fireSum += c.fireCount;
|
|
195
|
-
errorSum += c.errorCount;
|
|
196
|
-
if (c.deadSince !== null)
|
|
197
|
-
deadCount += 1;
|
|
198
|
-
}
|
|
199
|
-
store.exec(`CREATE TABLE IF NOT EXISTS kevin_metrics (
|
|
200
|
-
key TEXT PRIMARY KEY,
|
|
201
|
-
value INTEGER NOT NULL DEFAULT 0,
|
|
202
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
203
|
-
)`);
|
|
204
|
-
const derive = store.prepare(`INSERT INTO kevin_metrics (key, value, updated_at)
|
|
205
|
-
VALUES (?, ?, datetime('now'))
|
|
206
|
-
ON CONFLICT(key) DO UPDATE SET
|
|
207
|
-
value = excluded.value,
|
|
208
|
-
updated_at = datetime('now')`);
|
|
209
|
-
derive.run("hook_fires_total", fireSum);
|
|
210
|
-
derive.run("hook_errors_total", errorSum);
|
|
211
|
-
derive.run("hooks_dead_total", deadCount);
|
|
212
|
-
derive.run("injections_suppressed_dead_hook", this.suppressedSessions.size);
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
makeWrapper(key, delegate) {
|
|
216
|
-
const record = () => {
|
|
217
|
-
// v1.0.0 (K10-013) — dispose is recorded inside its own delegate
|
|
218
|
-
// (via recordDispose()) rather than here: the wrapper's post-return
|
|
219
|
-
// record would land after the store is closed and could never be
|
|
220
|
-
// persisted — the event being detected is the process ending.
|
|
221
|
-
if (key === "dispose")
|
|
222
|
-
return;
|
|
223
|
-
if (HOOK_NAMES.includes(key)) {
|
|
224
|
-
this.recordSuccess(key);
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
const recordError = (e) => {
|
|
228
|
-
if (HOOK_NAMES.includes(key)) {
|
|
229
|
-
this.recordError(key);
|
|
230
|
-
}
|
|
231
|
-
throw e;
|
|
232
|
-
};
|
|
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
|
-
}
|
|
241
|
-
try {
|
|
242
|
-
const result = await delegate(...callArgs);
|
|
243
|
-
record();
|
|
244
|
-
return result;
|
|
245
|
-
}
|
|
246
|
-
catch (e) {
|
|
247
|
-
return recordError(e);
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* v1.0.0 (K10-013 / plan §5.3) — records the `dispose` fire and
|
|
253
|
-
* flushes immediately, from inside the dispose delegate: this is the
|
|
254
|
-
* last write of the process, and nothing after it can persist.
|
|
255
|
-
*/
|
|
256
|
-
recordDispose() {
|
|
257
|
-
this.recordSuccess("dispose");
|
|
258
|
-
this.flush();
|
|
259
|
-
}
|
|
260
|
-
recordSuccess(hook) {
|
|
261
|
-
const c = this.counters.get(hook);
|
|
262
|
-
if (!c)
|
|
263
|
-
return;
|
|
264
|
-
c.fireCount += 1;
|
|
265
|
-
const now = new Date().toISOString();
|
|
266
|
-
if (c.firstSeenAt === null)
|
|
267
|
-
c.firstSeenAt = now;
|
|
268
|
-
c.lastSeenAt = now;
|
|
269
|
-
}
|
|
270
|
-
recordError(hook) {
|
|
271
|
-
const c = this.counters.get(hook);
|
|
272
|
-
if (!c)
|
|
273
|
-
return;
|
|
274
|
-
c.errorCount += 1;
|
|
275
|
-
}
|
|
276
|
-
loadFromDb() {
|
|
277
|
-
let rows = [];
|
|
278
|
-
try {
|
|
279
|
-
rows = this.store
|
|
280
|
-
.prepare("SELECT hook, fire_count, error_count, expected_count, first_seen_at, last_seen_at, dead_since FROM hook_liveness")
|
|
281
|
-
.all();
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
// Graceful: pre-010 DBs have no hook_liveness table; the seeded
|
|
285
|
-
// zeros remain until the first flush creates rows.
|
|
286
|
-
rows = [];
|
|
287
|
-
}
|
|
288
|
-
for (const row of rows) {
|
|
289
|
-
if (!HOOK_NAMES.includes(row.hook))
|
|
290
|
-
continue;
|
|
291
|
-
const c = this.counters.get(row.hook);
|
|
292
|
-
if (!c)
|
|
293
|
-
continue;
|
|
294
|
-
c.fireCount = row.fire_count;
|
|
295
|
-
c.errorCount = row.error_count;
|
|
296
|
-
c.expectedCount = row.expected_count;
|
|
297
|
-
c.firstSeenAt = row.first_seen_at;
|
|
298
|
-
c.lastSeenAt = row.last_seen_at;
|
|
299
|
-
c.deadSince = row.dead_since;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
export function reduceVerdict(reports) {
|
|
304
|
-
if (reports.length === 0) {
|
|
305
|
-
return { verdict: "unknown", reason: "no hook reports yet" };
|
|
306
|
-
}
|
|
307
|
-
const dead = reports.filter((r) => r.state === "dead");
|
|
308
|
-
if (dead.length > 0) {
|
|
309
|
-
return {
|
|
310
|
-
verdict: "degraded",
|
|
311
|
-
reason: `${dead.map((r) => r.hook).join(", ")} dead since ${dead[0].deadSince ?? "unknown"}; ${dead.length} affected hook(s)`,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
if (reports.every((r) => r.state === "live")) {
|
|
315
|
-
return { verdict: "healthy", reason: "all hooks live" };
|
|
316
|
-
}
|
|
317
|
-
const pending = reports.filter((r) => r.state === "unknown").length;
|
|
318
|
-
return {
|
|
319
|
-
verdict: "unknown",
|
|
320
|
-
reason: `${pending} hook(s) without checkpoint`,
|
|
321
|
-
};
|
|
322
|
-
}
|