@kolisachint/hoocode-agent 0.5.17 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/core/learn/cache.d.ts +58 -0
  3. package/dist/core/learn/cache.d.ts.map +1 -0
  4. package/dist/core/learn/cache.js +120 -0
  5. package/dist/core/learn/cache.js.map +1 -0
  6. package/dist/core/learn/coverage.d.ts +58 -0
  7. package/dist/core/learn/coverage.d.ts.map +1 -0
  8. package/dist/core/learn/coverage.js +142 -0
  9. package/dist/core/learn/coverage.js.map +1 -0
  10. package/dist/core/learn/digest.d.ts +1 -0
  11. package/dist/core/learn/digest.d.ts.map +1 -1
  12. package/dist/core/learn/digest.js +31 -4
  13. package/dist/core/learn/digest.js.map +1 -1
  14. package/dist/core/learn/extract.d.ts +71 -103
  15. package/dist/core/learn/extract.d.ts.map +1 -1
  16. package/dist/core/learn/extract.js +162 -437
  17. package/dist/core/learn/extract.js.map +1 -1
  18. package/dist/core/learn/mine.d.ts +123 -0
  19. package/dist/core/learn/mine.d.ts.map +1 -0
  20. package/dist/core/learn/mine.js +285 -0
  21. package/dist/core/learn/mine.js.map +1 -0
  22. package/dist/core/learn/reduce.d.ts +78 -0
  23. package/dist/core/learn/reduce.d.ts.map +1 -0
  24. package/dist/core/learn/reduce.js +123 -0
  25. package/dist/core/learn/reduce.js.map +1 -0
  26. package/dist/core/learn/state.d.ts +8 -0
  27. package/dist/core/learn/state.d.ts.map +1 -1
  28. package/dist/core/learn/state.js +18 -3
  29. package/dist/core/learn/state.js.map +1 -1
  30. package/dist/core/settings-manager.d.ts +2 -0
  31. package/dist/core/settings-manager.d.ts.map +1 -1
  32. package/dist/core/settings-manager.js +4 -0
  33. package/dist/core/settings-manager.js.map +1 -1
  34. package/dist/core/startup-progress.d.ts +12 -7
  35. package/dist/core/startup-progress.d.ts.map +1 -1
  36. package/dist/core/startup-progress.js +12 -7
  37. package/dist/core/startup-progress.js.map +1 -1
  38. package/dist/extensions/core/learn.d.ts +8 -4
  39. package/dist/extensions/core/learn.d.ts.map +1 -1
  40. package/dist/extensions/core/learn.js +208 -27
  41. package/dist/extensions/core/learn.js.map +1 -1
  42. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  43. package/dist/modes/interactive/components/footer.js +7 -25
  44. package/dist/modes/interactive/components/footer.js.map +1 -1
  45. package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
  46. package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
  47. package/dist/modes/interactive/components/progress-bar.js +77 -0
  48. package/dist/modes/interactive/components/progress-bar.js.map +1 -0
  49. package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
  50. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  51. package/dist/modes/interactive/voice/voice-panel.js +18 -14
  52. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  53. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  54. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  55. package/examples/extensions/sandbox/package.json +1 -1
  56. package/examples/extensions/with-deps/package.json +1 -1
  57. package/package.json +4 -4
  58. package/dist/core/learn/normalize.d.ts +0 -65
  59. package/dist/core/learn/normalize.d.ts.map +0 -1
  60. package/dist/core/learn/normalize.js +0 -245
  61. package/dist/core/learn/normalize.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"voice-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAI/D,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,cAAc,CAAC;AAsDnF,qBAAa,UAAW,YAAW,SAAS;IAe1C,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAf5B,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,aAAa,CAAqB;IAC1C,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,KAAK,CAA6C;IAE1D,YACkB,EAAE,EAAE,GAAG,GAAG,SAAS,EACnB,UAAU,EAAE,MAAM,EAChC;IAIJ,OAAO,CAAC,eAAe;IAWvB,kFAAkF;IAClF,OAAO,IAAI,IAAI,CAKd;IAED,oFAAoF;IACpF,UAAU,IAAI,IAAI,CAAG;IAIrB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAMjD;IAED;;;OAGG;IACH,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAK1E;IAED,cAAc,IAAI,IAAI,CAQrB;IAED,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAKlC;IAED,mEAAmE;IACnE,UAAU,IAAI,IAAI,CAKjB;IAED,eAAe,IAAI,IAAI,CAItB;IAID,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAO3B;IAED,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI7B;IAID;;;;;;OAMG;IACH,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,YAAY;IAOpB,iFAAgF;IAChF,OAAO,CAAC,iBAAiB;IAazB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4C9B;CACD","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * ● Listening · 0:03 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 256;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nfunction formatMb(bytes: number): string {\n\treturn `${Math.round(bytes / (1024 * 1024))} MB`;\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"›\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\t/** Determinate download bar: `·` fill over a dim track with % and MB counts. */\n\tprivate renderDownloadBar(): string {\n\t\tconst total = this.downloadTotal;\n\t\tif (total === null || total <= 0) {\n\t\t\treturn ` ${theme.fg(\"dim\", `downloaded ${formatMb(this.downloadReceived)}…`)}`;\n\t\t}\n\t\tconst ratio = Math.max(0, Math.min(1, this.downloadReceived / total));\n\t\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\t\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(PROGRESS_CELLS - filled));\n\t\tconst pct = `${Math.round(ratio * 100)}%`;\n\t\tconst sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;\n\t\treturn ` ${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${sizes}`)}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"Listening\")} ${theme.fg(\"dim\", `· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
