@cleocode/core 2026.8.9 → 2026.9.1
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/conduit/conduit-client.d.ts.map +1 -1
- package/dist/conduit/conduit-client.js +10 -2
- package/dist/conduit/conduit-client.js.map +1 -1
- package/dist/dispatch/mvi-projection.d.ts +38 -0
- package/dist/dispatch/mvi-projection.d.ts.map +1 -1
- package/dist/dispatch/mvi-projection.js +189 -11
- package/dist/dispatch/mvi-projection.js.map +1 -1
- package/dist/docs/export-document.js +41 -3
- package/dist/docs/export-document.js.map +2 -2
- package/dist/doctor/acceptance-drift.d.ts +258 -0
- package/dist/doctor/acceptance-drift.d.ts.map +1 -0
- package/dist/doctor/acceptance-drift.js +366 -0
- package/dist/doctor/acceptance-drift.js.map +1 -0
- package/dist/doctor/index.d.ts +4 -0
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +2 -0
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/legacy-reaper.d.ts +105 -0
- package/dist/doctor/legacy-reaper.d.ts.map +1 -0
- package/dist/doctor/legacy-reaper.js +151 -0
- package/dist/doctor/legacy-reaper.js.map +1 -0
- package/dist/doctor/malformed-task-ids.d.ts +111 -0
- package/dist/doctor/malformed-task-ids.d.ts.map +1 -0
- package/dist/doctor/malformed-task-ids.js +139 -0
- package/dist/doctor/malformed-task-ids.js.map +1 -0
- package/dist/doctor/nexus-schema-residency.d.ts +115 -0
- package/dist/doctor/nexus-schema-residency.d.ts.map +1 -0
- package/dist/doctor/nexus-schema-residency.js +178 -0
- package/dist/doctor/nexus-schema-residency.js.map +1 -0
- package/dist/error-registry.d.ts.map +1 -1
- package/dist/error-registry.js +18 -0
- package/dist/error-registry.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/llm/ai-sdk-warnings.d.ts +55 -0
- package/dist/llm/ai-sdk-warnings.d.ts.map +1 -0
- package/dist/llm/ai-sdk-warnings.js +83 -0
- package/dist/llm/ai-sdk-warnings.js.map +1 -0
- package/dist/llm/model-runner.d.ts +0 -8
- package/dist/llm/model-runner.d.ts.map +1 -1
- package/dist/llm/model-runner.js +8 -0
- package/dist/llm/model-runner.js.map +1 -1
- package/dist/llm/plugin-facade.js +73 -8
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/memory/embedding-queue.d.ts +12 -0
- package/dist/memory/embedding-queue.d.ts.map +1 -1
- package/dist/memory/embedding-queue.js +39 -5
- package/dist/memory/embedding-queue.js.map +1 -1
- package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
- package/dist/orchestrate/worktree-complete.js +26 -0
- package/dist/orchestrate/worktree-complete.js.map +1 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +25 -0
- package/dist/output.js.map +1 -1
- package/dist/release/pr-evidence.d.ts +13 -0
- package/dist/release/pr-evidence.d.ts.map +1 -1
- package/dist/release/pr-evidence.js +54 -1
- package/dist/release/pr-evidence.js.map +1 -1
- package/dist/render/session/briefing.d.ts.map +1 -1
- package/dist/render/session/briefing.js +9 -0
- package/dist/render/session/briefing.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +73 -8
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sessions/briefing.d.ts +13 -0
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/briefing.js +64 -7
- package/dist/sessions/briefing.js.map +1 -1
- package/dist/sessions/handoff.d.ts +32 -4
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/handoff.js +7 -7
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/shutdown-deadline.d.ts +144 -0
- package/dist/shutdown-deadline.d.ts.map +1 -0
- package/dist/shutdown-deadline.js +187 -0
- package/dist/shutdown-deadline.js.map +1 -0
- package/dist/shutdown.d.ts +5 -2
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +19 -14
- package/dist/shutdown.js.map +1 -1
- package/dist/spawn/branch-lock.d.ts.map +1 -1
- package/dist/spawn/branch-lock.js +33 -0
- package/dist/spawn/branch-lock.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +14 -0
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +16 -1
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/tasks/ac-immutability.d.ts.map +1 -1
- package/dist/tasks/ac-immutability.js +36 -0
- package/dist/tasks/ac-immutability.js.map +1 -1
- package/dist/tasks/add.d.ts +18 -0
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +28 -5
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/axis-filters.d.ts +53 -0
- package/dist/tasks/axis-filters.d.ts.map +1 -0
- package/dist/tasks/axis-filters.js +77 -0
- package/dist/tasks/axis-filters.js.map +1 -0
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +10 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/duplicate-detector.d.ts +67 -14
- package/dist/tasks/duplicate-detector.d.ts.map +1 -1
- package/dist/tasks/duplicate-detector.js +197 -54
- package/dist/tasks/duplicate-detector.js.map +1 -1
- package/dist/tasks/evidence.d.ts +139 -0
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +294 -25
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +9 -2
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +25 -4
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/heavy-tool-env.d.ts +20 -0
- package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
- package/dist/tasks/heavy-tool-env.js +74 -4
- package/dist/tasks/heavy-tool-env.js.map +1 -1
- package/dist/tasks/heavy-tool-limit.d.ts +134 -0
- package/dist/tasks/heavy-tool-limit.d.ts.map +1 -0
- package/dist/tasks/heavy-tool-limit.js +196 -0
- package/dist/tasks/heavy-tool-limit.js.map +1 -0
- package/dist/tasks/index.d.ts +1 -1
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/infer-add-params.d.ts +28 -0
- package/dist/tasks/infer-add-params.d.ts.map +1 -1
- package/dist/tasks/infer-add-params.js +96 -6
- package/dist/tasks/infer-add-params.js.map +1 -1
- package/dist/tasks/list.d.ts +30 -1
- package/dist/tasks/list.d.ts.map +1 -1
- package/dist/tasks/list.js +14 -0
- package/dist/tasks/list.js.map +1 -1
- package/dist/tasks/ops.d.ts +4 -0
- package/dist/tasks/ops.d.ts.map +1 -1
- package/dist/tasks/ops.js +2 -0
- package/dist/tasks/ops.js.map +1 -1
- package/dist/tasks/session-scope.d.ts +51 -4
- package/dist/tasks/session-scope.d.ts.map +1 -1
- package/dist/tasks/session-scope.js +68 -10
- package/dist/tasks/session-scope.js.map +1 -1
- package/dist/tasks/tool-cache.d.ts +116 -9
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +127 -16
- package/dist/tasks/tool-cache.js.map +1 -1
- package/dist/tasks/tool-semaphore.d.ts +91 -0
- package/dist/tasks/tool-semaphore.d.ts.map +1 -1
- package/dist/tasks/tool-semaphore.js +203 -14
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +64 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +249 -0
- package/dist/validation/doctor/checks.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +41 -5
- package/dist/validation/engine-ops.js.map +1 -1
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +28 -44
- package/dist/backfill/adr-backfill-walker.d.ts +0 -57
- package/dist/backfill/adr-backfill-walker.d.ts.map +0 -1
- package/dist/backfill/adr-backfill-walker.js +0 -631
- package/dist/backfill/adr-backfill-walker.js.map +0 -1
- package/dist/tasks/graph-rag.d.ts +0 -49
- package/dist/tasks/graph-rag.d.ts.map +0 -1
- package/dist/tasks/graph-rag.js +0 -328
- package/dist/tasks/graph-rag.js.map +0 -1
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Three-tier escalation (T1681):
|
|
8
8
|
*
|
|
9
|
-
* Tier 1 —
|
|
9
|
+
* Tier 1 — lexical OR vector similarity. NOT BM25, despite the historical
|
|
10
|
+
* naming: there is no IDF, no term frequency and no length normalisation.
|
|
11
|
+
* Two implementations, selected at runtime (see `runDuplicateCheck`):
|
|
12
|
+
* - lexical: Jaccard over CHARACTER TRIGRAMS of a title-2x-weighted blob
|
|
13
|
+
* - vector: cosine over embeddings, when an embedding provider is loaded
|
|
10
14
|
* score >= 0.92 → reject (clear match)
|
|
11
15
|
* score < 0.50 → insert (clear different)
|
|
12
16
|
* score in [0.50, 0.92) → escalate to Tier 2
|
|
@@ -19,11 +23,11 @@
|
|
|
19
23
|
* Tier 3 — LLM reasoning (max 1 call per cleo add):
|
|
20
24
|
* are_duplicate=true → reject
|
|
21
25
|
* are_duplicate=false → insert
|
|
22
|
-
* LLM error / timeout → fall back to
|
|
26
|
+
* LLM error / timeout → fall back to Tier-1-only decision (never block on error)
|
|
23
27
|
*
|
|
24
|
-
* Thresholds (original T1633 behavior preserved for
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Thresholds (original T1633 behavior preserved for the Tier-1 clear-match path):
|
|
29
|
+
* Tier-1 score >= 0.85 → warning emitted to stderr (non-blocking)
|
|
30
|
+
* Tier-1 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
|
|
27
31
|
*
|
|
28
32
|
* @epic T1627
|
|
29
33
|
* @task T1633
|
|
@@ -32,9 +36,9 @@
|
|
|
32
36
|
import type { Task } from '@cleocode/contracts';
|
|
33
37
|
import { z } from 'zod';
|
|
34
38
|
import type { DataAccessor } from '../store/data-accessor.js';
|
|
35
|
-
/**
|
|
39
|
+
/** Tier-1 score at which a non-blocking warning is emitted. (Name kept: exported API.) */
|
|
36
40
|
export declare const DUPLICATE_WARN_THRESHOLD = 0.85;
|
|
37
|
-
/**
|
|
41
|
+
/** Tier-1 score at which creation is rejected (clear match). (Name kept: exported API.) */
|
|
38
42
|
export declare const DUPLICATE_REJECT_THRESHOLD = 0.92;
|
|
39
43
|
/** Maximum number of candidates to surface in the warning/rejection message. */
|
|
40
44
|
export declare const MAX_CANDIDATES = 3;
|
|
@@ -64,6 +68,14 @@ export interface DuplicateCheckResult {
|
|
|
64
68
|
* Which tier produced the final decision.
|
|
65
69
|
* @task T1681
|
|
66
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Which tier produced the decision.
|
|
73
|
+
*
|
|
74
|
+
* `'bm25'` is a HISTORICAL label for the Tier-1 measure, kept because it is
|
|
75
|
+
* emitted in the envelope and renaming it would break consumers. Tier 1 is
|
|
76
|
+
* not BM25 — see the module docblock. Reporting WHICH Tier-1 implementation
|
|
77
|
+
* ran (lexical vs vector) is tracked separately.
|
|
78
|
+
*/
|
|
67
79
|
tier?: 'bm25' | 'jaccard' | 'llm';
|
|
68
80
|
}
|
|
69
81
|
/**
|
|
@@ -87,7 +99,7 @@ export type DuplicateReasoning = z.infer<typeof DuplicateReasoningSchema>;
|
|
|
87
99
|
*
|
|
88
100
|
* Uses Jaccard similarity over character trigrams of a weighted blob
|
|
89
101
|
* (title 2×, description 1×). This is zero-dependency, deterministic,
|
|
90
|
-
* and symmetrical.
|
|
102
|
+
* and symmetrical. This is the LEXICAL Tier-1 measure — it is not BM25.
|
|
91
103
|
*
|
|
92
104
|
* @param titleA - First title.
|
|
93
105
|
* @param descA - First description.
|
|
@@ -98,7 +110,7 @@ export type DuplicateReasoning = z.infer<typeof DuplicateReasoningSchema>;
|
|
|
98
110
|
export declare function computeLexicalSimilarity(titleA: string, descA: string, titleB: string, descB: string): number;
|
|
99
111
|
/**
|
|
100
112
|
* Compute Jaccard similarity over word-level n-grams including labels.
|
|
101
|
-
* Used as Tier-2 discriminator when
|
|
113
|
+
* Used as Tier-2 discriminator when the Tier-1 score is ambiguous.
|
|
102
114
|
*
|
|
103
115
|
* @param titleA - First title.
|
|
104
116
|
* @param descA - First description.
|
|
@@ -114,7 +126,7 @@ export declare function computeJaccardWordSimilarity(titleA: string, descA: stri
|
|
|
114
126
|
*
|
|
115
127
|
* Cost cap: max 1 call per `cleo add` invocation (enforced by the caller via
|
|
116
128
|
* a `llmCallMade` flag). Never throws — returns null on error/timeout so the
|
|
117
|
-
* caller can fall back to
|
|
129
|
+
* caller can fall back to a Tier-1-only decision.
|
|
118
130
|
*
|
|
119
131
|
* @param incomingTitle - Title of the task being added.
|
|
120
132
|
* @param incomingDescription - Description of the task being added.
|
|
@@ -132,11 +144,11 @@ export declare function callLlmDuplicateReasoning(incomingTitle: string, incomin
|
|
|
132
144
|
*
|
|
133
145
|
* Algorithm:
|
|
134
146
|
*
|
|
135
|
-
* Tier 1 —
|
|
147
|
+
* Tier 1 — lexical or vector similarity (per candidate):
|
|
136
148
|
* For each active task, compute vector cosine similarity (if embedding is available)
|
|
137
|
-
*
|
|
149
|
+
* Jaccard over character trigrams, or cosine over embeddings when a provider is loaded.
|
|
138
150
|
* - score >= DUPLICATE_REJECT_THRESHOLD (0.92): clear match → reject immediately.
|
|
139
|
-
* - score <
|
|
151
|
+
* - score < TIER1_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
|
|
140
152
|
* - score in [0.50, 0.92): ambiguous → collect for Tier-2 Jaccard evaluation.
|
|
141
153
|
*
|
|
142
154
|
* Tier 2 — Jaccard word n-grams (title+description+labels):
|
|
@@ -148,7 +160,7 @@ export declare function callLlmDuplicateReasoning(incomingTitle: string, incomin
|
|
|
148
160
|
*
|
|
149
161
|
* Tier 3 — LLM reasoning (max 1 call per `cleo add`):
|
|
150
162
|
* Call the daemon provider with both task descriptions and the structured-output schema.
|
|
151
|
-
* On error/timeout: fall back to Tier-1
|
|
163
|
+
* On error/timeout: fall back to the Tier-1 decision for the candidate (never block).
|
|
152
164
|
*
|
|
153
165
|
* @param title - Title of the task being added.
|
|
154
166
|
* @param description - Description of the task being added (empty string if not provided).
|
|
@@ -189,4 +201,45 @@ export declare function buildRejectMessage(candidates: DuplicateCandidate[]): st
|
|
|
189
201
|
* @returns Array of non-terminal tasks (pending, active, blocked).
|
|
190
202
|
*/
|
|
191
203
|
export declare function loadActiveTasks(accessor: DataAccessor): Promise<Task[]>;
|
|
204
|
+
/** Env var overriding {@link DUPLICATE_CHECK_BUDGET_MS}. */
|
|
205
|
+
export declare const DUPLICATE_CHECK_BUDGET_ENV = "CLEO_DUPLICATE_CHECK_BUDGET_MS";
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the duplicate-detection budget, honouring an operator override.
|
|
208
|
+
*
|
|
209
|
+
* @param env - environment to read the override from.
|
|
210
|
+
* @returns budget in ms; the default when unset or malformed.
|
|
211
|
+
*/
|
|
212
|
+
export declare function resolveDuplicateCheckBudgetMs(env?: NodeJS.ProcessEnv): number;
|
|
213
|
+
/**
|
|
214
|
+
* {@link checkDuplicates} with a hard deadline and a fail-OPEN result.
|
|
215
|
+
*
|
|
216
|
+
* ## Why fail open (T12117 · issue #1244)
|
|
217
|
+
*
|
|
218
|
+
* Duplicate detection runs before the insert, so anything that stalls it
|
|
219
|
+
* doesn't slow the write down — it *loses* it. Reported from the field: three
|
|
220
|
+
* `cleo add` attempts at 280 s each, none of which created a row, on a host
|
|
221
|
+
* under memory pressure. No error, no id, no task. A silent write failure is
|
|
222
|
+
* the worst failure mode a task tracker has, because the agent that filed the
|
|
223
|
+
* task believes the work is recorded.
|
|
224
|
+
*
|
|
225
|
+
* It is also silent in the dangerous direction relative to CLEO's documented
|
|
226
|
+
* recovery advice. A killed mutation is supposed to mean "query before
|
|
227
|
+
* retrying — the write has usually landed", which was independently confirmed
|
|
228
|
+
* true for a timeout-killed write. But these writes had NOT landed, and nothing
|
|
229
|
+
* in the output distinguishes the two cases. An agent following the contract
|
|
230
|
+
* correctly reaches the wrong conclusion in one direction or the other.
|
|
231
|
+
*
|
|
232
|
+
* So: the row is the product, detection is enrichment. When the budget is
|
|
233
|
+
* blown, report that on stderr and let the insert proceed. A duplicate that
|
|
234
|
+
* slips through is recoverable by a human in seconds; a task that was never
|
|
235
|
+
* created is lost work nobody knows to look for.
|
|
236
|
+
*
|
|
237
|
+
* @param args - forwarded verbatim to {@link checkDuplicates}.
|
|
238
|
+
* @returns the real verdict, or a permissive one when the budget was exceeded.
|
|
239
|
+
*
|
|
240
|
+
* @task T12117
|
|
241
|
+
*/
|
|
242
|
+
export declare function checkDuplicatesBounded(...args: Parameters<typeof checkDuplicates>): Promise<DuplicateCheckResult & {
|
|
243
|
+
timedOut: boolean;
|
|
244
|
+
}>;
|
|
192
245
|
//# sourceMappingURL=duplicate-detector.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"duplicate-detector.d.ts","sourceRoot":"","sources":["../../src/tasks/duplicate-detector.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"duplicate-detector.d.ts","sourceRoot":"","sources":["../../src/tasks/duplicate-detector.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Three-tier escalation (T1681):
|
|
8
8
|
*
|
|
9
|
-
* Tier 1 —
|
|
9
|
+
* Tier 1 — lexical OR vector similarity. NOT BM25, despite the historical
|
|
10
|
+
* naming: there is no IDF, no term frequency and no length normalisation.
|
|
11
|
+
* Two implementations, selected at runtime (see `runDuplicateCheck`):
|
|
12
|
+
* - lexical: Jaccard over CHARACTER TRIGRAMS of a title-2x-weighted blob
|
|
13
|
+
* - vector: cosine over embeddings, when an embedding provider is loaded
|
|
10
14
|
* score >= 0.92 → reject (clear match)
|
|
11
15
|
* score < 0.50 → insert (clear different)
|
|
12
16
|
* score in [0.50, 0.92) → escalate to Tier 2
|
|
@@ -19,11 +23,11 @@
|
|
|
19
23
|
* Tier 3 — LLM reasoning (max 1 call per cleo add):
|
|
20
24
|
* are_duplicate=true → reject
|
|
21
25
|
* are_duplicate=false → insert
|
|
22
|
-
* LLM error / timeout → fall back to
|
|
26
|
+
* LLM error / timeout → fall back to Tier-1-only decision (never block on error)
|
|
23
27
|
*
|
|
24
|
-
* Thresholds (original T1633 behavior preserved for
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Thresholds (original T1633 behavior preserved for the Tier-1 clear-match path):
|
|
29
|
+
* Tier-1 score >= 0.85 → warning emitted to stderr (non-blocking)
|
|
30
|
+
* Tier-1 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
|
|
27
31
|
*
|
|
28
32
|
* @epic T1627
|
|
29
33
|
* @task T1633
|
|
@@ -33,15 +37,15 @@ import { z } from 'zod';
|
|
|
33
37
|
// ============================================================================
|
|
34
38
|
// Thresholds
|
|
35
39
|
// ============================================================================
|
|
36
|
-
/**
|
|
40
|
+
/** Tier-1 score at which a non-blocking warning is emitted. (Name kept: exported API.) */
|
|
37
41
|
export const DUPLICATE_WARN_THRESHOLD = 0.85;
|
|
38
|
-
/**
|
|
42
|
+
/** Tier-1 score at which creation is rejected (clear match). (Name kept: exported API.) */
|
|
39
43
|
export const DUPLICATE_REJECT_THRESHOLD = 0.92;
|
|
40
44
|
/**
|
|
41
|
-
*
|
|
45
|
+
* Tier-1 lower bound for the ambiguous range.
|
|
42
46
|
* Scores below this are considered clear-different and skip Jaccard + LLM.
|
|
43
47
|
*/
|
|
44
|
-
const
|
|
48
|
+
const TIER1_ESCALATE_LOW = 0.5;
|
|
45
49
|
/**
|
|
46
50
|
* Jaccard reject threshold.
|
|
47
51
|
* Scores >= this after Tier-2 escalation cause rejection without calling LLM.
|
|
@@ -58,6 +62,29 @@ const MAX_ACTIVE_TASKS_SCAN = 500;
|
|
|
58
62
|
export const MAX_CANDIDATES = 3;
|
|
59
63
|
/** LLM call timeout in milliseconds. */
|
|
60
64
|
const LLM_TIMEOUT_MS = 15_000;
|
|
65
|
+
/**
|
|
66
|
+
* Maximum candidates that may be scored with local embeddings (T12117).
|
|
67
|
+
*
|
|
68
|
+
* The vector tier used to run inside the per-candidate loop with no bound, so
|
|
69
|
+
* `cleo add` performed one local model inference **per active task** before
|
|
70
|
+
* committing the row — O(active tasks) inferences on the write path. In a
|
|
71
|
+
* project with ~1,000 active tasks that is thousands of inferences to create
|
|
72
|
+
* one row, which is why `cleo add` was observed taking 180-280 s and failing
|
|
73
|
+
* outright on a memory-constrained host (issue #1244).
|
|
74
|
+
*
|
|
75
|
+
* Candidates beyond this budget fall back to the lexical score, which is the
|
|
76
|
+
* same fallback already used whenever embeddings are unavailable — so this
|
|
77
|
+
* bounds cost without introducing a new code path.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_VECTOR_CANDIDATES = 25;
|
|
80
|
+
/**
|
|
81
|
+
* Wall-clock budget for the whole vector-scoring phase, in milliseconds.
|
|
82
|
+
*
|
|
83
|
+
* A second bound beside {@link MAX_VECTOR_CANDIDATES} because the count cap
|
|
84
|
+
* assumes each inference is fast, and a cold model load is not. Whichever
|
|
85
|
+
* bound trips first ends the phase.
|
|
86
|
+
*/
|
|
87
|
+
const VECTOR_PHASE_BUDGET_MS = 5_000;
|
|
61
88
|
// ============================================================================
|
|
62
89
|
// LLM structured-output schema (Tier 3)
|
|
63
90
|
// ============================================================================
|
|
@@ -79,7 +106,7 @@ export const DuplicateReasoningSchema = z.object({
|
|
|
79
106
|
suggestion: z.enum(['merge', 'keep-both', 'block-new']),
|
|
80
107
|
});
|
|
81
108
|
// ============================================================================
|
|
82
|
-
// Similarity Primitives — Tier 1 (
|
|
109
|
+
// Similarity Primitives — Tier 1 (lexical: character-trigram Jaccard)
|
|
83
110
|
// ============================================================================
|
|
84
111
|
/**
|
|
85
112
|
* Normalise text for comparison: lowercase, collapse whitespace,
|
|
@@ -151,7 +178,7 @@ function buildSearchBlob(title, description) {
|
|
|
151
178
|
*
|
|
152
179
|
* Uses Jaccard similarity over character trigrams of a weighted blob
|
|
153
180
|
* (title 2×, description 1×). This is zero-dependency, deterministic,
|
|
154
|
-
* and symmetrical.
|
|
181
|
+
* and symmetrical. This is the LEXICAL Tier-1 measure — it is not BM25.
|
|
155
182
|
*
|
|
156
183
|
* @param titleA - First title.
|
|
157
184
|
* @param descA - First description.
|
|
@@ -202,7 +229,7 @@ function tokenise(text) {
|
|
|
202
229
|
}
|
|
203
230
|
/**
|
|
204
231
|
* Build word-level n-gram set from title + description + labels.
|
|
205
|
-
* This differs from the
|
|
232
|
+
* This differs from the Tier-1 lexical blob (character trigrams, no labels).
|
|
206
233
|
*
|
|
207
234
|
* @param title - Task title.
|
|
208
235
|
* @param description - Task description.
|
|
@@ -210,7 +237,7 @@ function tokenise(text) {
|
|
|
210
237
|
* @returns Set of word unigrams + bigrams.
|
|
211
238
|
*/
|
|
212
239
|
function buildWordNgramSet(title, description, labels) {
|
|
213
|
-
// Title carries
|
|
240
|
+
// Title carries 2x weight via repetition (matches the Tier-1 blob weighting)
|
|
214
241
|
const titleTokens = tokenise(title);
|
|
215
242
|
const descTokens = tokenise(description);
|
|
216
243
|
const labelTokens = labels.flatMap((l) => tokenise(l));
|
|
@@ -219,7 +246,7 @@ function buildWordNgramSet(title, description, labels) {
|
|
|
219
246
|
}
|
|
220
247
|
/**
|
|
221
248
|
* Compute Jaccard similarity over word-level n-grams including labels.
|
|
222
|
-
* Used as Tier-2 discriminator when
|
|
249
|
+
* Used as Tier-2 discriminator when the Tier-1 score is ambiguous.
|
|
223
250
|
*
|
|
224
251
|
* @param titleA - First title.
|
|
225
252
|
* @param descA - First description.
|
|
@@ -238,24 +265,45 @@ export function computeJaccardWordSimilarity(titleA, descA, labelsA, titleB, des
|
|
|
238
265
|
// BRAIN-powered Vector Similarity (opportunistic, Tier 1)
|
|
239
266
|
// ============================================================================
|
|
240
267
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* Embeds the incoming blob and the candidate task's blob, then computes
|
|
244
|
-
* cosine similarity from the dot product (assuming unit-norm vectors from
|
|
245
|
-
* standard embedding models).
|
|
268
|
+
* Embed the incoming search blob ONCE for the whole duplicate check.
|
|
246
269
|
*
|
|
247
|
-
* Returns `null` when embedding is unavailable
|
|
270
|
+
* Returns `null` when embedding is unavailable, which makes the caller fall
|
|
271
|
+
* back to the lexical (trigram) measure for every candidate.
|
|
248
272
|
*
|
|
249
273
|
* @param incomingBlob - Normalised search blob for the new task.
|
|
250
|
-
* @
|
|
251
|
-
|
|
274
|
+
* @returns The incoming embedding, or `null` when embedding is unavailable.
|
|
275
|
+
*/
|
|
276
|
+
async function embedIncomingOnce(incomingBlob) {
|
|
277
|
+
try {
|
|
278
|
+
const { isEmbeddingAvailable, embedText } = await import('../memory/brain-embedding.js');
|
|
279
|
+
if (!isEmbeddingAvailable())
|
|
280
|
+
return null;
|
|
281
|
+
return (await embedText(incomingBlob)) ?? null;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Cosine similarity between a PRE-COMPUTED incoming vector and a candidate blob.
|
|
289
|
+
*
|
|
290
|
+
* The incoming vector is computed once per duplicate check by
|
|
291
|
+
* {@link embedIncomingOnce} and passed in. It used to be embedded inside this
|
|
292
|
+
* function, which ran once per candidate — so a store with 1,126 active tasks
|
|
293
|
+
* embedded the same incoming text 1,126 times per `cleo add`. Only the candidate
|
|
294
|
+
* is embedded here.
|
|
295
|
+
*
|
|
296
|
+
* @param incomingVec - Pre-computed embedding of the incoming blob.
|
|
297
|
+
* @param candidateBlob - Candidate search blob to embed and compare.
|
|
298
|
+
* @returns Cosine similarity in [0, 1], or `null` when embedding is unavailable.
|
|
252
299
|
*/
|
|
253
|
-
async function tryVectorSimilarity(
|
|
300
|
+
async function tryVectorSimilarity(incomingVec, candidateBlob) {
|
|
254
301
|
try {
|
|
255
302
|
const { isEmbeddingAvailable, embedText } = await import('../memory/brain-embedding.js');
|
|
256
303
|
if (!isEmbeddingAvailable())
|
|
257
304
|
return null;
|
|
258
|
-
const
|
|
305
|
+
const vecA = incomingVec;
|
|
306
|
+
const vecB = await embedText(candidateBlob);
|
|
259
307
|
if (!vecA || !vecB || vecA.length !== vecB.length)
|
|
260
308
|
return null;
|
|
261
309
|
// Cosine similarity (assumes unit-norm vectors from embedding model)
|
|
@@ -317,7 +365,7 @@ function buildDuplicateUserPrompt(incomingTitle, incomingDescription, candidateT
|
|
|
317
365
|
*
|
|
318
366
|
* Cost cap: max 1 call per `cleo add` invocation (enforced by the caller via
|
|
319
367
|
* a `llmCallMade` flag). Never throws — returns null on error/timeout so the
|
|
320
|
-
* caller can fall back to
|
|
368
|
+
* caller can fall back to a Tier-1-only decision.
|
|
321
369
|
*
|
|
322
370
|
* @param incomingTitle - Title of the task being added.
|
|
323
371
|
* @param incomingDescription - Description of the task being added.
|
|
@@ -331,7 +379,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
|
|
|
331
379
|
const { authHeadersFromSealed } = await import('../llm/credentials.js');
|
|
332
380
|
const { resolveLLMForRole } = await import('../llm/role-resolver.js');
|
|
333
381
|
// T9255: route through the role-based resolver. Duplicate-detection is a
|
|
334
|
-
// consolidation-tier call (LLM acts as the tie-breaker between
|
|
382
|
+
// consolidation-tier call (LLM acts as the tie-breaker between Tier 1 and
|
|
335
383
|
// Jaccard tiers). The resolver walks `llm.roles.consolidation` →
|
|
336
384
|
// `llm.default` → `llm.daemon` → implicit fallback, preserving the
|
|
337
385
|
// prior `config.llm.daemon.*` defaulting behaviour.
|
|
@@ -412,7 +460,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
|
|
|
412
460
|
return validation.data;
|
|
413
461
|
}
|
|
414
462
|
catch {
|
|
415
|
-
// LLM call errors always fall back to
|
|
463
|
+
// LLM call errors always fall back to a Tier-1-only decision
|
|
416
464
|
return null;
|
|
417
465
|
}
|
|
418
466
|
}
|
|
@@ -425,11 +473,11 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
|
|
|
425
473
|
*
|
|
426
474
|
* Algorithm:
|
|
427
475
|
*
|
|
428
|
-
* Tier 1 —
|
|
476
|
+
* Tier 1 — lexical or vector similarity (per candidate):
|
|
429
477
|
* For each active task, compute vector cosine similarity (if embedding is available)
|
|
430
|
-
*
|
|
478
|
+
* Jaccard over character trigrams, or cosine over embeddings when a provider is loaded.
|
|
431
479
|
* - score >= DUPLICATE_REJECT_THRESHOLD (0.92): clear match → reject immediately.
|
|
432
|
-
* - score <
|
|
480
|
+
* - score < TIER1_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
|
|
433
481
|
* - score in [0.50, 0.92): ambiguous → collect for Tier-2 Jaccard evaluation.
|
|
434
482
|
*
|
|
435
483
|
* Tier 2 — Jaccard word n-grams (title+description+labels):
|
|
@@ -441,7 +489,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
|
|
|
441
489
|
*
|
|
442
490
|
* Tier 3 — LLM reasoning (max 1 call per `cleo add`):
|
|
443
491
|
* Call the daemon provider with both task descriptions and the structured-output schema.
|
|
444
|
-
* On error/timeout: fall back to Tier-1
|
|
492
|
+
* On error/timeout: fall back to the Tier-1 decision for the candidate (never block).
|
|
445
493
|
*
|
|
446
494
|
* @param title - Title of the task being added.
|
|
447
495
|
* @param description - Description of the task being added (empty string if not provided).
|
|
@@ -473,12 +521,22 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
|
|
|
473
521
|
return false;
|
|
474
522
|
}
|
|
475
523
|
})();
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
524
|
+
// Embed the incoming blob ONCE for the whole check. This used to happen inside
|
|
525
|
+
// `tryVectorSimilarity`, which runs once per candidate — so a store with 1,126
|
|
526
|
+
// active tasks embedded the same incoming text 1,126 times per `cleo add`.
|
|
527
|
+
// `null` here simply means every candidate uses the lexical measure.
|
|
528
|
+
const incomingVec = embeddingEnabled ? await embedIncomingOnce(incomingBlob) : null;
|
|
529
|
+
// ---- Tier 1: lexical or vector --------------------------------------------
|
|
530
|
+
// Candidates that need Tier-2 Jaccard evaluation (Tier-1 ambiguous range).
|
|
531
|
+
// Map: candidate → tier1Score
|
|
479
532
|
const tier1Ambiguous = [];
|
|
480
|
-
// Clear-match candidates (
|
|
533
|
+
// Clear-match candidates (tier1 >= DUPLICATE_REJECT_THRESHOLD or >= DUPLICATE_WARN_THRESHOLD)
|
|
481
534
|
const clearMatchCandidates = [];
|
|
535
|
+
// T12117: bound the vector tier. Exceeding either budget degrades the
|
|
536
|
+
// remaining candidates to the lexical score — the same path taken when
|
|
537
|
+
// embeddings are unavailable — rather than extending the write.
|
|
538
|
+
let vectorBudget = MAX_VECTOR_CANDIDATES;
|
|
539
|
+
const vectorDeadline = Date.now() + VECTOR_PHASE_BUDGET_MS;
|
|
482
540
|
for (const task of activeTasks) {
|
|
483
541
|
// Do not compare a task with itself (e.g. if being re-added after creation)
|
|
484
542
|
if (task.title === title) {
|
|
@@ -487,31 +545,38 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
|
|
|
487
545
|
continue; // will be caught by findRecentDuplicate
|
|
488
546
|
}
|
|
489
547
|
const candidateBlob = buildSearchBlob(task.title, task.description ?? '');
|
|
490
|
-
let
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
548
|
+
let tier1Score;
|
|
549
|
+
// Both bounds apply. #1258 caps the vector phase by COUNT and DEADLINE so a
|
|
550
|
+
// large store cannot stall a write; this branch additionally passes the
|
|
551
|
+
// PRE-COMPUTED incoming vector, so the incoming blob is embedded once for
|
|
552
|
+
// the whole check rather than once per candidate. `incomingVec` is non-null
|
|
553
|
+
// only when embedding is available, so it subsumes the old
|
|
554
|
+
// `embeddingEnabled` test.
|
|
555
|
+
if (incomingVec && vectorBudget > 0 && Date.now() < vectorDeadline) {
|
|
556
|
+
vectorBudget--;
|
|
557
|
+
const vecScore = await tryVectorSimilarity(incomingVec, candidateBlob);
|
|
558
|
+
tier1Score =
|
|
494
559
|
vecScore ??
|
|
495
560
|
computeLexicalSimilarity(title, description, task.title, task.description ?? '');
|
|
496
561
|
}
|
|
497
562
|
else {
|
|
498
|
-
|
|
563
|
+
tier1Score = computeLexicalSimilarity(title, description, task.title, task.description ?? '');
|
|
499
564
|
}
|
|
500
|
-
if (
|
|
565
|
+
if (tier1Score >= DUPLICATE_REJECT_THRESHOLD) {
|
|
501
566
|
// Clear match — reject without escalating
|
|
502
|
-
clearMatchCandidates.push({ id: task.id, title: task.title, score:
|
|
567
|
+
clearMatchCandidates.push({ id: task.id, title: task.title, score: tier1Score });
|
|
503
568
|
}
|
|
504
|
-
else if (
|
|
569
|
+
else if (tier1Score >= TIER1_ESCALATE_LOW) {
|
|
505
570
|
// Ambiguous — collect for Tier 2
|
|
506
|
-
tier1Ambiguous.push({ task,
|
|
571
|
+
tier1Ambiguous.push({ task, tier1Score });
|
|
507
572
|
// Also surface in warn range (>= DUPLICATE_WARN_THRESHOLD) even before Tier 2
|
|
508
|
-
if (
|
|
509
|
-
clearMatchCandidates.push({ id: task.id, title: task.title, score:
|
|
573
|
+
if (tier1Score >= DUPLICATE_WARN_THRESHOLD) {
|
|
574
|
+
clearMatchCandidates.push({ id: task.id, title: task.title, score: tier1Score });
|
|
510
575
|
}
|
|
511
576
|
}
|
|
512
|
-
//
|
|
577
|
+
// tier1Score < TIER1_ESCALATE_LOW → clear different, skip
|
|
513
578
|
}
|
|
514
|
-
// If we already have a clear-match reject, return immediately (
|
|
579
|
+
// If we already have a clear-match reject, return immediately (Tier-1-only path).
|
|
515
580
|
if (clearMatchCandidates.some((c) => c.score >= DUPLICATE_REJECT_THRESHOLD)) {
|
|
516
581
|
const sorted = clearMatchCandidates
|
|
517
582
|
.filter((c) => c.score >= DUPLICATE_REJECT_THRESHOLD)
|
|
@@ -530,7 +595,7 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
|
|
|
530
595
|
// Candidates that are still ambiguous after Tier 2 collect for LLM escalation.
|
|
531
596
|
const tier2Ambiguous = [];
|
|
532
597
|
const tier2Candidates = [];
|
|
533
|
-
for (const { task,
|
|
598
|
+
for (const { task, tier1Score: _tier1 } of tier1Ambiguous) {
|
|
534
599
|
const jScore = computeJaccardWordSimilarity(title, description, incomingLabels, task.title, task.description ?? '', task.labels ?? []);
|
|
535
600
|
if (jScore >= JACCARD_REJECT_THRESHOLD) {
|
|
536
601
|
// Jaccard says: reject
|
|
@@ -592,11 +657,11 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
|
|
|
592
657
|
tier: 'llm',
|
|
593
658
|
};
|
|
594
659
|
}
|
|
595
|
-
// LLM failed/timed out — fall back to
|
|
596
|
-
// The
|
|
660
|
+
// LLM failed/timed out — fall back to a Tier-1-only decision.
|
|
661
|
+
// The Tier-1 score for the top candidate is in [0.5, 0.92), which means
|
|
597
662
|
// it's in the warn zone only if >= DUPLICATE_WARN_THRESHOLD.
|
|
598
|
-
const
|
|
599
|
-
const fallbackScore =
|
|
663
|
+
const tier1Entry = tier1Ambiguous.find((e) => e.task.id === topCandidate.task.id);
|
|
664
|
+
const fallbackScore = tier1Entry?.tier1Score ?? topCandidate.jaccardScore;
|
|
600
665
|
if (fallbackScore >= DUPLICATE_WARN_THRESHOLD) {
|
|
601
666
|
const candidate = {
|
|
602
667
|
id: topCandidate.task.id,
|
|
@@ -612,7 +677,7 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
|
|
|
612
677
|
};
|
|
613
678
|
}
|
|
614
679
|
}
|
|
615
|
-
// ---- Collect any
|
|
680
|
+
// ---- Collect any Tier-1 warn-zone candidates (non-rejecting) --------------
|
|
616
681
|
const warnCandidates = clearMatchCandidates
|
|
617
682
|
.filter((c) => c.score >= DUPLICATE_WARN_THRESHOLD && c.score < DUPLICATE_REJECT_THRESHOLD)
|
|
618
683
|
.sort((a, b) => b.score - a.score)
|
|
@@ -675,4 +740,82 @@ export async function loadActiveTasks(accessor) {
|
|
|
675
740
|
});
|
|
676
741
|
return tasks;
|
|
677
742
|
}
|
|
743
|
+
/**
|
|
744
|
+
* Total wall-clock budget for duplicate detection on the write path, in ms.
|
|
745
|
+
*
|
|
746
|
+
* Detection is three tiers deep and two of them can call out of process (local
|
|
747
|
+
* embeddings, then an LLM). Each has its own bound, but "bounded individually"
|
|
748
|
+
* is not "bounded in aggregate" — and this runs BEFORE the row is inserted.
|
|
749
|
+
*/
|
|
750
|
+
const DUPLICATE_CHECK_BUDGET_MS = 20_000;
|
|
751
|
+
/** Env var overriding {@link DUPLICATE_CHECK_BUDGET_MS}. */
|
|
752
|
+
export const DUPLICATE_CHECK_BUDGET_ENV = 'CLEO_DUPLICATE_CHECK_BUDGET_MS';
|
|
753
|
+
/**
|
|
754
|
+
* Resolve the duplicate-detection budget, honouring an operator override.
|
|
755
|
+
*
|
|
756
|
+
* @param env - environment to read the override from.
|
|
757
|
+
* @returns budget in ms; the default when unset or malformed.
|
|
758
|
+
*/
|
|
759
|
+
export function resolveDuplicateCheckBudgetMs(env = process.env) {
|
|
760
|
+
const raw = Number.parseInt(env[DUPLICATE_CHECK_BUDGET_ENV] ?? '', 10);
|
|
761
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DUPLICATE_CHECK_BUDGET_MS;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* {@link checkDuplicates} with a hard deadline and a fail-OPEN result.
|
|
765
|
+
*
|
|
766
|
+
* ## Why fail open (T12117 · issue #1244)
|
|
767
|
+
*
|
|
768
|
+
* Duplicate detection runs before the insert, so anything that stalls it
|
|
769
|
+
* doesn't slow the write down — it *loses* it. Reported from the field: three
|
|
770
|
+
* `cleo add` attempts at 280 s each, none of which created a row, on a host
|
|
771
|
+
* under memory pressure. No error, no id, no task. A silent write failure is
|
|
772
|
+
* the worst failure mode a task tracker has, because the agent that filed the
|
|
773
|
+
* task believes the work is recorded.
|
|
774
|
+
*
|
|
775
|
+
* It is also silent in the dangerous direction relative to CLEO's documented
|
|
776
|
+
* recovery advice. A killed mutation is supposed to mean "query before
|
|
777
|
+
* retrying — the write has usually landed", which was independently confirmed
|
|
778
|
+
* true for a timeout-killed write. But these writes had NOT landed, and nothing
|
|
779
|
+
* in the output distinguishes the two cases. An agent following the contract
|
|
780
|
+
* correctly reaches the wrong conclusion in one direction or the other.
|
|
781
|
+
*
|
|
782
|
+
* So: the row is the product, detection is enrichment. When the budget is
|
|
783
|
+
* blown, report that on stderr and let the insert proceed. A duplicate that
|
|
784
|
+
* slips through is recoverable by a human in seconds; a task that was never
|
|
785
|
+
* created is lost work nobody knows to look for.
|
|
786
|
+
*
|
|
787
|
+
* @param args - forwarded verbatim to {@link checkDuplicates}.
|
|
788
|
+
* @returns the real verdict, or a permissive one when the budget was exceeded.
|
|
789
|
+
*
|
|
790
|
+
* @task T12117
|
|
791
|
+
*/
|
|
792
|
+
export async function checkDuplicatesBounded(...args) {
|
|
793
|
+
let timer;
|
|
794
|
+
const budgetMs = resolveDuplicateCheckBudgetMs();
|
|
795
|
+
const timeout = new Promise((resolve) => {
|
|
796
|
+
timer = setTimeout(() => resolve(null), budgetMs);
|
|
797
|
+
// Must not hold the CLI's event loop open on its own.
|
|
798
|
+
timer.unref();
|
|
799
|
+
});
|
|
800
|
+
try {
|
|
801
|
+
const verdict = await Promise.race([checkDuplicates(...args), timeout]);
|
|
802
|
+
if (verdict === null) {
|
|
803
|
+
process.stderr.write(`cleo: duplicate detection exceeded ${budgetMs}ms and was skipped; ` +
|
|
804
|
+
`creating the task anyway. Run 'cleo find' to check for duplicates.\n`);
|
|
805
|
+
return {
|
|
806
|
+
maxScore: 0,
|
|
807
|
+
candidates: [],
|
|
808
|
+
shouldReject: false,
|
|
809
|
+
shouldWarn: false,
|
|
810
|
+
tier: 'bm25',
|
|
811
|
+
timedOut: true,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
return { ...verdict, timedOut: false };
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
if (timer)
|
|
818
|
+
clearTimeout(timer);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
678
821
|
//# sourceMappingURL=duplicate-detector.js.map
|