@dzhechkov/harness-core 0.3.92 → 0.3.94
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/__tests__/golden-baseline.test.d.ts +2 -0
- package/dist/__tests__/golden-baseline.test.d.ts.map +1 -0
- package/dist/__tests__/golden-baseline.test.js +60 -0
- package/dist/__tests__/golden-baseline.test.js.map +1 -0
- package/dist/agentdb-index.d.ts +9 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +44 -1
- package/dist/agentdb-index.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +69 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +79 -9
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/learning-backend.d.ts +85 -0
- package/dist/learning-backend.d.ts.map +1 -0
- package/dist/learning-backend.js +133 -0
- package/dist/learning-backend.js.map +1 -0
- package/dist/patterns.d.ts +43 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +0 -0
- package/dist/patterns.js.map +1 -1
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +17 -3
- package/dist/recommend.js.map +1 -1
- package/dist/statusline.d.ts +3 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +2 -0
- package/dist/statusline.js.map +1 -1
- package/dist/usage.d.ts +70 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +255 -0
- package/dist/usage.js.map +1 -0
- package/dist/vector-tier.d.ts +15 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +101 -19
- package/dist/vector-tier.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/golden-baseline.test.ts +67 -0
- package/src/agentdb-index.ts +52 -1
- package/src/feature-adr-routing.ts +131 -8
- package/src/index.ts +14 -4
- package/src/learning-backend.ts +202 -0
- package/src/patterns.ts +0 -0
- package/src/recommend.ts +19 -5
- package/src/statusline.ts +5 -0
- package/src/usage.ts +289 -0
- package/src/vector-tier.ts +116 -16
package/src/usage.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz usage` data source — a READONLY, never-throw, best-effort estimate of Claude SESSION
|
|
3
|
+
* (active 5h-block) and WEEKLY (rolling 7d) token usage, aggregated from the local Claude Code
|
|
4
|
+
* transcript files under `~/.claude/projects/<munged>/<session>.jsonl`.
|
|
5
|
+
*
|
|
6
|
+
* ## Honest-uncertainty contract (LOAD-BEARING — NFR-4)
|
|
7
|
+
*
|
|
8
|
+
* These percentages are **ESTIMATES** derived by aggregating local transcript token counts against
|
|
9
|
+
* a USER-CONFIGURED limit. There is **no official usage API** being consulted — this is a fuel
|
|
10
|
+
* gauge built from the flow meter, not a tank sensor. Therefore:
|
|
11
|
+
*
|
|
12
|
+
* - Every payload carries `estimated: true` so a consumer can NEVER mistake it for ground truth.
|
|
13
|
+
* - When a limit is **unconfigured** (`.dz/config.json` has no `memory.usage.sessionTokenLimit` /
|
|
14
|
+
* `weeklyTokenLimit`), the corresponding `pct` is `null` — **unknown, never 0, never a guess**.
|
|
15
|
+
* - **Calibration protocol (observed-exhaustion):** when a real limit-hit occurs at an estimated
|
|
16
|
+
* `X%`, scale the configured limit by `X/100` so the next estimate lines up with reality. The
|
|
17
|
+
* 4 token fields are summed with equal weight; the relative weighting (cache-read tokens
|
|
18
|
+
* dominate real transcripts) is absorbed into the calibrated limit value.
|
|
19
|
+
*
|
|
20
|
+
* ## Statusline discipline (NFR-1)
|
|
21
|
+
*
|
|
22
|
+
* Modeled on {@link ./statusline.ts} and {@link ./vector-tier.ts}'s `readVectorEngineMode`:
|
|
23
|
+
* - **never-throw** — any error (missing `~/.claude/projects`, corrupt jsonl line, missing/corrupt
|
|
24
|
+
* config, garbled usage object) collapses to a value with `null` pcts, never an exception.
|
|
25
|
+
* - **readonly** — zero writes/appends/mkdir/unlink anywhere (the optional aggregate cache in
|
|
26
|
+
* arch §2.2(6) is DEFERRED — v1 ships with zero writes so READONLY is trivially true).
|
|
27
|
+
* - **<100ms steady-state** via an `mtime` prefilter: a file whose `mtime` is older than the weekly
|
|
28
|
+
* window cannot contribute and is skipped WITHOUT opening it; only files touched inside the
|
|
29
|
+
* session window are line-parsed for the active block.
|
|
30
|
+
* - **injectable clock** — `computeUsage(root, now?)` takes an optional `now` (ms epoch) so all
|
|
31
|
+
* window math is deterministic under test.
|
|
32
|
+
*
|
|
33
|
+
* @packageDocumentation
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
41
|
+
const SESSION_BLOCK_MS = 5 * HOUR_MS; // ccusage 5h-block
|
|
42
|
+
const WEEK_MS = 7 * 24 * HOUR_MS; // rolling 7-day weekly window
|
|
43
|
+
// mtime prefilter slack (+1h) — guards against clock skew between the writer and this reader
|
|
44
|
+
// (a file with a slightly-stale mtime that is actually in-window must not be dropped).
|
|
45
|
+
const MTIME_SLACK_MS = HOUR_MS;
|
|
46
|
+
|
|
47
|
+
/** Optional, plan-dependent calibration limits from `.dz/config.json`. Absent ⇒ pct is `null`. */
|
|
48
|
+
export interface UsageLimits {
|
|
49
|
+
readonly sessionTokenLimit?: number;
|
|
50
|
+
readonly weeklyTokenLimit?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A never-throw usage estimate. `estimated` is ALWAYS `true` (honest-uncertainty marker). */
|
|
54
|
+
export interface UsageEstimate {
|
|
55
|
+
/** Active 5h-block token total (all projects). `0` when there is no active block. */
|
|
56
|
+
readonly sessionTokens: number;
|
|
57
|
+
/** Rolling 7d token total (all projects). */
|
|
58
|
+
readonly weeklyTokens: number;
|
|
59
|
+
/** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never 0). */
|
|
60
|
+
readonly sessionPct: number | null;
|
|
61
|
+
/** `null` ⇔ `weeklyTokenLimit` unconfigured (unknown — never 0). */
|
|
62
|
+
readonly weeklyPct: number | null;
|
|
63
|
+
/** ISO — active block start + 5h; `null` when there is no active block. */
|
|
64
|
+
readonly sessionResetsAt: string | null;
|
|
65
|
+
/** ISO — oldest in-window sample ts + 7d (estimate); `null` when the weekly window is empty. */
|
|
66
|
+
readonly weeklyResetsAt: string | null;
|
|
67
|
+
/** ALWAYS `true` — these are estimates from local aggregation, not an official API. */
|
|
68
|
+
readonly estimated: true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `~/.claude/projects` root (the account-wide transcript store). Overridable via
|
|
73
|
+
* `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree (and by any user who relocates
|
|
74
|
+
* the Claude home). Never throws.
|
|
75
|
+
*/
|
|
76
|
+
function claudeProjectsRoot(): string {
|
|
77
|
+
const override = process.env['DZ_CLAUDE_PROJECTS_ROOT'];
|
|
78
|
+
if (typeof override === 'string' && override.length > 0) return override;
|
|
79
|
+
return join(homedir(), '.claude', 'projects');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read `memory.usage.{sessionTokenLimit,weeklyTokenLimit}` from `<projectRoot>/.dz/config.json`.
|
|
84
|
+
* NEVER throws — absent/corrupt/partial config ⇒ `{}` (⇒ pct `null`). Mirrors the
|
|
85
|
+
* `readVectorEngineMode` never-throw shape exactly. `projectRoot` is the ONLY thing that scopes to
|
|
86
|
+
* a project; the MEASUREMENT below is account-wide (FR-1.6).
|
|
87
|
+
*/
|
|
88
|
+
export function readUsageLimits(projectRoot: string): UsageLimits {
|
|
89
|
+
try {
|
|
90
|
+
const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
|
|
91
|
+
memory?: { usage?: { sessionTokenLimit?: unknown; weeklyTokenLimit?: unknown } };
|
|
92
|
+
};
|
|
93
|
+
const u = cfg.memory?.usage;
|
|
94
|
+
if (!u || typeof u !== 'object') return {};
|
|
95
|
+
const out: { sessionTokenLimit?: number; weeklyTokenLimit?: number } = {};
|
|
96
|
+
const s = u.sessionTokenLimit;
|
|
97
|
+
const w = u.weeklyTokenLimit;
|
|
98
|
+
if (typeof s === 'number' && isFinite(s) && s > 0) out.sessionTokenLimit = s;
|
|
99
|
+
if (typeof w === 'number' && isFinite(w) && w > 0) out.weeklyTokenLimit = w;
|
|
100
|
+
return out;
|
|
101
|
+
} catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One extracted transcript sample. */
|
|
107
|
+
interface Sample {
|
|
108
|
+
readonly ts: number; // ms epoch
|
|
109
|
+
readonly tokens: number;
|
|
110
|
+
readonly key: string; // dedup key: message.id + ':' + requestId
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* List every `*.jsonl` under `~/.claude/projects/<dir>/`, best-effort. Never throws — an
|
|
115
|
+
* unreadable dir/file is skipped. Returns absolute paths + their `mtimeMs` (the prefilter lever).
|
|
116
|
+
*/
|
|
117
|
+
function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: number }> {
|
|
118
|
+
const out: Array<{ path: string; mtimeMs: number }> = [];
|
|
119
|
+
let dirs: string[];
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(root)) return out;
|
|
122
|
+
dirs = readdirSync(root);
|
|
123
|
+
} catch {
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
for (const d of dirs) {
|
|
127
|
+
const projDir = join(root, d);
|
|
128
|
+
let files: string[];
|
|
129
|
+
try {
|
|
130
|
+
const st = statSync(projDir);
|
|
131
|
+
if (!st.isDirectory()) continue;
|
|
132
|
+
files = readdirSync(projDir);
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
138
|
+
const p = join(projDir, f);
|
|
139
|
+
try {
|
|
140
|
+
out.push({ path: p, mtimeMs: statSync(p).mtimeMs });
|
|
141
|
+
} catch {
|
|
142
|
+
// skip a file we can't stat
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract usage samples from one transcript file. Never throws — a corrupt line is skipped
|
|
151
|
+
* (statusline discipline). A cheap `"usage"` substring pre-filter avoids `JSON.parse` on lines
|
|
152
|
+
* that cannot carry a token count. `weeklyCutoff` drops samples older than the rolling window.
|
|
153
|
+
*/
|
|
154
|
+
function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen: Set<string>): void {
|
|
155
|
+
let raw: string;
|
|
156
|
+
try {
|
|
157
|
+
raw = readFileSync(path, 'utf-8');
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const lines = raw.split('\n');
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
if (line.length === 0) continue;
|
|
164
|
+
if (line.indexOf('usage') === -1) continue; // cheap pre-filter before the parse
|
|
165
|
+
let rec: {
|
|
166
|
+
timestamp?: unknown;
|
|
167
|
+
requestId?: unknown;
|
|
168
|
+
message?: {
|
|
169
|
+
id?: unknown;
|
|
170
|
+
usage?: {
|
|
171
|
+
input_tokens?: unknown;
|
|
172
|
+
cache_creation_input_tokens?: unknown;
|
|
173
|
+
cache_read_input_tokens?: unknown;
|
|
174
|
+
output_tokens?: unknown;
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
rec = JSON.parse(line);
|
|
180
|
+
} catch {
|
|
181
|
+
continue; // corrupt line — skip, never throw
|
|
182
|
+
}
|
|
183
|
+
const usage = rec.message?.usage;
|
|
184
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
185
|
+
const tsRaw = rec.timestamp;
|
|
186
|
+
if (typeof tsRaw !== 'string' && typeof tsRaw !== 'number') continue;
|
|
187
|
+
const ts = typeof tsRaw === 'number' ? tsRaw : Date.parse(tsRaw);
|
|
188
|
+
if (!isFinite(ts)) continue;
|
|
189
|
+
if (ts < weeklyCutoff) continue; // outside the weekly window — cannot contribute
|
|
190
|
+
const n = (v: unknown): number => (typeof v === 'number' && isFinite(v) && v > 0 ? v : 0);
|
|
191
|
+
const tokens =
|
|
192
|
+
n(usage.input_tokens) +
|
|
193
|
+
n(usage.cache_creation_input_tokens) +
|
|
194
|
+
n(usage.cache_read_input_tokens) +
|
|
195
|
+
n(usage.output_tokens);
|
|
196
|
+
if (tokens <= 0) continue;
|
|
197
|
+
// Dedup: streamed assistant messages repeat their usage object across chunks.
|
|
198
|
+
const id = typeof rec.message?.id === 'string' ? rec.message.id : '';
|
|
199
|
+
const reqId = typeof rec.requestId === 'string' ? rec.requestId : '';
|
|
200
|
+
const key = id + ':' + reqId;
|
|
201
|
+
if (id !== '' || reqId !== '') {
|
|
202
|
+
if (seen.has(key)) continue;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
}
|
|
205
|
+
into.push({ ts, tokens, key });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Compute the SESSION active-block total using ccusage 5h-block semantics: walk samples in ts
|
|
211
|
+
* order; a block starts at the first sample after the previous block ends, FLOORED to the hour;
|
|
212
|
+
* `end = start + 5h`. The ACTIVE block is the one whose `[start, end)` contains `now`. No active
|
|
213
|
+
* block ⇒ `{ tokens: 0, resetsAt: null }`.
|
|
214
|
+
*/
|
|
215
|
+
function activeBlock(samplesAsc: Sample[], now: number): { tokens: number; resetsAt: string | null } {
|
|
216
|
+
let blockStart = -1;
|
|
217
|
+
let blockEnd = -1;
|
|
218
|
+
let tokens = 0;
|
|
219
|
+
let activeStart = -1;
|
|
220
|
+
let activeTokens = 0;
|
|
221
|
+
for (const s of samplesAsc) {
|
|
222
|
+
if (blockStart === -1 || s.ts >= blockEnd) {
|
|
223
|
+
// close the previous block, open a new one floored to the hour
|
|
224
|
+
blockStart = Math.floor(s.ts / HOUR_MS) * HOUR_MS;
|
|
225
|
+
blockEnd = blockStart + SESSION_BLOCK_MS;
|
|
226
|
+
tokens = 0;
|
|
227
|
+
}
|
|
228
|
+
tokens += s.tokens;
|
|
229
|
+
if (now >= blockStart && now < blockEnd) {
|
|
230
|
+
activeStart = blockStart;
|
|
231
|
+
activeTokens = tokens;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (activeStart === -1) return { tokens: 0, resetsAt: null };
|
|
235
|
+
return { tokens: activeTokens, resetsAt: new Date(activeStart + SESSION_BLOCK_MS).toISOString() };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Estimate SESSION + WEEKLY token usage from the local Claude transcript store. NEVER throws;
|
|
240
|
+
* READONLY; `<100ms` steady-state via the `mtime` prefilter. `projectRoot` scopes ONLY the config
|
|
241
|
+
* (limits) read — measurement is account-wide (all projects). `now` is injectable for tests.
|
|
242
|
+
*/
|
|
243
|
+
export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
|
|
244
|
+
const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
|
|
245
|
+
const limits = readUsageLimits(projectRoot);
|
|
246
|
+
const weeklyCutoff = nowMs - WEEK_MS - MTIME_SLACK_MS;
|
|
247
|
+
|
|
248
|
+
const samples: Sample[] = [];
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
try {
|
|
251
|
+
const files = listTranscriptFiles(claudeProjectsRoot());
|
|
252
|
+
for (const f of files) {
|
|
253
|
+
// mtime prefilter: a file last written before the weekly cutoff cannot hold in-window
|
|
254
|
+
// samples — skip it WITHOUT opening it (the <100ms lever).
|
|
255
|
+
if (f.mtimeMs < weeklyCutoff) continue;
|
|
256
|
+
extractSamples(f.path, weeklyCutoff, samples, seen);
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// total scan failure ⇒ fall through with empty samples (nulls), never throw
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Weekly total: every sample inside the exact 7d window (the +slack was only a prefilter guard).
|
|
263
|
+
const weeklyHardCutoff = nowMs - WEEK_MS;
|
|
264
|
+
let weeklyTokens = 0;
|
|
265
|
+
let oldestInWindow = -1;
|
|
266
|
+
for (const s of samples) {
|
|
267
|
+
if (s.ts < weeklyHardCutoff) continue;
|
|
268
|
+
weeklyTokens += s.tokens;
|
|
269
|
+
if (oldestInWindow === -1 || s.ts < oldestInWindow) oldestInWindow = s.ts;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const asc = samples.slice().sort((a, b) => a.ts - b.ts);
|
|
273
|
+
const block = activeBlock(asc, nowMs);
|
|
274
|
+
|
|
275
|
+
const pct = (tokens: number, limit?: number): number | null =>
|
|
276
|
+
typeof limit === 'number' && limit > 0 ? Math.round((100 * tokens) / limit) : null;
|
|
277
|
+
|
|
278
|
+
const weeklyResetsAt = oldestInWindow === -1 ? null : new Date(oldestInWindow + WEEK_MS).toISOString();
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
sessionTokens: block.tokens,
|
|
282
|
+
weeklyTokens,
|
|
283
|
+
sessionPct: pct(block.tokens, limits.sessionTokenLimit),
|
|
284
|
+
weeklyPct: pct(weeklyTokens, limits.weeklyTokenLimit),
|
|
285
|
+
sessionResetsAt: block.resetsAt,
|
|
286
|
+
weeklyResetsAt,
|
|
287
|
+
estimated: true,
|
|
288
|
+
};
|
|
289
|
+
}
|
package/src/vector-tier.ts
CHANGED
|
@@ -47,8 +47,11 @@ import {
|
|
|
47
47
|
patternIdentityOf,
|
|
48
48
|
dreamRecordId,
|
|
49
49
|
loadStoreRecords,
|
|
50
|
+
readMemoryLearningConfig,
|
|
51
|
+
readReinforcementState,
|
|
50
52
|
removePatternsByIds,
|
|
51
53
|
snapshotStore,
|
|
54
|
+
updateReinforcementState,
|
|
52
55
|
type PatternRecord,
|
|
53
56
|
type RecallHit,
|
|
54
57
|
} from './patterns.js';
|
|
@@ -62,6 +65,7 @@ import {
|
|
|
62
65
|
reindexAgentdbRows,
|
|
63
66
|
} from './agentdb-index.js';
|
|
64
67
|
import { currentEmbedManifest, guardEmbedSpace, DEFAULT_EMBED_DIM, resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
|
|
68
|
+
import { applyLearningSignals, resolveLearningBackend, type LearningSignalBackend } from './learning-backend.js';
|
|
65
69
|
|
|
66
70
|
/* ------------------------------------------------------------------ */
|
|
67
71
|
/* Types (04_domain_model §3.4 / §4.1) */
|
|
@@ -85,6 +89,8 @@ export interface VectorEntry {
|
|
|
85
89
|
readonly taskType: string;
|
|
86
90
|
readonly tags?: readonly string[] | undefined;
|
|
87
91
|
readonly metadata?: Record<string, unknown> | undefined;
|
|
92
|
+
readonly uses?: number | undefined;
|
|
93
|
+
readonly avgReward?: number | undefined;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
/** One semantic search hit — a POINTER into the lexical store, never a pattern by itself. */
|
|
@@ -179,6 +185,7 @@ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
|
|
|
179
185
|
|
|
180
186
|
/** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
|
|
181
187
|
export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
|
|
188
|
+
export const REINFORCE_RRF_CAP = (1 / (60 + 1)) - (1 / (60 + 4));
|
|
182
189
|
|
|
183
190
|
/* ------------------------------------------------------------------ */
|
|
184
191
|
/* Harmonize + import types (dz-vector-harmonize-import 05 §2.1/§2.2) */
|
|
@@ -262,6 +269,10 @@ export interface ReindexVectorReport {
|
|
|
262
269
|
readonly error?: string;
|
|
263
270
|
}
|
|
264
271
|
|
|
272
|
+
export type TeachGuardResult =
|
|
273
|
+
| { readonly action: 'teach'; readonly reason?: string }
|
|
274
|
+
| { readonly action: 'reinforce'; readonly dzId: string; readonly cosine: number };
|
|
275
|
+
|
|
265
276
|
/* ------------------------------------------------------------------ */
|
|
266
277
|
/* Timeout wrapper (both legs — NC1/QR-1) */
|
|
267
278
|
/* ------------------------------------------------------------------ */
|
|
@@ -351,6 +362,7 @@ export function dreamVectorEntry(d: DreamPattern): VectorEntry | undefined {
|
|
|
351
362
|
/** ACL: stored {@link MemoryRecord} → {@link VectorEntry} (the consolidate-backfill mapper). */
|
|
352
363
|
export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefined {
|
|
353
364
|
if (isVectorNoise(r.text)) return undefined;
|
|
365
|
+
const state = readReinforcementState(r);
|
|
354
366
|
return {
|
|
355
367
|
dzId: r.id,
|
|
356
368
|
text: r.text,
|
|
@@ -358,6 +370,8 @@ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefine
|
|
|
358
370
|
taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
|
|
359
371
|
tags: ['dz-backfill', r.outcome],
|
|
360
372
|
metadata: { dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId },
|
|
373
|
+
uses: state.uses,
|
|
374
|
+
avgReward: state.avgReward,
|
|
361
375
|
};
|
|
362
376
|
}
|
|
363
377
|
|
|
@@ -726,6 +740,14 @@ export function mergeHybridHits(
|
|
|
726
740
|
}));
|
|
727
741
|
}
|
|
728
742
|
|
|
743
|
+
function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): void {
|
|
744
|
+
const cfg = readMemoryLearningConfig(projectRoot);
|
|
745
|
+
if (cfg.backend === 'off' || cfg.onRecallHits === false) return;
|
|
746
|
+
const ts = new Date().toISOString();
|
|
747
|
+
for (const h of hits) backend.addSample({ dzId: idOf(h.pattern), kind: 'recall-hit', reward: h.pattern.reward, ts });
|
|
748
|
+
void backend.train().catch(() => undefined);
|
|
749
|
+
}
|
|
750
|
+
|
|
729
751
|
/**
|
|
730
752
|
* Hybrid recall (FR-3): lexical `recallPatterns` FIRST (always, sync, UNCHANGED — AC-5), then a
|
|
731
753
|
* time-bounded semantic leg merged via RRF. Degradation contract (I-1): with no engine — or on
|
|
@@ -749,23 +771,50 @@ export async function recallHybrid(
|
|
|
749
771
|
const mode = opts.mode ?? 'hybrid';
|
|
750
772
|
const lexical = recallPatterns(projectRoot, query, limit);
|
|
751
773
|
const lexicalBackend: 'sqlite' | 'json' = lexical[0]?.backend === 'sqlite' ? 'sqlite' : 'json';
|
|
774
|
+
const records = loadStoreRecords(projectRoot);
|
|
775
|
+
const idToRecord = new Map<string, MemoryRecord>();
|
|
776
|
+
const identityToId = new Map<string, string>();
|
|
777
|
+
for (const r of records) {
|
|
778
|
+
idToRecord.set(r.id, r);
|
|
779
|
+
identityToId.set(patternIdentityOf(recordToPattern(r)), r.id);
|
|
780
|
+
}
|
|
781
|
+
const idOf = (p: PatternRecord): string => identityToId.get(patternIdentityOf(p)) ?? patternRecordId(p);
|
|
782
|
+
const learning = resolveLearningBackend(projectRoot);
|
|
783
|
+
const enhance = (hits: readonly HybridHit[]): HybridHit[] => applyLearningSignals(
|
|
784
|
+
hits,
|
|
785
|
+
learning,
|
|
786
|
+
hits.map((h) => {
|
|
787
|
+
const dzId = idOf(h.pattern);
|
|
788
|
+
const rec = idToRecord.get(dzId);
|
|
789
|
+
return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
|
|
790
|
+
}),
|
|
791
|
+
REINFORCE_RRF_CAP,
|
|
792
|
+
);
|
|
752
793
|
const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
|
|
753
|
-
hits: lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })),
|
|
794
|
+
hits: enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) }))),
|
|
754
795
|
lexicalBackend,
|
|
755
796
|
vectorEngine: 'none',
|
|
756
797
|
...extra,
|
|
757
798
|
});
|
|
758
799
|
|
|
759
|
-
if (mode === 'lexical')
|
|
800
|
+
if (mode === 'lexical') {
|
|
801
|
+
const out = lexicalOnly({});
|
|
802
|
+
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
760
805
|
|
|
761
806
|
let resolved: ResolvedVectorEngine;
|
|
762
807
|
try {
|
|
763
808
|
resolved = pickEngine(projectRoot, opts);
|
|
764
809
|
} catch (err) {
|
|
765
|
-
|
|
810
|
+
const out = lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
|
|
811
|
+
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
812
|
+
return out;
|
|
766
813
|
}
|
|
767
814
|
if (resolved.engine === undefined) {
|
|
768
|
-
|
|
815
|
+
const out = lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
|
|
816
|
+
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
817
|
+
return out;
|
|
769
818
|
}
|
|
770
819
|
const engine = resolved.engine;
|
|
771
820
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
|
|
@@ -779,22 +828,16 @@ export async function recallHybrid(
|
|
|
779
828
|
() => ({ hits: [] as VectorHit[], error: `vector search timed out after ${timeoutMs}ms` }),
|
|
780
829
|
);
|
|
781
830
|
if (sr.error !== undefined) {
|
|
782
|
-
|
|
831
|
+
const out = { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
|
|
832
|
+
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
833
|
+
return out;
|
|
783
834
|
}
|
|
784
835
|
|
|
785
836
|
// Resolve dzId → the FULL lexical record (source of truth). Orphans are dropped (V-1/QR-4).
|
|
786
|
-
let records: MemoryRecord[];
|
|
787
|
-
try {
|
|
788
|
-
records = loadStoreRecords(projectRoot);
|
|
789
|
-
} catch {
|
|
790
|
-
records = [];
|
|
791
|
-
}
|
|
792
837
|
const idToPattern = new Map<string, PatternRecord>();
|
|
793
|
-
const identityToId = new Map<string, string>();
|
|
794
838
|
for (const r of records) {
|
|
795
839
|
const p = recordToPattern(r);
|
|
796
840
|
idToPattern.set(r.id, p);
|
|
797
|
-
identityToId.set(patternIdentityOf(p), r.id);
|
|
798
841
|
}
|
|
799
842
|
const semantic: RankedPattern[] = [];
|
|
800
843
|
const seen = new Set<string>();
|
|
@@ -810,10 +853,34 @@ export async function recallHybrid(
|
|
|
810
853
|
pattern: h.pattern,
|
|
811
854
|
backend: h.backend,
|
|
812
855
|
}));
|
|
813
|
-
const hits = mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 });
|
|
856
|
+
const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
|
|
857
|
+
markRecallHits(projectRoot, learning, hits, idOf);
|
|
814
858
|
return { hits, lexicalBackend, vectorEngine: engine.kind };
|
|
815
859
|
}
|
|
816
860
|
|
|
861
|
+
export async function teachGuard(
|
|
862
|
+
projectRoot: string,
|
|
863
|
+
text: string,
|
|
864
|
+
opts: VectorServiceOptions & { readonly reward?: number | undefined; readonly threshold?: number | undefined } = {},
|
|
865
|
+
): Promise<TeachGuardResult> {
|
|
866
|
+
const cfg = readMemoryLearningConfig(projectRoot);
|
|
867
|
+
const threshold = Math.max(0.95, opts.threshold ?? cfg.reinforceThreshold);
|
|
868
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
|
|
869
|
+
const result = await withVectorTimeout(
|
|
870
|
+
searchAgentdbPatterns(projectRoot, text, { limit: 1 }),
|
|
871
|
+
timeoutMs,
|
|
872
|
+
() => ({ hits: [], error: `teach guard timed out after ${timeoutMs}ms` }),
|
|
873
|
+
);
|
|
874
|
+
if (result.error !== undefined) return { action: 'teach', reason: result.error };
|
|
875
|
+
const hit = result.hits[0];
|
|
876
|
+
if (hit === undefined || hit.dzId === undefined || hit.similarity < threshold) return { action: 'teach' };
|
|
877
|
+
const rec = loadStoreRecords(projectRoot).find((r) => r.id === hit.dzId);
|
|
878
|
+
if (opts.reward !== undefined && rec !== undefined && Math.abs(rec.score - opts.reward) > 0.000001) {
|
|
879
|
+
return { action: 'teach', reason: 'reward differs; preserving corrected-reward fork' };
|
|
880
|
+
}
|
|
881
|
+
return { action: 'reinforce', dzId: hit.dzId, cosine: hit.similarity };
|
|
882
|
+
}
|
|
883
|
+
|
|
817
884
|
/* ------------------------------------------------------------------ */
|
|
818
885
|
/* Status (dz vector status / dz doctor divergence line) */
|
|
819
886
|
/* ------------------------------------------------------------------ */
|
|
@@ -963,8 +1030,15 @@ function buildCluster(
|
|
|
963
1030
|
const members = indices.map((i) => items[i]!);
|
|
964
1031
|
const keeperIdx = indices[selectClusterKeeper(members)]!;
|
|
965
1032
|
const keeper = items[keeperIdx]!;
|
|
1033
|
+
// FORK-PRESERVATION CARVE-OUT (the ADR's #1 safety property — distinct-lesson-never-lost): a
|
|
1034
|
+
// SAME-TEXT member whose reward differs from the keeper's is a fork-on-corrected-reward
|
|
1035
|
+
// (patterns.ts:279-284 intent) — the CORRECTION must survive even when it is a DOWNGRADE
|
|
1036
|
+
// (keeper = max reward would otherwise delete it). Mirrors teachGuard's reward-diff ε. Scoped to
|
|
1037
|
+
// SAME TEXT only: semantically-similar-but-differently-worded near-dups still merge normally
|
|
1038
|
+
// (that IS harmonize's job; the keeper keeps max reward + provenance).
|
|
966
1039
|
const drops = indices
|
|
967
1040
|
.filter((i) => i !== keeperIdx)
|
|
1041
|
+
.filter((i) => !(items[i]!.text === keeper.text && Math.abs(items[i]!.reward - keeper.reward) > 0.000001))
|
|
968
1042
|
.map((i) => ({ dzId: items[i]!.dzId, text: items[i]!.text, reward: items[i]!.reward, cos: cosToKeeper(i, keeperIdx) }));
|
|
969
1043
|
return { keep: { dzId: keeper.dzId, text: keeper.text, reward: keeper.reward, ts: keeper.ts }, drops };
|
|
970
1044
|
}
|
|
@@ -981,7 +1055,8 @@ function semanticClusters(items: readonly HarmonizeItem[], vecs: readonly Float3
|
|
|
981
1055
|
const clusters: HarmonizeCluster[] = [];
|
|
982
1056
|
for (const comp of connectedComponents(n, edges)) {
|
|
983
1057
|
if (comp.length < 2) continue;
|
|
984
|
-
|
|
1058
|
+
const c = buildCluster(items, comp, (d, k) => cosineSimilarity(vecs[d]!, vecs[k]!));
|
|
1059
|
+
if (c.drops.length > 0) clusters.push(c); // all members carved out as reward-forks ⇒ no-op cluster
|
|
985
1060
|
}
|
|
986
1061
|
return clusters;
|
|
987
1062
|
}
|
|
@@ -997,7 +1072,8 @@ function exactClusters(items: readonly HarmonizeItem[]): HarmonizeCluster[] {
|
|
|
997
1072
|
const clusters: HarmonizeCluster[] = [];
|
|
998
1073
|
for (const indices of byText.values()) {
|
|
999
1074
|
if (indices.length < 2) continue;
|
|
1000
|
-
|
|
1075
|
+
const c = buildCluster(items, indices, () => 1.0);
|
|
1076
|
+
if (c.drops.length > 0) clusters.push(c); // same-text reward-forks are preserved, not merged
|
|
1001
1077
|
}
|
|
1002
1078
|
return clusters;
|
|
1003
1079
|
}
|
|
@@ -1124,6 +1200,28 @@ export async function harmonizeVectorStore(projectRoot: string, opts: HarmonizeO
|
|
|
1124
1200
|
// Backup write failed ⇒ ABORT the drop (no partial mutation — the store is untouched).
|
|
1125
1201
|
return { ...base, error: `backup failed — drop aborted: ${snap.error}` };
|
|
1126
1202
|
}
|
|
1203
|
+
for (const c of clusters) {
|
|
1204
|
+
const keepRec = records.find((r) => r.id === c.keep.dzId);
|
|
1205
|
+
if (keepRec === undefined) continue;
|
|
1206
|
+
const keepState = readReinforcementState(keepRec);
|
|
1207
|
+
let uses = keepState.uses + c.drops.length;
|
|
1208
|
+
const rewards = [c.keep.reward];
|
|
1209
|
+
const mergedFrom = [...keepState.mergedFrom];
|
|
1210
|
+
for (const d of c.drops) {
|
|
1211
|
+
const rec = records.find((r) => r.id === d.dzId);
|
|
1212
|
+
const st = rec !== undefined ? readReinforcementState(rec) : undefined;
|
|
1213
|
+
uses += st?.uses ?? 0;
|
|
1214
|
+
rewards.push(d.reward);
|
|
1215
|
+
if (st !== undefined) mergedFrom.push(...st.mergedFrom);
|
|
1216
|
+
mergedFrom.push(d.dzId);
|
|
1217
|
+
}
|
|
1218
|
+
await updateReinforcementState(projectRoot, c.keep.dzId, {
|
|
1219
|
+
uses,
|
|
1220
|
+
lastUsedTs: new Date().toISOString(),
|
|
1221
|
+
avgReward: rewards.reduce((a, b) => a + b, 0) / rewards.length,
|
|
1222
|
+
mergedFrom,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1127
1225
|
const removal = removePatternsByIds(projectRoot, dropDzIds);
|
|
1128
1226
|
logHarmonizeNote(projectRoot, { dropped: removal.removed, kept, engine: engineKind, error: removal.error });
|
|
1129
1227
|
return { ...base, backupPath, ...(removal.error !== undefined ? { error: removal.error } : {}) };
|
|
@@ -1264,6 +1362,8 @@ function agentdbVectorEngine(projectRoot: string): VectorEngine {
|
|
|
1264
1362
|
score: e.score,
|
|
1265
1363
|
...(e.tags !== undefined ? { tags: e.tags } : {}),
|
|
1266
1364
|
...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
|
|
1365
|
+
...(e.uses !== undefined ? { uses: e.uses } : {}),
|
|
1366
|
+
...(e.avgReward !== undefined ? { avgReward: e.avgReward } : {}),
|
|
1267
1367
|
})),
|
|
1268
1368
|
);
|
|
1269
1369
|
return { indexed: r.indexed, ...(r.error !== undefined ? { error: r.error } : {}) };
|