1
+ {"version":3,"file":"voice-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAK/D,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,cAAc,CAAC;AA4DnF,qBAAa,UAAW,YAAW,SAAS;IAe1C,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAf5B,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,aAAa,CAAqB;IAC1C,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,KAAK,CAA6C;IAE1D,YACkB,EAAE,EAAE,GAAG,GAAG,SAAS,EACnB,UAAU,EAAE,MAAM,EAChC;IAIJ,OAAO,CAAC,eAAe;IAWvB,kFAAkF;IAClF,OAAO,IAAI,IAAI,CAKd;IAED,oFAAoF;IACpF,UAAU,IAAI,IAAI,CAAG;IAIrB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAMjD;IAED;;;OAGG;IACH,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAK1E;IAED,cAAc,IAAI,IAAI,CAQrB;IAED,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAKlC;IAED,mEAAmE;IACnE,UAAU,IAAI,IAAI,CAKjB;IAED,eAAe,IAAI,IAAI,CAItB;IAID,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAO3B;IAED,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI7B;IAID;;;;;;OAMG;IACH,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,YAAY;IAOpB;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAIzB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4C9B;CACD","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * ● Listening · 0:03 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { renderDownloadProgress } from \"../components/progress-bar.js\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\n/**\n * A rotation, deliberately not the two-frame `○`/`●` pulse the transcript\n * Loader uses.\n *\n * Frame count is functional here, not cosmetic. Two frames read as the line\n * switching on and off, so the Loader beats them slowly (640ms) to avoid a\n * strobe; enough frames to read as motion can run fast without flickering. This\n * panel animates at 100ms because it also drives the silence countdown, and a\n * two-frame pulse at that cadence is exactly the strobe the Loader avoids.\n */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 256;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"›\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\t/**\n\t * The model download's progress, rendered by the shared progress-bar\n\t * component so this panel and the footer always agree. The panel is wider\n\t * than a footer line, so it asks for a finer track — width is the only thing\n\t * a surface gets to choose.\n\t */\n\tprivate renderDownloadBar(): string {\n\t\treturn ` ${renderDownloadProgress(this.downloadReceived, this.downloadTotal, { cells: PROGRESS_CELLS })}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"Listening\")} ${theme.fg(\"dim\", `· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -23,7 +23,18 @@
23
23
  * disposes it on collapse.
24
24
  */
25
25
  import { truncateToWidth, visibleWidth } from "@kolisachint/hoocode-tui";
26
+ import { renderDownloadProgress } from "../components/progress-bar.js";
26
27
  import { theme } from "../theme/theme.js";
28
+ /**
29
+ * A rotation, deliberately not the two-frame `○`/`●` pulse the transcript
30
+ * Loader uses.
31
+ *
32
+ * Frame count is functional here, not cosmetic. Two frames read as the line
33
+ * switching on and off, so the Loader beats them slowly (640ms) to avoid a
34
+ * strobe; enough frames to read as motion can run fast without flickering. This
35
+ * panel animates at 100ms because it also drives the silence countdown, and a
36
+ * two-frame pulse at that cadence is exactly the strobe the Loader avoids.
37
+ */
27
38
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
39
  // 8 filled levels; index 0 renders as a baseline dot so an empty history still
29
40
  // reads as "a track", not a blank line.
@@ -65,9 +76,6 @@ function lineWithHint(width, leftPlain, leftStyled, hintPlain, hintStyled) {
65
76
  return leftStyled;
66
77
  return truncateToWidth(leftStyled, width, theme.fg("dim", "…"));
67
78
  }
68
- function formatMb(bytes) {
69
- return `${Math.round(bytes / (1024 * 1024))} MB`;
70
- }
71
79
  export class VoicePanel {
72
80
  ui;
73
81
  cancelHint;
@@ -199,18 +207,14 @@ export class VoicePanel {
199
207
  const s = secs % 60;
200
208
  return `${m}:${s.toString().padStart(2, "0")}`;
201
209
  }
202
- /** Determinate download bar: `·` fill over a dim track with % and MB counts. */
210
+ /**
211
+ * The model download's progress, rendered by the shared progress-bar
212
+ * component so this panel and the footer always agree. The panel is wider
213
+ * than a footer line, so it asks for a finer track — width is the only thing
214
+ * a surface gets to choose.
215
+ */
203
216
  renderDownloadBar() {
204
- const total = this.downloadTotal;
205
- if (total === null || total <= 0) {
206
- return ` ${theme.fg("dim", `downloaded ${formatMb(this.downloadReceived)}…`)}`;
207
- }
208
- const ratio = Math.max(0, Math.min(1, this.downloadReceived / total));
209
- const filled = Math.round(ratio * PROGRESS_CELLS);
210
- const bar = theme.fg("accent", "·".repeat(filled)) + theme.fg("dim", "·".repeat(PROGRESS_CELLS - filled));
211
- const pct = `${Math.round(ratio * 100)}%`;
212
- const sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;
213
- return ` ${bar} ${theme.fg("muted", pct)} ${theme.fg("dim", `· ${sizes}`)}`;
217
+ return ` ${renderDownloadProgress(this.downloadReceived, this.downloadTotal, { cells: PROGRESS_CELLS })}`;
214
218
  }
215
219
  render(width) {
216
220
  const spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0];
@@ -1 +1 @@
1
- {"version":3,"file":"voice-panel.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-panel.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * ● Listening · 0:03 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 256;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nfunction formatMb(bytes: number): string {\n\treturn `${Math.round(bytes / (1024 * 1024))} MB`;\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"›\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\t/** Determinate download bar: `·` fill over a dim track with % and MB counts. */\n\tprivate renderDownloadBar(): string {\n\t\tconst total = this.downloadTotal;\n\t\tif (total === null || total <= 0) {\n\t\t\treturn ` ${theme.fg(\"dim\", `downloaded ${formatMb(this.downloadReceived)}…`)}`;\n\t\t}\n\t\tconst ratio = Math.max(0, Math.min(1, this.downloadReceived / total));\n\t\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\t\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(PROGRESS_CELLS - filled));\n\t\tconst pct = `${Math.round(ratio * 100)}%`;\n\t\tconst sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;\n\t\treturn ` ${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${sizes}`)}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"Listening\")} ${theme.fg(\"dim\", `· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
1
+ {"version":3,"file":"voice-panel.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAI1C;;;;;;;;;GASG;AACH,MAAM,cAAc,GAAG,CAAC,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,CAAC,CAAC;AAC1E,+EAA+E;AAC/E,wCAAwC;AACxC,MAAM,WAAW,GAAG,CAAC,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,EAAE,KAAG,CAAC,CAAC;AAClE,MAAM,OAAO,GAAG,GAAG,CAAC;AACpB,MAAM,WAAW,GAAG,GAAG,CAAC;AACxB,iFAAiF;AACjF,qFAAqF;AACrF,MAAM,UAAU,GAAG,CAAC,CAAC;AACrB,kFAAkF;AAClF,kFAAkF;AAClF,MAAM,UAAU,GAAG,IAAI,CAAC;AACxB,oFAAoF;AACpF,MAAM,cAAc,GAAG,EAAE,CAAC;AAE1B,mDAAmD;AACnD,SAAS,UAAU,CAAC,GAAW,EAAU;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,UAAU,CAAC,CAAC,CAAC;IACxD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;IACxD,OAAO,WAAW,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,CAAC,CAAE,CAAC;AAAA,CAC3C;AAED,gFAAgF;AAChF,SAAS,SAAS,CAAC,IAAY,EAAE,GAAW,EAAU;IACrD,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IACxB,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IAC3C,8EAA8E;IAC9E,uEAAuE;IACvE,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;IACpC,OAAO,MAAI,IAAI,EAAE,CAAC;AAAA,CAClB;AAED,kFAAkF;AAClF,SAAS,YAAY,CACpB,KAAa,EACb,SAAiB,EACjB,UAAkB,EAClB,SAAiB,EACjB,UAAkB,EACT;IACT,MAAM,EAAE,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;IACnC,IAAI,SAAS,IAAI,EAAE,GAAG,CAAC,GAAG,YAAY,CAAC,SAAS,CAAC,IAAI,KAAK,EAAE,CAAC;QAC5D,OAAO,UAAU,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,GAAG,EAAE,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC,GAAG,UAAU,CAAC;IACnF,CAAC;IACD,IAAI,EAAE,IAAI,KAAK;QAAE,OAAO,UAAU,CAAC;IACnC,OAAO,eAAe,CAAC,UAAU,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,KAAG,CAAC,CAAC,CAAC;AAAA,CAChE;AAED,MAAM,OAAO,UAAU;IAeJ,EAAE;IACF,UAAU;IAfpB,KAAK,GAAoB,SAAS,CAAC;IACnC,KAAK,GAAG,CAAC,CAAC;IACV,MAAM,GAAa,EAAE,CAAC;IACtB,OAAO,GAAG,EAAE,CAAC;IACb,gBAAgB,GAAG,CAAC,CAAC;IACrB,kBAAkB,GAAG,CAAC,CAAC;IACvB,cAAc,GAAG,2BAAyB,CAAC;IAC3C,aAAa,CAAqB;IAClC,gBAAgB,GAAG,CAAC,CAAC;IACrB,aAAa,GAAkB,IAAI,CAAC;IACpC,WAAW,GAAG,KAAK,CAAC;IACpB,KAAK,CAA6C;IAE1D,YACkB,EAAmB,EACnB,UAAkB,EAClC;kBAFgB,EAAE;0BACF,UAAU;IACzB,CAAC;IAEJ,yEAAyE;IAEjE,eAAe,GAAS;QAC/B,IAAI,IAAI,CAAC,KAAK;YAAE,OAAO;QACvB,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;YAC9B,IAAI,CAAC,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,MAAM,CAAC;YACtD,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,IAAI,CAAC,kBAAkB,GAAG,CAAC,EAAE,CAAC;gBAC7D,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,kBAAkB,GAAG,OAAO,CAAC,CAAC;YAC1E,CAAC;YACD,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;QAAA,CACzB,EAAE,OAAO,CAAC,CAAC;IAAA,CACZ;IAED,kFAAkF;IAClF,OAAO,GAAS;QACf,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC1B,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACxB,CAAC;IAAA,CACD;IAED,oFAAoF;IACpF,UAAU,GAAS,EAAC,CAAC;IAErB,wEAAwE;IAExE,UAAU,CAAC,OAAe,EAAE,MAAe,EAAQ;QAClD,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC;QAC9B,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC;QAC5B,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED;;;OAGG;IACH,mBAAmB,CAAC,aAAqB,EAAE,UAAyB,EAAQ;QAC3E,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QACxB,IAAI,CAAC,gBAAgB,GAAG,aAAa,CAAC;QACtC,IAAI,CAAC,aAAa,GAAG,UAAU,CAAC;QAChC,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,cAAc,GAAS;QACtB,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC;QACzB,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;QACjB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACnC,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,YAAY,CAAC,OAAe,EAAQ;QACnC,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,IAAI,CAAC,kBAAkB,GAAG,OAAO,CAAC;QAClC,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,mEAAmE;IACnE,UAAU,GAAS;QAClB,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC9B,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC;YACzB,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;QAC1B,CAAC;IAAA,CACD;IAED,eAAe,GAAS;QACvB,IAAI,CAAC,KAAK,GAAG,cAAc,CAAC;QAC5B,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,yEAAyE;IAEzE,SAAS,CAAC,GAAW,EAAQ;QAC5B,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACtB,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,WAAW;YAAE,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QAC1D,2EAA2E;QAC3E,6DAA6D;QAC7D,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,GAAG,GAAG,UAAU;YAAE,IAAI,CAAC,UAAU,EAAE,CAAC;QACpE,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,UAAU,CAAC,IAAY,EAAQ;QAC9B,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;YAAE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChD,IAAI,CAAC,EAAE,EAAE,aAAa,EAAE,CAAC;IAAA,CACzB;IAED,yEAAyE;IAEzE;;;;;;OAMG;IACK,cAAc,CAAC,KAAa,EAAU;QAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QACrC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC7C,MAAM,KAAK,GAAG,KAAG,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;QAC7D,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC/F,OAAO,UAAU,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAAA,CAC3C;IAEO,aAAa,CAAC,KAAa,EAAsB;QACxD,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,OAAO,SAAS,CAAC;QACpC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,KAAK,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,KAAG,CAAC,CAAC,CAAC;QACzF,OAAO,IAAI,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,KAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,MAAM,EAAE,CAAC;IAAA,CACtE;IAEO,YAAY,GAAW;QAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAAC,CAAC;QACrE,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,CAAC;QAChC,MAAM,CAAC,GAAG,IAAI,GAAG,EAAE,CAAC;QACpB,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IAAA,CAC/C;IAED;;;;;OAKG;IACK,iBAAiB,GAAW;QACnC,OAAO,IAAI,sBAAsB,CAAC,IAAI,CAAC,gBAAgB,EAAE,IAAI,CAAC,aAAa,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC;IAAA,CAC1G;IAED,MAAM,CAAC,KAAa,EAAY;QAC/B,MAAM,OAAO,GAAG,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,cAAc,CAAC,CAAC,CAAE,CAAC;QACjE,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC;QAClC,MAAM,UAAU,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QACpD,MAAM,KAAK,GAAa,CAAC,EAAE,CAAC,CAAC;QAE7B,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC9B,MAAM,SAAS,GAAG,IAAI,OAAO,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACvD,MAAM,UAAU,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,cAAc,CAAC,EAAE,CAAC;YAC9F,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;YAC9E,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;gBACtB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC;YACtC,CAAC;YACD,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;gBACxB,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;YACzD,CAAC;YACD,OAAO,KAAK,CAAC;QACd,CAAC;QAED,IAAI,IAAI,CAAC,KAAK,KAAK,cAAc,EAAE,CAAC;YACnC,MAAM,SAAS,GAAG,IAAI,OAAO,kBAAgB,CAAC;YAC9C,MAAM,UAAU,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAe,CAAC,EAAE,CAAC;YAC5F,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;YAC9E,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAC1C,IAAI,OAAO;gBAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACjC,OAAO,KAAK,CAAC;QACd,CAAC;QAED,6EAA2E;QAC3E,2EAA2E;QAC3E,wDAAsD;QACtD,MAAM,GAAG,GAAG,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,KAAG,CAAC,CAAC;QACnC,IAAI,SAAS,GAAG,qBAAkB,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;QACxD,IAAI,UAAU,GAAG,IAAI,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAK,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC,EAAE,CAAC;QAC3G,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC9B,MAAM,SAAS,GAAG,CAAC,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;YAC9D,SAAS,IAAI,sBAAqB,SAAS,GAAG,CAAC;YAC/C,UAAU,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,qBAAoB,SAAS,GAAG,CAAC,EAAE,CAAC;QAC3E,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;QAC9E,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC7C,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,OAAO;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACjC,OAAO,KAAK,CAAC;IAAA,CACb;CACD","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * ● Listening · 0:03 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { renderDownloadProgress } from \"../components/progress-bar.js\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\n/**\n * A rotation, deliberately not the two-frame `○`/`●` pulse the transcript\n * Loader uses.\n *\n * Frame count is functional here, not cosmetic. Two frames read as the line\n * switching on and off, so the Loader beats them slowly (640ms) to avoid a\n * strobe; enough frames to read as motion can run fast without flickering. This\n * panel animates at 100ms because it also drives the silence countdown, and a\n * two-frame pulse at that cadence is exactly the strobe the Loader avoids.\n */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 256;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"›\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\t/**\n\t * The model download's progress, rendered by the shared progress-bar\n\t * component so this panel and the footer always agree. The panel is wider\n\t * than a footer line, so it asks for a finer track — width is the only thing\n\t * a surface gets to choose.\n\t */\n\tprivate renderDownloadBar(): string {\n\t\treturn ` ${renderDownloadProgress(this.downloadReceived, this.downloadTotal, { cells: PROGRESS_CELLS })}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"Listening\")} ${theme.fg(\"dim\", `· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-anthropic",
3
3
  "private": true,
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-gitlab-duo",
3
3
  "private": true,
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-sandbox",
3
3
  "private": true,
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-with-deps",
3
3
  "private": true,
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-agent",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "hoocodeConfig": {
@@ -49,9 +49,9 @@
49
49
  "prepublishOnly": "npm run clean && npm run build"
50
50
  },
51
51
  "dependencies": {
52
- "@kolisachint/hoocode-agent-core": "^0.5.17",
53
- "@kolisachint/hoocode-ai": "^0.5.17",
54
- "@kolisachint/hoocode-tui": "^0.5.17",
52
+ "@kolisachint/hoocode-agent-core": "^0.5.18",
53
+ "@kolisachint/hoocode-ai": "^0.5.18",
54
+ "@kolisachint/hoocode-tui": "^0.5.18",
55
55
  "@silvia-odwyer/photon-node": "^0.3.4",
56
56
  "chalk": "^5.5.0",
57
57
  "cli-highlight": "^2.1.11",
@@ -1,65 +0,0 @@
1
- /**
2
- * Normalization and noise filters for the `/learn` extractor.
3
- *
4
- * Everything downstream — clustering, dedupe, cross-session counting — is only
5
- * as good as what happens here. Two runs of the same failing command differ by
6
- * absolute paths, line numbers, PIDs and timings; two statements of the same
7
- * rule differ by politeness and word order. Collapsing that variance is what
8
- * turns a pile of transcripts into counts, and the counts are the product.
9
- *
10
- * These are deliberately lexical and cheap. Semantic grouping is left to the
11
- * model, which sees the deduped candidates and can tell that "run tests with
12
- * coverage" and "use --coverage on the suite" are one rule. Trying to do that
13
- * here would cost a dependency and still be worse.
14
- */
15
- /**
16
- * Collapse a raw error or command output into a comparable signature.
17
- *
18
- * Case is preserved: `TS2307` and `ERR_MODULE_NOT_FOUND` carry meaning that
19
- * lowercasing would blur into surrounding prose, and they are exactly the
20
- * tokens worth matching on.
21
- */
22
- export declare function normalizeErrorSignature(text: string): string;
23
- /**
24
- * Normalize a shell command so two invocations of "the same" command match.
25
- *
26
- * Paths are collapsed but flags are kept — `npm test` and `npm test --coverage`
27
- * are genuinely different commands, and flags are frequently the fix itself.
28
- */
29
- export declare function normalizeCommand(command: string): string;
30
- /**
31
- * The first `tokens` words of what a command actually does, with the
32
- * where-and-how prefixes stripped. `cd <path> && npm run check` becomes
33
- * `npm run`, which is the part worth comparing across sessions.
34
- */
35
- export declare function commandHead(command: string, tokens?: number): string;
36
- export declare function extractErrorRegion(output: string, maxChars?: number): string;
37
- /**
38
- * Output that reports no failure worth learning from: an abort is the user
39
- * changing their mind, and an empty result says nothing at all.
40
- */
41
- export declare function isUninformativeFailure(output: string): boolean;
42
- /**
43
- * Normalize a user directive for clustering: lowercase, strip filler and
44
- * punctuation, collapse whitespace. Unlike error signatures these are compared
45
- * loosely, so case and symbols are noise.
46
- */
47
- export declare function normalizeDirective(text: string): string;
48
- /** Content words of a directive, for overlap comparisons against existing rules. */
49
- export declare function contentWords(text: string): string[];
50
- /**
51
- * Fraction of `words` that appear in `haystack`, 0 when there is nothing to
52
- * compare. Used to decide whether a proposed rule is already written down.
53
- */
54
- export declare function wordOverlap(words: string[], haystack: string): number;
55
- /**
56
- * Whether a user message is worth mining as a potential rule.
57
- *
58
- * Deliberately strict. The extractor's output is read by a model with a limited
59
- * budget, and recall costs precision: every task statement that slips through
60
- * displaces a real recurring directive in the ranking.
61
- */
62
- export declare function isRuleShapedDirective(text: string): boolean;
63
- /** Whether a non-zero exit from this command should be ignored by the fix extractor. */
64
- export declare function isBenignFailure(command: string): boolean;
65
- //# sourceMappingURL=normalize.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../../src/core/learn/normalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAwCH;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAMxD;AAYD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,MAAM,CAO/D;AAgBD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAOzE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAI9D;AAuCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWvD;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEnD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKrE;AAmBD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAU3D;AAYD,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAGxD","sourcesContent":["/**\n * Normalization and noise filters for the `/learn` extractor.\n *\n * Everything downstream — clustering, dedupe, cross-session counting — is only\n * as good as what happens here. Two runs of the same failing command differ by\n * absolute paths, line numbers, PIDs and timings; two statements of the same\n * rule differ by politeness and word order. Collapsing that variance is what\n * turns a pile of transcripts into counts, and the counts are the product.\n *\n * These are deliberately lexical and cheap. Semantic grouping is left to the\n * model, which sees the deduped candidates and can tell that \"run tests with\n * coverage\" and \"use --coverage on the suite\" are one rule. Trying to do that\n * here would cost a dependency and still be worse.\n */\n\n/** Longest text we normalize; error dumps can run to megabytes. */\nconst MAX_SIGNATURE_CHARS = 400;\n\n/**\n * Volatile fragments that make two runs of the same failure look different.\n *\n * Order is load-bearing, because these patterns overlap and the first one to\n * match wins the characters. Longest-and-most-specific therefore runs first:\n * paths before line numbers so `foo.ts:12:3` does not leave a bare `:12:3`, and\n * timestamps before line numbers so the `:00:00` inside `10:00:00` is not\n * mistaken for a line/column pair. Adding a pattern means placing it by\n * specificity, not appending it.\n */\nconst VOLATILE_PATTERNS: Array<[RegExp, string]> = [\n\t// Absolute POSIX and Windows paths, including the temp dirs that change per run.\n\t[/\\/(?:[\\w.@+-]+\\/)+[\\w.@+-]+/g, \"<path>\"],\n\t[/[A-Za-z]:\\\\(?:[\\w.@+-]+\\\\)+[\\w.@+-]+/g, \"<path>\"],\n\t// UUIDs, before the shorter hex rule can eat their first group.\n\t[/\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b/gi, \"<uuid>\"],\n\t// ISO timestamps and clock times, before the line/column rule.\n\t[/\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z?/g, \"<time>\"],\n\t[/\\b\\d{1,2}:\\d{2}:\\d{2}\\b/g, \"<time>\"],\n\t// Ports, before the line/column rule can claim the colon.\n\t[/\\b(?:127\\.0\\.0\\.1|localhost):\\d+/gi, \"localhost:<port>\"],\n\t// file:line:col and bare line references.\n\t[/:\\d+:\\d+/g, \":<line>\"],\n\t[/\\bline \\d+/gi, \"line <line>\"],\n\t// Hex-ish identifiers: git shas, content hashes, addresses.\n\t[/\\b0x[0-9a-f]+\\b/gi, \"<addr>\"],\n\t[/\\b[0-9a-f]{7,40}\\b/gi, \"<hash>\"],\n\t// Durations and byte counts reported by build tools.\n\t[/\\b\\d+(?:\\.\\d+)?\\s?(?:ms|s|sec|secs|seconds|m|min|mins|kb|mb|gb)\\b/gi, \"<qty>\"],\n\t// PIDs, which vary per machine and per run.\n\t[/\\bpid[= ]\\d+/gi, \"pid <pid>\"],\n\t// Anything left that is a bare multi-digit run.\n\t[/\\b\\d{3,}\\b/g, \"<n>\"],\n];\n\n/**\n * Collapse a raw error or command output into a comparable signature.\n *\n * Case is preserved: `TS2307` and `ERR_MODULE_NOT_FOUND` carry meaning that\n * lowercasing would blur into surrounding prose, and they are exactly the\n * tokens worth matching on.\n */\nexport function normalizeErrorSignature(text: string): string {\n\tlet out = text.slice(0, MAX_SIGNATURE_CHARS * 4);\n\tfor (const [pattern, replacement] of VOLATILE_PATTERNS) {\n\t\tout = out.replace(pattern, replacement);\n\t}\n\treturn out.replace(/\\s+/g, \" \").trim().slice(0, MAX_SIGNATURE_CHARS);\n}\n\n/**\n * Normalize a shell command so two invocations of \"the same\" command match.\n *\n * Paths are collapsed but flags are kept — `npm test` and `npm test --coverage`\n * are genuinely different commands, and flags are frequently the fix itself.\n */\nexport function normalizeCommand(command: string): string {\n\tconst collapsed = command\n\t\t.replace(/\\s+/g, \" \")\n\t\t.replace(/(?:^|\\s)\\/(?:[\\w.@+-]+\\/)+[\\w.@+-]+/g, \" <path>\")\n\t\t.trim();\n\treturn collapsed.slice(0, MAX_SIGNATURE_CHARS);\n}\n\n/**\n * Prefixes that say where or how a command runs rather than what it does.\n *\n * Nearly every command an agent runs is wrapped in `cd <somewhere> && …`, and\n * once paths are collapsed that prefix is identical everywhere. Taking the head\n * of the raw command therefore yields `cd <path>` for the whole session, which\n * makes every bash step look like every other one.\n */\nconst COMMAND_PREFIX = /^(?:cd\\s+\\S+\\s*&&\\s*|(?:sudo|env|time|nice|exec)\\s+)/;\n\n/**\n * The first `tokens` words of what a command actually does, with the\n * where-and-how prefixes stripped. `cd <path> && npm run check` becomes\n * `npm run`, which is the part worth comparing across sessions.\n */\nexport function commandHead(command: string, tokens = 2): string {\n\tlet out = normalizeCommand(command);\n\t// Prefixes stack: `cd x && sudo env FOO=1 cmd`.\n\tfor (let i = 0; i < 4 && COMMAND_PREFIX.test(out); i++) {\n\t\tout = out.replace(COMMAND_PREFIX, \"\");\n\t}\n\treturn out.split(\" \").slice(0, tokens).join(\" \").trim();\n}\n\n/**\n * The part of a command's output that is actually the error.\n *\n * Build tools lead with a banner — npm's `> pkg@1.0 check > biome check …`, a\n * compiler's version line — that is byte-identical across every run. Signing a\n * failure by its whole output therefore signs mostly the banner, and two\n * unrelated failures of the same command look like the same problem. Scanning\n * for the first error-shaped line fixes both the signature and what the reader\n * is shown; failing that, the tail beats the head, because tools print the\n * banner first and the verdict last.\n */\nconst ERROR_LINE =\n\t/\\b(?:error|err!|errors?:|failed|failure|cannot|can't|unable to|not found|no such|unexpected|expected|invalid|denied|refused|timed out|traceback|panic|exception|fatal)\\b|^\\s*(?:×|✖|✗)/i;\n\nexport function extractErrorRegion(output: string, maxChars = 400): string {\n\tconst lines = output.split(\"\\n\");\n\tconst start = lines.findIndex((line) => ERROR_LINE.test(line));\n\tif (start >= 0) {\n\t\treturn lines.slice(start).join(\"\\n\").slice(0, maxChars).trim();\n\t}\n\treturn output.slice(-maxChars).trim();\n}\n\n/**\n * Output that reports no failure worth learning from: an abort is the user\n * changing their mind, and an empty result says nothing at all.\n */\nexport function isUninformativeFailure(output: string): boolean {\n\tconst trimmed = output.trim();\n\tif (trimmed.length === 0) return true;\n\treturn /^(?:command aborted|aborted|cancelled|canceled|interrupted|killed|sigint|sigterm)\\b/i.test(trimmed);\n}\n\n/** Filler that carries no rule content, stripped before directives are compared. */\nconst DIRECTIVE_FILLER =\n\t/^(?:please|pls|hey|ok|okay|now|also|and|so|just|quickly|can you|could you|would you|i want you to|i'd like you to|let's|lets)\\b[\\s,]*/i;\n\n/** Words too common to distinguish one directive from another. */\nconst STOPWORDS = new Set([\n\t\"a\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"but\",\n\t\"by\",\n\t\"do\",\n\t\"for\",\n\t\"from\",\n\t\"in\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"of\",\n\t\"on\",\n\t\"or\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"this\",\n\t\"to\",\n\t\"we\",\n\t\"when\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/**\n * Normalize a user directive for clustering: lowercase, strip filler and\n * punctuation, collapse whitespace. Unlike error signatures these are compared\n * loosely, so case and symbols are noise.\n */\nexport function normalizeDirective(text: string): string {\n\tlet out = text.trim();\n\t// Filler can stack (\"ok so please also ...\"), so strip until it stops matching.\n\tfor (let i = 0; i < 4 && DIRECTIVE_FILLER.test(out); i++) {\n\t\tout = out.replace(DIRECTIVE_FILLER, \"\");\n\t}\n\treturn out\n\t\t.toLowerCase()\n\t\t.replace(/[^\\w\\s@./+-]/g, \" \")\n\t\t.replace(/\\s+/g, \" \")\n\t\t.trim();\n}\n\n/** Content words of a directive, for overlap comparisons against existing rules. */\nexport function contentWords(text: string): string[] {\n\treturn [...new Set(normalizeDirective(text).split(\" \"))].filter((w) => w.length > 2 && !STOPWORDS.has(w));\n}\n\n/**\n * Fraction of `words` that appear in `haystack`, 0 when there is nothing to\n * compare. Used to decide whether a proposed rule is already written down.\n */\nexport function wordOverlap(words: string[], haystack: string): number {\n\tif (words.length === 0) return 0;\n\tconst hay = ` ${normalizeDirective(haystack)} `;\n\tconst hits = words.filter((w) => hay.includes(` ${w} `)).length;\n\treturn hits / words.length;\n}\n\n/** Approvals and acknowledgements. These are the bulk of user turns and none are rules. */\nconst ACKNOWLEDGEMENT =\n\t/^(?:y|n|ok|okay|yes|no|yep|yeah|nope|sure|thanks|thank you|ty|go|go ahead|continue|proceed|do it|next|done|good|great|nice|perfect|lgtm|cool|k|fine|stop|wait|hmm|\\?+|\\.+)$/i;\n\n/**\n * Imperative or corrective phrasing — the shape a rule takes when it is spoken\n * aloud. A directive that matches none of these is almost always a task\n * (\"add a button here\"), which is worth doing once and never worth writing down.\n */\nconst RULE_SHAPED =\n\t/\\b(?:always|never|don'?t|do not|avoid|make sure|ensure|must|should|prefer|instead|remember|from now on|going forward|every time|each time|whenever|stop|use|run|keep|only|no need to)\\b/i;\n\n/** Minimum length before a directive is worth considering at all. */\nconst MIN_DIRECTIVE_CHARS = 12;\n/** Above this a \"directive\" is a task description or a pasted spec, not a rule. */\nconst MAX_DIRECTIVE_CHARS = 400;\n\n/**\n * Whether a user message is worth mining as a potential rule.\n *\n * Deliberately strict. The extractor's output is read by a model with a limited\n * budget, and recall costs precision: every task statement that slips through\n * displaces a real recurring directive in the ranking.\n */\nexport function isRuleShapedDirective(text: string): boolean {\n\tconst trimmed = text.trim();\n\tif (trimmed.length < MIN_DIRECTIVE_CHARS || trimmed.length > MAX_DIRECTIVE_CHARS) return false;\n\tif (ACKNOWLEDGEMENT.test(trimmed)) return false;\n\t// Slash commands are invocations, not instructions.\n\tif (trimmed.startsWith(\"/\")) return false;\n\t// Pasted output, diffs and stack traces are not directives.\n\tif (/^(?:```|diff --git|\\s*at\\s|\\{|\\[)/.test(trimmed)) return false;\n\tif (trimmed.split(\"\\n\").length > 6) return false;\n\treturn RULE_SHAPED.test(trimmed);\n}\n\n/**\n * Commands whose non-zero exit is a normal answer rather than a failure.\n *\n * `rg`/`grep` exit 1 on \"no match\", `test`/`diff`/`cmp` exit non-zero to report\n * their result, and a TDD loop is *supposed* to go red. Without this filter the\n * fail→fix extractor is mostly noise, since these are among the most-run\n * commands in any session.\n */\nconst BENIGN_EXIT_COMMANDS = /^(?:rg|grep|egrep|fgrep|ag|ack|test|\\[|diff|cmp|which|command|type|hash|find|fd)\\b/;\n\n/** Whether a non-zero exit from this command should be ignored by the fix extractor. */\nexport function isBenignFailure(command: string): boolean {\n\tconst first = command.trim().replace(/^(?:sudo|env|time|nice)\\s+/, \"\");\n\treturn BENIGN_EXIT_COMMANDS.test(first);\n}\n"]}
@@ -1,245 +0,0 @@
1
- /**
2
- * Normalization and noise filters for the `/learn` extractor.
3
- *
4
- * Everything downstream — clustering, dedupe, cross-session counting — is only
5
- * as good as what happens here. Two runs of the same failing command differ by
6
- * absolute paths, line numbers, PIDs and timings; two statements of the same
7
- * rule differ by politeness and word order. Collapsing that variance is what
8
- * turns a pile of transcripts into counts, and the counts are the product.
9
- *
10
- * These are deliberately lexical and cheap. Semantic grouping is left to the
11
- * model, which sees the deduped candidates and can tell that "run tests with
12
- * coverage" and "use --coverage on the suite" are one rule. Trying to do that
13
- * here would cost a dependency and still be worse.
14
- */
15
- /** Longest text we normalize; error dumps can run to megabytes. */
16
- const MAX_SIGNATURE_CHARS = 400;
17
- /**
18
- * Volatile fragments that make two runs of the same failure look different.
19
- *
20
- * Order is load-bearing, because these patterns overlap and the first one to
21
- * match wins the characters. Longest-and-most-specific therefore runs first:
22
- * paths before line numbers so `foo.ts:12:3` does not leave a bare `:12:3`, and
23
- * timestamps before line numbers so the `:00:00` inside `10:00:00` is not
24
- * mistaken for a line/column pair. Adding a pattern means placing it by
25
- * specificity, not appending it.
26
- */
27
- const VOLATILE_PATTERNS = [
28
- // Absolute POSIX and Windows paths, including the temp dirs that change per run.
29
- [/\/(?:[\w.@+-]+\/)+[\w.@+-]+/g, "<path>"],
30
- [/[A-Za-z]:\\(?:[\w.@+-]+\\)+[\w.@+-]+/g, "<path>"],
31
- // UUIDs, before the shorter hex rule can eat their first group.
32
- [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<uuid>"],
33
- // ISO timestamps and clock times, before the line/column rule.
34
- [/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g, "<time>"],
35
- [/\b\d{1,2}:\d{2}:\d{2}\b/g, "<time>"],
36
- // Ports, before the line/column rule can claim the colon.
37
- [/\b(?:127\.0\.0\.1|localhost):\d+/gi, "localhost:<port>"],
38
- // file:line:col and bare line references.
39
- [/:\d+:\d+/g, ":<line>"],
40
- [/\bline \d+/gi, "line <line>"],
41
- // Hex-ish identifiers: git shas, content hashes, addresses.
42
- [/\b0x[0-9a-f]+\b/gi, "<addr>"],
43
- [/\b[0-9a-f]{7,40}\b/gi, "<hash>"],
44
- // Durations and byte counts reported by build tools.
45
- [/\b\d+(?:\.\d+)?\s?(?:ms|s|sec|secs|seconds|m|min|mins|kb|mb|gb)\b/gi, "<qty>"],
46
- // PIDs, which vary per machine and per run.
47
- [/\bpid[= ]\d+/gi, "pid <pid>"],
48
- // Anything left that is a bare multi-digit run.
49
- [/\b\d{3,}\b/g, "<n>"],
50
- ];
51
- /**
52
- * Collapse a raw error or command output into a comparable signature.
53
- *
54
- * Case is preserved: `TS2307` and `ERR_MODULE_NOT_FOUND` carry meaning that
55
- * lowercasing would blur into surrounding prose, and they are exactly the
56
- * tokens worth matching on.
57
- */
58
- export function normalizeErrorSignature(text) {
59
- let out = text.slice(0, MAX_SIGNATURE_CHARS * 4);
60
- for (const [pattern, replacement] of VOLATILE_PATTERNS) {
61
- out = out.replace(pattern, replacement);
62
- }
63
- return out.replace(/\s+/g, " ").trim().slice(0, MAX_SIGNATURE_CHARS);
64
- }
65
- /**
66
- * Normalize a shell command so two invocations of "the same" command match.
67
- *
68
- * Paths are collapsed but flags are kept — `npm test` and `npm test --coverage`
69
- * are genuinely different commands, and flags are frequently the fix itself.
70
- */
71
- export function normalizeCommand(command) {
72
- const collapsed = command
73
- .replace(/\s+/g, " ")
74
- .replace(/(?:^|\s)\/(?:[\w.@+-]+\/)+[\w.@+-]+/g, " <path>")
75
- .trim();
76
- return collapsed.slice(0, MAX_SIGNATURE_CHARS);
77
- }
78
- /**
79
- * Prefixes that say where or how a command runs rather than what it does.
80
- *
81
- * Nearly every command an agent runs is wrapped in `cd <somewhere> && …`, and
82
- * once paths are collapsed that prefix is identical everywhere. Taking the head
83
- * of the raw command therefore yields `cd <path>` for the whole session, which
84
- * makes every bash step look like every other one.
85
- */
86
- const COMMAND_PREFIX = /^(?:cd\s+\S+\s*&&\s*|(?:sudo|env|time|nice|exec)\s+)/;
87
- /**
88
- * The first `tokens` words of what a command actually does, with the
89
- * where-and-how prefixes stripped. `cd <path> && npm run check` becomes
90
- * `npm run`, which is the part worth comparing across sessions.
91
- */
92
- export function commandHead(command, tokens = 2) {
93
- let out = normalizeCommand(command);
94
- // Prefixes stack: `cd x && sudo env FOO=1 cmd`.
95
- for (let i = 0; i < 4 && COMMAND_PREFIX.test(out); i++) {
96
- out = out.replace(COMMAND_PREFIX, "");
97
- }
98
- return out.split(" ").slice(0, tokens).join(" ").trim();
99
- }
100
- /**
101
- * The part of a command's output that is actually the error.
102
- *
103
- * Build tools lead with a banner — npm's `> pkg@1.0 check > biome check …`, a
104
- * compiler's version line — that is byte-identical across every run. Signing a
105
- * failure by its whole output therefore signs mostly the banner, and two
106
- * unrelated failures of the same command look like the same problem. Scanning
107
- * for the first error-shaped line fixes both the signature and what the reader
108
- * is shown; failing that, the tail beats the head, because tools print the
109
- * banner first and the verdict last.
110
- */
111
- const ERROR_LINE = /\b(?:error|err!|errors?:|failed|failure|cannot|can't|unable to|not found|no such|unexpected|expected|invalid|denied|refused|timed out|traceback|panic|exception|fatal)\b|^\s*(?:×|✖|✗)/i;
112
- export function extractErrorRegion(output, maxChars = 400) {
113
- const lines = output.split("\n");
114
- const start = lines.findIndex((line) => ERROR_LINE.test(line));
115
- if (start >= 0) {
116
- return lines.slice(start).join("\n").slice(0, maxChars).trim();
117
- }
118
- return output.slice(-maxChars).trim();
119
- }
120
- /**
121
- * Output that reports no failure worth learning from: an abort is the user
122
- * changing their mind, and an empty result says nothing at all.
123
- */
124
- export function isUninformativeFailure(output) {
125
- const trimmed = output.trim();
126
- if (trimmed.length === 0)
127
- return true;
128
- return /^(?:command aborted|aborted|cancelled|canceled|interrupted|killed|sigint|sigterm)\b/i.test(trimmed);
129
- }
130
- /** Filler that carries no rule content, stripped before directives are compared. */
131
- const DIRECTIVE_FILLER = /^(?:please|pls|hey|ok|okay|now|also|and|so|just|quickly|can you|could you|would you|i want you to|i'd like you to|let's|lets)\b[\s,]*/i;
132
- /** Words too common to distinguish one directive from another. */
133
- const STOPWORDS = new Set([
134
- "a",
135
- "an",
136
- "and",
137
- "are",
138
- "as",
139
- "at",
140
- "be",
141
- "but",
142
- "by",
143
- "do",
144
- "for",
145
- "from",
146
- "in",
147
- "is",
148
- "it",
149
- "its",
150
- "of",
151
- "on",
152
- "or",
153
- "that",
154
- "the",
155
- "then",
156
- "this",
157
- "to",
158
- "we",
159
- "when",
160
- "with",
161
- "you",
162
- "your",
163
- ]);
164
- /**
165
- * Normalize a user directive for clustering: lowercase, strip filler and
166
- * punctuation, collapse whitespace. Unlike error signatures these are compared
167
- * loosely, so case and symbols are noise.
168
- */
169
- export function normalizeDirective(text) {
170
- let out = text.trim();
171
- // Filler can stack ("ok so please also ..."), so strip until it stops matching.
172
- for (let i = 0; i < 4 && DIRECTIVE_FILLER.test(out); i++) {
173
- out = out.replace(DIRECTIVE_FILLER, "");
174
- }
175
- return out
176
- .toLowerCase()
177
- .replace(/[^\w\s@./+-]/g, " ")
178
- .replace(/\s+/g, " ")
179
- .trim();
180
- }
181
- /** Content words of a directive, for overlap comparisons against existing rules. */
182
- export function contentWords(text) {
183
- return [...new Set(normalizeDirective(text).split(" "))].filter((w) => w.length > 2 && !STOPWORDS.has(w));
184
- }
185
- /**
186
- * Fraction of `words` that appear in `haystack`, 0 when there is nothing to
187
- * compare. Used to decide whether a proposed rule is already written down.
188
- */
189
- export function wordOverlap(words, haystack) {
190
- if (words.length === 0)
191
- return 0;
192
- const hay = ` ${normalizeDirective(haystack)} `;
193
- const hits = words.filter((w) => hay.includes(` ${w} `)).length;
194
- return hits / words.length;
195
- }
196
- /** Approvals and acknowledgements. These are the bulk of user turns and none are rules. */
197
- const ACKNOWLEDGEMENT = /^(?:y|n|ok|okay|yes|no|yep|yeah|nope|sure|thanks|thank you|ty|go|go ahead|continue|proceed|do it|next|done|good|great|nice|perfect|lgtm|cool|k|fine|stop|wait|hmm|\?+|\.+)$/i;
198
- /**
199
- * Imperative or corrective phrasing — the shape a rule takes when it is spoken
200
- * aloud. A directive that matches none of these is almost always a task
201
- * ("add a button here"), which is worth doing once and never worth writing down.
202
- */
203
- const RULE_SHAPED = /\b(?:always|never|don'?t|do not|avoid|make sure|ensure|must|should|prefer|instead|remember|from now on|going forward|every time|each time|whenever|stop|use|run|keep|only|no need to)\b/i;
204
- /** Minimum length before a directive is worth considering at all. */
205
- const MIN_DIRECTIVE_CHARS = 12;
206
- /** Above this a "directive" is a task description or a pasted spec, not a rule. */
207
- const MAX_DIRECTIVE_CHARS = 400;
208
- /**
209
- * Whether a user message is worth mining as a potential rule.
210
- *
211
- * Deliberately strict. The extractor's output is read by a model with a limited
212
- * budget, and recall costs precision: every task statement that slips through
213
- * displaces a real recurring directive in the ranking.
214
- */
215
- export function isRuleShapedDirective(text) {
216
- const trimmed = text.trim();
217
- if (trimmed.length < MIN_DIRECTIVE_CHARS || trimmed.length > MAX_DIRECTIVE_CHARS)
218
- return false;
219
- if (ACKNOWLEDGEMENT.test(trimmed))
220
- return false;
221
- // Slash commands are invocations, not instructions.
222
- if (trimmed.startsWith("/"))
223
- return false;
224
- // Pasted output, diffs and stack traces are not directives.
225
- if (/^(?:```|diff --git|\s*at\s|\{|\[)/.test(trimmed))
226
- return false;
227
- if (trimmed.split("\n").length > 6)
228
- return false;
229
- return RULE_SHAPED.test(trimmed);
230
- }
231
- /**
232
- * Commands whose non-zero exit is a normal answer rather than a failure.
233
- *
234
- * `rg`/`grep` exit 1 on "no match", `test`/`diff`/`cmp` exit non-zero to report
235
- * their result, and a TDD loop is *supposed* to go red. Without this filter the
236
- * fail→fix extractor is mostly noise, since these are among the most-run
237
- * commands in any session.
238
- */
239
- const BENIGN_EXIT_COMMANDS = /^(?:rg|grep|egrep|fgrep|ag|ack|test|\[|diff|cmp|which|command|type|hash|find|fd)\b/;
240
- /** Whether a non-zero exit from this command should be ignored by the fix extractor. */
241
- export function isBenignFailure(command) {
242
- const first = command.trim().replace(/^(?:sudo|env|time|nice)\s+/, "");
243
- return BENIGN_EXIT_COMMANDS.test(first);
244
- }
245
- //# sourceMappingURL=normalize.js.map