Skip to content

Commit 5636d21

Browse files
committed
feat(dashboard/config): add Experimental section with all four feature flags
The dashboard ConfigEditor had no surface for `experimental.*` settings, forcing users into the raw JSONC editor to enable any of them. Renamed `SECTION_ICONS` already had the 🧪 icon mapped, but no `Experimental` section actually emitted fields. Added a custom full-width Experimental card rendered just before Tags & Cleanup (so the "advanced" surface stays at the bottom). Each of the four flags renders as a master toggle with child controls that fold out when enabled, mirroring the existing Commit Cluster Trigger pattern inside Historian: - **temporal_awareness** (boolean) Inject elapsed-time markers + compartment date ranges. - **git_commit_indexing** (boolean + since_days + max_commits) 4th `ctx_search` source. Sub-fields appear when enabled. - **auto_search** (boolean + score_threshold + min_prompt_chars) Compact ctx-search hint on each user turn. Sub-fields appear when enabled. - **caveman_text_compression** (boolean + min_chars) Age-tiered compression for long text parts. Includes inline warning when both this and `ctx_reduce_enabled` are on, because the runtime gates this behind `ctx_reduce_enabled=false` and would otherwise silently no-op. Implementation notes: - All four use the existing `experimental` config key as a single nested object, so we only need one merge entry. Added `experimental` to the deep-merge whitelist in `handleFormSave` so we don't blow away sub-keys the form doesn't expose (the previous shallow spread would have overwritten the whole sub-tree on save). - Description text uses inline `<code>` for config keys and provider terms — same convention as the Memory/Embedding card. - Numeric inputs clamp to schema bounds (7..3650 days, 100..20000 commits, 0.30..0.95 score, 5..500 chars, 100..10000 chars) so invalid values can't be saved through the form path. Raw JSONC editing is still possible via the existing escape hatch. Verified: dashboard frontend build clean, biome clean, cargo check clean, plugin tests still 973/973.
1 parent 860f04c commit 5636d21

1 file changed

Lines changed: 345 additions & 2 deletions

File tree

packages/dashboard/src/components/ConfigEditor/ConfigEditor.tsx

