{"version":3,"file":"cluster.d.ts","sourceRoot":"","sources":["../../../src/core/learn/cluster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAkB,KAAK,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAehD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,SAAmB,GAAG,MAAM,EAAE,CAIjF;AAOD,2FAA2F;AAC3F,MAAM,WAAW,YAAY;IAC5B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,CACvB,MAAM,EAAE,YAAY,EAAE,EACtB,WAAW,EAAE,MAAM,EAAE,EACrB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAqBlC,qDAAqD;AACrD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,MAAM,CAgB1F;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAiC5F;AAED,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CA2CjE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQlD","sourcesContent":["/**\n * The naming pass: decide which occurrences are the same point.\n *\n * This is the stage the pipeline was missing. Mining is a map over sessions and\n * counting is a reduce over labels, but nothing sat in between to agree on what\n * the labels *are*. The miner was asked to produce them from inside a single\n * session — to hit a shared vocabulary it had never seen — and on a real corpus\n * it agreed with itself 3 times in 188 candidates. `use-bun-not-npm` and\n * `prefer-bun-over-npm` are the same rule and never met.\n *\n * So naming happens once, with everything visible at the same time. That is a\n * different question than the miner was being asked: not \"what is a good name\n * for this sentence\" but \"which of these sentences are the same point\", which\n * is only answerable in the presence of the others.\n *\n * Two properties matter more than elegance here:\n *\n * - **Stability across runs.** State keys are `directive:<label>`, so a label\n *   that drifts between runs silently breaks suppression — every proposal you\n *   already decided on comes back forever. The labels already on record are\n *   therefore sent as a preferred vocabulary, and reusing one is the first\n *   instruction the model gets.\n * - **Degrading in order.** A window too large for one call is processed in\n *   sequence, with the names assigned so far carried into the next call. That is\n *   worse than seeing everything at once, but it is worse in a predictable\n *   direction: later candidates join earlier clusters rather than starting\n *   rival ones.\n */\n\nimport { completeSimple, type Model } from \"@kolisachint/hoocode-ai\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Candidates named per call.\n *\n * Sized so a typical window is one call: 200 quotes at ~120 characters is well\n * inside a small model's window with room for the reply. Past that, clustering\n * quality would degrade anyway — a list nobody can hold in mind is one nobody\n * names consistently.\n */\nconst MAX_CANDIDATES_PER_CALL = 200;\n\n/** Known labels offered as vocabulary. Enough to cover a real state file, short of flooding the prompt. */\nconst MAX_KNOWN_LABELS = 150;\n\n/**\n * Trim the vocabulary to what fits, keeping both ends.\n *\n * The list is ordered: labels already on record first, then names invented\n * earlier in this run. Those are two different anchors — the first keeps the\n * bookmark matching across runs, the second keeps a split window from starting\n * rival names for one point — and taking a plain prefix silently drops the\n * second exactly when batching makes it necessary.\n */\nexport function trimVocabulary(labels: string[], max = MAX_KNOWN_LABELS): string[] {\n\tif (labels.length <= max) return labels;\n\tconst head = Math.ceil(max / 2);\n\treturn [...labels.slice(0, head), ...labels.slice(-(max - head))];\n}\n\n/** Quote characters sent per candidate. A directive is identifiable long before this. */\nconst QUOTE_CHARS = 240;\n\nconst MAX_RESPONSE_TOKENS = 4_000;\n\n/** One candidate to be named, with the identity the caller needs to put the label back. */\nexport interface ClusterInput {\n\t/** Caller's handle for this candidate; returned untouched. */\n\tid: number;\n\tkind: MinedCandidate[\"kind\"];\n\ttext: string;\n}\n\n/**\n * Assign a label to each input. Missing entries are left for the caller to\n * handle; a clusterer may legitimately decline to name something.\n */\nexport type Clusterer = (\n\tinputs: ClusterInput[],\n\tknownLabels: string[],\n\tsignal?: AbortSignal,\n) => Promise<Map<number, string>>;\n\nconst CLUSTER_SYSTEM_PROMPT = `You group occurrences from coding sessions by what they MEAN, and give each group a name.\n\nYou are given numbered ITEMS. Each is something a user said, or something that happened, across many sessions. Different sessions phrase the same point differently — your job is to recognise that and name the point once.\n\nRules, in order of importance:\n\n1. If a label in KNOWN LABELS already names the point, reuse it EXACTLY. These are names already on record; reusing one is how a proposal the reader already decided on stays decided. Do not invent a synonym for a label that exists.\n2. Items meaning the same thing MUST get the same label, even when the wording shares no words.\n   - \"we're on bun now\" / \"stop using npm install\" / \"pnpm isn't what we use here\" → use-bun-not-npm\n   - \"never force push\" / \"don't rewrite shared history\" → never-force-push\n3. Items meaning different things MUST NOT share a label, even when the wording is similar. \"doc tools off by default\" and \"network tools off by default\" are the same shape and different rules.\n4. A label is a short kebab-case slug naming the point, 2-5 words. Name the point, not the session it came from.\n5. Never group across kinds. A directive (\"how work should be done\") and a request (\"do this piece of work\") are never the same item, even when they are about the same subject.\n\nOutput STRICT JSON, no markdown fence, no prose. One entry per item, using the item's number:\n{\"labels\":[{\"id\":1,\"label\":\"use-bun-not-npm\"},{\"id\":2,\"label\":\"use-bun-not-npm\"}]}\n\nEvery item gets exactly one label. An item that means something no other item means still gets its own label — a group of one is a normal answer.`;\n\n/** Render the numbered list the prompt describes. */\nexport function renderClusterRequest(inputs: ClusterInput[], knownLabels: string[]): string {\n\tconst lines: string[] = [];\n\n\tif (knownLabels.length > 0) {\n\t\tlines.push(\"KNOWN LABELS (reuse exactly when one fits):\");\n\t\tfor (const label of trimVocabulary(knownLabels)) lines.push(`- ${label}`);\n\t\tlines.push(\"\");\n\t}\n\n\tlines.push(\"ITEMS:\");\n\tfor (const input of inputs) {\n\t\tconst quote = input.text.length > QUOTE_CHARS ? `${input.text.slice(0, QUOTE_CHARS)}…` : input.text;\n\t\tlines.push(`${input.id}. [${input.kind}] ${quote.replace(/\\s+/g, \" \")}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Read the label assignments out of a model response.\n *\n * Same forgiving parse as the miner: models fence JSON they were told not to\n * fence, and one unparseable response should cost the run its grouping, not its\n * life. An id the caller never asked about is dropped rather than trusted.\n */\nexport function parseClusterLabels(response: string, known: Set<number>): Map<number, string> {\n\tconst out = new Map<number, string>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { labels?: unknown })?.labels;\n\tif (!Array.isArray(raw)) return out;\n\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst entry = item as Record<string, unknown>;\n\t\tconst id = typeof entry.id === \"number\" ? entry.id : Number.NaN;\n\t\tconst label = typeof entry.label === \"string\" ? entry.label.trim().toLowerCase() : \"\";\n\t\tif (!Number.isInteger(id) || !known.has(id) || !label) continue;\n\t\t// Normalized to the slug shape the state file keys on, so a model that\n\t\t// answers \"Use Bun Not Npm\" does not fork the vocabulary on punctuation.\n\t\tout.set(\n\t\t\tid,\n\t\t\tlabel\n\t\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t\t.slice(0, 60),\n\t\t);\n\t}\n\treturn out;\n}\n\nexport interface ClustererDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmClusterer(deps: ClustererDeps): Clusterer {\n\treturn async (inputs, knownLabels, signal) => {\n\t\tconst assigned = new Map<number, string>();\n\t\t// Labels invented in an earlier batch join the vocabulary for the next, so\n\t\t// a split window still converges on one name per point.\n\t\tconst vocabulary = [...knownLabels];\n\n\t\tfor (let offset = 0; offset < inputs.length; offset += MAX_CANDIDATES_PER_CALL) {\n\t\t\tif (signal?.aborted) break;\n\t\t\tconst batch = inputs.slice(offset, offset + MAX_CANDIDATES_PER_CALL);\n\n\t\t\tconst response = await completeSimple(\n\t\t\t\tdeps.model,\n\t\t\t\t{\n\t\t\t\t\tsystemPrompt: CLUSTER_SYSTEM_PROMPT,\n\t\t\t\t\tmessages: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\trole: \"user\",\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: renderClusterRequest(batch, vocabulary) }],\n\t\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t\t);\n\n\t\t\tif (response.stopReason === \"error\") {\n\t\t\t\tthrow new Error(response.errorMessage || \"clustering call failed\");\n\t\t\t}\n\n\t\t\tconst text = response.content\n\t\t\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t\t\t.map((block) => block.text)\n\t\t\t\t.join(\"\");\n\n\t\t\tfor (const [id, label] of parseClusterLabels(text, new Set(batch.map((item) => item.id)))) {\n\t\t\t\tassigned.set(id, label);\n\t\t\t\tif (!vocabulary.includes(label)) vocabulary.push(label);\n\t\t\t}\n\t\t}\n\n\t\treturn assigned;\n\t};\n}\n\n/**\n * Fallback naming for a candidate the clusterer did not label.\n *\n * A run whose clustering call failed should still propose something, so an\n * unlabelled candidate falls back to a slug of its own text. That groups\n * identical wording and nothing else — the behaviour the pipeline had before\n * clustering existed, which is the right floor to fail to.\n */\nexport function fallbackLabel(text: string): string {\n\treturn (\n\t\ttext\n\t\t\t.toLowerCase()\n\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t.slice(0, 60) || \"unlabelled\"\n\t);\n}\n"]}