@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.
- package/.dz-manifest.json +37 -37
- package/README.md +78 -0
- package/dist/agentdb-index.d.ts +17 -6
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +61 -17
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +56 -5
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +344 -28
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +43 -4
- package/dist/operations.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +72 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +150 -4
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/release.d.ts +72 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +236 -19
- package/dist/release.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +112 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +36 -36
- package/src/agentdb-index.ts +65 -17
- package/src/apply-leg.ts +339 -28
- package/src/index.ts +10 -2
- package/src/operations.ts +41 -4
- package/src/publish-sibling-drift.ts +209 -4
- package/src/release.ts +263 -17
- package/src/vector-tier.ts +147 -5
package/src/vector-tier.ts
CHANGED
|
@@ -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
|
-
/**
|
|
196
|
-
|
|
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
|
-
|
|
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
|
/**
|