compaction: the surface — settings row, two ruled dividers, summary dialog, send gate (round 14 slice 3)

- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
  in a Context group — CommitInput mirroring the graph budget row, hint
  resolved through the SAME core resolver the trigger reads; PUT rejects
  below the 32k floor with the anti-churn rationale; own section
  (compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
  the marker (retrying-message register, no rule lines) and the longer
  CUT divider emitted immediately before the first kept message
  (phase-divider geometry, chip carries '· Nk tok'); both carry the full
  summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
  (pure exported body for SSR pins, the delete-dialog pattern; Esc +
  Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
  canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
  ('compacting session…') gate on it (the server's registry mutex is the
  structural gate; this is the affordance); dialog mounts beside the
  abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
This commit is contained in:
amosblomqvist
2026-10-05 22:51:08 +03:00
parent dd159cc8fb
commit ffc4df1938
14 changed files with 465 additions and 19 deletions
+3 -2
View File
@@ -575,8 +575,9 @@ function sanitizeGraph(raw: Record<string, unknown>): GraphSettings {
/** clamp-never-reject (the sanitizer's discipline): a finite positive
* number floors to COMPACTION_THRESHOLD_FLOOR, anything else drops (the
* factory default resolves at read time). */
const COMPACTION_THRESHOLD_FLOOR = 32_000;
* factory default resolves at read time). Exported for the PUT parser's
* reject-with-rationale (the graph chunk-budget precedent). */
export const COMPACTION_THRESHOLD_FLOOR = 32_000;
function sanitizeCompaction(raw: Record<string, unknown>): CompactionSettings {
const out: CompactionSettings = {};
+41
View File
@@ -35,6 +35,7 @@
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts";
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
import { QUESTION_TOOLS } from "./pending.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
import type { PlanBlockData } from "./contracts/chat.ts";
@@ -145,9 +146,30 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
return undefined;
};
// The active compaction (owner round 14): the fold emits the CUT
// DIVIDER — the longer, at-the-seam marker — immediately BEFORE the
// first kept message, so the transcript shows exactly where history
// was summarized away. Everything before it stays visible to the
// user (full history is never hidden); only the MODEL's context jumps.
const compaction = activeCompactionOf(messages);
for (const m of messages) {
const role = (m as { role?: string }).role;
if (compaction && m.id === compaction.details.firstKeptMessageId) {
out.push({
id: `cut_${compaction.id}`,
role: "user",
parts: [
{
type: "data-compaction-cut",
id: `cc_${compaction.id}`,
data: { content: compaction.content, details: compaction.details },
},
],
});
}
if (role === "user") {
const content = m.content as string | Array<{ type: string; text?: string }>;
const texts =
@@ -383,6 +405,25 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
// UI-only (the ask-style dismissal): the synthesized conclude
// pair — which rides the NEXT input — carries the record's face.
return;
case "compaction": {
// THE MOMENT DIVIDER (owner round 14): minimal centered small
// text at the compaction moment — where the user currently is
// (the marker appends at the record's tail). Clicking opens the
// full summary. The cut divider is emitted by the fold loop at
// the kept boundary (see projectView).
out.push({
id: marker.id,
role: "user",
parts: [
{
type: "data-compaction",
id: `cm_${marker.id}`,
data: { content: marker.content, details: marker.details as CompactionDetails },
},
],
});
return;
}
case "plan-block": {
// No message of its own: splice into the preceding assistant
// group, IN FLOW after the last successful plan_init call.
+31 -1
View File
@@ -17,12 +17,13 @@ import type {
AgentSettings,
AppearanceSettings,
BackgroundModelRole,
CompactionSettings,
GeneralSettings,
GraphSettings,
ModelsSettings,
RoleModelOverride,
} from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
@@ -342,6 +343,34 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<Model
return { ok: true, settings };
}
/** compaction (owner round 14): the context-compaction threshold — the
* projected-token budget over which a session summarizes its own history
* into the compaction marker. Strict write side: a present
* thresholdTokens must be a number at or above the floor (the floor is
* anti-churn: each compaction keeps a ~20k-token recent tail, so a
* smaller threshold would re-summarize the tail it just wrote). Absent =
* factory default 150k at resolution. Replace-whole, like every
* section. */
export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<CompactionSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
const settings: CompactionSettings = {};
if (raw.thresholdTokens !== undefined) {
if (typeof raw.thresholdTokens !== "number" || !Number.isFinite(raw.thresholdTokens)) {
return { ok: false, error: "thresholdTokens must be a number" };
}
const floored = Math.floor(raw.thresholdTokens);
if (floored < COMPACTION_THRESHOLD_FLOOR) {
return {
ok: false,
error: `thresholdTokens must be at least ${COMPACTION_THRESHOLD_FLOOR} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)`,
};
}
settings.thresholdTokens = floored;
}
return { ok: true, settings };
}
/** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */
export const SETTINGS_SECTIONS = {
@@ -350,6 +379,7 @@ export const SETTINGS_SECTIONS = {
agent: parseAgentSettingsBody,
appearance: parseAppearanceSettingsBody,
graph: parseGraphSettingsBody,
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
} as const;