@dzhechkov/harness-core 0.8.30 → 0.8.31

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 (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. package/src/vector-tier.ts +45 -4
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Small, shared helpers for the READ-ONLY sqlite paths (`book-kb.ts`, `agentdb-index.ts`,
3
+ * `patterns.ts`) — feature `readonly-residuals`.
4
+ *
5
+ * Two residuals fixed here (`features/readonly-residuals/01_requirements.md`):
6
+ *
7
+ * FR-1. `applyReadonlyPragmas` closes a handle that was already opened by
8
+ * `openSqliteReadOnly` (`@dzhechkov/memory`) if the follow-up `busy_timeout` pragma throws.
9
+ * Before this helper existed, `book-kb.ts`/`agentdb-index.ts` called `db.pragma(...)`
10
+ * directly on the handle's `db`, outside any `try` — a pragma failure left BOTH the native
11
+ * connection and a tmp-copy directory (ADR-001 ladder rung 2) leaked, because `handle.cleanup()`
12
+ * is only wired into the `close()` this code never reaches.
13
+ *
14
+ * FR-2. `classifySqliteReadFailure` tells apart "the native module isn't installed here"
15
+ * (silent JSON fallback, unchanged behaviour) from "the store file itself is unreadable"
16
+ * (corrupt file, permission failure, …) — the second case used to be swallowed by the same
17
+ * bare `catch {}` in `patterns.ts`, so a corrupted `patterns.sqlite` silently looked like
18
+ * "fewer lessons" instead of "the store broke". `warnOnce` prints that second case exactly
19
+ * once per process, on stderr, without changing the JSON-fallback data path.
20
+ */
21
+
22
+ /** Minimal shape `applyReadonlyPragmas` needs from an `openSqliteReadOnly` handle. */
23
+ export interface PragmaTarget {
24
+ readonly db: { pragma: (sql: string) => void; close: () => void };
25
+ readonly cleanup: () => void;
26
+ }
27
+
28
+ function closeQuietly(db: { close: () => void }): void {
29
+ try {
30
+ db.close();
31
+ } catch {
32
+ // already failing on the caller's side — a close failure here must not mask the real cause
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run `fn()` and swallow any exception it throws — used for `handle.cleanup()` in the
38
+ * pragma-failure path (fix round 1, MEDIUM #2): a throwing `cleanup()` must not replace the
39
+ * original pragma error the caller is already in the middle of rethrowing.
40
+ */
41
+ function cleanupQuietly(fn: () => void): void {
42
+ try {
43
+ fn();
44
+ } catch {
45
+ // the pragma failure is the error of record — a cleanup failure here must not mask it
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Run `db.pragma('busy_timeout = 5000')` on an already-opened read-only handle. If the pragma
51
+ * throws, the connection is closed and `handle.cleanup()` is called (removing a tmp-copy
52
+ * directory, ADR-001 rung 2) BEFORE rethrowing — so a pragma failure never leaks either the
53
+ * native connection or the temporary copy. Both `close()` and `cleanup()` are individually
54
+ * guarded (`closeQuietly` / `cleanupQuietly`, fix round 1, MEDIUM #2) so that EITHER one throwing
55
+ * still lets the original pragma cause reach the caller with the db path attached, and both are
56
+ * still attempted exactly once each regardless of which one (if any) throws.
57
+ */
58
+ export function applyReadonlyPragmas(handle: PragmaTarget, path: string): void {
59
+ try {
60
+ handle.db.pragma('busy_timeout = 5000'); // harmless on a readonly connection — kept for parity with the writer opener
61
+ } catch (err) {
62
+ closeQuietly(handle.db);
63
+ cleanupQuietly(() => handle.cleanup());
64
+ const cause = err instanceof Error ? err.message : String(err);
65
+ throw new Error(`failed to prepare ${path} for reading: ${cause}`);
66
+ }
67
+ }
68
+
69
+ /** Which of the two known catch-all reasons a store-opener failure names. */
70
+ export type SqliteReadFailureKind = 'native-unavailable' | 'store-unreadable';
71
+
72
+ /**
73
+ * Loader-specific signatures of "the `better-sqlite3` native module itself could not be loaded"
74
+ * (fix round 1, MEDIUM #1 — narrows the classifier after Codex review found the previous bare
75
+ * `/better-sqlite3/.test(msg)` swallowed `better-sqlite3: file is not a database`, a genuine
76
+ * corruption, as if the module were merely absent). Each pattern names a load-time failure mode,
77
+ * never a runtime/data failure that merely happens to mention the package name.
78
+ */
79
+ const NATIVE_LOADER_PATTERNS: readonly RegExp[] = [
80
+ /Cannot find module ['"]?better-sqlite3/,
81
+ /better-sqlite3 is unavailable/,
82
+ /Could not locate the bindings file/,
83
+ /was compiled against a different Node\.js version/,
84
+ /invalid ELF header/,
85
+ /NODE_MODULE_VERSION/,
86
+ ];
87
+
88
+ /**
89
+ * Classify an error caught around `SqliteBackend.openReadOnly` (or an equivalent opener):
90
+ * `'native-unavailable'` only when the failure is genuinely about `better-sqlite3` failing to
91
+ * LOAD — either `code === 'MODULE_NOT_FOUND'` with `better-sqlite3` named in the message or
92
+ * `requireStack` (a bare `MODULE_NOT_FOUND` for an unrelated module is NOT this — it is a
93
+ * different bug and must not be hidden as "native unavailable"), or the message matches one of
94
+ * the {@link NATIVE_LOADER_PATTERNS} native-module-loader signatures (missing module, ABI/version
95
+ * mismatch, corrupt/foreign binary). The existing silent JSON fallback is correct here, nothing
96
+ * changes. Anything else — a corrupt file (`file is not a database`), a permission failure, a
97
+ * genuinely unreadable store — is `'store-unreadable'`: still falls back to JSON (FR-2 does not
98
+ * change returned data), but the caller is expected to warn once via {@link warnOnce}.
99
+ */
100
+ export function classifySqliteReadFailure(err: unknown): SqliteReadFailureKind {
101
+ const code = (err as { code?: string } | undefined)?.code;
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ const requireStackRaw = (err as { requireStack?: unknown } | undefined)?.requireStack;
104
+ const requireStack = Array.isArray(requireStackRaw) ? requireStackRaw.join('\n') : '';
105
+
106
+ if (code === 'MODULE_NOT_FOUND' && (/better-sqlite3/.test(msg) || /better-sqlite3/.test(requireStack))) {
107
+ return 'native-unavailable';
108
+ }
109
+ if (NATIVE_LOADER_PATTERNS.some((re) => re.test(msg))) return 'native-unavailable';
110
+ return 'store-unreadable';
111
+ }
112
+
113
+ const warnedKeys = new Set<string>();
114
+
115
+ /**
116
+ * Write `text` to stderr the first time this exact `key` is seen in this process, and never
117
+ * again. Used to surface a `'store-unreadable'` classification exactly once per store path per
118
+ * process, instead of once per read call.
119
+ */
120
+ export function warnOnce(key: string, text: string): void {
121
+ if (warnedKeys.has(key)) return;
122
+ warnedKeys.add(key);
123
+ try {
124
+ process.stderr.write(`${text}\n`);
125
+ } catch {
126
+ // a closed/broken stderr must not break the caller's fallback
127
+ }
128
+ }
package/src/statusline.ts CHANGED
@@ -70,15 +70,30 @@ export interface StatuslineData {
70
70
  readonly patterns: number;
71
71
  /** Absent on parity; missing/unreadable mirror is explicitly unavailable. */
72
72
  readonly patternMirror?:
73
- | { readonly state: 'different'; readonly lexical: number; readonly vector: number }
73
+ | {
74
+ readonly state: 'in-sync' | 'different';
75
+ readonly lexicalMirrorable: number;
76
+ readonly vector: number;
77
+ readonly excluded: { readonly class: number; readonly noise: number };
78
+ }
74
79
  | { readonly state: 'unavailable' };
80
+ /** Vector-mirror inventory; absence is explicit instead of being encoded as a missing field. */
81
+ readonly mirror: {
82
+ readonly available: boolean;
83
+ readonly rows: number;
84
+ readonly lessons: number;
85
+ readonly pending: number;
86
+ readonly source: 'agentdb';
87
+ };
75
88
  /** Exact lexical-tier availability split; omitted when the enhanced readonly count cannot be established. */
76
89
  readonly patternBreakdown?: {
77
- readonly source: 'lexical';
90
+ readonly source: 'lexical' | 'lexical+mirror';
78
91
  readonly active: number;
79
92
  readonly quarantined: number;
80
93
  /** True once quarantine contains at least one third of the lexical pool. */
81
94
  readonly attention: boolean;
95
+ /** Exact label drift by dzId; present only when both tiers expose readable identities. */
96
+ readonly tierParity?: { readonly lexicalOnly: number; readonly mirrorOnly: number };
82
97
  /** Absolute lexical/vector quarantine-label delta, present only above the tolerated drift threshold. */
83
98
  readonly tierDelta?: number;
84
99
  };
@@ -663,14 +678,38 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
663
678
  // зеркалом не пользуется, он горел бы всегда, а вечно горящий показатель не несёт сведений.
664
679
  // Зеркало ЕСТЬ, но прочитать или разложить его не удалось: это отказ инструмента, и он горит.
665
680
  const mirrorLessons = storeRows?.vectorLessonRows;
681
+ const mirrorRows = storeRows?.vectorRows;
666
682
  const mirrorAbsent = storeRows !== undefined && storeRows.vectorSourcePath === undefined;
683
+ const lexicalMirrorable = storeRows?.lexicalMirrorableRows;
684
+ const excluded = {
685
+ class: storeRows?.lexicalExcludedClassRows ?? 0,
686
+ noise: storeRows?.lexicalExcludedNoiseRows ?? 0,
687
+ };
688
+ const mirrorAvailable = storeRows?.vectorSourcePath !== undefined
689
+ && typeof mirrorRows === 'number'
690
+ && mirrorLessons !== undefined;
667
691
  const patternMirror: StatuslineData['patternMirror'] = mirrorAbsent
668
692
  ? undefined
669
- : storeRows === undefined || storeRows.vectorRows === 'unreadable' || mirrorLessons === undefined
693
+ : storeRows === undefined || storeRows.vectorRows === 'unreadable' || storeRows.vectorRows === 'busy'
694
+ || mirrorLessons === undefined || lexicalMirrorable === undefined
670
695
  ? { state: 'unavailable' }
671
- : mirrorLessons !== patterns
672
- ? { state: 'different', lexical: patterns, vector: mirrorLessons }
673
- : undefined;
696
+ : {
697
+ state: mirrorLessons !== lexicalMirrorable ? 'different' : 'in-sync',
698
+ lexicalMirrorable,
699
+ vector: mirrorLessons,
700
+ excluded,
701
+ };
702
+ const mirror: StatuslineData['mirror'] = {
703
+ available: mirrorAvailable,
704
+ rows: typeof mirrorRows === 'number' ? mirrorRows : 0,
705
+ lessons: mirrorAvailable ? mirrorLessons : 0,
706
+ pending: mirrorAbsent && lexicalMirrorable !== undefined
707
+ ? lexicalMirrorable
708
+ : mirrorAvailable && lexicalMirrorable !== undefined
709
+ ? Math.max(0, lexicalMirrorable - mirrorLessons)
710
+ : 0,
711
+ source: 'agentdb',
712
+ };
674
713
 
675
714
  let patternBreakdown: StatuslineData['patternBreakdown'];
676
715
  try {
@@ -678,14 +717,22 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
678
717
  && typeof storeRows.lexicalRows === 'number'
679
718
  && typeof storeRows.lexicalQuarantinedRows === 'number') {
680
719
  const quarantined = storeRows.lexicalQuarantinedRows;
681
- const tierDelta = typeof storeRows.vectorQuarantinedRows === 'number'
682
- ? Math.abs(quarantined - storeRows.vectorQuarantinedRows)
720
+ const tierDelta = mirrorAvailable
721
+ && typeof storeRows.lexicalMirrorableQuarantinedRows === 'number'
722
+ && typeof storeRows.vectorQuarantinedRows === 'number'
723
+ ? Math.abs(storeRows.lexicalMirrorableQuarantinedRows - storeRows.vectorQuarantinedRows)
683
724
  : undefined;
684
725
  patternBreakdown = {
685
- source: 'lexical',
726
+ source: mirrorAvailable ? 'lexical+mirror' : 'lexical',
686
727
  active: storeRows.lexicalRows - quarantined,
687
728
  quarantined,
688
729
  attention: quarantined > 0 && quarantined * 3 >= storeRows.lexicalRows,
730
+ ...(storeRows.quarantineTierParity === undefined ? {} : {
731
+ tierParity: {
732
+ lexicalOnly: storeRows.quarantineTierParity.lexicalOnly,
733
+ mirrorOnly: storeRows.quarantineTierParity.mirrorOnly,
734
+ },
735
+ }),
689
736
  ...(tierDelta !== undefined && tierDelta > QUARANTINE_TIER_DRIFT_TOLERANCE ? { tierDelta } : {}),
690
737
  };
691
738
  }
@@ -723,6 +770,7 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
723
770
 
724
771
  return {
725
772
  patterns,
773
+ mirror,
726
774
  ...(patternMirror !== undefined ? { patternMirror } : {}),
727
775
  ...(patternBreakdown !== undefined ? { patternBreakdown } : {}),
728
776
  ...(usedPatterns !== undefined ? { usedPatterns } : {}),
@@ -2,10 +2,16 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
+ import type { MemoryRecord } from '@dzhechkov/memory';
6
+ import { patternRecordId, recordToPattern } from './patterns.js';
7
+ import { isMirrorableRecord } from './vector-tier.js';
5
8
 
6
9
  interface ReadonlyCountDb {
7
10
  pragma: (s: string) => void;
8
- prepare: (q: string) => { get: (...a: unknown[]) => unknown };
11
+ prepare: (q: string) => {
12
+ get: (...a: unknown[]) => unknown;
13
+ all: (...a: unknown[]) => unknown[];
14
+ };
9
15
  close: () => void;
10
16
  }
11
17
 
@@ -16,6 +22,91 @@ export interface StoreCountOptions {
16
22
  readonly attempts?: number;
17
23
  }
18
24
 
25
+ export interface QuarantineTierRow {
26
+ readonly id?: string;
27
+ readonly dzId?: string;
28
+ /** Content-addressed alias used by the teach-time mirror before a lexical row is re-keyed. */
29
+ readonly patternRecordId?: string;
30
+ readonly quarantined?: boolean;
31
+ readonly qStatus?: unknown;
32
+ readonly metadata?: Record<string, unknown> | string | null;
33
+ }
34
+
35
+ export interface QuarantineTierParity {
36
+ readonly both: number;
37
+ readonly lexicalOnly: number;
38
+ readonly mirrorOnly: number;
39
+ readonly ids: {
40
+ readonly both: readonly string[];
41
+ readonly lexicalOnly: readonly string[];
42
+ readonly mirrorOnly: readonly string[];
43
+ };
44
+ }
45
+
46
+ function rowMetadata(row: QuarantineTierRow): Record<string, unknown> {
47
+ if (typeof row.metadata === 'object' && row.metadata !== null) return row.metadata;
48
+ if (typeof row.metadata !== 'string') return {};
49
+ try {
50
+ const parsed = JSON.parse(row.metadata) as unknown;
51
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
52
+ ? parsed as Record<string, unknown>
53
+ : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ interface QuarantineIdentity {
60
+ readonly primary: string;
61
+ readonly aliases: ReadonlySet<string>;
62
+ }
63
+
64
+ function quarantineIdentities(rows: readonly QuarantineTierRow[]): QuarantineIdentity[] {
65
+ const identities = new Map<string, QuarantineIdentity>();
66
+ for (const row of rows) {
67
+ const metadata = rowMetadata(row);
68
+ const quarantined = row.quarantined === true
69
+ || row.qStatus === 'quarantined'
70
+ || metadata['qStatus'] === 'quarantined';
71
+ if (!quarantined) continue;
72
+ const candidates = [row.dzId, metadata['dzId'], row.id, row.patternRecordId]
73
+ .filter((id): id is string => typeof id === 'string' && id !== '');
74
+ const primary = candidates[0];
75
+ if (primary === undefined) continue;
76
+ identities.set(primary, { primary, aliases: new Set(candidates) });
77
+ }
78
+ return [...identities.values()];
79
+ }
80
+
81
+ /** Pure dual-key comparison for active quarantine labels in the lexical and mirror tiers. */
82
+ export function quarantineTierParity(
83
+ lexicalRows: readonly QuarantineTierRow[],
84
+ mirrorRows: readonly QuarantineTierRow[],
85
+ ): QuarantineTierParity {
86
+ const lexical = quarantineIdentities(lexicalRows);
87
+ const mirror = quarantineIdentities(mirrorRows);
88
+ const lexicalKeys = new Set(lexical.flatMap((identity) => [...identity.aliases]));
89
+ const mirrorKeys = new Set(mirror.flatMap((identity) => [...identity.aliases]));
90
+ const both = [...new Set(lexical.flatMap((identity) => {
91
+ const matched = [...identity.aliases].find((id) => mirrorKeys.has(id));
92
+ return matched === undefined ? [] : [matched];
93
+ }))].sort();
94
+ const lexicalOnly = lexical
95
+ .filter((identity) => ![...identity.aliases].some((id) => mirrorKeys.has(id)))
96
+ .map((identity) => identity.primary)
97
+ .sort();
98
+ const mirrorOnly = mirror
99
+ .filter((identity) => ![...identity.aliases].some((id) => lexicalKeys.has(id)))
100
+ .map((identity) => identity.primary)
101
+ .sort();
102
+ return {
103
+ both: both.length,
104
+ lexicalOnly: lexicalOnly.length,
105
+ mirrorOnly: mirrorOnly.length,
106
+ ids: { both, lexicalOnly, mirrorOnly },
107
+ };
108
+ }
109
+
19
110
  type DatabaseRequire = (id: string) => unknown;
20
111
 
21
112
  function isBusy(error: unknown): boolean {
@@ -45,6 +136,13 @@ function normalizedOptions(options: StoreCountOptions): { busyTimeoutMs: number;
45
136
 
46
137
  export interface LearningStoreRowCounts {
47
138
  readonly lexicalRows: StoreRowCount;
139
+ /** Lexical rows accepted by the same class/noise gate as the vector mirror writer. */
140
+ readonly lexicalMirrorableRows?: number;
141
+ /** Mirrorable lexical rows whose current qStatus is quarantined. */
142
+ readonly lexicalMirrorableQuarantinedRows?: number;
143
+ /** Mutually exclusive exclusion buckets; class takes precedence over noise. */
144
+ readonly lexicalExcludedClassRows?: number;
145
+ readonly lexicalExcludedNoiseRows?: number;
48
146
  /** Exact active quarantine labels in the selected lexical SQLite tier; absent for fallback/error paths. */
49
147
  readonly lexicalQuarantinedRows?: number;
50
148
  /** Physical lexical population counted; jsonl and SQLite maxima are not comparable. */
@@ -71,6 +169,8 @@ export interface LearningStoreRowCounts {
71
169
  readonly vectorQuarantinedRows?: number;
72
170
  /** Present when the vector store file exists, including when its count is unreadable. */
73
171
  readonly vectorSourcePath?: string;
172
+ /** Exact qStatus parity by dzId; absent when either tier cannot establish quarantine ids. */
173
+ readonly quarantineTierParity?: QuarantineTierParity;
74
174
  }
75
175
 
76
176
  function countJsonlRowsReadonly(path: string): number | 'unreadable' {
@@ -133,8 +233,132 @@ export function countSqliteRowsReadonly(
133
233
  interface SqliteRowsWithQuarantine {
134
234
  readonly rows: number;
135
235
  readonly quarantinedRows?: number;
236
+ readonly mirrorableRows?: number;
237
+ readonly mirrorableQuarantinedRows?: number;
238
+ readonly excludedClassRows?: number;
239
+ readonly excludedNoiseRows?: number;
136
240
  /** Только для зеркала: подсчёт уроков внутри общего объёма. */
137
241
  readonly lessonRows?: number;
242
+ readonly quarantinedIdentities?: readonly QuarantineTierRow[];
243
+ }
244
+
245
+ function lexicalMirrorPopulation(db: ReadonlyCountDb): Pick<
246
+ SqliteRowsWithQuarantine,
247
+ 'mirrorableRows' | 'mirrorableQuarantinedRows' | 'excludedClassRows' | 'excludedNoiseRows' | 'quarantinedIdentities'
248
+ > | undefined {
249
+ let rows: unknown[];
250
+ let idsEstablished = true;
251
+ try {
252
+ rows = db.prepare('SELECT id, skill_id, text, score, outcome, timestamp, metadata FROM memory_records').all();
253
+ } catch {
254
+ try {
255
+ rows = db.prepare('SELECT id, text, metadata FROM memory_records').all();
256
+ } catch {
257
+ idsEstablished = false;
258
+ try {
259
+ rows = db.prepare('SELECT text, metadata FROM memory_records').all();
260
+ } catch {
261
+ try {
262
+ // Legacy/minimal schemas can still establish the class exclusion. With no text column there
263
+ // is no observable noise payload, so every non-class row stays in the comparable population.
264
+ rows = db.prepare("SELECT '' AS text, metadata FROM memory_records").all();
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ let mirrorableRows = 0;
273
+ let mirrorableQuarantinedRows = 0;
274
+ let excludedClassRows = 0;
275
+ let excludedNoiseRows = 0;
276
+ const quarantinedRows: QuarantineTierRow[] = [];
277
+ for (const value of rows) {
278
+ const row = value as {
279
+ id?: unknown;
280
+ skill_id?: unknown;
281
+ text?: unknown;
282
+ score?: unknown;
283
+ outcome?: unknown;
284
+ timestamp?: unknown;
285
+ metadata?: unknown;
286
+ };
287
+ let metadata: Record<string, unknown> = {};
288
+ if (typeof row.metadata === 'string') {
289
+ try {
290
+ const parsed = JSON.parse(row.metadata) as unknown;
291
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
292
+ metadata = parsed as Record<string, unknown>;
293
+ }
294
+ } catch { /* malformed metadata carries no class/quarantine claim */ }
295
+ }
296
+ const text = typeof row.text === 'string' ? row.text : '';
297
+ const lessonForm = metadata['lessonForm'] === 'class' ? 'class' as const : undefined;
298
+ const record = { pattern: text, ...(lessonForm === undefined ? {} : { lessonForm }) };
299
+ if (!isMirrorableRecord(record)) {
300
+ if (lessonForm === 'class') excludedClassRows += 1;
301
+ else excludedNoiseRows += 1;
302
+ continue;
303
+ }
304
+ mirrorableRows += 1;
305
+ if (metadata['qStatus'] === 'quarantined') {
306
+ mirrorableQuarantinedRows += 1;
307
+ if (typeof row.id !== 'string' || row.id === '') {
308
+ idsEstablished = false;
309
+ continue;
310
+ }
311
+ let derivedId: string | undefined;
312
+ if (typeof row.text === 'string'
313
+ && typeof row.score === 'number'
314
+ && typeof row.outcome === 'string'
315
+ && typeof row.timestamp === 'string') {
316
+ const stringMetadata = Object.fromEntries(
317
+ Object.entries(metadata).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
318
+ );
319
+ const record: MemoryRecord = {
320
+ id: row.id,
321
+ skillId: typeof row.skill_id === 'string' ? row.skill_id : '',
322
+ text: row.text,
323
+ score: row.score,
324
+ outcome: row.outcome,
325
+ timestamp: row.timestamp,
326
+ metadata: stringMetadata,
327
+ };
328
+ derivedId = patternRecordId(recordToPattern(record));
329
+ }
330
+ quarantinedRows.push({
331
+ id: row.id,
332
+ ...(derivedId === undefined ? {} : { patternRecordId: derivedId }),
333
+ qStatus: 'quarantined',
334
+ });
335
+ }
336
+ }
337
+ return {
338
+ mirrorableRows,
339
+ mirrorableQuarantinedRows,
340
+ excludedClassRows,
341
+ excludedNoiseRows,
342
+ ...(idsEstablished ? { quarantinedIdentities: quarantinedRows } : {}),
343
+ };
344
+ }
345
+
346
+ function vectorQuarantinedIds(db: ReadonlyCountDb): readonly QuarantineTierRow[] | undefined {
347
+ try {
348
+ const rows = db.prepare(`SELECT metadata FROM reasoning_patterns
349
+ WHERE task_type IN ('dz-teach', 'dz-learning')
350
+ AND json_valid(metadata)
351
+ AND json_extract(metadata, '$.qStatus') = 'quarantined'`).all() as Array<{ metadata?: unknown }>;
352
+ const rowsWithIds: QuarantineTierRow[] = [];
353
+ for (const row of rows) {
354
+ const metadata = rowMetadata({ metadata: typeof row.metadata === 'string' ? row.metadata : null });
355
+ if (typeof metadata['dzId'] !== 'string' || metadata['dzId'] === '') return undefined;
356
+ rowsWithIds.push({ dzId: metadata['dzId'], qStatus: 'quarantined' });
357
+ }
358
+ return rowsWithIds;
359
+ } catch {
360
+ return undefined;
361
+ }
138
362
  }
139
363
 
140
364
  /** One aggregate query on the healthy path; an unsupported metadata shape falls back to total-only. */
@@ -168,9 +392,13 @@ function countSqliteRowsWithQuarantineReadonly(
168
392
  FROM reasoning_patterns`;
169
393
  const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
170
394
  if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return 'unreadable';
395
+ const lexical = table === 'memory_records' ? lexicalMirrorPopulation(db) : undefined;
396
+ const quarantinedRows = table === 'reasoning_patterns' ? vectorQuarantinedIds(db) : undefined;
171
397
  return {
172
398
  rows: row.cnt,
173
399
  quarantinedRows: row.quarantined,
400
+ ...(lexical ?? {}),
401
+ ...(quarantinedRows === undefined ? {} : { quarantinedIdentities: quarantinedRows }),
174
402
  ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
175
403
  };
176
404
  } catch (error) {
@@ -247,8 +475,23 @@ export function countLearningStoreRowsReadonly(
247
475
  const ignoredJsonl = lexicalSource === 'sqlite' && existsSync(jsonlPath)
248
476
  ? countJsonlRowsReadonly(jsonlPath)
249
477
  : undefined;
478
+ const quarantineParity = typeof lexicalSqlite === 'object'
479
+ && lexicalSqlite.quarantinedIdentities !== undefined
480
+ && typeof vectorSqlite === 'object'
481
+ && vectorSqlite.quarantinedIdentities !== undefined
482
+ ? quarantineTierParity(
483
+ lexicalSqlite.quarantinedIdentities,
484
+ vectorSqlite.quarantinedIdentities,
485
+ )
486
+ : undefined;
250
487
  return {
251
488
  lexicalRows,
489
+ ...(typeof lexicalSqlite !== 'object' || lexicalSqlite.mirrorableRows === undefined ? {} : {
490
+ lexicalMirrorableRows: lexicalSqlite.mirrorableRows,
491
+ lexicalMirrorableQuarantinedRows: lexicalSqlite.mirrorableQuarantinedRows ?? 0,
492
+ lexicalExcludedClassRows: lexicalSqlite.excludedClassRows ?? 0,
493
+ lexicalExcludedNoiseRows: lexicalSqlite.excludedNoiseRows ?? 0,
494
+ }),
252
495
  ...(typeof lexicalSqlite !== 'object' || lexicalSqlite.quarantinedRows === undefined ? {} : {
253
496
  lexicalQuarantinedRows: lexicalSqlite.quarantinedRows,
254
497
  }),
@@ -267,5 +510,6 @@ export function countLearningStoreRowsReadonly(
267
510
  vectorQuarantinedRows: vectorSqlite.quarantinedRows,
268
511
  }),
269
512
  ...(vectorExists ? { vectorSourcePath: vectorPath } : {}),
513
+ ...(quarantineParity === undefined ? {} : { quarantineTierParity: quarantineParity }),
270
514
  };
271
515
  }