@dzhechkov/harness-core 0.8.33 → 0.8.34

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.
@@ -32,7 +32,7 @@
32
32
  * @packageDocumentation
33
33
  */
34
34
 
35
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync, appendFileSync, copyFileSync } from 'node:fs';
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync, appendFileSync, copyFileSync, statSync, realpathSync } from 'node:fs';
36
36
  import { basename, dirname, join } from 'node:path';
37
37
  import { pathToFileURL } from 'node:url';
38
38
  import { createRequire } from 'node:module';
@@ -192,8 +192,15 @@ export interface ResolvedVectorEngine {
192
192
  readonly reason?: string | undefined;
193
193
  }
194
194
 
195
- /** Recall mode: `hybrid` (default), `semantic` (`--semantic`, 2× vector weight), `lexical` (`--no-semantic`). */
196
- export type HybridRecallMode = 'hybrid' | 'semantic' | 'lexical';
195
+ /**
196
+ * Recall mode: `hybrid` (default), `semantic` (`--semantic`, 2× vector weight), `lexical`
197
+ * (`--no-semantic`), `hook` (feature `hook-recall-hybrid-parity`, ADR-001 D1/D2) — the SAME ranking
198
+ * as `hybrid` (no weight change), used by the recall daemon so `mode` travels end to end for
199
+ * observability; the caller is responsible for also passing `deferExposures: true` and never
200
+ * calling the returned `commitExposures` — that is what actually keeps a per-prompt hook recall
201
+ * from moving the bandit's exposure counts (FR-1/FR-7 in that feature's requirements).
202
+ */
203
+ export type HybridRecallMode = 'hybrid' | 'semantic' | 'lexical' | 'hook';
197
204
 
198
205
  /** One merged recall hit (RRF-scored). `pattern` ALWAYS comes from the lexical store (V-1). */
