refactor(ts-migration): Phase 11 batch 4 — the agent layer
All thirteen modules under `src/lib/agents/`. Renamed and annotated; no
logic touched. 13/13 erase to byte-identical JavaScript, and the bundle
still hashes to 74d17e2d…
Project typecheck errors are now 9, down from the 20 this phase started
from. Nothing was suppressed: batch 3's `Frontmatter` cleared 4 and this
batch's annotations cleared 7 more that had been sitting in `agentStore`,
`runtime` and `useAgents` since before the migration.
Two findings, both recorded rather than fixed:
`agentSkillIds(agent)` takes one parameter and is called with two, at
`runtime.ts:83` and `:95`. Not a bug — its own doc comment says so:
subagent skills were deliberately removed from it, because CLAUDE.md §3
makes `subagents` a delegation list rather than a skill list, and
"`agents` is still accepted so every call site keeps working; it is no
longer read." The contract is restored with an overload signature, which
emits no JavaScript — confirmed by reading the emitted output, where
`agentSkillIds` still takes exactly one parameter. Worth knowing that
`agentScopedDisabledWith` therefore computes what `agentScopedDisabled`
computes; that is intended, and `skill-check.mjs` asserts the behaviour
at eleven call sites.
`useAgents` returns five different shapes from eleven `return`
statements, which is the latent problem the migration plan predicted
here. `AgentActionResult` writes them down, but open: `ok` plus four
optional fields. Nothing stops a caller reading `.agent` off a failure
and getting `undefined` — `AgentDetail.jsx` reads `.conflict` and
`.error` off the same value. Closing it properly needs `as const` on
eleven literals so `ok` stops widening to `boolean` and starts
discriminating, which is an edit to these function bodies and not
something a rename may do. The type is the record of the decision, not
the decision.
Writing that interface also corrected my own count. I described four
shapes; the compiler rejected `duplicate`'s `{ ...result, id }` and made
it five.
Other annotations: React Query v5 infers `void` for an unconstrained
`mutationFn` parameter, so both mutations in `agentStore` had their
variables stated; `existingIds = []` in `agentLifecycle` infers
`undefined[]`, which rejects `.includes(id)`, so it is `string[]`; two
accumulators and two inline JSDoc hatches restated as annotations.
Measured against `fc8d7ee`:
typecheck 9 errors, down from 16; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash
type erasure 46/46 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
153
src/lib/agents/conversationInsights.ts
Normal file
153
src/lib/agents/conversationInsights.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What the stored conversations add up to.
|
||||
*
|
||||
* Pure selectors over the archive `history.js` returns: no reading, no React,
|
||||
* no knowledge of who is asking. Both the Insights view and the Conversation
|
||||
* Reviews view read through here, so a figure quoted in one and a list shown in
|
||||
* the other cannot describe different things.
|
||||
*
|
||||
* **Nothing is invented.** Every counter is derived from records that exist,
|
||||
* and a workspace with no conversations returns `empty: true` rather than a row
|
||||
* of zeros. A zero and an absence look identical on a dashboard and mean
|
||||
* completely different things — one says the agent was asked and did nothing,
|
||||
* the other says it has not been asked. The empty flag is what lets the view
|
||||
* say which.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const at = (record) => new Date(record?.updatedAt || 0).getTime();
|
||||
|
||||
/** Records for one agent, or all of them when no agent is named. */
|
||||
export function conversationsForAgent(records = [], agentId = null) {
|
||||
if (!agentId) return [...records];
|
||||
return records.filter((record) => record.agentId === agentId);
|
||||
}
|
||||
|
||||
/** Conversations nobody has rated yet — the queue a reviewer works through. */
|
||||
export const unratedConversations = (records = [], agentId = null) =>
|
||||
conversationsForAgent(records, agentId).filter((record) => !record.feedback);
|
||||
|
||||
/** Counts by a key each record contributes many of. */
|
||||
function tally(records, pick) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
for (const value of pick(record) || []) {
|
||||
if (!value) continue;
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Counts by a key each record contributes one of. */
|
||||
function tallyOne(records, pick) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
const value = pick(record);
|
||||
if (!value) continue;
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The figures an Insights view reports.
|
||||
*
|
||||
* `since` windows the archive; `agentId` narrows it to one agent. Both are
|
||||
* optional, and neither invents a record that is not there.
|
||||
*/
|
||||
export function conversationStats(records = [], { agentId = null, since = null } = {}) {
|
||||
const scoped = conversationsForAgent(records, agentId)
|
||||
.filter((record) => (since ? at(record) >= new Date(since).getTime() : true));
|
||||
|
||||
if (!scoped.length) {
|
||||
return {
|
||||
empty: true,
|
||||
total: 0,
|
||||
turns: 0,
|
||||
averageTurns: 0,
|
||||
pages: 0,
|
||||
days: 0,
|
||||
activeDays: 0,
|
||||
feedback: { up: 0, down: 0, unrated: 0, score: null },
|
||||
byAgent: [],
|
||||
byPage: [],
|
||||
bySkill: [],
|
||||
byTool: [],
|
||||
byDay: [],
|
||||
};
|
||||
}
|
||||
|
||||
const turns = scoped.reduce((sum, record) => sum + (record.turns || 0), 0);
|
||||
|
||||
const up = scoped.filter((r) => r.feedback?.rating === 'up').length;
|
||||
const down = scoped.filter((r) => r.feedback?.rating === 'down').length;
|
||||
const rated = up + down;
|
||||
|
||||
/* Conversations per day, oldest first, over the days that actually have
|
||||
one. Padding out empty days would draw a chart mostly made of zeros and
|
||||
make a quiet week look like an outage. */
|
||||
const perDay = new Map();
|
||||
for (const record of scoped) {
|
||||
const day = new Date(at(record));
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const key = day.toISOString().slice(0, 10);
|
||||
perDay.set(key, (perDay.get(key) || 0) + 1);
|
||||
}
|
||||
const byDay = [...perDay.entries()]
|
||||
.map(([day, count]) => ({ id: day, day, count }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
|
||||
const span = scoped.length
|
||||
? Math.max(1, Math.round((Math.max(...scoped.map(at)) - Math.min(...scoped.map(at))) / DAY) + 1)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
empty: false,
|
||||
total: scoped.length,
|
||||
turns,
|
||||
averageTurns: Math.round((turns / scoped.length) * 10) / 10,
|
||||
pages: new Set(scoped.map((r) => r.contextId).filter(Boolean)).size,
|
||||
/* Days the archive spans, and days anything was actually asked. Reporting
|
||||
only the first would make a busy afternoon look like a busy fortnight. */
|
||||
days: span,
|
||||
activeDays: byDay.length,
|
||||
feedback: {
|
||||
up,
|
||||
down,
|
||||
unrated: scoped.length - rated,
|
||||
/* Null rather than 0 when nothing is rated: a score of zero reads as
|
||||
unanimous disapproval. */
|
||||
score: rated ? Math.round((up / rated) * 100) : null,
|
||||
},
|
||||
byAgent: tallyOne(scoped, (r) => r.agentId),
|
||||
byPage: tallyOne(scoped, (r) => r.pageContext?.page || r.page),
|
||||
bySkill: tally(scoped, (r) => r.skillsUsed),
|
||||
byTool: tally(scoped, (r) => r.toolsUsed),
|
||||
byDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation, reduced to what a review list shows.
|
||||
*
|
||||
* The thread itself is deliberately not included: a list renders forty of these
|
||||
* and only one is ever opened.
|
||||
*/
|
||||
export const reviewRow = (record) => ({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
agentId: record.agentId ?? null,
|
||||
page: record.pageContext?.page || record.page || '',
|
||||
route: record.pageContext?.route ?? null,
|
||||
turns: record.turns || 0,
|
||||
skillsUsed: record.skillsUsed || [],
|
||||
toolsUsed: record.toolsUsed || [],
|
||||
feedback: record.feedback ?? null,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
Reference in New Issue
Block a user