@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,42 @@
|
|
|
1
|
+
import type { MemoryService } from "./MemoryService.js";
|
|
2
|
+
import type { Store } from "./Store.js";
|
|
3
|
+
import type { Metrics } from "./metrics.js";
|
|
4
|
+
/**
|
|
5
|
+
* v0.5.0 Archiver (K5-012 / plan §5.4, D5-05).
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle tail of the memory loop: reflectors mark a lesson `stale` when
|
|
8
|
+
* its fingerprint recurred enough times (penalizeRecurringReflectors,
|
|
9
|
+
* migration 003), but nothing ever retires those rows — they keep
|
|
10
|
+
* circulating through status filters and inflating counts. The Archiver
|
|
11
|
+
* runs on `session.idle` and retires stale memories whose last activity
|
|
12
|
+
* (`updated_at`, falling back to `last_verified_at`) is older than
|
|
13
|
+
* `archive_after_days` (kevin_settings, seeded '30' in migration 006).
|
|
14
|
+
*
|
|
15
|
+
* Rules:
|
|
16
|
+
* - Only `status = 'stale'` rows with `archived_at IS NULL`.
|
|
17
|
+
* - `type != 'pattern'`: a pattern is the FIXED form of a fingerprint
|
|
18
|
+
* (K4-025); archiving it would break kevin_why and the recurrence
|
|
19
|
+
* re-admission path (QualityGate rule 4).
|
|
20
|
+
* - Clock: the injection clock (K5-008) — tests freeze it; production
|
|
21
|
+
* defaults to `new Date()`. Never `Date.now()` inside.
|
|
22
|
+
* - `memories_archived` is bumped by the batch size, once per run.
|
|
23
|
+
* - Pre-006 DBs (no `archived_at`) degrade to a no-op.
|
|
24
|
+
*
|
|
25
|
+
* Archived rows remain queryable with `includeSuperseded: true`-style
|
|
26
|
+
* reads (status filter is exclusive, not destructive).
|
|
27
|
+
*/
|
|
28
|
+
export declare class Archiver {
|
|
29
|
+
private readonly store;
|
|
30
|
+
private readonly memoryService;
|
|
31
|
+
private readonly metrics;
|
|
32
|
+
private readonly now;
|
|
33
|
+
constructor(store: Store, memoryService: MemoryService, metrics?: Metrics | null, now?: () => Date);
|
|
34
|
+
/**
|
|
35
|
+
* Archives every eligible stale memory. Returns the number of rows
|
|
36
|
+
* retired. Idempotent: archived rows are excluded by `archived_at IS
|
|
37
|
+
* NULL`, so a second run archives nothing.
|
|
38
|
+
*/
|
|
39
|
+
run(): number;
|
|
40
|
+
private archiveAfterDays;
|
|
41
|
+
private hasArchivedColumn;
|
|
42
|
+
}
|
package/dist/Archiver.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { hasArchivedColumn } from "./columns.js";
|
|
2
|
+
/**
|
|
3
|
+
* v0.5.0 Archiver (K5-012 / plan §5.4, D5-05).
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle tail of the memory loop: reflectors mark a lesson `stale` when
|
|
6
|
+
* its fingerprint recurred enough times (penalizeRecurringReflectors,
|
|
7
|
+
* migration 003), but nothing ever retires those rows — they keep
|
|
8
|
+
* circulating through status filters and inflating counts. The Archiver
|
|
9
|
+
* runs on `session.idle` and retires stale memories whose last activity
|
|
10
|
+
* (`updated_at`, falling back to `last_verified_at`) is older than
|
|
11
|
+
* `archive_after_days` (kevin_settings, seeded '30' in migration 006).
|
|
12
|
+
*
|
|
13
|
+
* Rules:
|
|
14
|
+
* - Only `status = 'stale'` rows with `archived_at IS NULL`.
|
|
15
|
+
* - `type != 'pattern'`: a pattern is the FIXED form of a fingerprint
|
|
16
|
+
* (K4-025); archiving it would break kevin_why and the recurrence
|
|
17
|
+
* re-admission path (QualityGate rule 4).
|
|
18
|
+
* - Clock: the injection clock (K5-008) — tests freeze it; production
|
|
19
|
+
* defaults to `new Date()`. Never `Date.now()` inside.
|
|
20
|
+
* - `memories_archived` is bumped by the batch size, once per run.
|
|
21
|
+
* - Pre-006 DBs (no `archived_at`) degrade to a no-op.
|
|
22
|
+
*
|
|
23
|
+
* Archived rows remain queryable with `includeSuperseded: true`-style
|
|
24
|
+
* reads (status filter is exclusive, not destructive).
|
|
25
|
+
*/
|
|
26
|
+
export class Archiver {
|
|
27
|
+
store;
|
|
28
|
+
memoryService;
|
|
29
|
+
metrics;
|
|
30
|
+
now;
|
|
31
|
+
constructor(store, memoryService, metrics, now = () => new Date()) {
|
|
32
|
+
this.store = store;
|
|
33
|
+
this.memoryService = memoryService;
|
|
34
|
+
this.metrics = metrics ?? null;
|
|
35
|
+
this.now = now;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Archives every eligible stale memory. Returns the number of rows
|
|
39
|
+
* retired. Idempotent: archived rows are excluded by `archived_at IS
|
|
40
|
+
* NULL`, so a second run archives nothing.
|
|
41
|
+
*/
|
|
42
|
+
run() {
|
|
43
|
+
if (!this.hasArchivedColumn())
|
|
44
|
+
return 0;
|
|
45
|
+
const days = this.archiveAfterDays();
|
|
46
|
+
if (days <= 0)
|
|
47
|
+
return 0;
|
|
48
|
+
const now = this.now();
|
|
49
|
+
const cutoff = formatSqliteUtc(new Date(now.getTime() - days * 86_400_000));
|
|
50
|
+
this.store
|
|
51
|
+
.prepare(`UPDATE memories
|
|
52
|
+
SET status = 'archived', archived_at = ?, updated_at = ?
|
|
53
|
+
WHERE status = 'stale'
|
|
54
|
+
AND type != 'pattern'
|
|
55
|
+
AND archived_at IS NULL
|
|
56
|
+
AND updated_at < ?`)
|
|
57
|
+
.run(formatSqliteUtc(now), formatSqliteUtc(now), cutoff);
|
|
58
|
+
const changed = this.store.prepare("SELECT changes() AS n").get();
|
|
59
|
+
const n = changed.n;
|
|
60
|
+
if (n > 0)
|
|
61
|
+
this.metrics?.incr("memories_archived", n);
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
archiveAfterDays() {
|
|
65
|
+
try {
|
|
66
|
+
const raw = this.memoryService.getSetting("archive_after_days", "30");
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
return Number.isFinite(n) ? n : 30;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 30;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
hasArchivedColumn() {
|
|
75
|
+
return hasArchivedColumn(this.store);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 'YYYY-MM-DD HH:MM:SS' UTC — the format SQLite `datetime('now')` uses,
|
|
79
|
+
* so lexicographic comparisons against `updated_at` are valid. */
|
|
80
|
+
function formatSqliteUtc(d) {
|
|
81
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
82
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Store } from "./Store.js";
|
|
2
|
+
import type { Metrics } from "./metrics.js";
|
|
3
|
+
export declare const MARKER_BEGIN = "<!-- kevin:begin \u2014 curated by opencode-kevin, safe to edit -->";
|
|
4
|
+
export declare const MARKER_END = "<!-- kevin:end -->";
|
|
5
|
+
export type WriteOutcome = "written" | "noop" | "refused";
|
|
6
|
+
/**
|
|
7
|
+
* v0.8.0 (K8-019 / D8-08) — the two write modes. `markers` is the
|
|
8
|
+
* v0.6.0 behaviour, byte for byte: a splice between the two marker
|
|
9
|
+
* comments, used for `AGENTS.md`, a file humans edit. `whole` replaces
|
|
10
|
+
* the entire file and is used only for Kevin-owned paths such as
|
|
11
|
+
* `.kevin/knowledge.okf` — a file humans do not hand-edit.
|
|
12
|
+
*/
|
|
13
|
+
export type WriteMode = "markers" | "whole";
|
|
14
|
+
export interface WriteRequest {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly mode: WriteMode;
|
|
17
|
+
/** marker block body ("markers"), or whole-file content ("whole"). */
|
|
18
|
+
readonly content: string;
|
|
19
|
+
/**
|
|
20
|
+
* Caller-side refusal reason (K8-020 / D6-03): when present, the plan
|
|
21
|
+
* is refused — nothing is written, and the refusal is audited with
|
|
22
|
+
* both hashes. The refusal conditions belong to the caller; the
|
|
23
|
+
* writer only records them.
|
|
24
|
+
*/
|
|
25
|
+
readonly refusal?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface WritePlan {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly before: string;
|
|
30
|
+
readonly after: string;
|
|
31
|
+
readonly diff: string;
|
|
32
|
+
readonly outcome: WriteOutcome;
|
|
33
|
+
readonly reason?: string;
|
|
34
|
+
readonly hashBefore: string;
|
|
35
|
+
readonly hashAfter: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Rule 9, layer (a) — superseded in v1.0.0 by `escapeForMarkerBlock`
|
|
39
|
+
* in `plugin/escape.ts` (K10-027), which carries the same idempotent
|
|
40
|
+
* entity discipline.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* v1.0.0 (K10-027 / plan §5.7) — the boundary functions now live in
|
|
44
|
+
* `plugin/escape.ts`; this composer applies them in fence-then-marker
|
|
45
|
+
* order. Layer (b) — strip any line containing `kevin:begin` or
|
|
46
|
+
* `kevin:end`, in any casing — is a filter rather than an escape and
|
|
47
|
+
* stays here. Without layer (b), a memory containing a literal
|
|
48
|
+
* `<!-- kevin:end -->` line would close the marker comment early and
|
|
49
|
+
* let subsequent content escape the curated region on the next
|
|
50
|
+
* regeneration — a marker-injection variant of the v0.1.5
|
|
51
|
+
* prompt-injection defect (plan §3.5). The trailing `-->` strip is kept
|
|
52
|
+
* as defence in depth although `escapeForMarkerBlock` already escapes
|
|
53
|
+
* every `>` that could form one.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sanitizeArtifactBody(body: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* v1.0.0 (K10-027 / plan §5.7, rule 2) — whole-file writes escape by
|
|
58
|
+
* container. Only `.okf` files are line-oriented JSON: each line gets
|
|
59
|
+
* `escapeForOkfLine`, which is the identity on a well-formed OKF line
|
|
60
|
+
* (canonical JSON never contains raw control characters) and therefore
|
|
61
|
+
* preserves the re-render `noop`. Kevin-owned markdown paths keep their
|
|
62
|
+
* bytes — fences there are legitimate content.
|
|
63
|
+
*/
|
|
64
|
+
export declare function escapeForContainer(path: string, content: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* v0.6.0 (K6-005 / plan §5.1) — the single write path to disk (D6-01).
|
|
67
|
+
*
|
|
68
|
+
* `plan()` is pure: it reads the target file, locates the marker pair, splices
|
|
69
|
+
* the body between the markers and returns a {@link WritePlan}. It performs no
|
|
70
|
+
* writes — rule 1. `apply()` is implemented by K6-007; until then it is a stub
|
|
71
|
+
* that throws, so no caller can accidentally write before the audit trail
|
|
72
|
+
* exists.
|
|
73
|
+
*
|
|
74
|
+
* `projectId` is a constructor argument rather than a per-call argument so that
|
|
75
|
+
* every audit row is attributed without the call site having to remember.
|
|
76
|
+
*/
|
|
77
|
+
export declare class ArtifactWriter {
|
|
78
|
+
private readonly store;
|
|
79
|
+
private readonly projectId;
|
|
80
|
+
private readonly metrics;
|
|
81
|
+
constructor(store: Store, projectId: string, metrics?: Metrics | null);
|
|
82
|
+
plan(path: string, body: string): WritePlan;
|
|
83
|
+
plan(request: WriteRequest): WritePlan;
|
|
84
|
+
/**
|
|
85
|
+
* K8-019 (D8-08) — the single write funnel: every file Kevin writes
|
|
86
|
+
* goes through this method, which is the ONLY call site of `apply()`
|
|
87
|
+
* in the plugin (asserted by tests/unit/single_write_path.test.ts).
|
|
88
|
+
*/
|
|
89
|
+
write(request: WriteRequest, proposalId?: string): WriteOutcome;
|
|
90
|
+
apply(plan: WritePlan, proposalId?: string): WriteOutcome;
|
|
91
|
+
private renameTemp;
|
|
92
|
+
private audit;
|
|
93
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
3
|
+
import { unifiedDiff } from "./diff.js";
|
|
4
|
+
import { escapeForFence, escapeForMarkerBlock, escapeForOkfLine, } from "./escape.js";
|
|
5
|
+
import { uuidv7 } from "./uuid.js";
|
|
6
|
+
// v0.6.0 (K6-005 / plan §5.1, D6-02) — the frozen marker contract. The exact
|
|
7
|
+
// byte sequences are load-bearing: README, v1.0.0 plan C-01 and the round-trip
|
|
8
|
+
// test all depend on them.
|
|
9
|
+
export const MARKER_BEGIN = "<!-- kevin:begin — curated by opencode-kevin, safe to edit -->";
|
|
10
|
+
export const MARKER_END = "<!-- kevin:end -->";
|
|
11
|
+
function sha256(text) {
|
|
12
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Rule 5 (K6-008 / plan §5.1, D6-02) — the line-ending style comes from the
|
|
16
|
+
* FIRST line ending in the existing file: CRLF if it is CRLF, otherwise LF.
|
|
17
|
+
* A CRLF file whose last line lacks a terminator, and a mixed-ending file,
|
|
18
|
+
* are both resolved by the same first-ending rule, deterministically.
|
|
19
|
+
*/
|
|
20
|
+
function detectEol(text) {
|
|
21
|
+
const nl = text.indexOf("\n");
|
|
22
|
+
if (nl === -1)
|
|
23
|
+
return "\n";
|
|
24
|
+
return nl > 0 && text[nl - 1] === "\r" ? "\r\n" : "\n";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Rule 5 (K6-008) — normalize the generated body into the file's line-ending
|
|
28
|
+
* style so a CRLF file stays CRLF everywhere, including inside the block.
|
|
29
|
+
*/
|
|
30
|
+
function normalizeEol(body, eol) {
|
|
31
|
+
return body.replace(/\r\n/g, "\n").replace(/\n/g, eol);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rule 9, layer (a) — superseded in v1.0.0 by `escapeForMarkerBlock`
|
|
35
|
+
* in `plugin/escape.ts` (K10-027), which carries the same idempotent
|
|
36
|
+
* entity discipline.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* v1.0.0 (K10-027 / plan §5.7) — the boundary functions now live in
|
|
40
|
+
* `plugin/escape.ts`; this composer applies them in fence-then-marker
|
|
41
|
+
* order. Layer (b) — strip any line containing `kevin:begin` or
|
|
42
|
+
* `kevin:end`, in any casing — is a filter rather than an escape and
|
|
43
|
+
* stays here. Without layer (b), a memory containing a literal
|
|
44
|
+
* `<!-- kevin:end -->` line would close the marker comment early and
|
|
45
|
+
* let subsequent content escape the curated region on the next
|
|
46
|
+
* regeneration — a marker-injection variant of the v0.1.5
|
|
47
|
+
* prompt-injection defect (plan §3.5). The trailing `-->` strip is kept
|
|
48
|
+
* as defence in depth although `escapeForMarkerBlock` already escapes
|
|
49
|
+
* every `>` that could form one.
|
|
50
|
+
*/
|
|
51
|
+
export function sanitizeArtifactBody(body) {
|
|
52
|
+
const escaped = escapeForMarkerBlock(escapeForFence(body));
|
|
53
|
+
const kept = escaped
|
|
54
|
+
.split("\n")
|
|
55
|
+
.filter((line) => !/kevin:begin|kevin:end/i.test(line))
|
|
56
|
+
.join("\n");
|
|
57
|
+
return kept.replace(/-->/g, "");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* v1.0.0 (K10-027 / plan §5.7, rule 2) — whole-file writes escape by
|
|
61
|
+
* container. Only `.okf` files are line-oriented JSON: each line gets
|
|
62
|
+
* `escapeForOkfLine`, which is the identity on a well-formed OKF line
|
|
63
|
+
* (canonical JSON never contains raw control characters) and therefore
|
|
64
|
+
* preserves the re-render `noop`. Kevin-owned markdown paths keep their
|
|
65
|
+
* bytes — fences there are legitimate content.
|
|
66
|
+
*/
|
|
67
|
+
export function escapeForContainer(path, content) {
|
|
68
|
+
if (!path.endsWith(".okf"))
|
|
69
|
+
return content;
|
|
70
|
+
return content
|
|
71
|
+
.split("\n")
|
|
72
|
+
.map((line) => {
|
|
73
|
+
// A CRLF file (healHeader preserves the original EOL) leaves a
|
|
74
|
+
// trailing \r on each line after the split — it is line-ending
|
|
75
|
+
// bytes, not statement content, so it must survive untouched.
|
|
76
|
+
if (line.endsWith("\r")) {
|
|
77
|
+
return `${escapeForOkfLine(line.slice(0, -1))}\r`;
|
|
78
|
+
}
|
|
79
|
+
return escapeForOkfLine(line);
|
|
80
|
+
})
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* v0.6.0 (K6-005 / plan §5.1) — the single write path to disk (D6-01).
|
|
85
|
+
*
|
|
86
|
+
* `plan()` is pure: it reads the target file, locates the marker pair, splices
|
|
87
|
+
* the body between the markers and returns a {@link WritePlan}. It performs no
|
|
88
|
+
* writes — rule 1. `apply()` is implemented by K6-007; until then it is a stub
|
|
89
|
+
* that throws, so no caller can accidentally write before the audit trail
|
|
90
|
+
* exists.
|
|
91
|
+
*
|
|
92
|
+
* `projectId` is a constructor argument rather than a per-call argument so that
|
|
93
|
+
* every audit row is attributed without the call site having to remember.
|
|
94
|
+
*/
|
|
95
|
+
export class ArtifactWriter {
|
|
96
|
+
store;
|
|
97
|
+
projectId;
|
|
98
|
+
metrics;
|
|
99
|
+
constructor(store, projectId, metrics) {
|
|
100
|
+
this.store = store;
|
|
101
|
+
this.projectId = projectId;
|
|
102
|
+
this.metrics = metrics ?? null;
|
|
103
|
+
}
|
|
104
|
+
plan(pathOrRequest, body) {
|
|
105
|
+
const request = typeof pathOrRequest === "string"
|
|
106
|
+
? { path: pathOrRequest, mode: "markers", content: body ?? "" }
|
|
107
|
+
: pathOrRequest;
|
|
108
|
+
const { path, mode, content } = request;
|
|
109
|
+
let before;
|
|
110
|
+
try {
|
|
111
|
+
// Read as Buffer, not as utf8 text: readFileSync's text decoding
|
|
112
|
+
// strips a leading BOM, which would silently drop it on the next
|
|
113
|
+
// write. Buffer.toString keeps \uFEFF as a character of `before`.
|
|
114
|
+
before = readFileSync(path).toString("utf8");
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
118
|
+
before = "";
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (mode === "whole") {
|
|
125
|
+
// K8-019 (D8-08) — the whole-file path. The file is Kevin-owned,
|
|
126
|
+
// so there are no markers, no sanitization and no EOL
|
|
127
|
+
// normalization: the rendered bytes are written as-is, which is
|
|
128
|
+
// what makes a re-render of the same content a `noop`. A
|
|
129
|
+
// caller-side refusal leaves the file untouched and is audited
|
|
130
|
+
// like any other refusal: after = before, both hashes recorded.
|
|
131
|
+
const refusal = request.refusal;
|
|
132
|
+
const refused = refusal !== undefined;
|
|
133
|
+
// K10-027 — the container boundary: OKF lines are escaped at the
|
|
134
|
+
// single write path, never by callers.
|
|
135
|
+
const after = refused ? before : escapeForContainer(path, content);
|
|
136
|
+
return {
|
|
137
|
+
path,
|
|
138
|
+
before,
|
|
139
|
+
after,
|
|
140
|
+
diff: unifiedDiff(path, before, after),
|
|
141
|
+
outcome: refused ? "refused" : after === before ? "noop" : "written",
|
|
142
|
+
...(refusal !== undefined ? { reason: refusal } : {}),
|
|
143
|
+
hashBefore: sha256(before),
|
|
144
|
+
hashAfter: sha256(after),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const eol = detectEol(before);
|
|
148
|
+
// Rule 9 — sanitation happens in plan(), before hashing, so the hashes
|
|
149
|
+
// describe what was actually written.
|
|
150
|
+
const bodyEol = normalizeEol(sanitizeArtifactBody(content), eol);
|
|
151
|
+
const firstBegin = before.indexOf(MARKER_BEGIN);
|
|
152
|
+
const firstEnd = before.indexOf(MARKER_END);
|
|
153
|
+
let after;
|
|
154
|
+
let outcome = "written";
|
|
155
|
+
let reason;
|
|
156
|
+
if (firstBegin === -1 && firstEnd === -1) {
|
|
157
|
+
// Rule 2 — create: the block is appended at the end of the content,
|
|
158
|
+
// preceded by a blank line. For an empty file (missing file treated
|
|
159
|
+
// as "") the result is exactly: blank line, MARKER_BEGIN, body,
|
|
160
|
+
// MARKER_END, trailing newline.
|
|
161
|
+
const separator = before === "" ? eol : before.endsWith(eol) ? eol : eol + eol;
|
|
162
|
+
after =
|
|
163
|
+
before +
|
|
164
|
+
separator +
|
|
165
|
+
MARKER_BEGIN +
|
|
166
|
+
eol +
|
|
167
|
+
bodyEol +
|
|
168
|
+
eol +
|
|
169
|
+
MARKER_END +
|
|
170
|
+
eol;
|
|
171
|
+
}
|
|
172
|
+
else if (firstBegin === -1 || firstEnd === -1) {
|
|
173
|
+
// Rule 3 — exactly one marker present.
|
|
174
|
+
after = before;
|
|
175
|
+
outcome = "refused";
|
|
176
|
+
reason =
|
|
177
|
+
firstBegin === -1
|
|
178
|
+
? "kevin:end marker present without kevin:begin"
|
|
179
|
+
: "kevin:begin marker present without kevin:end";
|
|
180
|
+
}
|
|
181
|
+
else if (firstEnd < firstBegin) {
|
|
182
|
+
// Rule 3 — MARKER_END precedes MARKER_BEGIN.
|
|
183
|
+
after = before;
|
|
184
|
+
outcome = "refused";
|
|
185
|
+
reason = "kevin:end appears before kevin:begin";
|
|
186
|
+
}
|
|
187
|
+
else if (before.indexOf(MARKER_BEGIN, firstBegin + MARKER_BEGIN.length) !== -1 ||
|
|
188
|
+
before.indexOf(MARKER_END, firstEnd + MARKER_END.length) !== -1) {
|
|
189
|
+
// Rule 3 — more than one pair (any additional marker occurrence).
|
|
190
|
+
after = before;
|
|
191
|
+
outcome = "refused";
|
|
192
|
+
reason = "more than one marker pair present";
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Rule 4 — bytes outside the marker pair are byte-identical. The
|
|
196
|
+
// block between the markers (begin marker, old block, end marker)
|
|
197
|
+
// is replaced by the regenerated block.
|
|
198
|
+
const blockEnd = firstEnd + MARKER_END.length;
|
|
199
|
+
after =
|
|
200
|
+
before.slice(0, firstBegin) +
|
|
201
|
+
MARKER_BEGIN +
|
|
202
|
+
eol +
|
|
203
|
+
bodyEol +
|
|
204
|
+
eol +
|
|
205
|
+
MARKER_END +
|
|
206
|
+
before.slice(blockEnd);
|
|
207
|
+
// Rule 6 — unchanged content is a noop, never a write.
|
|
208
|
+
outcome = after === before ? "noop" : "written";
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
path,
|
|
212
|
+
before,
|
|
213
|
+
after,
|
|
214
|
+
// v0.6.0 (K6-006 / plan §5.2, D6-05) — approval prompts show bytes,
|
|
215
|
+
// never prose. Identical inputs yield "".
|
|
216
|
+
diff: unifiedDiff(path, before, after),
|
|
217
|
+
outcome,
|
|
218
|
+
...(reason !== undefined ? { reason } : {}),
|
|
219
|
+
hashBefore: sha256(before),
|
|
220
|
+
hashAfter: sha256(after),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* K8-019 (D8-08) — the single write funnel: every file Kevin writes
|
|
225
|
+
* goes through this method, which is the ONLY call site of `apply()`
|
|
226
|
+
* in the plugin (asserted by tests/unit/single_write_path.test.ts).
|
|
227
|
+
*/
|
|
228
|
+
write(request, proposalId) {
|
|
229
|
+
return this.apply(this.plan(request), proposalId);
|
|
230
|
+
}
|
|
231
|
+
// v0.6.0 (K6-007 / plan §5.1, rules 7–8) — atomic write + audit row.
|
|
232
|
+
apply(plan, proposalId) {
|
|
233
|
+
// Rule 8 — refusals and noops still leave an audit trail; a refusal
|
|
234
|
+
// that leaves no trace is indistinguishable from a write that never
|
|
235
|
+
// happened. Rule 6 — a noop creates no temp file and writes nothing.
|
|
236
|
+
if (plan.outcome === "noop" || plan.outcome === "refused") {
|
|
237
|
+
if (plan.outcome === "noop") {
|
|
238
|
+
this.metrics?.incr("artifact_writes_noop", 1);
|
|
239
|
+
}
|
|
240
|
+
this.audit(plan, proposalId);
|
|
241
|
+
return plan.outcome;
|
|
242
|
+
}
|
|
243
|
+
// Rule 7 — write to `<path>.kevin.tmp` in the same directory (same
|
|
244
|
+
// filesystem, so rename is atomic), fsync, close, then rename over the
|
|
245
|
+
// target. Never write the target path directly, never truncate-then-write.
|
|
246
|
+
const tmpPath = `${plan.path}.kevin.tmp`;
|
|
247
|
+
let fd;
|
|
248
|
+
try {
|
|
249
|
+
fd = openSync(tmpPath, "w");
|
|
250
|
+
writeSync(fd, plan.after, null, "utf8");
|
|
251
|
+
fsyncSync(fd);
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
fd = undefined;
|
|
254
|
+
this.renameTemp(tmpPath, plan.path);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
// Never leave .kevin.tmp litter next to the user's file.
|
|
258
|
+
if (fd !== undefined) {
|
|
259
|
+
try {
|
|
260
|
+
closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// already closed or unusable; unlink below is the cleanup
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
unlinkSync(tmpPath);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// nothing to clean up
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
this.metrics?.incr("artifact_writes_total", 1);
|
|
275
|
+
this.audit(plan, proposalId);
|
|
276
|
+
return "written";
|
|
277
|
+
}
|
|
278
|
+
// Fault-injection seam for the atomicity test: the rename is the point
|
|
279
|
+
// where a failure must leave the target untouched and the temp file gone.
|
|
280
|
+
renameTemp(tmpPath, target) {
|
|
281
|
+
renameSync(tmpPath, target);
|
|
282
|
+
}
|
|
283
|
+
audit(plan, proposalId) {
|
|
284
|
+
this.store
|
|
285
|
+
.prepare(`INSERT INTO artifact_writes
|
|
286
|
+
(id, proposal_id, project_id, path, bytes_before, bytes_after,
|
|
287
|
+
hash_before, hash_after, outcome, reason)
|
|
288
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
289
|
+
.run(uuidv7(), proposalId ?? null, this.projectId, plan.path, Buffer.byteLength(plan.before, "utf8"), Buffer.byteLength(plan.after, "utf8"), plan.hashBefore, plan.hashAfter, plan.outcome, plan.reason ?? null);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type EnrichFn } from "./LessonFixer.js";
|
|
2
|
+
import type { MemoryService } from "./MemoryService.js";
|
|
3
|
+
import type { Store } from "./Store.js";
|
|
4
|
+
import type { Metrics } from "./metrics.js";
|
|
5
|
+
export declare class CausalChain {
|
|
6
|
+
private store;
|
|
7
|
+
private memoryService;
|
|
8
|
+
private metrics;
|
|
9
|
+
private enrichFn?;
|
|
10
|
+
constructor(store: Store, memoryService: MemoryService, metrics: Metrics | null, enrichFn?: EnrichFn | undefined);
|
|
11
|
+
onSuccess(_tool: string, _args: Record<string, unknown>, _projectId: string | null, sessionId: string): void;
|
|
12
|
+
onSessionIdle(sessionId: string): Promise<number>;
|
|
13
|
+
/**
|
|
14
|
+
* v0.4.0 (K4-015) — fire the opt-in enrich hook at most once per
|
|
15
|
+
* promoted pattern. The hook's one-line phrase replaces the
|
|
16
|
+
* deterministic `Fixed by:` line; null keeps it. A call (phrase or
|
|
17
|
+
* not) stamps `metadata.enriched` so repeated idle cycles stay at
|
|
18
|
+
* one LLM call per pattern.
|
|
19
|
+
*/
|
|
20
|
+
private enrichIfEnabled;
|
|
21
|
+
private isLlmReflectionEnabled;
|
|
22
|
+
}
|