@dzhechkov/harness-core 0.3.90 → 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.
Files changed (55) hide show
  1. package/dist/__tests__/golden-baseline.test.d.ts +2 -0
  2. package/dist/__tests__/golden-baseline.test.d.ts.map +1 -0
  3. package/dist/__tests__/golden-baseline.test.js +60 -0
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -0
  5. package/dist/agentdb-index.d.ts +9 -0
  6. package/dist/agentdb-index.d.ts.map +1 -1
  7. package/dist/agentdb-index.js +44 -1
  8. package/dist/agentdb-index.js.map +1 -1
  9. package/dist/brain.d.ts +26 -0
  10. package/dist/brain.d.ts.map +1 -1
  11. package/dist/brain.js +105 -4
  12. package/dist/brain.js.map +1 -1
  13. package/dist/feature-adr-routing.d.ts +69 -0
  14. package/dist/feature-adr-routing.d.ts.map +1 -1
  15. package/dist/feature-adr-routing.js +79 -9
  16. package/dist/feature-adr-routing.js.map +1 -1
  17. package/dist/index.d.ts +12 -8
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/learning-backend.d.ts +85 -0
  22. package/dist/learning-backend.d.ts.map +1 -0
  23. package/dist/learning-backend.js +133 -0
  24. package/dist/learning-backend.js.map +1 -0
  25. package/dist/patterns.d.ts +43 -0
  26. package/dist/patterns.d.ts.map +1 -1
  27. package/dist/patterns.js +0 -0
  28. package/dist/patterns.js.map +1 -1
  29. package/dist/recommend.d.ts.map +1 -1
  30. package/dist/recommend.js +17 -3
  31. package/dist/recommend.js.map +1 -1
  32. package/dist/statusline.d.ts +3 -0
  33. package/dist/statusline.d.ts.map +1 -1
  34. package/dist/statusline.js +2 -0
  35. package/dist/statusline.js.map +1 -1
  36. package/dist/usage.d.ts +70 -0
  37. package/dist/usage.d.ts.map +1 -0
  38. package/dist/usage.js +255 -0
  39. package/dist/usage.js.map +1 -0
  40. package/dist/vector-tier.d.ts +15 -0
  41. package/dist/vector-tier.d.ts.map +1 -1
  42. package/dist/vector-tier.js +101 -19
  43. package/dist/vector-tier.js.map +1 -1
  44. package/package.json +4 -4
  45. package/src/__tests__/golden-baseline.test.ts +67 -0
  46. package/src/agentdb-index.ts +52 -1
  47. package/src/brain.ts +127 -4
  48. package/src/feature-adr-routing.ts +131 -8
  49. package/src/index.ts +15 -4
  50. package/src/learning-backend.ts +202 -0
  51. package/src/patterns.ts +0 -0
  52. package/src/recommend.ts +19 -5
  53. package/src/statusline.ts +5 -0
  54. package/src/usage.ts +289 -0
  55. package/src/vector-tier.ts +116 -16
package/src/brain.ts CHANGED
@@ -207,6 +207,36 @@ export function readBookKus(opts: {
207
207
  }
208
208
  }
209
209
 
210
+ /**
211
+ * Exact by-kuId FULL-content lookup over the brain's lexical store (`brainBooksPath(home)` by
212
+ * default) — the by-id reader behind `dz brain expand <kuId>` (brain-ground-expand Tier 1) and the
213
+ * worth-enrichment pass of the budgeted-eager grounding path. Reuses the audited {@link readBookKus}
214
+ * primitive (shared require-path resolution, schema detection, error surfacing), then filters by
215
+ * `kuId`, returning the reconstructed {@link BookKU} with its whole `content` (never a snippet).
216
+ *
217
+ * Best-effort and deterministic: a missing store, unknown kuId, or dependency error returns
218
+ * `{ error }`, never throws. `source` narrows the scan to one book slug (saves a full-table scan on a
219
+ * large brain); omit to search all sources.
220
+ */
221
+ export function expandKu(opts: {
222
+ kuId: string;
223
+ brainHome?: string;
224
+ depsRoot?: string;
225
+ source?: string;
226
+ }): { ku?: BookKU; error?: string } {
227
+ const home = opts.brainHome ?? brainHome();
228
+ const storePath = brainBooksPath(home);
229
+ const res = readBookKus({
230
+ storePath,
231
+ ...(opts.depsRoot !== undefined ? { depsRoot: opts.depsRoot } : {}),
232
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
233
+ });
234
+ if (res.error !== undefined) return { error: res.error };
235
+ const ku = res.kus.find((k) => k.kuId === opts.kuId);
236
+ if (ku === undefined) return { error: `kuId not found: ${opts.kuId}` };
237
+ return { ku };
238
+ }
239
+
210
240
  /**
211
241
  * The vector primitive is insert-only, so before re-indexing a book we delete its existing brain
212
242
  * vector rows — mirroring the reindex approach. Best-effort: a missing store/table is a no-op.
@@ -1077,11 +1107,36 @@ function snippet(s: string, max = 160): string {
1077
1107
  return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`;
1078
1108
  }
1079
1109
 
1110
+ /**
1111
+ * Approximate token count for `s`. ASCII/Latin ≈ chars/4, but CYRILLIC — the brain's primary KU
1112
+ * language — costs ~2 tokens/char under multilingual tokenizers, so a plain chars/4 UNDERCOUNTS
1113
+ * Russian content and lets `--budget N` OVERSHOOT the real token budget. We count Cyrillic at chars/2
1114
+ * and the rest at chars/4; OVERESTIMATING is the safe direction for a soft ceiling (inlines slightly
1115
+ * less, never more). Used only by the budgeted-eager grounding path; never on the default hot path.
1116
+ */
1117
+ function approxTokens(s: string): number {
1118
+ const cyrillic = (s.match(/[Ѐ-ӿ]/g) || []).length;
1119
+ return Math.ceil((s.length - cyrillic) / 4 + cyrillic / 2);
1120
+ }
1121
+
1080
1122
  /** The directive line prepended to every grounding block (§7.1). */
