diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 029f520..2ff52d0 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,4 @@ -import { deleteChatSession, getChat, isTurnRunning, stores, type ChatDeleteMode } from "@/src/server"; +import { deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -6,7 +6,10 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str if (!chat) return Response.json({ error: "Not found" }, { status: 404 }); // turnInFlight lets a returning client re-attach to a running turn // (chat-handler write-ahead + registry — see src/server/turn-liveness.ts). - return Response.json({ ...chat, turnInFlight: isTurnRunning(id) }); + // coverage (extraction-UX redesign, 2026-09-24): the chat view's ring + // meter — one session's read of the same derivation /api/graph/coverage + // serves for all sessions. getChat already 404'd, so the block exists. + return Response.json({ ...chat, turnInFlight: isTurnRunning(id), coverage: sessionCoverage(id) }); } /** diff --git a/config.ts b/config.ts index 05b5d68..adaa46e 100644 --- a/config.ts +++ b/config.ts @@ -59,17 +59,6 @@ export type ModelRole = * (server/turn-service.ts) so it can be swapped without touching the * pipeline. */ -/** - * The extraction trigger (vision-extraction-coverage.md, 2026-09-15): a run - * fires when a chat's UNCOVERED transcript tokens reach the chunk budget. - * Runs are token-budgeted batches of the oldest uncovered messages — this - * knob is the transcript share of the extractor's context (it also carries - * the whole graph + instructions), so it bounds both cost and chunk size. - * Replaces the old every-3-quiz-answers trigger, which never fired for - * quiz-less sessions. - */ -export const EXTRACTION = { chunkTokens: 4096 } as const; - /** * The LIVE graph store (src/store/graph.ts) — owned by the app's extraction * pipeline. Line shapes: src/core/contracts.ts. diff --git a/scripts/backfill-graph.ts b/scripts/backfill-graph.ts index 194addc..ba1dd6c 100644 --- a/scripts/backfill-graph.ts +++ b/scripts/backfill-graph.ts @@ -9,12 +9,18 @@ import { chatStore } from "../src/store/chats.ts"; import { graphStore } from "../src/store/graph.ts"; import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts"; import { getGraph } from "../src/core/graph.ts"; +import { effectiveSettings } from "../src/server/stores.ts"; async function main() { const only = process.argv[2]; // optional: extract just one chat id const chats = only ? chatStore.listChats().filter((c) => c.id === only) : chatStore.listChats(); console.log(`[backfill] ${chats.length} chats to extract`); - const pipeline = createExtractionPipeline({ chatStore, graphStore }); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore, + // Live budget: the graph section's setting, same as the app's trigger + getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens, + }); for (const chat of chats) { const t0 = Date.now(); try { diff --git a/scripts/import-notes.ts b/scripts/import-notes.ts index c2a9735..0ded548 100644 --- a/scripts/import-notes.ts +++ b/scripts/import-notes.ts @@ -16,7 +16,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts"; import { getGraph } from "../src/core/graph.ts"; -import { stores } from "../src/server/stores.ts"; +import { effectiveSettings, stores } from "../src/server/stores.ts"; async function main() { const dir = process.argv[2] ?? "real-data-extraction/learn-sessions"; @@ -27,7 +27,11 @@ async function main() { .sort(); console.log(`[import-notes] ${files.length} note files from ${dir} (concurrency ${limit})`); - const pipeline = createExtractionPipeline(stores); + const pipeline = createExtractionPipeline({ + ...stores, + // Live budget: the graph section's setting, same as the app's trigger + getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens, + }); const queue = [...files]; const totals = { nodes: 0, edges: 0, events: 0, failed: 0 }; const workers = Array.from({ length: Math.min(limit, files.length) }, async () => { diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 43a485e..19d436d 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -4,7 +4,7 @@ import type { Usage } from "@earendil-works/pi-ai"; import { z } from "zod"; import { lhTool, toPiTools } from "../lh-tool.ts"; import type { ModelRole } from "../../../config.ts"; -import { ModelsNotConfiguredError, ROLE_SLOT, type ModelSlotName } from "../../core/settings.ts"; +import { FACTORY_SETTINGS, ModelsNotConfiguredError, ROLE_SLOT, type ModelSlotName } from "../../core/settings.ts"; import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; import type { @@ -20,7 +20,6 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../ import type { GraphStore } from "../../core/ports.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/coverage.ts"; -import { EXTRACTION } from "../../../config.ts"; /** * The extraction pipeline (vision §1 construction, 2026-09-02 redesign) — the @@ -569,7 +568,15 @@ export interface ExtractionModels { engineFor?: (role: ModelRole) => PiEngine | undefined; } -export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {} +export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels { + /** The chunk budget — the transcript-token share of the extractor's + * context; batches fill to it (the trigger fires on the same number, + * read fresh per turn by the composition root). OPTIONAL with the + * factory default (4096) so test fixtures stay minimal; the server + * callers wire the live effective settings (extraction-UX redesign, + * 2026-09-24). */ + getChunkTokens?: () => number; +} export interface NotesImportInput { /** The source's file path — its identity (deterministic src id) AND its @@ -760,10 +767,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti console.info(`[extraction ${chatId}] nothing uncovered — no-op`); return { nodes: 0, edges: 0, events: 0 }; } - const batches = batchRuns(covered.uncovered, EXTRACTION.chunkTokens); + const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens; + const batches = batchRuns(covered.uncovered, chunkTokens); console.info( `[extraction ${chatId}] pipeline start — ${covered.uncoveredTokens} uncovered tok, ` + - `${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${EXTRACTION.chunkTokens} tok`, + `${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${chunkTokens} tok`, ); for (const batch of batches) { // The batch's prompt view: SESSION header + the batch's lines, diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index 82c6fdf..aa70015 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -205,6 +205,10 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings // the embedding/clustering/labeling rungs and the BYOK embeddingModel // are gone (stale record keys are simply never read here). enabled: graph.enabled ?? FACTORY_SETTINGS.graph.enabled, + // The chunk budget (extraction-UX redesign, 2026-09-24): absent + // (pre-redesign record, fresh install) resolves to the factory + // 4096; the sanitizer has already clamped/floored any stored value. + extractionChunkTokens: graph.extractionChunkTokens ?? FACTORY_SETTINGS.graph.extractionChunkTokens, }, }; } @@ -216,7 +220,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings * switch. Used by the chat-handler's post-turn trigger. (Wave 7: the master * switch is the ONLY graph setting left — the section collapsed.) */ -export function extractionEnabled(graph: Required): boolean { +export function extractionEnabled(graph: { enabled: boolean }): boolean { return graph.enabled; } @@ -228,7 +232,7 @@ export function extractionEnabled(graph: Required): boolean { * Teacher's tool factories consume this at registration time. */ export function graphToolsEnabled(effective: { - graph: Required; + graph: { enabled: boolean }; agent: { graphTools: { enabled?: boolean } }; }): boolean { return effective.graph.enabled && (effective.agent.graphTools.enabled ?? true); diff --git a/src/core/settings.ts b/src/core/settings.ts index bc912b7..4316a4b 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -255,6 +255,26 @@ export interface GraphSettings { * names, and the prober graph tools read the ledgers directly — there * is nothing left to configure. Data stays stored either way. */ enabled?: boolean; + /** The chunk budget (extraction-UX redesign, owner 2026-09-24): the + * transcript-token share of the extractor's context — a run fires + * when a chat's UNCOVERED tokens reach it, and the run batches the + * oldest uncovered messages into runs of ≤ this many tokens. It + * bounds both cost and chunk size (the extractor prompt also + * carries the whole graph + instructions, so a smaller budget means + * more, smaller runs — each paying that fixed overhead). + * + * FACTORY DEFAULT 4096 (the pre-setting constant, config.ts's + * EXTRACTION.chunkTokens — vision-extraction-coverage.md, 2026-09-15). + * + * FLOOR 500 (EXTRACTION_CHUNK_TOKENS_FLOOR, the implementer's call + * per the owner's dispatch): every run pays the graph-carrying + * prompt overhead out of the write channel's money — a budget below + * ~500 tokens would fire penny-batches that cost more per extracted + * token than the content is worth, so the sanitizer clamps and the + * PUT rejects below it. NO CEILING on purpose: a large budget just + * delays extraction — the user's stated cost/thoroughness tradeoff, + * not a misconfiguration to police. */ + extractionChunkTokens?: number; } /** THE record. Every section optional (delta); unknown top-level keys are @@ -307,9 +327,14 @@ export const FACTORY_SETTINGS: FactorySettings = { sessionNameSerif: true, mdRendering: { headingWeight: 900, boldColor: null, italicColor: null }, }, - graph: { enabled: true }, + graph: { enabled: true, extractionChunkTokens: 4096 }, }; +/** The chunk budget's validated floor (GraphSettings.extractionChunkTokens + * — see its doc for the penny-batch rationale; the settings PUT names this + * in its rejection, the sanitizer clamps to it). */ +export const EXTRACTION_CHUNK_TOKENS_FLOOR = 500; + // ─── Sanitizer ───────────────────────────────────────────────────────────── const PROSE_FONT_MIN = 12; @@ -462,9 +487,20 @@ function sanitizeGraph(raw: Record): GraphSettings { // this reconstruction — the migration story: the next graph-section // write (PUT is replace-whole) persists the collapsed shape, and no // active code ever looks at the stale fields. + // + // extractionChunkTokens (extraction-UX redesign, 2026-09-24): the chunk + // budget — clamp-never-reject (the bd68486 discipline): a finite + // positive number floors to EXTRACTION_CHUNK_TOKENS_FLOOR (fractions + // floor to integers), anything else drops to the factory default at + // resolution. A disk record below the floor (older app, hand edit) + // clamps UP — the floor is a cost protection, not data to honor. const out: GraphSettings = {}; const enabled = boolOrDrop(raw.enabled); if (enabled !== undefined) out.enabled = enabled; + const rawTokens = raw.extractionChunkTokens; + if (typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens > 0) { + out.extractionChunkTokens = Math.max(EXTRACTION_CHUNK_TOKENS_FLOOR, Math.floor(rawTokens)); + } return out; } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 70382d7..80d24fd 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -317,7 +317,10 @@ export async function handleChatRequest( // pipeline knobs — embedding/clustering/labeling — gate inside the // pipeline and its consumers, later waves, via the same resolved view.) if (extractionEnabled(effective.graph)) { - triggerExtraction(stores, extraction, chatId); + // Fresh per-turn read of the graph section's chunk budget + // (extraction-UX redesign, 2026-09-24): the trigger compares + // against it and the pipeline's batches share it. + triggerExtraction(stores, extraction, chatId, effective.graph.extractionChunkTokens); } // LAST: a poller that sees not-in-flight must read an already-persisted // record — endTurn only after persistTurn has done its job. diff --git a/src/server/chats.ts b/src/server/chats.ts index 68f433f..62a2d91 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -1,6 +1,8 @@ import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts"; +import { coverageOf } from "../core/coverage.ts"; import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts"; import { titleFromPrompt } from "../core/contracts.ts"; +import type { UIMessage } from "ai"; import { graphRevision } from "./graph.ts"; import { stores } from "./stores.ts"; import { isTurnRunning } from "./turn-liveness.ts"; @@ -110,3 +112,47 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P stores.chatStore.deleteChat(chatId); return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() }; } + +/** One session's coverage read (extraction-UX redesign, owner dispatch + * 2026-09-24): the chat view's ring meter + popover data — the same + * derivation the all-sessions list serves (/api/graph/coverage), scoped + * to one chat. "Uncovered" stays DERIVED per request (fingerprint the + * transcript, diff the coverage ledger — never dual-tracked). + * + * lastFoldedAt: the newest coverage row's fold timestamp — a cheap single + * pass over the chat's ledger rows (the ring popover's "last extraction" + * line); null before the session's first fold. */ +export interface SessionCoverage { + totalMessages: number; + coveredCount: number; + uncoveredCount: number; + coveredTokens: number; + uncoveredTokens: number; + /** True while a chat-kind extraction run is registered for this session. */ + running: boolean; + /** ISO of the newest fold in this session's coverage rows; null before any. */ + lastFoldedAt: string | null; +} + +/** The per-session coverage block (see SessionCoverage). Null when the + * chat doesn't exist — callers own the 404. */ +export function sessionCoverage(chatId: string): SessionCoverage | null { + const record = stores.chatStore.getChat(chatId); + if (!record) return null; + const entries = stores.graphStore.readCoverage(); + const cov = coverageOf(record.messages as UIMessage[], entries, chatId); + let lastFoldedAt: string | null = null; + for (const entry of entries) { + if (entry.chatId !== chatId) continue; + if (lastFoldedAt === null || entry.foldedAt > lastFoldedAt) lastFoldedAt = entry.foldedAt; + } + return { + totalMessages: cov.slots.length, + coveredCount: cov.coveredCount, + uncoveredCount: cov.uncoveredCount, + coveredTokens: cov.coveredTokens, + uncoveredTokens: cov.uncoveredTokens, + running: extractionStatuses().some((run) => run.kind === "chat" && run.chatId === chatId), + lastFoldedAt, + }; +} diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index f24df8b..905bace 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -22,7 +22,7 @@ import type { ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; -import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS } from "../core/settings.ts"; +import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { TOGGLEABLE_TOOLS } from "../core/pending.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ @@ -283,15 +283,33 @@ export function parseAppearanceSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; - // Wave 7 collapse (owner ruling 2026-09-23): the section is the master - // switch ONLY. PUT is replace-whole — a body like {enabled: true} IS the - // whole section now; the dead rungs' keys in old records are unread - // (the sanitizer drops them; see sanitizeGraph). + // Wave 7 collapse (owner ruling 2026-09-23) + the extraction-UX + // redesign (2026-09-24): the section is the master switch and the + // chunk budget. PUT is replace-whole — a body like {enabled: true} IS + // the whole section (extractionChunkTokens absent = factory default at + // resolution); the dead rungs' keys in old records are unread (the + // sanitizer drops them; see sanitizeGraph). const settings: GraphSettings = {}; if (raw.enabled !== undefined) { if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" }; settings.enabled = raw.enabled; } + if (raw.extractionChunkTokens !== undefined) { + if (typeof raw.extractionChunkTokens !== "number" || !Number.isFinite(raw.extractionChunkTokens)) { + return { ok: false, error: "extractionChunkTokens must be a number" }; + } + const floored = Math.floor(raw.extractionChunkTokens); + if (floored < EXTRACTION_CHUNK_TOKENS_FLOOR) { + // the floor's rationale lives on GraphSettings.extractionChunkTokens: + // every run pays the graph-carrying prompt overhead — penny-batches + // cost more per extracted token than the content is worth. + return { + ok: false, + error: `extractionChunkTokens must be at least ${EXTRACTION_CHUNK_TOKENS_FLOOR} (smaller batches waste the per-run graph overhead)`, + }; + } + settings.extractionChunkTokens = floored; + } return { ok: true, settings }; } diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index a7e3f36..568ba61 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -19,7 +19,6 @@ import type { UIMessage } from "ai"; import { withPlanBlocks } from "../core/plan-blocks.ts"; import { coverageOf } from "../core/coverage.ts"; import type { ChatStore, GraphStore } from "../core/ports.ts"; -import { EXTRACTION } from "../../config.ts"; import type { ExtractionPipeline } from "../agent/subagents/extraction.ts"; import { ModelsNotConfiguredError } from "../core/settings.ts"; @@ -74,11 +73,14 @@ export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMe /** * The graph write channel (vision §1 construction): the extraction pipeline * (extractor → auditor → merger — the sole graph writer) runs ASYNC once a - * chat's UNCOVERED transcript tokens reach EXTRACTION.chunkTokens + * chat's UNCOVERED transcript tokens reach the chunk budget * (vision-extraction-coverage.md, 2026-09-15 — replacing the old * every-3-quiz-answers count: content size is the honest signal, and - * quiz-less sessions extract too). The pipeline itself extracts only the - * uncovered batches. Fire-and-forget — never blocks the turn; failures are + * quiz-less sessions extract too). The budget is the graph section's + * extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) — + * the composition root passes the per-turn effective read (chat-handler + * resolves fresh per turn, same as every other gate). The pipeline + * itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are * logged by the pipeline and leave coverage untouched, so the next trigger * (or the manual extract button) re-runs what's left. * @@ -90,13 +92,14 @@ export function triggerExtraction( deps: { chatStore: ChatStore; graphStore: GraphStore }, extraction: ExtractionPipeline, chatId: string, + chunkTokens: number, ): void { if (process.env.NODE_ENV === "test") return; try { const record = deps.chatStore.getChat(chatId); if (!record) return; const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId); - if (covered.uncoveredTokens < EXTRACTION.chunkTokens) return; + if (covered.uncoveredTokens < chunkTokens) return; void extraction.runExtraction(chatId).catch((err) => { // BYOK (owner, 2026-09-22): an unconfigured write channel SKIPS // (the coverage ledger keeps the messages uncovered — the next diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 69005e7..0b8e59b 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -890,7 +890,7 @@ function passThroughAuditStep(): FauxResponseStep { } /** A session with UNCOVERED-multiple-batch mass: five long distinct messages - * (~1200 tok each — 3600+1200 > EXTRACTION.chunkTokens 4096 → batches of + * (~1200 tok each — 3600+1200 > the factory chunk budget 4096 → batches of * [m1,m2,m3] and [m4,m5]). */ function chunkyChat() { const chat = chatStore.createChat("Chunky coverage fixture"); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 4bf7600..7fde5d9 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -21,6 +21,7 @@ import type { UIMessage } from "ai"; import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts"; import type { QuestionAnswerData } from "../src/core/question-core.ts"; import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts"; +import { coverageSlots } from "../src/core/coverage.ts"; import { graphStore } from "../src/store/graph.ts"; import { chatStore } from "../src/store/chats.ts"; import { LEDGER_FILES, PATHS, SOURCES_FILE } from "../config.ts"; @@ -618,7 +619,7 @@ describe("graph ledger store", () => { // ─── session deletion (wave 6 — the first destructive ledger operation) ───── import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts"; -import { deleteChatSession } from "../src/server/chats.ts"; +import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts"; import { beginTurn, endTurn } from "../src/server/turn-liveness.ts"; import { jobStore } from "../src/store/jobs.ts"; import { @@ -989,3 +990,71 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu assert.deepEqual(deleteGraphNode("n_orch"), { ok: false, status: 404, code: "not_found", error: "No such node" }); }); }); + +describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => { + it("one session's block: derived uncovered, the newest fold, honest nulls", () => { + // Empty coverage: everything uncovered, no fold yet, not running. + const chat = fixtureChat("coverage read", [ + user("cov_u1", "tell me about coverage reads"), + user("cov_u2", "and about per-session blocks"), + user("cov_u3", "a third distinct message"), + ]); + const empty = sessionCoverage(chat.id)!; + assert.ok(empty); + assert.equal(empty.totalMessages, 3); + assert.equal(empty.coveredCount, 0); + assert.equal(empty.uncoveredCount, 3); + assert.equal(empty.coveredTokens, 0); + assert.ok(empty.uncoveredTokens > 0); + assert.equal(empty.running, false); + assert.equal(empty.lastFoldedAt, null, "no fold yet"); + + // Fold the first two messages: the read derives the split + the + // newest fold timestamp from the ledger rows. + const slots = coverageSlots(chatStore.getChat(chat.id)!.messages as UIMessage[]); + graphStore.appendCoverage({ chatId: chat.id, fp: slots[0]!.fp, tokens: slots[0]!.tokens, foldedAt: "2026-09-24T10:00:00.000Z" }); + graphStore.appendCoverage({ chatId: chat.id, fp: slots[1]!.fp, tokens: slots[1]!.tokens, foldedAt: "2026-09-24T11:30:00.000Z" }); + // Another session's row must not leak into this read. + graphStore.appendCoverage({ chatId: "chat_other", fp: slots[2]!.fp, tokens: slots[2]!.tokens, foldedAt: "2026-09-24T23:00:00.000Z" }); + + const cov = sessionCoverage(chat.id)!; + assert.equal(cov.totalMessages, 3); + assert.equal(cov.coveredCount, 2); + assert.equal(cov.uncoveredCount, 1); + assert.equal(cov.coveredTokens, slots[0]!.tokens + slots[1]!.tokens); + assert.equal(cov.uncoveredTokens, slots[2]!.tokens); + assert.equal(cov.lastFoldedAt, "2026-09-24T11:30:00.000Z", "the NEWEST fold, and scoped to this session's rows"); + }); + + it("running: true only for THIS session's chat-kind run (the registry pin)", () => { + const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")]); + const g = globalThis as { __lhExtractionRuns?: unknown }; + const realRuns = g.__lhExtractionRuns; + try { + g.__lhExtractionRuns = { + running: new Map([ + // a notes run for the same id is NOT this session's chat run + ["src_notes", { chatId: "src_notes", kind: "notes", phase: "extracting", startedAt: 1, usage: {}, controller: new AbortController() }], + ["other_chat", { chatId: "other_chat", kind: "chat", phase: "auditing", startedAt: 2, usage: {}, controller: new AbortController() }], + ]), + inFlight: new Set(), + lastStartedAt: 0, + }; + assert.equal(sessionCoverage(chat.id)!.running, false, "no chat-kind run for this id"); + g.__lhExtractionRuns = { + running: new Map([ + [chat.id, { chatId: chat.id, kind: "chat", phase: "extracting", startedAt: 3, usage: {}, controller: new AbortController() }], + ]), + inFlight: new Set(), + lastStartedAt: 0, + }; + assert.equal(sessionCoverage(chat.id)!.running, true); + } finally { + g.__lhExtractionRuns = realRuns; + } + }); + + it("unknown chat → null (the caller owns the 404)", () => { + assert.equal(sessionCoverage("chat_never_was"), null); + }); +}); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 0061970..4b08146 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -162,6 +162,28 @@ describe("parseGraphSettingsBody", () => { assert.equal(parseGraphSettingsBody("nope").ok, false); }); + it("extractionChunkTokens: whole tokens ≥ the 500 floor (extraction-UX redesign, 2026-09-24)", () => { + const ok = parseGraphSettingsBody({ enabled: true, extractionChunkTokens: 2048 }); + assert.ok(ok.ok); + assert.deepEqual(ok.settings, { enabled: true, extractionChunkTokens: 2048 }); + // absent = unset — replace-whole keeps the factory default at resolution + const absent = parseGraphSettingsBody({ enabled: true }); + assert.ok(absent.ok); + assert.deepEqual(absent.settings, { enabled: true }); + // fractions floor to whole tokens + const fraction = parseGraphSettingsBody({ extractionChunkTokens: 1024.9 }); + assert.ok(fraction.ok); + assert.equal(fraction.settings.extractionChunkTokens, 1024); + // below the cost floor: 400 with the floor named (penny-batches) + const tiny = parseGraphSettingsBody({ extractionChunkTokens: 100 }); + assert.equal(tiny.ok, false, "below-floor rejected"); + assert.match(tiny.ok === false ? tiny.error : "", /at least 500/); + // junk shapes: 400 + assert.equal(parseGraphSettingsBody({ extractionChunkTokens: "4096" }).ok, false); + assert.equal(parseGraphSettingsBody({ extractionChunkTokens: NaN }).ok, false); + assert.equal(parseGraphSettingsBody({ extractionChunkTokens: null }).ok, false); + }); + it("the dead wave-6 rungs in a body are ignored — the section collapses on write", () => { // Old clients may still send {embedding: true, embeddingModel: …}; // the parser reads ONLY the master switch — the dead keys are not diff --git a/tests/settings-resolution.test.ts b/tests/settings-resolution.test.ts index 2aee848..ae1f37f 100644 --- a/tests/settings-resolution.test.ts +++ b/tests/settings-resolution.test.ts @@ -42,7 +42,9 @@ describe("resolveSettings", () => { assert.equal(eff.appearance.proseFontSize, 16); assert.equal(eff.appearance.sessionNameSerif, true); assert.equal(eff.appearance.mdRendering.headingWeight, 900); - assert.deepEqual(eff.graph, { enabled: true }); // wave 7 collapse: the master switch ONLY + // wave 7 collapse: the master switch ONLY + the redesign's chunk + // budget — absent record resolves the factory 4096 (the old config constant) + assert.deepEqual(eff.graph, { enabled: true, extractionChunkTokens: 4096 }); }); it("explicit record values win over every factory default", () => { @@ -75,8 +77,12 @@ describe("resolveSettings", () => { assert.equal(eff.appearance.mode, "light"); assert.equal(eff.appearance.proseFontSize, 20); assert.equal(eff.appearance.mdRendering.headingWeight, 600); - // wave 7 collapse: the section IS the master switch. - assert.deepEqual(eff.graph, { enabled: false }); + // wave 7 collapse: the section IS the master switch (absent budget + // field = factory 4096 — a pre-redesign record resolves cleanly). + assert.deepEqual(eff.graph, { enabled: false, extractionChunkTokens: 4096 }); + // The chunk budget rides the same resolution: stored value wins. + const budgeted = resolveSettings({ version: 1, graph: { extractionChunkTokens: 2000 } }); + assert.deepEqual(budgeted.graph, { enabled: true, extractionChunkTokens: 2000 }); }); it("partial role override: thinkingLevel alone borrows the SLOT's model (or nothing, without one)", () => { diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 23d3294..bc768ae 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -21,7 +21,9 @@ describe("FACTORY_SETTINGS (the compiled factory layer)", () => { it("pins the owner-decided defaults", () => { assert.equal(CURRENT_SETTINGS_VERSION, 1); assert.deepEqual(FACTORY_SETTINGS.general, { autoUpdate: true }); - assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true }); // wave 7 collapse: the master switch ONLY + // wave 7 collapse: the master switch ONLY + the extraction-UX + // redesign's chunk budget (2026-09-24) — default = the old config constant + assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, extractionChunkTokens: 4096 }); const a = FACTORY_SETTINGS.appearance; assert.equal(a.mode, "dark"); assert.equal(a.background, null); // the mode's own default, not a factory hex @@ -106,6 +108,21 @@ describe("sanitizeUserSettings", () => { assert.deepEqual(out.agent, {}); assert.deepEqual(out.appearance, {}); assert.deepEqual(out.graph, {}); + + // extractionChunkTokens (extraction-UX redesign, 2026-09-24): + // clamp-never-reject — a finite positive value survives (fractions + // floor), below-floor values clamp UP to the cost floor, junk drops + // (the factory default resolves at read time). + const budget = sanitizeUserSettings({ version: 1, graph: { enabled: true, extractionChunkTokens: 2048 } }); + assert.deepEqual(budget?.graph, { enabled: true, extractionChunkTokens: 2048 }); + const fraction = sanitizeUserSettings({ version: 1, graph: { extractionChunkTokens: 1024.9 } }); + assert.equal(fraction?.graph?.extractionChunkTokens, 1024, "fractions floor to whole tokens"); + const tiny = sanitizeUserSettings({ version: 1, graph: { extractionChunkTokens: 10 } }); + assert.equal(tiny?.graph?.extractionChunkTokens, 500, "below the floor clamps UP — penny-batches are a cost bug, not a preference"); + for (const junk of [0, -1000, NaN, Infinity, "4096", null, true]) { + const dropped = sanitizeUserSettings({ version: 1, graph: { extractionChunkTokens: junk } }); + assert.equal(dropped?.graph?.extractionChunkTokens, undefined, `junk ${String(junk)} drops`); + } }); it("out-of-range numbers CLAMP (proseFontSize, headingWeight)", () => {