Files
krow_talent_app/src/lib/agents/conversationInsights.ts
Aravind 55ddaa134a 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
2026-09-18 15:25:35 +05:30

154 lines
5.2 KiB
TypeScript

/**
* 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,
});