@jmtrin/opencode-kevin 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -8
- package/dist/migrations/001_initial.sql +91 -91
- package/dist/migrations/002_indexes.sql +13 -13
- package/dist/migrations/003_v02_signal.sql +57 -57
- package/dist/migrations/004_v03_knowledge.sql +138 -138
- package/dist/migrations/005_v04_signal.sql +57 -57
- package/dist/migrations/006_v05_glassbox.sql +118 -118
- package/dist/migrations/007_v06_pull.sql +144 -144
- package/dist/migrations/012_v11_drift.sql +24 -0
- package/dist/plugin/Archiver.js +2 -17
- package/dist/plugin/CausalChain.js +32 -13
- package/dist/plugin/ChatBridge.d.ts +41 -0
- package/dist/plugin/ChatBridge.js +103 -0
- package/dist/plugin/ConflictDetector.js +7 -29
- package/dist/plugin/DashboardHtml.d.ts +5 -0
- package/dist/plugin/DashboardHtml.js +180 -0
- package/dist/plugin/Feedback.js +2 -19
- package/dist/plugin/HookLiveness.d.ts +1 -0
- package/dist/plugin/HookLiveness.js +11 -27
- package/dist/plugin/InjectionLedger.js +104 -57
- package/dist/plugin/Materializer.js +1 -88
- package/dist/plugin/MemoryService.d.ts +59 -1
- package/dist/plugin/MemoryService.js +13 -106
- package/dist/plugin/Migrate.js +5 -0
- package/dist/plugin/Retrospective.js +7 -0
- package/dist/plugin/ToolCallObserver.js +18 -5
- package/dist/plugin/TuiActions.d.ts +43 -0
- package/dist/plugin/TuiActions.js +181 -0
- package/dist/plugin/TuiSnapshots.d.ts +24 -0
- package/dist/plugin/TuiSnapshots.js +158 -0
- package/dist/plugin/capabilities.d.ts +2 -0
- package/dist/plugin/capabilities.js +3 -0
- package/dist/plugin/columns.d.ts +11 -0
- package/dist/plugin/columns.js +54 -0
- package/dist/plugin/contract.d.ts +8 -0
- package/dist/plugin/contract.js +23 -5
- package/dist/plugin/index.d.ts +2 -2
- package/dist/plugin/index.js +315 -10
- package/dist/plugin/kevin_audit.d.ts +17 -1
- package/dist/plugin/kevin_audit.js +69 -1
- package/dist/plugin/kevin_forget.d.ts +33 -0
- package/dist/plugin/kevin_forget.js +260 -0
- package/dist/plugin/kevin_why.js +1 -18
- package/dist/plugin/metrics.d.ts +1 -1
- package/dist/plugin/metrics.js +8 -0
- package/dist/plugin/query-tokenizer.js +56 -8
- package/dist/plugin/time-ms.d.ts +1 -0
- package/dist/plugin/time-ms.js +16 -0
- package/dist/plugin/tui-types.d.ts +59 -0
- package/dist/plugin/tui-types.js +4 -0
- package/dist/plugin/tui.d.ts +18 -0
- package/dist/plugin/tui.js +198 -0
- package/package.json +8 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deterministicFixLine } from "./LessonFixer.js";
|
|
2
|
+
import { hasCuratedColumn as columnsHasCurated, hasIgnoredColumn as columnsHasIgnored, hasLayerColumn as columnsHasLayer, hasRecurrenceColumn as columnsHasRecurrence, hasRepoIdColumn as columnsHasRepoId, hasTruthColumns as columnsHasTruth, } from "./columns.js";
|
|
2
3
|
import { computeConfidence } from "./confidence.js";
|
|
3
4
|
import { fingerprint as computeFingerprint } from "./fingerprint.js";
|
|
4
5
|
import { classify } from "./inferability.js";
|
|
@@ -82,106 +83,21 @@ const MEMORY_ROW_SELECT = `id, type, content, scope, relevance_score, source_too
|
|
|
82
83
|
metadata, created_at, updated_at, expires_at,
|
|
83
84
|
project_id, fingerprint, origin,
|
|
84
85
|
evidence_count, recurrence_count, last_verified_at, status, fix_args`;
|
|
85
|
-
|
|
86
|
-
* v0.5.0 (K5-008/009 / plan §5.6) — cached per-store probe for the 006-only
|
|
87
|
-
* `ignored` column (pre-006 DBs must not reference it). Shared by the
|
|
88
|
-
* retrieval filter, the row SELECT, and the save() ignored stamp.
|
|
89
|
-
* v0.6.0 (K6-001a) — positive-only caching: a successful probe is cached,
|
|
90
|
-
* a failed probe is NOT. A Store migrated in place heals on the next call.
|
|
91
|
-
*/
|
|
92
|
-
const ignoredColumnCache = new WeakMap();
|
|
86
|
+
// v1.1.0 (K11-011 / plan §5.5, D11-06) — probes delegate to columns registry
|
|
93
87
|
function hasIgnoredColumn(store) {
|
|
94
|
-
|
|
95
|
-
if (cached === true)
|
|
96
|
-
return true;
|
|
97
|
-
try {
|
|
98
|
-
store.prepare("SELECT ignored FROM memories LIMIT 1").get();
|
|
99
|
-
ignoredColumnCache.set(store, true);
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
88
|
+
return columnsHasIgnored(store);
|
|
105
89
|
}
|
|
106
|
-
/**
|
|
107
|
-
* v0.6.0 (K6-011 / plan §5.4) — cached per-store probe for the 007-only
|
|
108
|
-
* `curated` column (pre-007 DBs must not reference it). Same positive-only
|
|
109
|
-
* caching discipline as `ignored` (K6-001a): a successful probe is cached,
|
|
110
|
-
* a failed probe is NOT.
|
|
111
|
-
*/
|
|
112
|
-
const curatedColumnCache = new WeakMap();
|
|
113
90
|
function hasCuratedColumn(store) {
|
|
114
|
-
|
|
115
|
-
if (cached === true)
|
|
116
|
-
return true;
|
|
117
|
-
try {
|
|
118
|
-
store.prepare("SELECT curated FROM memories LIMIT 1").get();
|
|
119
|
-
curatedColumnCache.set(store, true);
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
91
|
+
return columnsHasCurated(store);
|
|
125
92
|
}
|
|
126
|
-
/**
|
|
127
|
-
* v0.7.0 (K7-008 / migration 008) — cached per-store probe for the 008-only
|
|
128
|
-
* `truth_penalty` column (pre-008 DBs must not reference it). Same
|
|
129
|
-
* positive-only caching discipline as `curated` (K6-001a).
|
|
130
|
-
*/
|
|
131
|
-
const truthColumnsCache = new WeakMap();
|
|
132
93
|
function hasTruthColumns(store) {
|
|
133
|
-
|
|
134
|
-
if (cached === true)
|
|
135
|
-
return true;
|
|
136
|
-
try {
|
|
137
|
-
store.prepare("SELECT truth_penalty FROM memories LIMIT 1").get();
|
|
138
|
-
truthColumnsCache.set(store, true);
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
94
|
+
return columnsHasTruth(store);
|
|
144
95
|
}
|
|
145
|
-
/**
|
|
146
|
-
* v0.8.0 (K8-007 / plan §5.7) — cached per-store probe for the 009-only
|
|
147
|
-
* `repo_id` column (pre-009 DBs must not reference it). Same positive-only
|
|
148
|
-
* caching discipline as `ignored` (K6-001a): a successful probe is cached,
|
|
149
|
-
* a failed probe is NOT. Exported so `kevin_audit`'s rollups scope on the
|
|
150
|
-
* same column.
|
|
151
|
-
*/
|
|
152
|
-
const repoIdColumnCache = new WeakMap();
|
|
153
96
|
export function hasRepoIdColumn(store) {
|
|
154
|
-
|
|
155
|
-
if (cached === true)
|
|
156
|
-
return true;
|
|
157
|
-
try {
|
|
158
|
-
store.prepare("SELECT repo_id FROM memories LIMIT 1").get();
|
|
159
|
-
repoIdColumnCache.set(store, true);
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
97
|
+
return columnsHasRepoId(store);
|
|
165
98
|
}
|
|
166
|
-
/**
|
|
167
|
-
* v0.8.0 (K8-018 / plan §5.2) — cached per-store probe for the 009-only
|
|
168
|
-
* `layer` column (pre-009 DBs must not reference it). Same positive-only
|
|
169
|
-
* caching discipline: pre-009 databases have no shared layer, so the
|
|
170
|
-
* immutability refusal in `update()` is skipped there entirely.
|
|
171
|
-
*/
|
|
172
|
-
const layerColumnCache = new WeakMap();
|
|
173
99
|
function hasLayerColumn(store) {
|
|
174
|
-
|
|
175
|
-
if (cached === true)
|
|
176
|
-
return true;
|
|
177
|
-
try {
|
|
178
|
-
store.prepare("SELECT layer FROM memories LIMIT 1").get();
|
|
179
|
-
layerColumnCache.set(store, true);
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return false;
|
|
184
|
-
}
|
|
100
|
+
return columnsHasLayer(store);
|
|
185
101
|
}
|
|
186
102
|
/**
|
|
187
103
|
* v0.5.0 (K5-009 / plan §5.3) — the 006-only columns are appended when the
|
|
@@ -204,7 +120,7 @@ function rowSelect(store) {
|
|
|
204
120
|
: withCurated;
|
|
205
121
|
return hasLayerColumn(store) ? `${withTruth}, layer` : withTruth;
|
|
206
122
|
}
|
|
207
|
-
function mapRow(row, score) {
|
|
123
|
+
export function mapRow(row, score) {
|
|
208
124
|
const mem = {
|
|
209
125
|
id: row.id,
|
|
210
126
|
type: row.type,
|
|
@@ -320,21 +236,10 @@ export class MemoryService {
|
|
|
320
236
|
}
|
|
321
237
|
// v0.4.0 (BUG-008) — cached column probe for pre-005 DBs (which lack
|
|
322
238
|
// `recurrence_count`); save() must not reference the column there.
|
|
239
|
+
// v1.1.0 (K11-011) — delegates to columns registry
|
|
323
240
|
hasRecurrenceColumn() {
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
this.store
|
|
327
|
-
.prepare("SELECT recurrence_count FROM memories LIMIT 1")
|
|
328
|
-
.get();
|
|
329
|
-
this._hasRecurrenceColumn = true;
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
this._hasRecurrenceColumn = false;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return this._hasRecurrenceColumn;
|
|
241
|
+
return columnsHasRecurrence(this.store);
|
|
336
242
|
}
|
|
337
|
-
_hasRecurrenceColumn;
|
|
338
243
|
// v0.5.0 (K5-008 / plan §5.6) — cached column probe for pre-006 DBs
|
|
339
244
|
// (which lack `ignored`); the retrieval filter must not reference the
|
|
340
245
|
// column there.
|
|
@@ -1246,8 +1151,10 @@ function originBoost(mem) {
|
|
|
1246
1151
|
* (when available) so the feedback loop can exclude the original call
|
|
1247
1152
|
* from the recurrence count. Returns null when metadata is absent,
|
|
1248
1153
|
* malformed, or lacks the field.
|
|
1154
|
+
* // v1.1.0 (K11-003 / plan §5.5, D11-05) — single source for origin lookup;
|
|
1155
|
+
* // InjectionLedger reuses this implementation (K11-013).
|
|
1249
1156
|
*/
|
|
1250
|
-
function readOriginCallId(metadata) {
|
|
1157
|
+
export function readOriginCallId(metadata) {
|
|
1251
1158
|
if (!metadata)
|
|
1252
1159
|
return null;
|
|
1253
1160
|
try {
|
package/dist/plugin/Migrate.js
CHANGED
|
@@ -247,6 +247,11 @@ export class Migrate {
|
|
|
247
247
|
applied: pending.map((m) => m.version),
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
|
+
// v1.1.0 (K11-015) — lexicographic ordering is valid through "999" because
|
|
251
|
+
// versions are zero-padded 3-digit strings ("001" … "999"). Any future
|
|
252
|
+
// migration beyond 999 must use a 4-digit prefix and this comparison must
|
|
253
|
+
// become numeric (parseInt). Until then, string > works and keeps the
|
|
254
|
+
// migration idempotency simple (plan §5.5, D11-??).
|
|
250
255
|
listPending(current) {
|
|
251
256
|
let files = [];
|
|
252
257
|
try {
|
|
@@ -74,6 +74,13 @@ export const METRIC_KEY_LABELS = {
|
|
|
74
74
|
dispose_misses_total: "Misses de dispose",
|
|
75
75
|
contract_digest_changes: "Cambios de digest de contrato",
|
|
76
76
|
bench_runs_total: "Ejecuciones de benchmark (totales)",
|
|
77
|
+
// v1.1.0 (K11-001 / plan §4) — drift metrics; labels required by BUG-014 regression.
|
|
78
|
+
bench_regression_failures: "Fallos de regresion de benchmark",
|
|
79
|
+
forget_requests_total: "Solicitudes de olvido totales",
|
|
80
|
+
forget_tombstones_published: "Tombstones publicados por olvido",
|
|
81
|
+
// v1.2.0 (K12-001 / plan §4) — surface metrics; labels required by BUG-014 regression.
|
|
82
|
+
tui_snapshots_flushed: "Snapshots TUI generados",
|
|
83
|
+
tui_actions_invoked: "Acciones TUI invocadas",
|
|
77
84
|
};
|
|
78
85
|
function originLabel(origin) {
|
|
79
86
|
if (origin === "reflector")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasColumn } from "./columns.js";
|
|
1
2
|
import { fingerprint as computeFingerprint } from "./fingerprint.js";
|
|
2
3
|
import { redactPaths, stripPrivate } from "./redact.js";
|
|
3
4
|
import { uuidv7 } from "./uuid.js";
|
|
@@ -54,11 +55,23 @@ export class ToolCallObserver {
|
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.
|
|
58
|
+
// v1.1.0 (K11-002 / plan §5.2, D11-01/D11-07) — dual-write: legacy `ts`
|
|
59
|
+
// stays (`datetime('now')`), new `ts_ms` holds Date.now() when the
|
|
60
|
+
// column exists. Probe is cached via columns registry (K11-011).
|
|
61
|
+
if (hasColumn(this.store, "tool_calls", "ts_ms")) {
|
|
62
|
+
this.store
|
|
63
|
+
.prepare(`INSERT INTO tool_calls
|
|
64
|
+
(id, session_id, ts, ts_ms, tool, args_summary, success, duration_ms, agent, error_type, metadata, project_id, fingerprint)
|
|
65
|
+
VALUES (?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
66
|
+
.run(input.callID ?? uuidv7(), sessionId, Date.now(), input.tool, argsSummary, success, durationMs, agent, errorType, metadata, projectId, fp);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.store
|
|
70
|
+
.prepare(`INSERT INTO tool_calls
|
|
71
|
+
(id, session_id, ts, tool, args_summary, success, duration_ms, agent, error_type, metadata, project_id, fingerprint)
|
|
72
|
+
VALUES (?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
73
|
+
.run(input.callID ?? uuidv7(), sessionId, input.tool, argsSummary, success, durationMs, agent, errorType, metadata, projectId, fp);
|
|
74
|
+
}
|
|
62
75
|
}
|
|
63
76
|
isDedupEnabled() {
|
|
64
77
|
try {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ActionResult, TuiAction } from "./tui-types.js";
|
|
2
|
+
export interface MailboxReadResult {
|
|
3
|
+
readonly actions: readonly TuiAction[];
|
|
4
|
+
readonly warnings: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read `join(root,"tui","actions.json")` tolerant.
|
|
8
|
+
* - missing file → {actions:[], warnings:[]}
|
|
9
|
+
* - malformed JSON → {actions:[], warnings:["malformed_json"]}
|
|
10
|
+
* - non-object or missing/non-array actions → {actions:[], warnings:["invalid_shape"]}
|
|
11
|
+
* - unknown type values are dropped with warning per entry
|
|
12
|
+
* Never deletes the file here.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readMailbox(root: string): MailboxReadResult;
|
|
15
|
+
export declare function proposalToken(proposalId: string, proposedText: string): string;
|
|
16
|
+
export interface PendingProposal {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly proposedText: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function verifyFresh(action: TuiAction, currentPending: readonly PendingProposal[]): {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
export type ActionStatus = ActionResult["status"];
|
|
27
|
+
export interface ProcessDeps {
|
|
28
|
+
readonly getPending: () => readonly PendingProposal[];
|
|
29
|
+
readonly approve: (proposalId: string) => unknown;
|
|
30
|
+
readonly reject: (proposalId: string, note?: string) => unknown;
|
|
31
|
+
readonly acknowledge: (conflictId: string) => unknown;
|
|
32
|
+
readonly metrics?: {
|
|
33
|
+
incr: (key: "tui_actions_invoked", by?: number) => void;
|
|
34
|
+
} | null;
|
|
35
|
+
}
|
|
36
|
+
export declare function processActions(actions: readonly TuiAction[], deps: ProcessDeps): ActionResult[];
|
|
37
|
+
export declare function writeResults(root: string, results: readonly ActionResult[]): void;
|
|
38
|
+
export declare function deleteMailbox(root: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Convenience: read → process → write results → delete queue.
|
|
41
|
+
* Returns results (empty if no actions). Mirrors idle-chain usage.
|
|
42
|
+
*/
|
|
43
|
+
export declare function consumeMailbox(root: string, deps: ProcessDeps): ActionResult[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// v1.2.0 (K12-005 / plan §4.3) — mailbox tolerant parser (phase F1).
|
|
2
|
+
// v1.2.0 (K12-006 / plan §4.3, D12-04) — token scheme + stale detection
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
function isRecord(v) {
|
|
7
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
}
|
|
9
|
+
function isValidAction(raw) {
|
|
10
|
+
if (!isRecord(raw))
|
|
11
|
+
return null;
|
|
12
|
+
const t = raw.type;
|
|
13
|
+
if (t === "approve") {
|
|
14
|
+
if (typeof raw.proposalId === "string" && typeof raw.token === "string") {
|
|
15
|
+
return { type: "approve", proposalId: raw.proposalId, token: raw.token };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (t === "reject") {
|
|
20
|
+
if (typeof raw.proposalId === "string" && typeof raw.token === "string") {
|
|
21
|
+
const note = typeof raw.note === "string" ? raw.note : undefined;
|
|
22
|
+
return note !== undefined
|
|
23
|
+
? { type: "reject", proposalId: raw.proposalId, token: raw.token, note }
|
|
24
|
+
: { type: "reject", proposalId: raw.proposalId, token: raw.token };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (t === "acknowledge") {
|
|
29
|
+
if (typeof raw.conflictId === "string") {
|
|
30
|
+
return { type: "acknowledge", conflictId: raw.conflictId };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read `join(root,"tui","actions.json")` tolerant.
|
|
38
|
+
* - missing file → {actions:[], warnings:[]}
|
|
39
|
+
* - malformed JSON → {actions:[], warnings:["malformed_json"]}
|
|
40
|
+
* - non-object or missing/non-array actions → {actions:[], warnings:["invalid_shape"]}
|
|
41
|
+
* - unknown type values are dropped with warning per entry
|
|
42
|
+
* Never deletes the file here.
|
|
43
|
+
*/
|
|
44
|
+
export function readMailbox(root) {
|
|
45
|
+
const path = join(root, "tui", "actions.json");
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(path, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const code = err?.code;
|
|
52
|
+
if (code === "ENOENT")
|
|
53
|
+
return { actions: [], warnings: [] };
|
|
54
|
+
return { actions: [], warnings: ["read_error"] };
|
|
55
|
+
}
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { actions: [], warnings: ["malformed_json"] };
|
|
62
|
+
}
|
|
63
|
+
if (!isRecord(parsed)) {
|
|
64
|
+
return { actions: [], warnings: ["invalid_shape"] };
|
|
65
|
+
}
|
|
66
|
+
const maybeActions = parsed.actions;
|
|
67
|
+
if (!Array.isArray(maybeActions)) {
|
|
68
|
+
return { actions: [], warnings: ["invalid_shape"] };
|
|
69
|
+
}
|
|
70
|
+
const actions = [];
|
|
71
|
+
const warnings = [];
|
|
72
|
+
for (const entry of maybeActions) {
|
|
73
|
+
if (!isRecord(entry)) {
|
|
74
|
+
warnings.push("dropped_invalid_entry");
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const type = entry.type;
|
|
78
|
+
if (type !== "approve" && type !== "reject" && type !== "acknowledge") {
|
|
79
|
+
warnings.push(`dropped_unknown_type:${String(type)}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const valid = isValidAction(entry);
|
|
83
|
+
if (!valid) {
|
|
84
|
+
warnings.push(`dropped_invalid_${String(type)}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
actions.push(valid);
|
|
88
|
+
}
|
|
89
|
+
return { actions, warnings };
|
|
90
|
+
}
|
|
91
|
+
// v1.2.0 (K12-006 / D12-04) — first 16 hex of SHA-256(proposalId + "\0" + proposedText)
|
|
92
|
+
export function proposalToken(proposalId, proposedText) {
|
|
93
|
+
return createHash("sha256")
|
|
94
|
+
.update(`${proposalId}\0${proposedText}`, "utf8")
|
|
95
|
+
.digest("hex")
|
|
96
|
+
.slice(0, 16);
|
|
97
|
+
}
|
|
98
|
+
export function verifyFresh(action, currentPending) {
|
|
99
|
+
if (action.type === "acknowledge")
|
|
100
|
+
return { ok: true };
|
|
101
|
+
const pending = currentPending.find((p) => p.id === action.proposalId);
|
|
102
|
+
if (!pending) {
|
|
103
|
+
return { ok: false, reason: "content_changed_or_absent" };
|
|
104
|
+
}
|
|
105
|
+
const expected = proposalToken(pending.id, pending.proposedText);
|
|
106
|
+
if (expected !== action.token) {
|
|
107
|
+
return { ok: false, reason: "content_changed_or_absent" };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
export function processActions(actions, deps) {
|
|
112
|
+
const results = [];
|
|
113
|
+
const pendingSnapshot = deps.getPending();
|
|
114
|
+
for (const action of actions) {
|
|
115
|
+
// Stale check for approve/reject
|
|
116
|
+
if (action.type === "approve" || action.type === "reject") {
|
|
117
|
+
const fresh = verifyFresh(action, pendingSnapshot);
|
|
118
|
+
if (!fresh.ok) {
|
|
119
|
+
results.push({ action, status: "stale_skipped", detail: fresh.reason });
|
|
120
|
+
try {
|
|
121
|
+
deps.metrics?.incr("tui_actions_invoked", 1);
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (action.type === "approve") {
|
|
129
|
+
deps.approve(action.proposalId);
|
|
130
|
+
results.push({ action, status: "applied" });
|
|
131
|
+
}
|
|
132
|
+
else if (action.type === "reject") {
|
|
133
|
+
deps.reject(action.proposalId, action.note);
|
|
134
|
+
results.push({ action, status: "rejected" });
|
|
135
|
+
}
|
|
136
|
+
else if (action.type === "acknowledge") {
|
|
137
|
+
deps.acknowledge(action.conflictId);
|
|
138
|
+
results.push({ action, status: "applied" });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
143
|
+
results.push({ action, status: "error", detail: msg });
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
deps.metrics?.incr("tui_actions_invoked", 1);
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
149
|
+
}
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
export function writeResults(root, results) {
|
|
153
|
+
const dir = join(root, "tui");
|
|
154
|
+
mkdirSync(dir, { recursive: true });
|
|
155
|
+
const payload = JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2);
|
|
156
|
+
const target = join(dir, "results.json");
|
|
157
|
+
const tmp = `${target}.tmp`;
|
|
158
|
+
writeFileSync(tmp, payload, "utf8");
|
|
159
|
+
renameSync(tmp, target);
|
|
160
|
+
}
|
|
161
|
+
export function deleteMailbox(root) {
|
|
162
|
+
try {
|
|
163
|
+
unlinkSync(join(root, "tui", "actions.json"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// missing is fine
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Convenience: read → process → write results → delete queue.
|
|
171
|
+
* Returns results (empty if no actions). Mirrors idle-chain usage.
|
|
172
|
+
*/
|
|
173
|
+
export function consumeMailbox(root, deps) {
|
|
174
|
+
const { actions } = readMailbox(root);
|
|
175
|
+
if (actions.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
const results = processActions(actions, deps);
|
|
178
|
+
writeResults(root, results);
|
|
179
|
+
deleteMailbox(root);
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Metrics } from "./metrics.js";
|
|
2
|
+
import type { ConflictView, HealthView, ProposalView } from "./tui-types.js";
|
|
3
|
+
export interface FlushInput {
|
|
4
|
+
readonly root: string;
|
|
5
|
+
readonly proposals: readonly ProposalView[];
|
|
6
|
+
readonly conflicts: readonly ConflictView[];
|
|
7
|
+
readonly health: HealthView;
|
|
8
|
+
readonly metrics?: Metrics | null;
|
|
9
|
+
readonly version?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FlushResult {
|
|
12
|
+
readonly written: string[];
|
|
13
|
+
readonly skipped: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function flushSnapshots(input: FlushInput): FlushResult;
|
|
16
|
+
/**
|
|
17
|
+
* Tolerant JSON reader used by tests and (duplicated) by the TUI.
|
|
18
|
+
* Returns {data} on success, {error} on missing/corrupt.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readJsonSafe(path: string): {
|
|
21
|
+
data: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
error: "missing" | "corrupt";
|
|
24
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// v1.2.0 (K12-003 / plan §4.2, D12-05) — snapshot flush (pure serialization + atomic write).
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const CAP_BYTES = 512 * 1024;
|
|
5
|
+
const SNAP_FILES = [
|
|
6
|
+
"proposals.json",
|
|
7
|
+
"conflicts.json",
|
|
8
|
+
"health.json",
|
|
9
|
+
"meta.json",
|
|
10
|
+
];
|
|
11
|
+
function byteLen(s) {
|
|
12
|
+
return Buffer.byteLength(s, "utf8");
|
|
13
|
+
}
|
|
14
|
+
function atomicWrite(target, content) {
|
|
15
|
+
const tmp = `${target}.tmp`;
|
|
16
|
+
writeFileSync(tmp, content, "utf8");
|
|
17
|
+
renameSync(tmp, target);
|
|
18
|
+
}
|
|
19
|
+
function truncateProposals(proposals, cap) {
|
|
20
|
+
// Estimate JSON overhead without diffs to compute available budget.
|
|
21
|
+
// We truncate diff fields proportionally to fit cap.
|
|
22
|
+
const serialized = JSON.stringify(proposals);
|
|
23
|
+
if (byteLen(serialized) <= cap)
|
|
24
|
+
return proposals;
|
|
25
|
+
// Compute total diff length
|
|
26
|
+
const totalDiff = proposals.reduce((acc, p) => acc + byteLen(p.diff), 0);
|
|
27
|
+
if (totalDiff === 0)
|
|
28
|
+
return proposals;
|
|
29
|
+
const overhead = byteLen(serialized) - totalDiff;
|
|
30
|
+
const budget = Math.max(0, cap - overhead - 1024); // leave margin
|
|
31
|
+
// Distribute budget proportionally
|
|
32
|
+
const out = [];
|
|
33
|
+
for (let i = 0; i < proposals.length; i++) {
|
|
34
|
+
const p = proposals[i];
|
|
35
|
+
const diffBytes = byteLen(p.diff);
|
|
36
|
+
const share = Math.floor((diffBytes / totalDiff) * budget);
|
|
37
|
+
// Ensure at least 100 bytes per entry if possible, else share
|
|
38
|
+
const sliceBytes = Math.min(diffBytes, Math.max(share, 100));
|
|
39
|
+
// Slice diff by bytes approximated via string length; diff is ascii mostly
|
|
40
|
+
// so byte length ~ char length. Use char slice.
|
|
41
|
+
const approxChars = Math.floor((sliceBytes / Math.max(diffBytes, 1)) * p.diff.length);
|
|
42
|
+
const truncatedDiff = p.diff.slice(0, Math.max(0, approxChars));
|
|
43
|
+
const needsTrunc = truncatedDiff.length < p.diff.length;
|
|
44
|
+
out.push({
|
|
45
|
+
...p,
|
|
46
|
+
diff: needsTrunc ? `${truncatedDiff}\n…[truncated]` : p.diff,
|
|
47
|
+
truncated: needsTrunc ? true : p.truncated,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// If still over cap after proportional truncation, iteratively trim more
|
|
51
|
+
let result = out;
|
|
52
|
+
let ser = JSON.stringify(result);
|
|
53
|
+
let iter = 0;
|
|
54
|
+
while (byteLen(ser) > cap && iter < 5) {
|
|
55
|
+
result = result.map((p) => {
|
|
56
|
+
if (!p.diff || p.diff.length < 200)
|
|
57
|
+
return p;
|
|
58
|
+
const half = Math.floor(p.diff.length / 2);
|
|
59
|
+
return {
|
|
60
|
+
...p,
|
|
61
|
+
diff: `${p.diff.slice(0, half)}\n…[truncated]`,
|
|
62
|
+
truncated: true,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
ser = JSON.stringify(result);
|
|
66
|
+
iter++;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
export function flushSnapshots(input) {
|
|
71
|
+
const { root, proposals, conflicts, health, metrics, version } = input;
|
|
72
|
+
const dir = join(root, "tui");
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
const generatedAt = new Date().toISOString();
|
|
75
|
+
const written = [];
|
|
76
|
+
const skipped = [];
|
|
77
|
+
// Proposals — cap with truncation
|
|
78
|
+
let propToWrite = proposals;
|
|
79
|
+
let propJson = JSON.stringify(propToWrite, null, 2);
|
|
80
|
+
if (byteLen(propJson) > CAP_BYTES) {
|
|
81
|
+
propToWrite = truncateProposals(proposals, CAP_BYTES - 1024);
|
|
82
|
+
propJson = JSON.stringify(propToWrite, null, 2);
|
|
83
|
+
if (byteLen(propJson) > CAP_BYTES) {
|
|
84
|
+
// Still over after truncation: truncate further by dropping diffs entirely
|
|
85
|
+
const minimal = propToWrite.map((p) => ({
|
|
86
|
+
...p,
|
|
87
|
+
diff: `${p.diff.slice(0, 500)}\n…[truncated]`,
|
|
88
|
+
truncated: true,
|
|
89
|
+
}));
|
|
90
|
+
propJson = JSON.stringify(minimal, null, 2);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
atomicWrite(join(dir, "proposals.json"), propJson);
|
|
94
|
+
written.push("proposals.json");
|
|
95
|
+
// Conflicts
|
|
96
|
+
let conflictsJson = JSON.stringify(conflicts, null, 2);
|
|
97
|
+
if (byteLen(conflictsJson) > CAP_BYTES) {
|
|
98
|
+
// Truncate summaries if needed
|
|
99
|
+
const truncated = conflicts.map((c) => ({
|
|
100
|
+
...c,
|
|
101
|
+
a_summary: c.a_summary.slice(0, 500),
|
|
102
|
+
b_summary: c.b_summary.slice(0, 500),
|
|
103
|
+
}));
|
|
104
|
+
conflictsJson = JSON.stringify(truncated, null, 2);
|
|
105
|
+
}
|
|
106
|
+
atomicWrite(join(dir, "conflicts.json"), conflictsJson);
|
|
107
|
+
written.push("conflicts.json");
|
|
108
|
+
// Health
|
|
109
|
+
let healthJson = JSON.stringify(health, null, 2);
|
|
110
|
+
if (byteLen(healthJson) > CAP_BYTES) {
|
|
111
|
+
// Health should never exceed cap, but truncate counters if it does
|
|
112
|
+
const truncatedHealth = {
|
|
113
|
+
...health,
|
|
114
|
+
counters: {},
|
|
115
|
+
};
|
|
116
|
+
healthJson = JSON.stringify(truncatedHealth, null, 2);
|
|
117
|
+
}
|
|
118
|
+
atomicWrite(join(dir, "health.json"), healthJson);
|
|
119
|
+
written.push("health.json");
|
|
120
|
+
// Meta
|
|
121
|
+
const meta = {
|
|
122
|
+
generatedAt,
|
|
123
|
+
version: version ?? "1.2.0",
|
|
124
|
+
files: SNAP_FILES.slice(0, 3),
|
|
125
|
+
};
|
|
126
|
+
atomicWrite(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
127
|
+
written.push("meta.json");
|
|
128
|
+
if (metrics) {
|
|
129
|
+
try {
|
|
130
|
+
metrics.incr("tui_snapshots_flushed");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// best-effort
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { written, skipped };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Tolerant JSON reader used by tests and (duplicated) by the TUI.
|
|
140
|
+
* Returns {data} on success, {error} on missing/corrupt.
|
|
141
|
+
*/
|
|
142
|
+
export function readJsonSafe(path) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = readFileSync(path, "utf8");
|
|
145
|
+
try {
|
|
146
|
+
return { data: JSON.parse(raw) };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { error: "corrupt" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const code = err?.code;
|
|
154
|
+
if (code === "ENOENT")
|
|
155
|
+
return { error: "missing" };
|
|
156
|
+
return { error: "corrupt" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -11,5 +11,7 @@ export interface Capabilities {
|
|
|
11
11
|
readonly skills: boolean;
|
|
12
12
|
readonly references: boolean;
|
|
13
13
|
readonly apiVersion: string | null;
|
|
14
|
+
/** v1.2.0 (K12-012 / plan D12-03) — additive probe for permission.ask. */
|
|
15
|
+
readonly permissionAsk?: boolean;
|
|
14
16
|
}
|
|
15
17
|
export declare function probe(input: unknown): Capabilities;
|
|
@@ -11,6 +11,7 @@ const ALL_FALSE = {
|
|
|
11
11
|
skills: false,
|
|
12
12
|
references: false,
|
|
13
13
|
apiVersion: null,
|
|
14
|
+
permissionAsk: false,
|
|
14
15
|
};
|
|
15
16
|
function hasCallable(input, domainKey, memberKey) {
|
|
16
17
|
const domain = input[domainKey];
|
|
@@ -27,10 +28,12 @@ export function probe(input) {
|
|
|
27
28
|
const apiVersion = typeof record.apiVersion === "string"
|
|
28
29
|
? record.apiVersion
|
|
29
30
|
: null;
|
|
31
|
+
const permissionAsk = hasCallable(record, "permission", "ask");
|
|
30
32
|
return {
|
|
31
33
|
skills: hasCallable(record, "skill", "source"),
|
|
32
34
|
references: hasCallable(record, "reference", "add"),
|
|
33
35
|
apiVersion,
|
|
36
|
+
permissionAsk,
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
catch {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Store } from "./Store.js";
|
|
2
|
+
export declare function hasColumn(store: Store, table: string, column: string): boolean;
|
|
3
|
+
export declare function hasIgnoredColumn(store: Store): boolean;
|
|
4
|
+
export declare function hasCuratedColumn(store: Store): boolean;
|
|
5
|
+
export declare function hasTruthColumns(store: Store): boolean;
|
|
6
|
+
export declare function hasRepoIdColumn(store: Store): boolean;
|
|
7
|
+
export declare function hasLayerColumn(store: Store): boolean;
|
|
8
|
+
export declare function hasRecurrenceColumn(store: Store): boolean;
|
|
9
|
+
export declare function hasArchivedColumn(store: Store): boolean;
|
|
10
|
+
export declare function hasFeedbackColumns(store: Store): boolean;
|
|
11
|
+
export declare function hasFeedbackTable(store: Store): boolean;
|