1081
1123
  const GROUNDING_DIRECTIVE =
1082
1124
  'Ground your answer in the KNOWLEDGE BRAIN below; prefer these ingested sources over ' +
1083
1125
  'training-data recall; cite [Kn] source+page per claim; if the brain is silent on a point, say so.';
1084
1126
 
1127
+ /**
1128
+ * Expand-capable variant of the grounding directive (brain-ground-expand Tier 1/2). Used ONLY when
1129
+ * `contentBudget > 0`; the default (no-budget) path keeps {@link GROUNDING_DIRECTIVE} byte-identical.
1130
+ * Augments the base directive with a model instruction: each citation carries its `kuId` as the first
1131
+ * field, and the model can pull a KU's FULL content ON DEMAND by running `dz brain expand <kuId>` —
1132
+ * so it expands only the KUs it actually needs (latency paid only on real use).
1133
+ */
1134
+ const GROUNDING_DIRECTIVE_EXPAND =
1135
+ 'Ground your answer in the KNOWLEDGE BRAIN below; prefer these ingested sources over ' +
1136
+ 'training-data recall; cite [Kn] source+page per claim; if the brain is silent on a point, say so. ' +
1137
+ "Each citation shows its kuId as the first field — to read a KU's FULL content on demand, run " +
1138
+ '`dz brain expand <kuId>`; expand only the KUs you actually need.';
1139
+
1085
1140
  /**
1086
1141
  * The grounding-enforcement hook entrypoint (ADR-001 §7, P1). Given a user prompt, deterministically
1087
1142
  * builds a GROUNDING DIRECTIVE block from the brain's top lexical hits — the mechanical half of
@@ -1105,6 +1160,12 @@ export async function groundPrompt(opts: {
1105
1160
  depsRoot?: string;
1106
1161
  k?: number;
1107
1162
  source?: string;
1163
+ /**
1164
+ * brain-ground-expand: approximate token budget (chars/4) for EAGERLY inlining full KU `content`
1165
+ * into the block, worth-ranked. `undefined`/`0` ⇒ the byte-identical pointer-only default (Tier 0);
1166
+ * `> 0` ⇒ the expand-capable block (Tier 1/2) with full content inlined for the top-K KUs that fit.
1167
+ */
1168
+ contentBudget?: number;
1108
1169
  }): Promise<{ emitted: boolean; block: string; hitCount: number; error?: string }> {
1109
1170
  const silent = { emitted: false, block: '', hitCount: 0 } as const;
1110
1171
  try {
@@ -1158,13 +1219,75 @@ export async function groundPrompt(opts: {
1158
1219
  if (covered < 2 && semanticHits.length === 0) return silent;
1159
1220
  }
1160
1221
 
1161
- // Build the GROUNDING DIRECTIVE block (§7.1): directive line + numbered citations.
1162
- const lines: string[] = [GROUNDING_DIRECTIVE, ''];
1222
+ // ── Tier 0: DEFAULT (backward-compatible pointer block) ────────────────────────────────────
1223
+ // No budget the exact block emitted before brain-ground-expand, byte-identical (property a).
1224
+ // `!opts.contentBudget` covers both `undefined` and `0` (--budget 0 ≡ no budget, FR-03.6).
1225
+ if (!opts.contentBudget) {
1226
+ // Build the GROUNDING DIRECTIVE block (§7.1): directive line + numbered citations.
1227
+ const lines: string[] = [GROUNDING_DIRECTIVE, ''];
1228
+ merged.forEach((h, i) => {
1229
+ const ch = h.chapter !== undefined && h.chapter !== '' ? ` гл.${h.chapter}` : '';
1230
+ const pg = h.pages !== undefined && h.pages.length > 0 ? ` с.${h.pages.join('–')}` : '';
1231
+ const body = snippet(h.problem !== '' ? h.problem : h.content);
1232
+ lines.push(`[K${i + 1}] ${h.book}${ch}${pg} — ${h.name}: ${body}`);
1233
+ });
1234
+ return { emitted: true, block: lines.join('\n'), hitCount: merged.length };
1235
+ }
1236
+
1237
+ // ── Tier 1/2: BUDGETED EAGER (contentBudget > 0) — inside the try/catch so any bug in ranking /
1238
+ // budgeting falls back to `silent` and NEVER errors the prompt (property e). Content is already
1239
+ // in-hand on every hit; budgeting only chooses what to RENDER (no extra retrieval).
1240
+
1241
+ // Enrich hits with declared worth for ranking — BookKUHit drops `metadata`, so recover it by a
1242
+ // by-kuId lookup. HONEST PERF NOTE: each expandKu re-reads the books store, so this is k FULL-store
1243
+ // reads (NOT a metadata-only pass) — fine at the top-K scale here (k≤~5, small brain); if k grows,
1244
+ // pre-load readBookKus once into a kuId→metadata map. Absent worth ⇒ worthScore's 0.5 default.
1245
+ type RankedHit = { hit: BookKUHit; worth: number; rank: number };
1246
+ const rankedHits: RankedHit[] = merged.map((h, i) => {
1247
+ const expanded = expandKu({
1248
+ kuId: h.kuId,
1249
+ ...(opts.brainHome !== undefined ? { brainHome: opts.brainHome } : {}),
1250
+ ...(opts.depsRoot !== undefined ? { depsRoot: opts.depsRoot } : {}),
1251
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
1252
+ });
1253
+ const worth = expanded.ku !== undefined ? worthScore(expanded.ku.metadata ?? {}) : 0.5;
1254
+ return { hit: h, worth, rank: i }; // rank = merge/rerank position (lower = more on-point)
1255
+ });
1256
+ // Rank: worth desc → similarity/rerank-order (rank) asc → kuId asc (stable, deterministic).
1257
+ rankedHits.sort((a, b) => b.worth - a.worth || a.rank - b.rank || a.hit.kuId.localeCompare(b.hit.kuId));
1258
+
1259
+ // Greedy budget fill: inline a KU's full content ONLY if it fits the remaining budget; the first
1260
+ // KU that would overflow stops the fill (property c: hard ceiling, atomic KUs — never partial).
1261
+ let tokensBudgeted = 0;
1262
+ const inlineSet = new Set<string>();
1263
+ for (const { hit } of rankedHits) {
1264
+ if (hit.content === '') continue; // empty content → stays a pointer (zero-token)
1265
+ const cost = approxTokens(hit.content);
1266
+ if (tokensBudgeted + cost <= opts.contentBudget) {
1267
+ inlineSet.add(hit.kuId);
1268
+ tokensBudgeted += cost;
1269
+ } else {
1270
+ break; // stop at first overflow (stop-don't-skip policy, FR-03.4)
1271
+ }
1272
+ }
1273
+
1274
+ // Emit the expand-capable block: directive names `dz brain expand <kuId>`; every citation exposes
1275
+ // its kuId (property b). Chosen KUs get full content inlined; the rest stay pointers.
1276
+ const lines: string[] = [GROUNDING_DIRECTIVE_EXPAND, ''];
1163
1277
  merged.forEach((h, i) => {
1164
1278
  const ch = h.chapter !== undefined && h.chapter !== '' ? ` гл.${h.chapter}` : '';
1165
1279
  const pg = h.pages !== undefined && h.pages.length > 0 ? ` с.${h.pages.join('–')}` : '';
1166
- const body = snippet(h.problem !== '' ? h.problem : h.content);
1167
- lines.push(`[K${i + 1}] ${h.book}${ch}${pg} ${h.name}: ${body}`);
1280
+ const header = `[K${i + 1}] ${h.kuId} · ${h.book}${ch}${pg} ${h.name}`;
1281
+ if (inlineSet.has(h.kuId) && h.content !== '') {
1282
+ // Inlined full content (budget allocated) — content supersedes the snippet suffix.
1283
+ lines.push(header);
1284
+ lines.push(h.content);
1285
+ lines.push(''); // blank separator between KUs for readability
1286
+ } else {
1287
+ // Pointer with kuId annotation; the model can expand this one on demand.
1288
+ const body = snippet(h.problem !== '' ? h.problem : h.content);
1289
+ lines.push(`${header}: ${body}`);
1290
+ }
1168
1291
  });
1169
1292
  return { emitted: true, block: lines.join('\n'), hitCount: merged.length };
1170
1293
  } catch {
@@ -33,6 +33,12 @@ export interface StageOpts {
33
33
  readonly agentType?: string;
34
34
  readonly codexModel?: string;
35
35
  readonly _reasoning?: string;
36
+ /**
37
+ * Observability marker set ONLY when the usage-adaptive override chose this stage's codex spec
38
+ * (not on a user's explicit codex routing). `modelLabel` appends ` (usage-switched)` when present
39
+ * so the switch is auditable in `modelsUsed` (AC-6).
40
+ */
41
+ _usageSwitched?: boolean;
36
42
  }
37
43
 
38
44
  /** The knobs `resolveStageModel` closes over — passed in from the workflow. */
@@ -55,6 +61,124 @@ export interface RoutingEnv {
55
61
  readonly codexAvailable?: boolean;
56
62
  /** Optional log sink (the workflow passes its `log`); defaults to a no-op. */
57
63
  readonly log?: (msg: string) => void;
64
+ /**
65
+ * USAGE-ADAPTIVE override bit (env-threaded, non-global — LOCKED L-5). When true,
66
+ * `resolveStageModel` routes ALL stages to `codex:<topCodexId>` REGARDLESS of `MODELS`/knobs.
67
+ * The single mutable `let usageOverride` lives ONLY in the workflow script; the library stays
68
+ * pure — the state travels through THIS field, never a module-level global. Absent/undefined ⇒
69
+ * byte-identical resolution to the pre-feature behavior (NFR-2 / AC-4).
70
+ */
71
+ readonly usageOverride?: boolean;
72
+ /**
73
+ * Per-stage reasoning for the usage-override spec (merged OVER {@link OVERRIDE_REASONING}).
74
+ * `args.usageReasoning` — stage → `'high'|'xhigh'|…`; a single stage may be overridden without
75
+ * touching the others.
76
+ */
77
+ readonly usageReasoning?: Record<string, string>;
78
+ }
79
+
80
+ // ── USAGE-ADAPTIVE ROUTING (pre-emptive codex switch at >= usageThreshold) ────
81
+
82
+ /** A probe reading. `null` on a pct ⇔ that limit is unconfigured (unknown — never a guess). */
83
+ export interface UsageSignal {
84
+ readonly sessionPct: number | null;
85
+ readonly weeklyPct: number | null;
86
+ }
87
+
88
+ /**
89
+ * The LOCKED 6-value action vocabulary (L-1). `decideUsageAction` emits the first five (probe
90
+ * path); `'reactive-switch'` is pushed only by the workflow's belt sites — one shared type, no
91
+ * parallel enums.
92
+ */
93
+ export type UsageAction =
94
+ | 'none'
95
+ | 'switch'
96
+ | 'restore'
97
+ | 'keep'
98
+ | 'fail-safe-switch'
99
+ | 'reactive-switch';
100
+
101
+ /** The `decideUsageAction` verdict: the new override bit + the event action. */
102
+ export interface UsageDecision {
103
+ readonly override: boolean;
104
+ readonly action: UsageAction;
105
+ }
106
+
107
+ /**
108
+ * Per-stage reasoning applied under the usage override (LOCKED L-4, arch §4.1 verbatim):
109
+ * design/code/plan stages ⇒ `xhigh`; router/qe/fleet ⇒ `high`. A pure DATA table (not control
110
+ * flow); user-overridable via `env.usageReasoning`. An unknown stage falls back to `'high'`.
111
+ */
112
+ export const OVERRIDE_REASONING: Record<string, 'high' | 'xhigh'> = {
113
+ router: 'high',
114
+ requirements: 'xhigh',
115
+ research: 'xhigh',
116
+ adr: 'xhigh',
117
+ ideation: 'xhigh',
118
+ ddd: 'xhigh',
119
+ architecture: 'xhigh',
120
+ plan: 'xhigh',
121
+ code: 'xhigh',
122
+ qe: 'high',
123
+ fleet: 'high',
124
+ };
125
+
126
+ /**
127
+ * The gpt-5.6-ready TOP codex-id pick — SHARED by the cross-model QE default and the usage
128
+ * override so adding an id to {@link KNOWN_CODEX} (e.g. `gpt-5.7`) retargets BOTH with zero
129
+ * control-flow diff (AC-3). Pinned `CODEX_MODEL` when ≠ `'auto'`; else the last non-`auto` key of
130
+ * `KNOWN_CODEX`.
131
+ */
132
+ export function topCodexId(env: RoutingEnv): string {
133
+ let top = env.CODEX_MODEL;
134
+ if (top === 'auto') {
135
+ const ids = Object.keys(KNOWN_CODEX);
136
+ for (let i = 0; i < ids.length; i++) {
137
+ if (ids[i] !== 'auto') top = ids[i] || top;
138
+ }
139
+ }
140
+ return top;
141
+ }
142
+
143
+ /**
144
+ * The PURE hysteresis core (the load-bearing safety property, AC-1). Given the previous override
145
+ * bit, a probe signal (or `null` when the probe agent itself DIED), and the threshold, decide the
146
+ * new override bit + the event action. Total function — no input throws or returns an
147
+ * out-of-vocabulary action.
148
+ *
149
+ * The load-bearing asymmetry (INV-2):
150
+ * - **agent-null** (`signal === null`, the probe dispatch died — OFTEN MEANS limits) ⇒ fail-safe
151
+ * switch TO codex (OFF→ON), or `keep` if already overridden.
152
+ * - **value-null** (a pct is `null` — limits unconfigured / garbled) ⇒ flips NOTHING: never
153
+ * switches a fresh run to codex AND never restores an active override (unknown ⇒ hysteresis, no
154
+ * flapping).
155
+ * - `>= threshold` on EITHER known metric ⇒ switch (OFF→ON) / keep (ON stays ON). Boundary `= 70`
156
+ * counts as over (`>=`).
157
+ * - ONLY a positive BOTH-below reading (both pcts known and `< threshold`) clears an override
158
+ * (`restore`); from OFF it is `none`.
159
+ *
160
+ * Non-finite pcts (`NaN`, negatives from a garbled probe) are treated as value-null.
161
+ */
162
+ export function decideUsageAction(
163
+ prevOverride: boolean,
164
+ signal: UsageSignal | null,
165
+ threshold: number,
166
+ ): UsageDecision {
167
+ if (signal === null || signal === undefined) {
168
+ if (prevOverride) return { override: true, action: 'keep' };
169
+ return { override: true, action: 'fail-safe-switch' };
170
+ }
171
+ const s = signal.sessionPct;
172
+ const w = signal.weeklyPct;
173
+ const sKnown = typeof s === 'number' && isFinite(s) && s >= 0;
174
+ const wKnown = typeof w === 'number' && isFinite(w) && w >= 0;
175
+ if ((sKnown && s >= threshold) || (wKnown && w >= threshold)) {
176
+ return { override: true, action: prevOverride ? 'keep' : 'switch' };
177
+ }
178
+ if (sKnown && wKnown) {
179
+ return { override: false, action: prevOverride ? 'restore' : 'none' };
180
+ }
181
+ return { override: prevOverride, action: prevOverride ? 'keep' : 'none' };
58
182
  }
59
183
 
60
184
  // ── Data tables (data-only extensibility — gpt-5.6-ready) ───────────────────
@@ -147,14 +271,7 @@ export function resolveQeSpec(env: RoutingEnv): string {
147
271
  if (coderIsCodex(env)) return 'opus';
148
272
  const CODEX_AVAILABLE = env.codexAvailable !== false;
149
273
  if (!CODEX_AVAILABLE) return 'opus';
150
- let top = env.CODEX_MODEL;
151
- if (top === 'auto') {
152
- const ids = Object.keys(KNOWN_CODEX);
153
- for (let i = 0; i < ids.length; i++) {
154
- if (ids[i] !== 'auto') top = ids[i] || top;
155
- }
156
- }
157
- return 'codex:' + top + ':high';
274
+ return 'codex:' + topCodexId(env) + ':high';
158
275
  }
159
276
 
160
277
  /**
@@ -201,6 +318,12 @@ export function qeShouldUseCodex(env: RoutingEnv): boolean {
201
318
  * 4. `code`/`qe` `null` sentinels resolve via the coder / cross-model rules
202
319
  */
203
320
  export function resolveStageModel(stage: string, env: RoutingEnv): StageOpts {
321
+ if (env.usageOverride) {
322
+ const r = (env.usageReasoning && env.usageReasoning[stage]) || OVERRIDE_REASONING[stage] || 'high';
323
+ const o = specToOpts('codex:' + topCodexId(env) + ':' + r, env);
324
+ o._usageSwitched = true;
325
+ return o;
326
+ }
204
327
  let spec = env.MODELS[stage];
205
328
  if (spec === undefined) {
206
329
  if (!routingRequested(env)) return {};
package/src/index.ts CHANGED
@@ -27,11 +27,14 @@ export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
27
27
  export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs } from './registry.js';
28
28
  export { recommend } from './recommend.js';
29
29
  export { pretrain } from './pretrain.js';
30
- export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore } from './patterns.js';
31
- export type { PatternRecord, SessionRecord, LearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult } from './patterns.js';
30
+ export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, updateReinforcementState, storeStats } from './patterns.js';
31
+ export type { PatternRecord, SessionRecord, LearningConfig, MemoryLearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult, ReinforcementState, ReinforcePatternResult, StoreStats } from './patterns.js';
32
+ export { DEFAULT_REINFORCE_THRESHOLD, NoopLearningBackend, NativeReinforcementBackend, resolveLearningBackend, isLearningSignalBackend } from './learning-backend.js';
33
+ export type { LearningSignalBackend, LearningSignalStats, LearningSample, SignalCandidate, EnhanceContext, TrainingResult, LearningBackendMode } from './learning-backend.js';
32
34
  export {
33
35
  DEFAULT_VECTOR_TIMEOUT_MS,
34
36
  DEFAULT_HARMONIZE_THRESHOLD,
37
+ REINFORCE_RRF_CAP,
35
38
  withVectorTimeout,
36
39
  isVectorNoise,
37
40
  patternVectorEntry,
@@ -46,6 +49,7 @@ export {
46
49
  backfillVectorMirror,
47
50
  mergeHybridHits,
48
51
  recallHybrid,
52
+ teachGuard,
49
53
  vectorTierStatus,
50
54
  reindexVectorStore,
51
55
  harmonizeVectorStore,
@@ -74,11 +78,12 @@ export type {
74
78
  ImportReport,
75
79
  ImportOptions,
76
80
  ReindexVectorReport,
81
+ TeachGuardResult,
77
82
  } from './vector-tier.js';
78
83
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
79
84
  export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStatePath } from './statusline.js';
80
85
  export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
81
- export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows } from './agentdb-index.js';
86
+ export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses } from './agentdb-index.js';
82
87
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
83
88
  export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
84
89
  export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
@@ -99,6 +104,7 @@ export {
99
104
  reindexBrainVectors,
100
105
  rerankHits,
101
106
  groundPrompt,
107
+ expandKu,
102
108
  buildPrimer,
103
109
  writePrimer,
104
110
  readBookKus,
@@ -178,5 +184,10 @@ export {
178
184
  DEFAULT_MODELS,
179
185
  KNOWN_CODEX,
180
186
  CLAUDE_NAMES,
187
+ topCodexId,
188
+ decideUsageAction,
189
+ OVERRIDE_REASONING,
181
190
  } from './feature-adr-routing.js';
182
- export type { StageOpts, RoutingEnv } from './feature-adr-routing.js';
191
+ export type { StageOpts, RoutingEnv, UsageSignal, UsageAction } from './feature-adr-routing.js';
192
+ export { computeUsage, readUsageLimits } from './usage.js';
193
+ export type { UsageEstimate, UsageLimits } from './usage.js';
@@ -0,0 +1,202 @@
1
+ import { writeFileSync } from 'node:fs';
2
+
3
+ import {
4
+ readMemoryLearningConfig,
5
+ readReinforcementState,
6
+ reinforcePattern,
7
+ type ReinforcementState,
8
+ type MemoryLearningConfig,
9
+ } from './patterns.js';
10
+
11
+ export const DEFAULT_REINFORCE_THRESHOLD = 0.95;
12
+
13
+ export type LearningBackendMode = 'native' | 'off' | 'ruvector-gnn';
14
+ export type LearningSampleKind = 'recall-hit' | 'reinforce' | 'merge';
15
+
16
+ export interface SignalCandidate {
17
+ readonly dzId: string;
18
+ readonly score: number;
19
+ readonly reinforcement?: ReinforcementState | undefined;
20
+ }
21
+
22
+ export interface EnhanceContext {
23
+ readonly kind: 'recall' | 'recommend';
24
+ readonly now?: number;
25
+ readonly cap?: number;
26
+ }
27
+
28
+ export interface LearningSample {
29
+ readonly dzId: string;
30
+ readonly kind: LearningSampleKind;
31
+ readonly reward?: number;
32
+ readonly ts: string;
33
+ }
34
+
35
+ export interface TrainingResult {
36
+ readonly trained: boolean;
37
+ readonly flushed: number;
38
+ readonly failed: number;
39
+ readonly error?: string;
40
+ }
41
+
42
+ export interface LearningSignalStats {
43
+ readonly enabled: boolean;
44
+ readonly backend: string;
45
+ readonly samplesCollected: number;
46
+ readonly lastTrainingTime: number | null;
47
+ readonly flushedTotal: number;
48
+ readonly failedTotal: number;
49
+ readonly advisory?: string;
50
+ }
51
+
52
+ export interface LearningSignalBackend {
53
+ enhance(candidates: readonly SignalCandidate[], ctx: EnhanceContext): Float32Array;
54
+ addSample(sample: LearningSample): void;
55
+ train(opts?: { readonly maxMs?: number }): Promise<TrainingResult>;
56
+ clearSamples(): void;
57
+ saveModel(path: string): Promise<void>;
58
+ loadModel(path: string): Promise<void>;
59
+ getStats(): LearningSignalStats;
60
+ reset(): void;
61
+ }
62
+
63
+ export function isLearningSignalBackend(v: unknown): v is LearningSignalBackend {
64
+ if (typeof v !== 'object' || v === null) return false;
65
+ const o = v as Record<string, unknown>;
66
+ return ['enhance', 'addSample', 'train', 'clearSamples', 'saveModel', 'loadModel', 'getStats', 'reset']
67
+ .every((k) => typeof o[k] === 'function');
68
+ }
69
+
70
+ export class NoopLearningBackend implements LearningSignalBackend {
71
+ enhance(candidates: readonly SignalCandidate[]): Float32Array {
72
+ return new Float32Array(candidates.length);
73
+ }
74
+ addSample(): void { /* no-op kill switch */ }
75
+ async train(): Promise<TrainingResult> { return { trained: false, flushed: 0, failed: 0 }; }
76
+ clearSamples(): void { /* no-op */ }
77
+ async saveModel(path: string): Promise<void> {
78
+ writeFileSync(path, JSON.stringify({ backend: 'off', note: 'NoopLearningBackend has no model state' }, null, 2));
79
+ }
80
+ async loadModel(): Promise<void> { /* no-op */ }
81
+ getStats(): LearningSignalStats {
82
+ return { enabled: false, backend: 'off', samplesCollected: 0, lastTrainingTime: null, flushedTotal: 0, failedTotal: 0 };
83
+ }
84
+ reset(): void { /* no-op */ }
85
+ }
86
+
87
+ export class NativeReinforcementBackend implements LearningSignalBackend {
88
+ private readonly samples: LearningSample[] = [];
89
+ private flushedTotal = 0;
90
+ private failedTotal = 0;
91
+ private lastTrainingTime: number | null = null;
92
+
93
+ constructor(
94
+ private readonly projectRoot: string,
95
+ private readonly opts: { readonly usesSat: number; readonly halfLifeDays: number; readonly advisory?: string } = { usesSat: 64, halfLifeDays: 30 },
96
+ ) {}
97
+
98
+ enhance(candidates: readonly SignalCandidate[], ctx: EnhanceContext): Float32Array {
99
+ const out = new Float32Array(candidates.length);
100
+ const now = ctx.now ?? Date.now();
101
+ for (let i = 0; i < candidates.length; i += 1) {
102
+ const st = candidates[i]!.reinforcement;
103
+ out[i] = st === undefined ? 0 : this.signal(st, now);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ addSample(sample: LearningSample): void {
109
+ this.samples.push(sample);
110
+ }
111
+
112
+ async train(): Promise<TrainingResult> {
113
+ const batch = this.samples.splice(0);
114
+ let flushed = 0;
115
+ let failed = 0;
116
+ let error: string | undefined;
117
+ for (const sample of batch) {
118
+ const r = await reinforcePattern(this.projectRoot, sample.dzId, {
119
+ ts: sample.ts,
120
+ ...(sample.reward !== undefined ? { reward: sample.reward } : {}),
121
+ });
122
+ if (r.ok) flushed += 1;
123
+ else {
124
+ failed += 1;
125
+ error = r.error;
126
+ }
127
+ }
128
+ this.flushedTotal += flushed;
129
+ this.failedTotal += failed;
130
+ this.lastTrainingTime = Date.now();
131
+ return { trained: batch.length > 0, flushed, failed, ...(error !== undefined ? { error } : {}) };
132
+ }
133
+
134
+ clearSamples(): void {
135
+ this.samples.splice(0);
136
+ }
137
+
138
+ async saveModel(path: string): Promise<void> {
139
+ writeFileSync(path, JSON.stringify({ backend: 'native', note: 'state lives in .dz/memory records' }, null, 2));
140
+ }
141
+ async loadModel(): Promise<void> { /* native state lives in the store */ }
142
+
143
+ getStats(): LearningSignalStats {
144
+ return {
145
+ enabled: true,
146
+ backend: 'native',
147
+ samplesCollected: this.samples.length,
148
+ lastTrainingTime: this.lastTrainingTime,
149
+ flushedTotal: this.flushedTotal,
150
+ failedTotal: this.failedTotal,
151
+ ...(this.opts.advisory !== undefined ? { advisory: this.opts.advisory } : {}),
152
+ };
153
+ }
154
+
155
+ reset(): void {
156
+ this.samples.splice(0);
157
+ this.flushedTotal = 0;
158
+ this.failedTotal = 0;
159
+ this.lastTrainingTime = null;
160
+ }
161
+
162
+ private signal(state: ReinforcementState, now: number): number {
163
+ if (state.uses <= 0) return 0;
164
+ const usesSat = Math.max(2, this.opts.usesSat);
165
+ const freq = Math.min(1, Math.log1p(state.uses) / Math.log1p(usesSat));
166
+ const t = state.lastUsedTs !== undefined ? Date.parse(state.lastUsedTs) : Number.NaN;
167
+ const halfLifeMs = Math.max(1, this.opts.halfLifeDays) * 86_400_000;
168
+ const age = Number.isFinite(t) ? Math.max(0, now - t) : halfLifeMs;
169
+ const recency = 0.5 + 0.5 * Math.exp(-age / halfLifeMs);
170
+ return Math.max(0, Math.min(1, freq * recency));
171
+ }
172
+ }
173
+
174
+ export function resolveLearningBackend(projectRoot: string, config: MemoryLearningConfig = readMemoryLearningConfig(projectRoot)): LearningSignalBackend {
175
+ try {
176
+ const cfg = config;
177
+ if (cfg.backend === 'off') return new NoopLearningBackend();
178
+ const advisory = cfg.backend === 'ruvector-gnn'
179
+ ? 'memory.learning.backend="ruvector-gnn" is reserved; falling back to native reinforcement'
180
+ : undefined;
181
+ return new NativeReinforcementBackend(projectRoot, {
182
+ usesSat: cfg.usesSat,
183
+ halfLifeDays: cfg.halfLifeDays,
184
+ ...(advisory !== undefined ? { advisory } : {}),
185
+ });
186
+ } catch {
187
+ return new NativeReinforcementBackend(projectRoot);
188
+ }
189
+ }
190
+
191
+ export function applyLearningSignals<H extends { readonly score: number }>(
192
+ hits: readonly H[],
193
+ backend: LearningSignalBackend,
194
+ candidates: readonly SignalCandidate[],
195
+ cap: number,
196
+ ): H[] {
197
+ const signals = backend.enhance(candidates, { kind: 'recall', cap });
198
+ return hits
199
+ .map((hit, i) => ({ hit, adjusted: hit.score + cap * (signals[i] ?? 0), i }))
200
+ .sort((a, b) => b.adjusted - a.adjusted || a.i - b.i)
201
+ .map((x) => x.hit);
202
+ }
package/src/patterns.ts CHANGED
Binary file
package/src/recommend.ts CHANGED
@@ -15,7 +15,8 @@
15
15
 
16
16
  import type { Registry, RegistryEntry } from './registry.js';
17
17
  import { pretrain } from './pretrain.js';
18
- import { loadPatterns, computePatternBoost, readLearningConfig } from './patterns.js';
18
+ import { computePatternBoost, loadPatterns, loadStoreRecords, readLearningConfig, readReinforcementState, recordToPattern } from './patterns.js';
19
+ import { resolveLearningBackend } from './learning-backend.js';
19
20
 
20
21
  /** A recommended skill with relevance score. */
21
22
  export interface SkillRecommendation {
@@ -202,21 +203,34 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
202
203
  // bounded, monotonic boost. Gated on the rollout flag; when memory is empty or
203
204
  // the flag is off, `patterns` is [] and the boost is 0 — ranking stays
204
205
  // byte-identical to the pure keyword scoring (the graceful invariant, R3).
205
- const patterns =
206
- projectRoot && readLearningConfig(projectRoot).recommendBoost ? loadPatterns(projectRoot) : [];
206
+ const records = projectRoot && readLearningConfig(projectRoot).recommendBoost ? loadStoreRecords(projectRoot) : [];
207
+ const patterns = projectRoot && readLearningConfig(projectRoot).recommendBoost ? loadPatterns(projectRoot) : [];
208
+ const backend = projectRoot !== undefined ? resolveLearningBackend(projectRoot) : undefined;
209
+ const boostFor = (entry: RegistryEntry): number => {
210
+ const base = computePatternBoost(entry.id, entry.description, patterns);
211
+ if (base <= 0 || backend === undefined) return base;
212
+ const matched = records.filter((r) => computePatternBoost(entry.id, entry.description, [recordToPattern(r)]) > 0);
213
+ if (matched.length === 0) return base;
214
+ const signals = backend.enhance(
215
+ matched.map((r) => ({ dzId: r.id, score: r.score, reinforcement: readReinforcementState(r) })),
216
+ { kind: 'recommend' },
217
+ );
218
+ const maxSignal = Math.max(0, ...signals);
219
+ return Math.min(50, Math.round(base * (1 + 0.25 * maxSignal)));
220
+ };
207
221
 
208
222
  // Score and rank skills
209
223
  const scored = registry.entries
210
224
  .map((e) => ({
211
225
  entry: e,
212
- score: scoreSkill(e, topics) + (patterns.length ? computePatternBoost(e.id, e.description, patterns) : 0),
226
+ score: scoreSkill(e, topics) + (patterns.length ? boostFor(e) : 0),
213
227
  }))
214
228
  .filter((s) => s.score > 0)
215
229
  .sort((a, b) => b.score - a.score);
216
230
 
217
231
  const skills: SkillRecommendation[] = scored.slice(0, 10).map((s) => {
218
232
  let reason = `Matches topics: ${topics.filter((t) => scoreSkill(s.entry, [t]) > 0).join(', ')}`;
219
- if (patterns.length && computePatternBoost(s.entry.id, s.entry.description, patterns) > 0) {
233
+ if (patterns.length && boostFor(s.entry) > 0) {
220
234
  reason += ' + learned patterns';
221
235
  }
222
236
  return {