session-orchestrator 3.19.0 → 3.20.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +36 -71
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings/kebab.mjs — the slug primitive behind every `learning_key`, and
|
|
3
|
+
* {@link learningKeyOf}, the whole-key derivation built on it.
|
|
4
|
+
*
|
|
5
|
+
* `learning_key` is not a stored field. It is DERIVED as
|
|
6
|
+
* `` `${type}/${kebab(title || subject)}` `` and is the logical identity the
|
|
7
|
+
* whole dedupe/idempotency layer keys on (`reconcile/idempotency.mjs` calls it
|
|
8
|
+
* "THE logical dedupe key"). That makes this a contract, not a formatting
|
|
9
|
+
* helper: two callers that derive differently do not produce two ugly slugs,
|
|
10
|
+
* they FORK the key space — the same learning renders under two identities,
|
|
11
|
+
* dedupe stops firing, and both halves look correct in isolation.
|
|
12
|
+
*
|
|
13
|
+
* Before this module existed there were four copies of `kebab` plus one inline
|
|
14
|
+
* expression, none exported, none shared (two of the four landed on the same
|
|
15
|
+
* day, from two different authors, each of whom flagged the duplication in
|
|
16
|
+
* their own report). They agreed on every reachable input — verified, not
|
|
17
|
+
* assumed — so consolidating here changed no existing key. See the byte-identity
|
|
18
|
+
* proof pinned in `tests/scripts/lib/learnings/kebab.test.mjs`.
|
|
19
|
+
*
|
|
20
|
+
* Consolidating the primitive left the SECOND half of the problem open, and it
|
|
21
|
+
* is the half that bites: what each call site FED the primitive. Four sites
|
|
22
|
+
* derived `${type}/…` verbatim while the writer (`reconcile/emitter.mjs`) alone
|
|
23
|
+
* derived `${kebab(type)}/…` — a divergence invisible while every live `type`
|
|
24
|
+
* is kebab-identical, and silent in BOTH directions the moment one is not.
|
|
25
|
+
* {@link learningKeyOf} exists so there is one derivation to agree with rather
|
|
26
|
+
* than five to keep in sync.
|
|
27
|
+
*
|
|
28
|
+
* ## Why this file lives under `learnings/` and not under `reconcile/`
|
|
29
|
+
*
|
|
30
|
+
* Dependency direction, measured rather than presumed (2026-08-13, HEAD
|
|
31
|
+
* 5d59e62): `grep -rn "from '../learnings/" scripts/lib/reconcile/` reports 6
|
|
32
|
+
* import edges across 4 files (`eligibility.mjs`, `engine.mjs` ×2,
|
|
33
|
+
* `emitter.mjs` ×2, `renderer.mjs` — three of them pulling THIS module, three
|
|
34
|
+
* pulling `learnings/schema.mjs`), and the reverse grep
|
|
35
|
+
* `from '../reconcile/'` over `scripts/lib/learnings/` reports 0. A primitive
|
|
36
|
+
* shared by both packages therefore belongs on the `learnings/` side; placing
|
|
37
|
+
* it under `reconcile/` would invert an edge and introduce a cycle.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately NOT re-exported from the `scripts/lib/learnings.mjs` barrel —
|
|
40
|
+
* `surface.mjs` and `affinity.mjs` set that precedent: consumers import the
|
|
41
|
+
* leaf directly, so the barrel stays the historical schema/io/filters surface.
|
|
42
|
+
*
|
|
43
|
+
* ## What this module is NOT
|
|
44
|
+
*
|
|
45
|
+
* Not a general-purpose slugifier. Vault note ids, tag segments, and rule
|
|
46
|
+
* filenames have their own slug rules with their own length caps and charset
|
|
47
|
+
* contracts (`vault-mirror/utils.mjs`, `vault-archive.mjs`). Do not route those
|
|
48
|
+
* through here — a shared slugifier across unrelated identity spaces is how a
|
|
49
|
+
* cap added for one consumer silently re-keys another.
|
|
50
|
+
*
|
|
51
|
+
* Pure, stdlib-only, no imports, no clock, no fs.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Slugify a string into the stable kebab-case token used for learning keys.
|
|
56
|
+
*
|
|
57
|
+
* Lowercases, collapses every run of non-`[a-z0-9]` characters into a single
|
|
58
|
+
* `-`, and trims leading/trailing `-`.
|
|
59
|
+
*
|
|
60
|
+
* Three properties callers depend on:
|
|
61
|
+
*
|
|
62
|
+
* 1. **Total** — never throws. Non-string input is coerced via `String()`,
|
|
63
|
+
* so `null`/`undefined`/numbers yield `"null"`/`"undefined"`/`"12345"`
|
|
64
|
+
* rather than a `TypeError`. Callers pass values typed `unknown` at
|
|
65
|
+
* their trust boundary; a throw there would abort a reconcile run.
|
|
66
|
+
* 2. **Lossy on non-ASCII** — German umlauts and em-dashes in the corpus are
|
|
67
|
+
* collapsed to `-` (`"Größe"` → `"gr-e"`), NOT transliterated. Ugly, and
|
|
68
|
+
* deliberately frozen: every stamped key in `.claude/rules/*.md` and in
|
|
69
|
+
* `.orchestrator/runtime/reconcile-candidates.jsonl` was minted this way.
|
|
70
|
+
* Adding transliteration would re-key the entire corpus.
|
|
71
|
+
* 3. **May return the empty string** — an all-symbol input (`"!!!"`) yields
|
|
72
|
+
* `""`. `reconcile/renderer.mjs::deriveSlug` branches on exactly that to
|
|
73
|
+
* fall back to its hash suffix, so an "always return something non-empty"
|
|
74
|
+
* change here would silently disable that branch.
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} s Value to slugify; coerced with `String()`.
|
|
77
|
+
* @returns {string} Kebab token; `''` when the input holds no `[a-z0-9]`.
|
|
78
|
+
*/
|
|
79
|
+
export function kebab(s) {
|
|
80
|
+
return String(s)
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
83
|
+
.replace(/^-+|-+$/g, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* THE derivation of a learning's logical key: `` `${type}/${kebab(title || subject)}` ``.
|
|
88
|
+
*
|
|
89
|
+
* One rule, four decisions, each of which was a divergence between call sites
|
|
90
|
+
* before this function existed:
|
|
91
|
+
*
|
|
92
|
+
* 1. **The TYPE half is verbatim (trimmed), never kebab'd.** The subject half
|
|
93
|
+
* is prose and must be slugged; the type half is an enum token a reader
|
|
94
|
+
* parses BACK OUT of the key — `backfill-learnings-from-vault.mjs` uses
|
|
95
|
+
* `learning_key.split('/')[0]` as the reconstructed `type` and
|
|
96
|
+
* cross-checks it against the vault note's type. Kebabbing it would make
|
|
97
|
+
* that half lossy, which is precisely the fidelity downgrade the backfill
|
|
98
|
+
* labels `derived:learning-key-slug (original prose not recoverable)` for
|
|
99
|
+
* the subject half. Frontmatter safety does not need it either: the key
|
|
100
|
+
* becomes an unquoted `learning-key:` scalar, and `reconcile/renderer.mjs`
|
|
101
|
+
* already asserts `LEARNING_KEY_RE` (`/^[a-z0-9/-]+$/`) on the whole value
|
|
102
|
+
* before rendering — an unsafe type is REJECTED loudly there rather than
|
|
103
|
+
* silently re-keyed here.
|
|
104
|
+
* 2. **`title` wins over `subject`**, and a whitespace-only `title` falls
|
|
105
|
+
* through to `subject` rather than yielding an empty slug.
|
|
106
|
+
* 3. **`null`, not a throw, for an unkeyable record.** Readers scan corpora
|
|
107
|
+
* that contain shape-foreign lines; an unkeyable record simply does not
|
|
108
|
+
* participate in key resolution. Writers that need a string check for
|
|
109
|
+
* `null` and reject the record with their own auditable reason.
|
|
110
|
+
* 4. **An empty slug is unkeyable, not a key.** `kebab('!!!')` is `''`, and
|
|
111
|
+
* `` `anti-pattern/` `` is not an identity — it is a bucket every
|
|
112
|
+
* all-symbol-subject record of that type would collide in.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} record A learning record (or anything; non-records yield `null`).
|
|
115
|
+
* @returns {string|null} `` `${type}/${slug}` ``, or `null` when unkeyable.
|
|
116
|
+
*/
|
|
117
|
+
export function learningKeyOf(record) {
|
|
118
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) return null;
|
|
119
|
+
const rec = /** @type {Record<string, unknown>} */ (record);
|
|
120
|
+
const type = typeof rec.type === 'string' ? rec.type.trim() : '';
|
|
121
|
+
const titleOrSubject =
|
|
122
|
+
(typeof rec.title === 'string' && rec.title.trim() !== '' ? rec.title : '') ||
|
|
123
|
+
(typeof rec.subject === 'string' && rec.subject.trim() !== '' ? rec.subject : '');
|
|
124
|
+
if (type === '' || titleOrSubject === '') return null;
|
|
125
|
+
const slug = kebab(titleOrSubject);
|
|
126
|
+
if (slug === '') return null;
|
|
127
|
+
return `${type}/${slug}`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings/select.mjs — choose which learnings enter ONE dispatched agent's
|
|
3
|
+
* compact index, given that agent's declared file scope (#1014).
|
|
4
|
+
*
|
|
5
|
+
* ## The problem
|
|
6
|
+
*
|
|
7
|
+
* ~10² learnings accumulated across 233 sessions and a wave-agent receives ZERO
|
|
8
|
+
* of them: the only read paths are a coordinator banner, an autopilot call, and
|
|
9
|
+
* a nudge banner — none reaches a dispatched agent. This module is the selection
|
|
10
|
+
* half of closing that loop; a sibling CLI renders/injects the result.
|
|
11
|
+
*
|
|
12
|
+
* ## Why two tiers (the measured reason)
|
|
13
|
+
*
|
|
14
|
+
* Only a SMALL MINORITY of live learnings carry a non-empty `file_paths`. A
|
|
15
|
+
* purely scope-matched index is therefore EMPTY for most agents — the feature
|
|
16
|
+
* would ship and deliver nothing. So selection runs in two tiers with SPLIT
|
|
17
|
+
* budgets:
|
|
18
|
+
*
|
|
19
|
+
* (a) SCOPED — learnings whose `file_paths` relate to the agent's scope
|
|
20
|
+
* (see {@link SCOPE_MATCH_MIN_PATH_SCORE}), capped at `maxScoped`.
|
|
21
|
+
* (b) GLOBAL — top-scoring remaining learnings (the path-less majority),
|
|
22
|
+
* capped separately at `maxGlobal`.
|
|
23
|
+
*
|
|
24
|
+
* Snapshot behind that shape — a MEASURED-AT figure, not a standing fact; the
|
|
25
|
+
* corpus grows every session, so re-measure before citing it (PSA-006):
|
|
26
|
+
* **17 of 100 records carry `file_paths`; 17 of the 94 that pass the active gate
|
|
27
|
+
* = 18.1%, leaving ~82% path-less.** Measured 2026-08-13 @5d59e62 via
|
|
28
|
+
* `jq -s '[.[]|select(((.file_paths // .files // [])|length)>0)]|length'
|
|
29
|
+
* .orchestrator/metrics/learnings.jsonl`. The tier-(b) argument depends only on
|
|
30
|
+
* the minority/majority split, which has held across every re-measurement so
|
|
31
|
+
* far — not on the exact percentage.
|
|
32
|
+
*
|
|
33
|
+
* The caps are SPLIT, never shared: a single shared cap lets the global tier
|
|
34
|
+
* crowd out the per-agent signal that is #1014's whole point. The split is
|
|
35
|
+
* observable in the return value (`scopeMatched` / `globalCount`) so the ratio
|
|
36
|
+
* stays measurable in production.
|
|
37
|
+
*
|
|
38
|
+
* The irony that motivates tier (b): the single most relevant learning for
|
|
39
|
+
* building this very feature carries no `file_paths` — tier (a) alone drops it.
|
|
40
|
+
*
|
|
41
|
+
* ## Composition (this module re-implements nothing)
|
|
42
|
+
*
|
|
43
|
+
* - `affinity()` from `./affinity.mjs` — the frozen relatedness surface. The
|
|
44
|
+
* agent's scope descriptor `{file_paths, text}` and a learning record are
|
|
45
|
+
* the same shape to it.
|
|
46
|
+
* - `effectiveScore()` / `surfaceTopN()` from `./surface.mjs` — the reader,
|
|
47
|
+
* the active-filter, and the #670 time-decay ranking. There is no second
|
|
48
|
+
* reader here and no second decay implementation.
|
|
49
|
+
* - `sanitizeProse()` from `../reconcile/sanitize.mjs` — untrusted-text
|
|
50
|
+
* containment. Every line this module renders is AGENT-AUTHORED text bound
|
|
51
|
+
* for a dispatched agent's prompt, which is the identical threat model
|
|
52
|
+
* #1015 hardened for `.claude/rules/`. The primitives are imported, never
|
|
53
|
+
* re-implemented: a second copy is how this channel shipped raw beside the
|
|
54
|
+
* hardened one in the first place.
|
|
55
|
+
*
|
|
56
|
+
* NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
|
|
57
|
+
* the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
|
|
58
|
+
* The file-scope axis lives in `file_paths[]`. This trap has misled readers
|
|
59
|
+
* before — do not "fix" it here.
|
|
60
|
+
*
|
|
61
|
+
* ## What this module owns
|
|
62
|
+
*
|
|
63
|
+
* Policy: thresholds, split caps, the char budget, tie-breaking, and the
|
|
64
|
+
* one-line rendering the budget is measured against. Ranking is ours precisely
|
|
65
|
+
* because `affinity()` reports `typeMatch` without folding it into its score.
|
|
66
|
+
* We deliberately apply NO same-type boost: a scope descriptor carries no
|
|
67
|
+
* `type`, so `typeMatch` is structurally always false on this axis.
|
|
68
|
+
*
|
|
69
|
+
* ## Budget
|
|
70
|
+
*
|
|
71
|
+
* {@link LEARNINGS_INDEX_MAX_CHARS} is a CODE CONSTANT with no
|
|
72
|
+
* `0 = unlimited` sentinel — that sentinel is the explicit upstream mistake
|
|
73
|
+
* #1014 exists to avoid. 2000 chars is 1.12% of the 178,095-byte per-agent
|
|
74
|
+
* prompt baseline measured during #1014 (a prompt measurement, not derivable
|
|
75
|
+
* from the tree — re-measure it before re-citing), and 0.92× the median
|
|
76
|
+
* `.claude/rules/` file: median 2,167 B over 29 files, re-verified
|
|
77
|
+
* 2026-08-13 @5d59e62 via
|
|
78
|
+
* `find .claude/rules -maxdepth 1 -name '*.md' -exec wc -c {} \; | sort -n`.
|
|
79
|
+
* Repo precedent for literal caps: `LOOP_MD_MAX_BYTES = 25_000`,
|
|
80
|
+
* `DEFAULT_MAX_LINE_CHARS = 400`, `MAX_TEXT_LEN = 256`.
|
|
81
|
+
*
|
|
82
|
+
* ## Contract
|
|
83
|
+
*
|
|
84
|
+
* 1. {@link selectLearnings} never throws. Hostile input yields
|
|
85
|
+
* {@link emptySelection} — this runs on the dispatch hot path and must
|
|
86
|
+
* never abort a wave (same posture as `affinity()` and `surfaceTopN()`).
|
|
87
|
+
* A record whose text forges the delivery wrapper is DROPPED and counted in
|
|
88
|
+
* `selection.rejected`, never rendered: the sanitiser's throw is caught
|
|
89
|
+
* per-entry so one hostile record costs one entry, not the whole index.
|
|
90
|
+
* 2. Zero matches yield an EMPTY selection: `text === ''`, no placeholder
|
|
91
|
+
* line. Callers rely on empty-means-inject-nothing.
|
|
92
|
+
* 3. `selection.text.length <= maxChars` always. An entry that does not fit
|
|
93
|
+
* is DROPPED (and `truncated` set), never emitted half-rendered.
|
|
94
|
+
* 4. Deterministic: same inputs → same ordering. Ties break by
|
|
95
|
+
* **score DESC, then `created_at` DESC, then `id` ASC**.
|
|
96
|
+
* 5. Expired and sub-floor entries are never selected.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import { affinity } from './affinity.mjs';
|
|
100
|
+
import {
|
|
101
|
+
INSIGHT_MAX_BYTES,
|
|
102
|
+
TITLE_MAX_BYTES,
|
|
103
|
+
sanitizeProse,
|
|
104
|
+
} from '../reconcile/sanitize.mjs';
|
|
105
|
+
import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Constants — exported so a later wave can wire Session Config keys onto them
|
|
109
|
+
// without touching the logic below (config lookups are deliberately absent).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hard character cap on the rendered index. NO `0 = unlimited` sentinel.
|
|
114
|
+
* 2000 = 1.12% of the measured 178,095 B per-agent prompt baseline.
|
|
115
|
+
*/
|
|
116
|
+
export const LEARNINGS_INDEX_MAX_CHARS = 2000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-entry line cap. 160 leaves room for a long subject without letting one
|
|
120
|
+
* entry eat the budget, and the split caps make the fit ARITHMETIC rather than
|
|
121
|
+
* empirical: a full index is at most `(8 + 4) × 160 + 11` newlines = 1,931 chars
|
|
122
|
+
* < {@link LEARNINGS_INDEX_MAX_CHARS}, so the two constants can never disagree.
|
|
123
|
+
* That bound is derived and cannot go stale; the observed mean line is the part
|
|
124
|
+
* that drifts — 163 B/entry, measured 2026-08-13 @5d59e62 over the live corpus
|
|
125
|
+
* (`selectLearningsFromFile` on `.orchestrator/metrics/learnings.jsonl`), up
|
|
126
|
+
* from the ~122 B seen when this cap was first set.
|
|
127
|
+
*/
|
|
128
|
+
export const LEARNINGS_INDEX_MAX_LINE_CHARS = 160;
|
|
129
|
+
|
|
130
|
+
/** Split budgets — scoped signal can never be crowded out by the global tier. */
|
|
131
|
+
export const DEFAULT_MAX_SCOPED = 8;
|
|
132
|
+
export const DEFAULT_MAX_GLOBAL = 4;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Minimum `pathScore` for tier (a) membership.
|
|
136
|
+
*
|
|
137
|
+
* Calibrated against `affinity`'s segment-aware pair scores:
|
|
138
|
+
* - exact path → 1.0 (in)
|
|
139
|
+
* - directory prefix → 0.75 (in)
|
|
140
|
+
* - sibling in the same dir, depth 4 → 0.375 (in)
|
|
141
|
+
* - sibling in the same dir, depth 2 → 0.25 (in, exactly on the boundary)
|
|
142
|
+
* - cousin dirs (`scripts/lib/a` vs `scripts/hooks/b`) → 0.167 (out)
|
|
143
|
+
*
|
|
144
|
+
* Only dyadic ratios land exactly on the boundary, so `>=` is safe here; the
|
|
145
|
+
* excluded cases sit an order of magnitude below it.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately NOT `sharedPaths.length === 0`: `sharedPaths` lists EXACT
|
|
148
|
+
* overlaps only, so a directory-prefix match scores 0.75 without appearing
|
|
149
|
+
* there. Using it as a proxy would silently drop the strongest partial matches.
|
|
150
|
+
*/
|
|
151
|
+
export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Blend of relevance (affinity to this agent's scope) against quality
|
|
155
|
+
* (recency-decayed confidence). Relevance dominates — per-agent differentiation
|
|
156
|
+
* IS the acceptance criterion. Weight-normalized like `affinity()`, so the
|
|
157
|
+
* result stays in [0,1] for any non-negative pair.
|
|
158
|
+
*/
|
|
159
|
+
export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How many active entries the file entry-point pulls before ranking.
|
|
163
|
+
* Ceiling: the live corpus is ~10² entries and scoring is O(pool × scopePaths);
|
|
164
|
+
* revisit if the corpus passes ~1,000 entries, where a pre-filter would pay off.
|
|
165
|
+
*/
|
|
166
|
+
export const CANDIDATE_POOL_SIZE = 200;
|
|
167
|
+
|
|
168
|
+
/** Mirrors `surfaceTopN`'s default — entries at or below this are dropped. */
|
|
169
|
+
export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @typedef {{file_paths?: string[], text?: string}} AgentScope
|
|
173
|
+
* A dispatched agent's declared file scope plus its task text.
|
|
174
|
+
*
|
|
175
|
+
* @typedef {{entry: object, score: number, relevance: number, quality: number,
|
|
176
|
+
* pathScore: number, scoped: boolean, line: string}} SelectedLearning
|
|
177
|
+
*
|
|
178
|
+
* @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
|
|
179
|
+
* text: string, chars: number, scopeMatched: number,
|
|
180
|
+
* globalCount: number, candidates: number, truncated: boolean,
|
|
181
|
+
* rejected: number}} Selection
|
|
182
|
+
* `rejected` counts records dropped by the untrusted-text guard — surfaced so
|
|
183
|
+
* a drop is observable in the injection event rather than silent.
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Internals
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/** True for a plain-ish object we may read properties off. */
|
|
191
|
+
function _isRecord(v) {
|
|
192
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Positive integer option, else the fallback. `0` is a legal cap (select none). */
|
|
196
|
+
function _capOpt(v, fallback) {
|
|
197
|
+
return Number.isInteger(v) && v >= 0 ? v : fallback;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The active gate: confidence strictly above the floor, and not expired.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately re-stated rather than imported: `surfaceTopN` inlines this filter
|
|
204
|
+
* and exports no predicate, and {@link selectLearnings} must hold contract
|
|
205
|
+
* point 5 for callers that hand it raw entries. Idempotent on the
|
|
206
|
+
* {@link selectLearningsFromFile} path, where `surfaceTopN` already applied it.
|
|
207
|
+
*/
|
|
208
|
+
function _isActive(entry, nowMs, confidenceFloor) {
|
|
209
|
+
if (typeof entry.confidence !== 'number' || entry.confidence <= confidenceFloor) return false;
|
|
210
|
+
if (typeof entry.expires_at === 'string') {
|
|
211
|
+
const expiresMs = Date.parse(entry.expires_at);
|
|
212
|
+
if (Number.isFinite(expiresMs) && expiresMs <= nowMs) return false;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Epoch ms from a Date | number | undefined clock option. */
|
|
218
|
+
function _resolveNowMs(now) {
|
|
219
|
+
if (now instanceof Date) return now.getTime();
|
|
220
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
221
|
+
return Date.now();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Merge caller decay overrides over the conservative #670 defaults. */
|
|
225
|
+
function _resolveDecay(decayOpt) {
|
|
226
|
+
return {
|
|
227
|
+
enabled: decayOpt?.enabled ?? DECAY_DEFAULTS.enabled,
|
|
228
|
+
halfLifeDays: decayOpt?.halfLifeDays ?? DECAY_DEFAULTS.halfLifeDays,
|
|
229
|
+
floorFactor: decayOpt?.floorFactor ?? DECAY_DEFAULTS.floorFactor,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Date.parse or 0 — used only as a tiebreaker, never as a filter. */
|
|
234
|
+
function _createdMs(entry) {
|
|
235
|
+
const v = entry?.created_at;
|
|
236
|
+
if (typeof v !== 'string') return 0;
|
|
237
|
+
const ms = Date.parse(v);
|
|
238
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Total order over scored candidates (contract point 4):
|
|
243
|
+
* score DESC → created_at DESC → id ASC. Array.prototype.sort is stable
|
|
244
|
+
* (ES2019+), so fully-equal records keep input order.
|
|
245
|
+
*/
|
|
246
|
+
function _compareCandidates(a, b) {
|
|
247
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
248
|
+
const timeDiff = _createdMs(b.entry) - _createdMs(a.entry);
|
|
249
|
+
if (timeDiff !== 0) return timeDiff;
|
|
250
|
+
const aId = typeof a.entry.id === 'string' ? a.entry.id : '';
|
|
251
|
+
const bId = typeof b.entry.id === 'string' ? b.entry.id : '';
|
|
252
|
+
return aId < bId ? -1 : aId > bId ? 1 : 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Public surface
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The zero-value selection. Built fresh per call so no consumer can mutate a
|
|
261
|
+
* shared singleton. `text` is `''` — never a "no learnings found" placeholder.
|
|
262
|
+
*
|
|
263
|
+
* @returns {Selection}
|
|
264
|
+
*/
|
|
265
|
+
export function emptySelection() {
|
|
266
|
+
return {
|
|
267
|
+
entries: [],
|
|
268
|
+
selected: [],
|
|
269
|
+
lines: [],
|
|
270
|
+
text: '',
|
|
271
|
+
chars: 0,
|
|
272
|
+
scopeMatched: 0,
|
|
273
|
+
globalCount: 0,
|
|
274
|
+
candidates: 0,
|
|
275
|
+
truncated: false,
|
|
276
|
+
rejected: 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
|
|
282
|
+
* boundary.
|
|
283
|
+
*
|
|
284
|
+
* A plain `str.slice(0, n)` cuts between the two halves of a surrogate pair and
|
|
285
|
+
* emits a LONE SURROGATE (U+D800–U+DFFF) — an unpaired code unit that is not a
|
|
286
|
+
* valid character, renders as U+FFFD, and is delivered straight into an agent
|
|
287
|
+
* prompt. Any entry whose text carries an emoji or an astral-plane character can
|
|
288
|
+
* land exactly on that boundary. The sibling `sanitize.mjs` already cuts on a
|
|
289
|
+
* code-point boundary (`truncateToBytes`); that one measures BYTES, while this
|
|
290
|
+
* budget is measured in UTF-16 chars (`Selection.chars` vs `maxChars`), so the
|
|
291
|
+
* unit differs and the function cannot simply be reused.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} str
|
|
294
|
+
* @param {number} maxUnits
|
|
295
|
+
* @returns {string}
|
|
296
|
+
*/
|
|
297
|
+
function _sliceCodePoints(str, maxUnits) {
|
|
298
|
+
if (str.length <= maxUnits) return str;
|
|
299
|
+
let out = '';
|
|
300
|
+
for (const ch of str) {
|
|
301
|
+
if (out.length + ch.length > maxUnits) break;
|
|
302
|
+
out += ch;
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Render ONE learning as a single index line: sanitised, whitespace-collapsed
|
|
309
|
+
* and capped.
|
|
310
|
+
*
|
|
311
|
+
* Collapsing whitespace is load-bearing, not cosmetic: a multi-line `insight`
|
|
312
|
+
* would otherwise break the one-line-per-entry shape the char budget is
|
|
313
|
+
* measured against — and, since the block's boundary recovery is line-based, a
|
|
314
|
+
* smuggled newline would also fabricate an extra entry.
|
|
315
|
+
*
|
|
316
|
+
* Untrusted (#1015): `type`, `subject` and `insight` are AGENT-AUTHORED and this
|
|
317
|
+
* line is delivered verbatim into a dispatched agent's prompt, so each field
|
|
318
|
+
* passes through {@link sanitizeProse} — dangerous invisibles (Unicode Tag
|
|
319
|
+
* block, bidi overrides, zero-width) and control characters stripped, the
|
|
320
|
+
* envelope marker neutralised, delivery-wrapper forgery REJECTED. There is
|
|
321
|
+
* deliberately no phrase blocklist: the corpus is full of legitimate imperative
|
|
322
|
+
* prose ("parse both readings and judge both, never pick one"), so a blocklist
|
|
323
|
+
* would be the guard that looks green and does not bite. Framing is the
|
|
324
|
+
* containment, and the block wrapper supplies it.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} entry
|
|
327
|
+
* @param {{maxLineChars?: number}} [opts]
|
|
328
|
+
* @returns {string} the line, or '' when the entry carries no renderable text
|
|
329
|
+
* @throws {Error} (`reconcile-sanitize: …`) when a field forges the delivery
|
|
330
|
+
* wrapper. {@link selectLearnings} catches this per entry and drops the record;
|
|
331
|
+
* a direct caller must decide for itself.
|
|
332
|
+
*/
|
|
333
|
+
export function renderIndexLine(entry, opts = {}) {
|
|
334
|
+
if (!_isRecord(entry)) return '';
|
|
335
|
+
const maxLineChars = _capOpt(opts.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
|
|
336
|
+
if (maxLineChars <= 0) return '';
|
|
337
|
+
|
|
338
|
+
// Sanitise BEFORE collapsing whitespace: stripping a zero-width character can
|
|
339
|
+
// leave adjacent spaces, and the collapse then normalises them away.
|
|
340
|
+
const clean = (v, maxBytes) =>
|
|
341
|
+
typeof v === 'string' && v !== ''
|
|
342
|
+
? sanitizeProse(v, { field: 'learnings-index', maxBytes })
|
|
343
|
+
.replace(/\s+/g, ' ')
|
|
344
|
+
.trim()
|
|
345
|
+
: '';
|
|
346
|
+
|
|
347
|
+
const type = clean(entry.type, TITLE_MAX_BYTES);
|
|
348
|
+
const subject = clean(entry.subject, TITLE_MAX_BYTES);
|
|
349
|
+
const insight = clean(entry.insight, INSIGHT_MAX_BYTES);
|
|
350
|
+
|
|
351
|
+
const head = [type, subject].filter(Boolean).join('/');
|
|
352
|
+
if (!head && !insight) return '';
|
|
353
|
+
|
|
354
|
+
let line = `- ${head}${head && insight ? ': ' : ''}${insight}`;
|
|
355
|
+
if (line.length > maxLineChars) line = `${_sliceCodePoints(line, maxLineChars - 1)}…`;
|
|
356
|
+
return line;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Score one learning against an agent scope.
|
|
361
|
+
*
|
|
362
|
+
* `score` = weight-normalized blend of relevance (`affinity().score`) and
|
|
363
|
+
* quality (`effectiveScore()` — recency-decayed confidence). `typeMatch` is
|
|
364
|
+
* deliberately not folded in; see the module header.
|
|
365
|
+
*
|
|
366
|
+
* @param {object} entry
|
|
367
|
+
* @param {AgentScope} scope
|
|
368
|
+
* @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
|
|
369
|
+
* @returns {{score: number, relevance: number, quality: number, pathScore: number}}
|
|
370
|
+
*/
|
|
371
|
+
export function scoreLearning(entry, scope, opts = {}) {
|
|
372
|
+
const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
|
|
373
|
+
if (!_isRecord(entry)) return zero;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const nowMs = _resolveNowMs(opts.now);
|
|
377
|
+
const decay = _resolveDecay(opts.decay);
|
|
378
|
+
const aff = affinity(scope, entry, opts.affinityOpts);
|
|
379
|
+
const quality = effectiveScore(entry, nowMs, decay);
|
|
380
|
+
const q = Number.isFinite(quality) ? Math.min(Math.max(quality, 0), 1) : 0;
|
|
381
|
+
|
|
382
|
+
const { relevanceWeight, qualityWeight } = SELECT_WEIGHTS;
|
|
383
|
+
const total = relevanceWeight + qualityWeight;
|
|
384
|
+
const score = total > 0 ? (relevanceWeight * aff.score + qualityWeight * q) / total : 0;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
score: Number.isFinite(score) ? score : 0,
|
|
388
|
+
relevance: aff.score,
|
|
389
|
+
quality: q,
|
|
390
|
+
pathScore: aff.pathScore,
|
|
391
|
+
};
|
|
392
|
+
} catch {
|
|
393
|
+
return zero;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Select the learnings that go into ONE agent's compact index.
|
|
399
|
+
*
|
|
400
|
+
* Two tiers with SPLIT budgets (see module header), then a greedy fill against
|
|
401
|
+
* the char cap in render order (scoped first, then global). An entry whose line
|
|
402
|
+
* does not fit is dropped and `truncated` is set — never emitted partially.
|
|
403
|
+
*
|
|
404
|
+
* @param {object[]} entries — candidate learnings (already read from disk)
|
|
405
|
+
* @param {AgentScope} scope — the agent's declared file scope + task text
|
|
406
|
+
* @param {object} [opts]
|
|
407
|
+
* @param {number} [opts.maxScoped=DEFAULT_MAX_SCOPED]
|
|
408
|
+
* @param {number} [opts.maxGlobal=DEFAULT_MAX_GLOBAL]
|
|
409
|
+
* @param {number} [opts.maxChars=LEARNINGS_INDEX_MAX_CHARS]
|
|
410
|
+
* @param {number} [opts.maxLineChars=LEARNINGS_INDEX_MAX_LINE_CHARS]
|
|
411
|
+
* @param {number} [opts.minPathScore=SCOPE_MATCH_MIN_PATH_SCORE]
|
|
412
|
+
* @param {number} [opts.confidenceFloor=DEFAULT_CONFIDENCE_FLOOR]
|
|
413
|
+
* @param {Date|number} [opts.now] — injectable clock
|
|
414
|
+
* @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
|
|
415
|
+
* @param {object} [opts.affinityOpts] — forwarded to affinity()
|
|
416
|
+
* @returns {Selection}
|
|
417
|
+
*/
|
|
418
|
+
export function selectLearnings(entries, scope, opts = {}) {
|
|
419
|
+
try {
|
|
420
|
+
if (!Array.isArray(entries) || entries.length === 0) return emptySelection();
|
|
421
|
+
|
|
422
|
+
const o = _isRecord(opts) ? opts : {};
|
|
423
|
+
const maxScoped = _capOpt(o.maxScoped, DEFAULT_MAX_SCOPED);
|
|
424
|
+
const maxGlobal = _capOpt(o.maxGlobal, DEFAULT_MAX_GLOBAL);
|
|
425
|
+
const maxChars = _capOpt(o.maxChars, LEARNINGS_INDEX_MAX_CHARS);
|
|
426
|
+
const maxLineChars = _capOpt(o.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
|
|
427
|
+
const minPathScore =
|
|
428
|
+
typeof o.minPathScore === 'number' && Number.isFinite(o.minPathScore)
|
|
429
|
+
? o.minPathScore
|
|
430
|
+
: SCOPE_MATCH_MIN_PATH_SCORE;
|
|
431
|
+
const confidenceFloor =
|
|
432
|
+
typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
|
|
433
|
+
? o.confidenceFloor
|
|
434
|
+
: DEFAULT_CONFIDENCE_FLOOR;
|
|
435
|
+
const nowMs = _resolveNowMs(o.now);
|
|
436
|
+
const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
|
|
437
|
+
|
|
438
|
+
/** @type {SelectedLearning[]} */
|
|
439
|
+
const scoped = [];
|
|
440
|
+
/** @type {SelectedLearning[]} */
|
|
441
|
+
const global = [];
|
|
442
|
+
let candidates = 0;
|
|
443
|
+
let rejected = 0;
|
|
444
|
+
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
if (!_isRecord(entry)) continue;
|
|
447
|
+
if (!_isActive(entry, nowMs, confidenceFloor)) continue;
|
|
448
|
+
candidates++;
|
|
449
|
+
|
|
450
|
+
const s = scoreLearning(entry, scope, scoreOpts);
|
|
451
|
+
// Fail CLOSED per entry: a record whose text forges the delivery wrapper
|
|
452
|
+
// is dropped, not neutralised in place — with 100 candidates competing for
|
|
453
|
+
// 12 slots, dropping one costs nothing, while a partially-neutralised line
|
|
454
|
+
// would leave a forged boundary that a "the literal is gone" assertion
|
|
455
|
+
// reads as clean. The drop is counted, never silent (`selection.rejected`).
|
|
456
|
+
let line;
|
|
457
|
+
try {
|
|
458
|
+
line = renderIndexLine(entry, { maxLineChars });
|
|
459
|
+
} catch {
|
|
460
|
+
rejected++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (line.length === 0) continue;
|
|
464
|
+
|
|
465
|
+
const isScoped = s.pathScore >= minPathScore;
|
|
466
|
+
const cand = {
|
|
467
|
+
entry,
|
|
468
|
+
score: s.score,
|
|
469
|
+
relevance: s.relevance,
|
|
470
|
+
quality: s.quality,
|
|
471
|
+
pathScore: s.pathScore,
|
|
472
|
+
scoped: isScoped,
|
|
473
|
+
line,
|
|
474
|
+
};
|
|
475
|
+
(isScoped ? scoped : global).push(cand);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
scoped.sort(_compareCandidates);
|
|
479
|
+
global.sort(_compareCandidates);
|
|
480
|
+
|
|
481
|
+
// Split caps: the global tier can never displace scoped signal.
|
|
482
|
+
const ordered = [...scoped.slice(0, maxScoped), ...global.slice(0, maxGlobal)];
|
|
483
|
+
|
|
484
|
+
/** @type {SelectedLearning[]} */
|
|
485
|
+
const selected = [];
|
|
486
|
+
const lines = [];
|
|
487
|
+
let chars = 0;
|
|
488
|
+
let truncated = scoped.length > maxScoped || global.length > maxGlobal;
|
|
489
|
+
|
|
490
|
+
for (const cand of ordered) {
|
|
491
|
+
const next = chars === 0 ? cand.line.length : chars + 1 + cand.line.length;
|
|
492
|
+
if (next > maxChars) {
|
|
493
|
+
truncated = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
chars = next;
|
|
497
|
+
selected.push(cand);
|
|
498
|
+
lines.push(cand.line);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const text = lines.join('\n');
|
|
502
|
+
return {
|
|
503
|
+
entries: selected.map((c) => c.entry),
|
|
504
|
+
selected,
|
|
505
|
+
lines,
|
|
506
|
+
text,
|
|
507
|
+
chars: text.length,
|
|
508
|
+
scopeMatched: selected.filter((c) => c.scoped).length,
|
|
509
|
+
globalCount: selected.filter((c) => !c.scoped).length,
|
|
510
|
+
candidates,
|
|
511
|
+
truncated,
|
|
512
|
+
rejected,
|
|
513
|
+
};
|
|
514
|
+
} catch {
|
|
515
|
+
// Contract point 1 — a ranking primitive on the dispatch hot path must
|
|
516
|
+
// never abort a wave. Every reachable path above is already total.
|
|
517
|
+
return emptySelection();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* File entry-point: read active learnings via `surfaceTopN` (the ONE reader —
|
|
523
|
+
* it owns the active-filter and the #670 decay ranking), then select.
|
|
524
|
+
*
|
|
525
|
+
* @param {string} filePath — absolute path to learnings.jsonl
|
|
526
|
+
* @param {AgentScope} scope
|
|
527
|
+
* @param {object} [opts] — everything {@link selectLearnings} accepts, plus
|
|
528
|
+
* `poolSize` (how many active entries to pull before ranking).
|
|
529
|
+
* @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
|
|
530
|
+
*/
|
|
531
|
+
export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
532
|
+
try {
|
|
533
|
+
const o = _isRecord(opts) ? opts : {};
|
|
534
|
+
const poolSize = _capOpt(o.poolSize, CANDIDATE_POOL_SIZE);
|
|
535
|
+
const nowMs = _resolveNowMs(o.now);
|
|
536
|
+
const confidenceFloor =
|
|
537
|
+
typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
|
|
538
|
+
? o.confidenceFloor
|
|
539
|
+
: DEFAULT_CONFIDENCE_FLOOR;
|
|
540
|
+
|
|
541
|
+
const entries = await surfaceTopN(filePath, poolSize, {
|
|
542
|
+
now: nowMs,
|
|
543
|
+
confidenceFloor,
|
|
544
|
+
decay: o.decay,
|
|
545
|
+
});
|
|
546
|
+
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
|
|
547
|
+
} catch {
|
|
548
|
+
return emptySelection();
|
|
549
|
+
}
|
|
550
|
+
}
|