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:
@@ -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 = {};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user