199
206
  export interface HybridHit {
@@ -838,10 +845,145 @@ export interface VectorServiceOptions {
838
845
  readonly timeoutMs?: number | undefined;
839
846
  }
840
847
 
841
- function pickEngine(projectRoot: string, opts: VectorServiceOptions): ResolvedVectorEngine {
848
+ /**
849
+ * Cache entry for {@link getOrOpenEngine}: the last {@link resolveVectorEngine} outcome for a
850
+ * project, tagged with the `.dz/agentdb.db` stat facts it was resolved against (AM-6: mtime ALONE
851
+ * is not a safe invalidation key — a file replaced by a temp+rename write, or two writes landing
852
+ * inside one filesystem-mtime tick, can leave mtime UNCHANGED while the content genuinely changed;
853
+ * size and inode are independent signals that a mtime-preserving replace still trips).
854
+ */
855
+ interface EngineCacheEntry {
856
+ readonly resolved: ResolvedVectorEngine;
857
+ readonly stat: AgentdbDbStat;
858
+ }
859
+
860
+ /** The three independent invalidation signals AM-6 asks for, plus the recency stamp
861
+ * {@link touchEngineCacheEntry} needs for the bounded-size eviction below. */
862
+ interface AgentdbDbStat {
863
+ readonly mtimeMs: number;
864
+ readonly size: number;
865
+ readonly ino: number;
866
+ lastUsedAt: number;
867
+ }
868
+
869
+ /** Module-level, per-process cache. Keyed by `realpath(projectRoot)` (AM-6): two callers that name
870
+ * the SAME project through different paths (a symlinked checkout, a relative vs. absolute cwd) must
871
+ * share one slot, not silently duplicate it — `realpath` is what `resolveVectorEngine` itself
872
+ * ultimately resolves native deps against. One long-lived caller (the recall daemon, feature
873
+ * `hook-recall-hybrid-parity`) serves exactly one project for its whole lifetime; a short-lived CLI
874
+ * invocation would populate + discard a slot within one process, which is why `pickEngine` below
875
+ * only ever reads this cache for `mode: 'hook'` — the CLI path never touches it (AM-10, I-1 parity).
876
+ *
877
+ * Bounded (AM-6): a daemon is expected to serve one project, but nothing enforces that structurally
878
+ * (`DZ_PROJECT_ROOT` could vary run to run in a shared test harness), so the cache evicts its LEAST
879
+ * RECENTLY USED entry rather than growing without bound for the lifetime of a long-lived process.
880
+ */
881
+ const engineCache = new Map<string, EngineCacheEntry>();
882
+ /** Measured: a daemon serves exactly one `DZ_PROJECT_ROOT` for its whole life (ADR-001 D1); 8 is a
883
+ * >>1 safety margin for a shared-process test harness that reuses the module across several fixture
884
+ * projects, never a production expectation. */
885
+ const ENGINE_CACHE_MAX_ENTRIES = 8;
886
+
887
+ /** Realpath of `projectRoot`, falling back to the raw path when it cannot be resolved (a project
888
+ * root that does not exist yet, or a permissions error) — the cache must still work, just without
889
+ * the symlink-collapsing benefit, exactly as it did before AM-6. */
890
+ function engineCacheKey(projectRoot: string): string {
891
+ try {
892
+ return realpathSync(projectRoot);
893
+ } catch {
894
+ return projectRoot;
895
+ }
896
+ }
897
+
898
+ /** stat facts of `<root>/.dz/agentdb.db`, or `-1`/`-1`/`-1` when absent — a distinct, stable cache
899
+ * key for "no store yet" so a project that later gains a store is never confused with one that
900
+ * never had (statSync's own floor is mtime 0). Never throws. */
901
+ function agentdbDbStat(projectRoot: string): { mtimeMs: number; size: number; ino: number } {
902
+ try {
903
+ const st = statSync(join(projectRoot, '.dz', 'agentdb.db'));
904
+ return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino };
905
+ } catch {
906
+ return { mtimeMs: -1, size: -1, ino: -1 };
907
+ }
908
+ }
909
+
910
+ /** True when NONE of the three independent signals changed — the only case where a cached engine
911
+ * may still be trusted (AM-6). Any one of them differing (a same-tick replace still bumps size or
912
+ * gets a fresh inode from a temp+rename write) forces a re-resolve. */
913
+ function agentdbDbStatUnchanged(a: AgentdbDbStat, b: { mtimeMs: number; size: number; ino: number }): boolean {
914
+ return a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino;
915
+ }
916
+
917
+ /** Evict the least-recently-used entry once the cache is at capacity — called only on a genuine
918
+ * miss, so a cache that never exceeds {@link ENGINE_CACHE_MAX_ENTRIES} never pays this scan. */
919
+ function evictLeastRecentlyUsed(): void {
920
+ if (engineCache.size < ENGINE_CACHE_MAX_ENTRIES) return;
921
+ let oldestKey: string | undefined;
922
+ let oldestAt = Infinity;
923
+ for (const [key, entry] of engineCache) {
924
+ if (entry.stat.lastUsedAt < oldestAt) {
925
+ oldestAt = entry.stat.lastUsedAt;
926
+ oldestKey = key;
927
+ }
928
+ }
929
+ if (oldestKey !== undefined) engineCache.delete(oldestKey);
930
+ }
931
+
932
+ /**
933
+ * FR-3 (`hook-recall-hybrid-parity`, ADR-001): resolve the vector engine ONCE per project and
934
+ * reuse it across calls in the SAME process, instead of re-running `resolveVectorEngine`'s
935
+ * `isPackageInstalled` walk + native-dep probe on every single request — the cost a long-lived
936
+ * daemon answering one recall per prompt would otherwise pay repeatedly. Invalidated the moment
937
+ * `.dz/agentdb.db`'s mtime, size, OR inode changes (AM-6 — a `dz teach`/`consolidate` landed
938
+ * between requests), so a cached engine can never silently outlive the store it was resolved
939
+ * against, even across a mtime-preserving replace.
940
+ *
941
+ * `resolve` is injectable (AC-3, spy-testable): production code always uses the default
942
+ * {@link resolveVectorEngine}; a test passes a counting wrapper as the second argument instead of
943
+ * mocking the module, which — for two functions in the SAME ES module — `vi.spyOn` cannot
944
+ * intercept reliably when the callee is invoked by its own local name.
945
+ */
946
+ export function getOrOpenEngine(
947
+ projectRoot: string,
948
+ resolve: (root: string) => ResolvedVectorEngine = resolveVectorEngine,
949
+ ): ResolvedVectorEngine {
950
+ const key = engineCacheKey(projectRoot);
951
+ const stat = agentdbDbStat(projectRoot);
952
+ const now = Date.now();
953
+ const cached = engineCache.get(key);
954
+ if (cached !== undefined && agentdbDbStatUnchanged(cached.stat, stat)) {
955
+ cached.stat.lastUsedAt = now;
956
+ return cached.resolved;
957
+ }
958
+ const resolved = resolve(projectRoot);
959
+ // Codex round-3 (2026-09-14): a REFRESH of an existing key replaces in place — evicting first would
960
+ // drop an unrelated entry and shrink the cache for nothing; only a brand-new key needs room.
961
+ if (cached === undefined) evictLeastRecentlyUsed();
962
+ engineCache.set(key, { resolved, stat: { ...stat, lastUsedAt: now } });
963
+ return resolved;
964
+ }
965
+
966
+ /** Test-only: drop every cached engine. A fresh process never needs this; a test suite reusing one
967
+ * project root across cases (or reusing this module's singleton cache across tests) does. */
968
+ export function __resetEngineCacheForTests(): void {
969
+ engineCache.clear();
970
+ }
971
+
972
+ /**
973
+ * AM-10 regression fix: caching was previously applied to EVERY caller of `pickEngine`, including
974
+ * `dz recall` itself — a short-lived CLI process gains nothing from caching (I-1 parity note above)
975
+ * and the cache's mtime-only key (pre-AM-6) could serve a STALE resolution across two calls in the
976
+ * SAME test/process whose `.dz/agentdb.db` was replaced within one mtime tick (MEASURED regression:
977
+ * `recall-output-honesty.test.ts` › "the setup advice does not fire when the vector tier is already
978
+ * installed" went red — an empty-footprint resolution from an earlier call in the same run was
979
+ * served back after `installEmptyVectorTier` had since written a real engine). Only `mode: 'hook'`
980
+ * (the recall daemon, the ONE long-lived caller this cache exists for) reads the cache; every other
981
+ * mode resolves fresh, byte-identical to the pre-cache behavior.
982
+ */
983
+ function pickEngine(projectRoot: string, opts: VectorServiceOptions & { readonly mode?: HybridRecallMode | undefined } = {}): ResolvedVectorEngine {
842
984
  if (opts.engine === null) return { reason: 'vector engine disabled (injected)' };
843
985
  if (opts.engine !== undefined) return { engine: opts.engine };
844
- return resolveVectorEngine(projectRoot);
986
+ return opts.mode === 'hook' ? getOrOpenEngine(projectRoot) : resolveVectorEngine(projectRoot);
845
987
  }
846
988
 
847
989
  /**