Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.
This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.
What was already correct, and was kept rather than rebuilt
Memory lineage. Memories already carried (branch_id, depth) and retrieval
already filtered through the capped-path clause; the ten-step negative control
was measured passing against b7005e6 before any change here. M6 adds the
regression tests that pin it, plus provenance and authority on the result.
Summary lineage — both halves
A summary is a row carrying the coordinate of the last node it covers, and
eligibility is the same head-capped lineage clause memories use. That alone
was not enough: generation was seeded from adventures.story_summary, a
campaign-global column with no lineage, so after a divergence the summariser
was handed the abandoned line's prose and asked to update it. The row it
produced was correctly anchored and therefore looked safe while its sentences
described a story the reader had left.
Generation is now seeded from summaries.current — the same question the
context builder asks — so the input and the output are scoped by one rule.
adventures.story_summary remains a reader-facing mirror for the Plot panel and
the export bundle, kept in step when a summary is written and when the head
moves, and nothing authoritative reads it.
Retrieval redundancy
With a real embedding model, four near-identical memories crowded out the one
distinctive clue, which survived only because the default memory_top_k is 5.
Retrieval now drops a candidate that repeats one already chosen, never across
authority classes, at a threshold measured against the configured embedding
model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
and are recorded as such.
Memory authority, budgeting, observability
Memory.authority is accepted_story or heuristic, classified by the application
and marked in the prompt; retrieval never writes state. The reply is reserved
out of the context budget, and an impossible configuration fails clearly
instead of overflowing. Each derived pass records ok/idle/failed per campaign,
served by GET /adventures/{id}/derived and shown in Insights, so the M2
failure — a dead memory bank with a green suite — is visible if it recurs.
Provider-wiring tests mock no factory.
Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.
Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
177 lines
7.3 KiB
React
177 lines
7.3 KiB
React
// The insights panel: what the last turn sent, and what it cost.
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { api } from '../../../api'
|
||
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
|
||
import { CacheReport, TokenBreakdown, WorldStateReport } from '../reports'
|
||
|
||
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||
const [report, setReport] = useState(null)
|
||
const [error, setError] = useState(null)
|
||
|
||
useEffect(() => {
|
||
let stale = false // a slow earlier request must not clobber a newer one
|
||
setError(null)
|
||
const load = inspectActionId
|
||
? api.getActionContext(advId, inspectActionId)
|
||
: api.getAdventureContext(advId)
|
||
load
|
||
.then((r) => { if (!stale) setReport(r) })
|
||
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
|
||
return () => { stale = true }
|
||
}, [advId, inspectActionId, refreshKey])
|
||
|
||
if (error) return <div className="empty">{error}</div>
|
||
if (!report) return <div className="empty">Loading…</div>
|
||
|
||
const { tokens, cards, history, sections } = report
|
||
const overBudget = tokens.total > tokens.budget
|
||
// The per-section sum, not tokens.total: the total also counts the separators
|
||
// between sections, and percentages have to add up to 100 for the reader.
|
||
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
|
||
const jumpToSection = (i) => {
|
||
document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
return (
|
||
<div className="insights">
|
||
<div className="insights-meta">
|
||
{inspectActionId ? (
|
||
<div className="insights-mode">
|
||
Snapshot of a past turn
|
||
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
|
||
</div>
|
||
) : (
|
||
<div className="insights-mode">What will be sent on the next turn</div>
|
||
)}
|
||
<div className={`token-total ${overBudget ? 'over' : ''}`}>
|
||
{tokens.total} / {tokens.budget} tokens
|
||
{/* M6: the reply has to fit in the same window, so say how much of it
|
||
is being held back for one. Without this the reader can see that
|
||
the prompt fits and still get a truncated turn. */}
|
||
{tokens.output_reserve > 0 && (
|
||
<span className="dim"> · {tokens.output_reserve} reserved for the reply</span>
|
||
)}
|
||
</div>
|
||
<TokenBreakdown
|
||
sections={sections}
|
||
tokens={tokens}
|
||
used={sectionTotal}
|
||
onJump={jumpToSection}
|
||
/>
|
||
<div className="insights-history">
|
||
History: {history.included} of {history.total} actions in context
|
||
{history.total > history.included && ' (older history trimmed)'}
|
||
{history.oldest_truncated && ' — oldest entry cut mid-text'}
|
||
</div>
|
||
<CacheReport usage={report.usage} />
|
||
{cards.length > 0 && (
|
||
<div className="insights-cards">
|
||
{cards.map((c, i) => (
|
||
<div key={i} className={c.included ? '' : 'dropped'}>
|
||
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
|
||
{!c.included && ' — dropped (over card budget)'}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{report.memories && (
|
||
<div className="insights-cards">
|
||
{report.memories.error && (
|
||
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
|
||
)}
|
||
{report.memories.used?.map((m, i) => (
|
||
<div key={i}>
|
||
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`})
|
||
{/* M6: what weight it carries, and where it came from. An
|
||
inference must not read like a record. */}
|
||
{m.authority === 'heuristic'
|
||
? <span className="mem-heuristic"> · inferred</span>
|
||
: <span className="dim"> · from the story</span>}
|
||
{m.source?.depth != null && (
|
||
<span className="dim">
|
||
{' '}· turn {m.source.source_start === m.source.source_end
|
||
? m.source.depth
|
||
: `${m.source.source_start}–${m.source.source_end}`}
|
||
</span>
|
||
)}
|
||
: {m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
|
||
</div>
|
||
))}
|
||
{report.memories.considered != null && (
|
||
<div className="dim">
|
||
{report.memories.considered} memories eligible on this line of the story.
|
||
</div>
|
||
)}
|
||
{!report.memories.error && report.memories.used?.length === 0 && (
|
||
<div className="dim">Memory bank on — no memories retrieved.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* M6: which summary was used, and what stretch of story it covers, so
|
||
"what history did that summary cover?" is answerable here. */}
|
||
{report.summary ? (
|
||
<div className="insights-cards">
|
||
<div>
|
||
▸ summary in use
|
||
<span className="dim">
|
||
{' '}· covers {report.summary.source_start != null
|
||
? `turns ${report.summary.source_start}–${report.summary.source_end}`
|
||
: `up to turn ${report.summary.source_end ?? report.summary.depth}`}
|
||
{report.summary.trigger === 'manual' ? ' · written by you' : ' · generated'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="insights-cards dim">▸ no summary is eligible for this point in the story.</div>
|
||
)}
|
||
{/* M6: a dead memory bank used to be invisible. It is not any more. */}
|
||
{report.derived?.some((d) => d.status === 'failed') && (
|
||
<div className="insights-cards">
|
||
{report.derived.filter((d) => d.status === 'failed').map((d) => (
|
||
<div key={d.kind} className="dropped">
|
||
⚠ Background {d.kind} work is failing ({d.failures}
|
||
{d.failures === 1 ? ' attempt' : ' attempts'}): {d.detail}
|
||
</div>
|
||
))}
|
||
<div className="dim">
|
||
The story itself is unaffected — this only stops new {' '}
|
||
{report.derived.filter((d) => d.status === 'failed')
|
||
.map((d) => d.kind).join(' and ')} work being written.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{sections.map((s, i) => (
|
||
<div
|
||
key={i}
|
||
id={`ctx-sec-${i}`}
|
||
className={`ctx-section ctx-${s.label}`}
|
||
style={{ borderLeftColor: sectionColor(s.label) }}
|
||
>
|
||
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
|
||
<span>{SECTION_LABELS[s.label] || s.label}</span>
|
||
<span>
|
||
{s.tokens} tok
|
||
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
|
||
</span>
|
||
</div>
|
||
<pre>{s.text}</pre>
|
||
</div>
|
||
))}
|
||
<WorldStateReport worldState={report.world_state} />
|
||
{report.raw_output && (
|
||
<div className="ctx-section">
|
||
<div className="ctx-header">
|
||
<span>Raw AI output (before state block was stripped)</span>
|
||
</div>
|
||
<pre>{report.raw_output}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export { InsightsPanel }
|