← Public packages
@kentcdodds/friction-log
Qualify and file friction papercuts to GitHub (any owner/repo) or wake Patch for Kody packages; daily sweep of kody-hq/kody friction issues.
src/shared.ts
216 lines · 6.0 KB · TypeScriptexport const SELF_KODY_ID = 'friction-log'
export const owner = 'kody-hq'
export const repo = 'kody'
export const repoSlug = `${owner}/${repo}`
export const repository = `https://github.com/${repoSlug}`
export const ref = 'main'
export const timezone = 'America/Denver'
export const frictionLabel = 'friction'
export const skippedLabel = 'friction-skipped'
export const skippedLabelDescription =
'Daily sweep will not re-investigate until this label is removed.'
export const skippedLabelColor = '6B5D52'
export const maxIssuesInPrompt = 10
export const agentModel = {
id: 'grok-4.6',
params: [
{ id: 'effort', value: 'high' },
{ id: 'fast', value: 'true' },
],
} as const
export type IssueComment = {
id: number
userLogin: string
createdAt: string
body: string
}
export type FrictionIssue = {
number: number
title: string
body: string
htmlUrl: string
userLogin: string
createdAt: string
updatedAt: string
labels: Array<string>
comments: Array<IssueComment>
}
export type EligibilityReason = 'open' | 'skipped'
export type Eligibility = {
eligible: boolean
reason: EligibilityReason
}
export type SweepRecord = {
day: string
scanned_at: string
eligible_count: number
issue_numbers_json: string
spawned: number
agent_id: string | null
agent_url: string | null
skipped: string | null
dry_run: number
}
export type OutcomeRecord = {
agent_id: string
day: string
outcome: string
summary: string
pr_url: string | null
completed_at: string
}
export function truncate(text: unknown, maxLength: number) {
const value = String(text ?? '')
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`
}
/**
* Neutralize untrusted text before it goes into an outbound Cursor prompt.
*
* Kody expands `{{secret:…}}` placeholders in outbound request bodies at fetch
* time, so a literal placeholder quoted in an issue would otherwise trigger a
* host-approval error for api.cursor.com (or, if approved, leak that secret
* into the agent prompt). Breaking the double brace keeps the text readable
* without being a placeholder.
*/
export function sanitizeForPrompt(text: unknown) {
return String(text ?? '')
.replaceAll('`', 'ˋ')
.replaceAll('«', '<')
.replaceAll('»', '>')
.replace(/\{\{(\s*secret\s*:)/gi, '{ {$1')
}
export function agentUrlOf(agent: { id?: string; url?: string } | null) {
if (!agent) return null
return agent.url || (agent.id ? `https://cursor.com/agents/${agent.id}` : null)
}
export function denverDayKey(now = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now)
}
export function normalizeFrictionTitle(title: string) {
const cleaned = String(title ?? '').trim().replace(/\s+/g, " ")
if (!cleaned) return ''
if (/^friction:\s*/i.test(cleaned)) {
return `Friction: ${cleaned.replace(/^friction:\s*/i, "")}`
}
return `Friction: ${cleaned}`
}
export const preliminaryInvestigationMaxChars = 1200
export const relevantFilesMaxCount = 20
export function normalizeRelevantFiles(
files: Array<string> | undefined,
): Array<string> {
if (!Array.isArray(files)) return []
const seen = new Set<string>()
const out: Array<string> = []
for (const entry of files) {
const path = String(entry ?? '').trim()
if (!path || seen.has(path)) continue
seen.add(path)
out.push(path)
if (out.length >= relevantFilesMaxCount) break
}
return out
}
/**
* Build the friction issue / reuse-comment body from structured fields.
* When `body` is set, it wins as-is. Otherwise sections are joined; include a
* short preliminary investigation (what was already looked at) and optional
* relevant files. Do not invent a root cause. Omit the files list when empty.
*/
export function buildFrictionBody(input: {
body?: string
whatHappened?: string
whatYouWanted?: string
howToReproduce?: string
cost?: string
preliminaryInvestigation?: string
relevantFiles?: Array<string>
}) {
const provided = String(input.body ?? '').trim()
if (provided) return provided
const parts: string[] = []
const sections = [
['What happened', input.whatHappened],
['What you wanted', input.whatYouWanted],
['How to reproduce', input.howToReproduce],
['Cost', input.cost],
] as const
for (const [heading, value] of sections) {
const text = String(value ?? '').trim()
if (!text) continue
parts.push(`## ${heading}\n\n${text}`)
}
const investigation = truncate(
String(input.preliminaryInvestigation ?? '').trim(),
preliminaryInvestigationMaxChars,
).trim()
const files = normalizeRelevantFiles(input.relevantFiles)
if (investigation || files.length > 0) {
const block = ['## Preliminary investigation', '']
if (investigation) block.push(investigation)
if (files.length > 0) {
if (investigation) block.push('')
block.push('Relevant files:')
for (const file of files) {
block.push(`- \`${file}\``)
}
}
parts.push(block.join('\n'))
}
return parts.join('\n\n')
}
function normalizeBodyForCompare(text: unknown) {
return String(text ?? '')
.replace(/\r\n/g, '\n')
.replace(/\s+/g, ' ')
.trim()
}
/**
* True when a reuse comment would only repeat text the issue already has
* (same body as the issue itself). Re-filing the identical papercut adds no
* new information, so `./file` skips the "Seen again" comment.
*/
export function isSameFrictionBody(newBody: unknown, existingBody: unknown) {
const next = normalizeBodyForCompare(newBody)
if (!next) return false
return next === normalizeBodyForCompare(existingBody)
}
export type ReuseCommentSkipped = 'same-as-issue-body' | 'disabled' | 'empty-body'
/**
* Decide whether a reuse should skip its "Seen again" comment. Returns the
* reason to skip, or undefined to post the comment.
*/
export function reuseCommentSkipReason(input: {
commentOnReuse: boolean
body: string
sameBodyAsExisting: boolean
}): ReuseCommentSkipped | undefined {
if (!input.commentOnReuse) return 'disabled'
if (!String(input.body ?? '').trim()) return 'empty-body'
if (input.sameBodyAsExisting) return 'same-as-issue-body'
return undefined
}