Lines changed: 345 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,11 @@ function ConfigForm(props: {
357357
// Merge form data with original to preserve unknown keys
358358
const original = parsed();
359359
const merged = { ...original, ...formData() };
360-
// Deep merge for nested objects
361-
for (const key of ["embedding", "memory"]) {
360+
// Deep merge for nested objects so we don't blow away sub-keys the form
361+
// doesn't currently expose (e.g. user-set values inside `experimental.*`
362+
// sub-objects that the UI doesn't render). The shallow `...formData()`
363+
// above would otherwise replace the whole sub-tree.
364+
for (const key of ["embedding", "memory", "experimental"]) {
362365
if (typeof formData()[key] === "object" && formData()[key] != null) {
363366
merged[key] = {
364367
...((original[key] as Record<string, unknown>) ?? {}),
@@ -1530,6 +1533,346 @@ function ConfigForm(props: {
15301533
</div>
15311534
</div>
15321535

1536+
{/* Experimental — opt-in features gated behind `experimental.*` flags.
1537+
May change between releases. Rendered before Tags & Cleanup so the
1538+
"advanced" surface stays at the bottom of the form. Each feature is
1539+
a master toggle; child controls only appear when the feature is on,
1540+
keeping the surface compact for users who never enable any of them. */}
1541+
{(() => {
1542+
const exp = () =>
1543+
(getNestedValue(formData(), "experimental") as
1544+
| Record<string, Record<string, unknown> | boolean>
1545+
| undefined) ?? {};
1546+
const setExperimentalKey = (key: string, value: unknown) => {
1547+
handleFieldChange("experimental", { ...exp(), [key]: value });
1548+
};
1549+
1550+
// temporal_awareness — boolean (no children)
1551+
const temporalAwareness = () => Boolean(exp().temporal_awareness);
1552+
1553+
// git_commit_indexing — { enabled, since_days, max_commits }
1554+
const gitCommit = () =>
1555+
(exp().git_commit_indexing as
1556+
| { enabled?: boolean; since_days?: number; max_commits?: number }
1557+
| undefined) ?? {};
1558+
const gitCommitEnabled = () => Boolean(gitCommit().enabled);
1559+
const gitCommitSinceDays = () => gitCommit().since_days ?? 365;
1560+
const gitCommitMaxCommits = () => gitCommit().max_commits ?? 2000;
1561+
1562+
// auto_search — { enabled, score_threshold, min_prompt_chars }
1563+
const autoSearch = () =>
1564+
(exp().auto_search as
1565+
| { enabled?: boolean; score_threshold?: number; min_prompt_chars?: number }
1566+
| undefined) ?? {};
1567+
const autoSearchEnabled = () => Boolean(autoSearch().enabled);
1568+
const autoSearchScoreThreshold = () => autoSearch().score_threshold ?? 0.55;
1569+
const autoSearchMinChars = () => autoSearch().min_prompt_chars ?? 20;
1570+
1571+
// caveman_text_compression — { enabled, min_chars }
1572+
const caveman = () =>
1573+
(exp().caveman_text_compression as
1574+
| { enabled?: boolean; min_chars?: number }
1575+
| undefined) ?? {};
1576+
const cavemanEnabled = () => Boolean(caveman().enabled);
1577+
const cavemanMinChars = () => caveman().min_chars ?? 500;
1578+
1579+
const ctxReduceEnabled = () => {
1580+
const v = getNestedValue(formData(), "ctx_reduce_enabled");
1581+
return v == null ? true : Boolean(v);
1582+
};
1583+
1584+
return (
1585+
<div class="config-card full-width">
1586+
<div class="config-card-header">
1587+
<span class="config-card-icon">🧪</span>
1588+
<span class="config-card-title">Experimental</span>
1589+
</div>
1590+
<div class="config-card-content">
1591+
<div
1592+
class="config-field-desc"
1593+
style={{ "margin-bottom": "8px", opacity: "0.85" }}
1594+
>
1595+
Opt-in features that may change between releases. Disabled by default.
1596+
</div>
1597+
1598+
{/* Temporal awareness */}
1599+
<div class="config-field">
1600+
<div class="config-field-header">
1601+
<span class="config-field-label">Temporal Awareness</span>
1602+
<span class="config-field-key">experimental.temporal_awareness</span>
1603+
</div>
1604+
<span class="config-field-desc">
1605+
Inject elapsed-time markers (e.g. <code>+12m</code>, <code>+3d 4h</code>)
1606+
between user messages with &gt;5 min gaps, and add{" "}
1607+
<code>start-date</code>/<code>end-date</code> attributes on rendered
1608+
compartments. Helps the agent reason about session pacing across long-running
1609+
and multi-day sessions.
1610+
</span>
1611+
<label class="toggle-switch">
1612+
<input
1613+
type="checkbox"
1614+
checked={temporalAwareness()}
1615+
onChange={(e) =>
1616+
setExperimentalKey("temporal_awareness", e.currentTarget.checked)
1617+
}
1618+
/>
1619+
<span class="toggle-slider" />
1620+
<span class="toggle-label">
1621+
{temporalAwareness() ? "Enabled" : "Disabled"}
1622+
</span>
1623+
</label>
1624+
</div>
1625+
1626+
{/* Git commit indexing */}
1627+
<div class="config-field">
1628+
<div class="config-field-header">
1629+
<span class="config-field-label">Git Commit Indexing</span>
1630+
<span class="config-field-key">experimental.git_commit_indexing.enabled</span>
1631+
</div>
1632+
<span class="config-field-desc">
1633+
Index <code>HEAD</code> non-merge commits into <code>ctx_search</code> as a
1634+
4th source alongside memories, facts, and message history. Useful for
1635+
agents recalling regressions, prior fixes, and decisions without running{" "}
1636+
<code>git log</code> manually.
1637+
</span>
1638+
<label class="toggle-switch">
1639+
<input
1640+
type="checkbox"
1641+
checked={gitCommitEnabled()}
1642+
onChange={(e) =>
1643+
setExperimentalKey("git_commit_indexing", {
1644+
...gitCommit(),
1645+
enabled: e.currentTarget.checked,
1646+
})
1647+
}
1648+
/>
1649+
<span class="toggle-slider" />
1650+
<span class="toggle-label">
1651+
{gitCommitEnabled() ? "Enabled" : "Disabled"}
1652+
</span>
1653+
</label>
1654+
</div>
1655+
1656+
<Show when={gitCommitEnabled()}>
1657+
<div class="config-field">
1658+
<div class="config-field-header">
1659+
<span class="config-field-label">History Window (days)</span>
1660+
<span class="config-field-key">
1661+
experimental.git_commit_indexing.since_days
1662+
</span>
1663+
</div>
1664+
<span class="config-field-desc">
1665+
Days of HEAD history to index. Older commits are excluded from search.
1666+
Range 7–3650, default 365.
1667+
</span>
1668+
<input
1669+
class="config-input"
1670+
type="number"
1671+
min={7}
1672+
max={3650}
1673+
value={gitCommitSinceDays()}
1674+
onInput={(e) => {
1675+
const v = e.currentTarget.value;
1676+
setExperimentalKey("git_commit_indexing", {
1677+
...gitCommit(),
1678+
since_days: v ? Math.max(7, Math.min(3650, Number(v))) : 365,
1679+
});
1680+
}}
1681+
/>
1682+
</div>
1683+
<div class="config-field">
1684+
<div class="config-field-header">
1685+
<span class="config-field-label">Max Commits</span>
1686+
<span class="config-field-key">
1687+
experimental.git_commit_indexing.max_commits
1688+
</span>
1689+
</div>
1690+
<span class="config-field-desc">
1691+
Maximum commits kept per project. Oldest evicted when the cap is reached.
1692+
Range 100–20000, default 2000.
1693+
</span>
1694+
<input
1695+
class="config-input"
1696+
type="number"
1697+
min={100}
1698+
max={20000}
1699+
value={gitCommitMaxCommits()}
1700+
onInput={(e) => {
1701+
const v = e.currentTarget.value;
1702+
setExperimentalKey("git_commit_indexing", {
1703+
...gitCommit(),
1704+
max_commits: v ? Math.max(100, Math.min(20000, Number(v))) : 2000,
1705+
});
1706+
}}
1707+
/>
1708+
</div>
1709+
</Show>
1710+
1711+
{/* Auto search */}
1712+
<div class="config-field">
1713+
<div class="config-field-header">
1714+
<span class="config-field-label">Auto Search Hint</span>
1715+
<span class="config-field-key">experimental.auto_search.enabled</span>
1716+
</div>
1717+
<span class="config-field-desc">
1718+
On each new user message, run <code>ctx_search</code> in the background and
1719+
append a compact <code>&lt;ctx-search-hint&gt;</code> block of vague
1720+
fragments when the top hit clears the score threshold. Does NOT inject full
1721+
content — just nudges the agent to run <code>ctx_search</code> for the real
1722+
result if relevant. Adds one embedding round-trip per new user turn.
1723+
</span>
1724+
<label class="toggle-switch">
1725+
<input
1726+
type="checkbox"
1727+
checked={autoSearchEnabled()}
1728+
onChange={(e) =>
1729+
setExperimentalKey("auto_search", {
1730+
...autoSearch(),
1731+
enabled: e.currentTarget.checked,
1732+
})
1733+
}
1734+
/>
1735+
<span class="toggle-slider" />
1736+
<span class="toggle-label">
1737+
{autoSearchEnabled() ? "Enabled" : "Disabled"}
1738+
</span>
1739+
</label>
1740+
</div>
1741+
1742+
<Show when={autoSearchEnabled()}>
1743+
<div class="config-field">
1744+
<div class="config-field-header">
1745+
<span class="config-field-label">Score Threshold</span>
1746+
<span class="config-field-key">
1747+
experimental.auto_search.score_threshold
1748+
</span>
1749+
</div>
1750+
<span class="config-field-desc">
1751+
Minimum top-hit score for the hint to fire. Higher = fewer but more
1752+
relevant hints. Range 0.30–0.95, default 0.55.
1753+
</span>
1754+
<input
1755+
class="config-input"
1756+
type="number"
1757+
min={0.3}
1758+
max={0.95}
1759+
step={0.05}
1760+
value={autoSearchScoreThreshold()}
1761+
onInput={(e) => {
1762+
const v = e.currentTarget.value;
1763+
setExperimentalKey("auto_search", {
1764+
...autoSearch(),
1765+
score_threshold: v ? Math.max(0.3, Math.min(0.95, Number(v))) : 0.55,
1766+
});
1767+
}}
1768+
/>
1769+
</div>
1770+
<div class="config-field">
1771+
<div class="config-field-header">
1772+
<span class="config-field-label">Min Prompt Chars</span>
1773+
<span class="config-field-key">
1774+
experimental.auto_search.min_prompt_chars
1775+
</span>
1776+
</div>
1777+
<span class="config-field-desc">
1778+
Skip the hint when a user message is shorter than this. Avoids embedding
1779+
cost on trivial replies. Range 5–500, default 20.
1780+
</span>
1781+
<input
1782+
class="config-input"
1783+
type="number"
1784+
min={5}
1785+
max={500}
1786+
value={autoSearchMinChars()}
1787+
onInput={(e) => {
1788+
const v = e.currentTarget.value;
1789+
setExperimentalKey("auto_search", {
1790+
...autoSearch(),
1791+
min_prompt_chars: v ? Math.max(5, Math.min(500, Number(v))) : 20,
1792+
});
1793+
}}
1794+
/>
1795+
</div>
1796+
</Show>
1797+
1798+
{/* Caveman text compression */}
1799+
<div class="config-field">
1800+
<div class="config-field-header">
1801+
<span class="config-field-label">Caveman Text Compression</span>
1802+
<span class="config-field-key">
1803+
experimental.caveman_text_compression.enabled
1804+
</span>
1805+
</div>
1806+
<span class="config-field-desc">
1807+
Age-tiered compression for long user/assistant text parts.{" "}
1808+
<strong>
1809+
Only active when Agent Controlled Reduction (
1810+
<code>ctx_reduce_enabled</code>) is OFF.
1811+
</strong>{" "}
1812+
Outside the protected tail, oldest 20% of eligible tags get ultra
1813+
compression, next 20% full, next 20% lite, newest 40% untouched. Always
1814+
compresses from the original source, so depth shifts are equivalent to
1815+
compressing the original text directly.
1816+
</span>
1817+
<Show when={ctxReduceEnabled() && cavemanEnabled()}>
1818+
<div
1819+
class="config-field-desc"
1820+
style={{ color: "var(--color-warning, #c8881f)", "margin-bottom": "4px" }}
1821+
>
1822+
⚠️ Caveman compression has no effect while Agent Controlled Reduction is
1823+
enabled. Disable <code>ctx_reduce_enabled</code> in General to use this.
1824+
</div>
1825+
</Show>
1826+
<label class="toggle-switch">
1827+
<input
1828+
type="checkbox"
1829+
checked={cavemanEnabled()}
1830+
onChange={(e) =>
1831+
setExperimentalKey("caveman_text_compression", {
1832+
...caveman(),
1833+
enabled: e.currentTarget.checked,
1834+
})
1835+
}
1836+
/>
1837+
<span class="toggle-slider" />
1838+
<span class="toggle-label">{cavemanEnabled() ? "Enabled" : "Disabled"}</span>
1839+
</label>
1840+
</div>
1841+
1842+
<Show when={cavemanEnabled()}>
1843+
<div class="config-field">
1844+
<div class="config-field-header">
1845+
<span class="config-field-label">Min Chars</span>
1846+
<span class="config-field-key">
1847+
experimental.caveman_text_compression.min_chars
1848+
</span>
1849+
</div>
1850+
<span class="config-field-desc">
1851+
Text parts shorter than this are left untouched. Range 100–10000,
1852+
default 500.
1853+
</span>
1854+
<input
1855+
class="config-input"
1856+
type="number"
1857+
min={100}
1858+
max={10000}
1859+
step={50}
1860+
value={cavemanMinChars()}
1861+
onInput={(e) => {
1862+
const v = e.currentTarget.value;
1863+
setExperimentalKey("caveman_text_compression", {
1864+
...caveman(),
1865+
min_chars: v ? Math.max(100, Math.min(10000, Number(v))) : 500,
1866+
});
1867+
}}
1868+
/>
1869+
</div>
1870+
</Show>
1871+
</div>
1872+
</div>
1873+
);
1874+
})()}
1875+
15331876
{/* Tags & Cleanup — rendered after agent cards */}
15341877
{(() => {
15351878
const tagsFields = sections().find(([name]) => name === "Tags & Cleanup");

0 commit comments

Comments
 (0)