Skip to content

refactor: DRY consolidation — shared utility modules#19

Merged
genesiscz merged 7 commits intomasterfrom
feat/dry
Feb 9, 2026
Merged

refactor: DRY consolidation — shared utility modules#19
genesiscz merged 7 commits intomasterfrom
feat/dry

Conversation

@genesiscz
Copy link
Owner

@genesiscz genesiscz commented Feb 9, 2026

Summary

  • 11 new shared utility modules under src/utils/ centralizing duplicated functions from across the codebase
  • 12 consumer files migrated to use shared imports instead of local implementations
  • Net: +669 new shared code, -411 removed duplicates = cleaner, more maintainable codebase

New modules

Module Functions
format.ts formatDuration (4 styles), formatBytes, formatCost, formatTokens, formatRelativeTime, formatList, formatNumber
string.ts slugify, stripAnsi, escapeShellArg, removeDiacritics, truncateText, sanitizeOutput
array.ts wrapArray
async.ts retry, debounce, throttle, withTimeout
fuzzy-match.ts expanded with levenshteinDistance, similarityScore
tokens.ts estimateTokens, countTokens, limitToTokens
date.ts getMonthDateRange, getDatesInMonth
json.ts parseJSON
object.ts isObject, deepMerge
verbose.ts createVerboseLogger
claude/index.ts getClaudeProjectsDir, parseJsonlTranscript
markdown/html-to-md.ts htmlToMarkdown

Migrated consumers

azure-devops (utils, history, history-search), timely (date, entry-processor), ask (helpers, cli), files-to-prompt, last-changes, mcp-ripgrep, mcp-tsc, colors.ts

Key design decisions

  • formatDuration uses a style parameter (tiered/hm-always/hm-smart/hms) to preserve exact per-caller output
  • formatRelativeTime uses options (maxDays, fallbackFormat, rounding) for caller-specific behavior
  • All migrations use re-exports or thin wrappers for backward compatibility — zero breaking changes to external consumers

Test plan

  • tsgo --noEmit passes (no new type errors)
  • Smoke-tested all format utilities (duration styles, bytes, cost, tokens, relative time)
  • Smoke-tested string utilities (slugify, removeDiacritics, stripAnsi, escapeShellArg, truncateText)
  • Smoke-tested array/fuzzy-match utilities

Summary by CodeRabbit

  • Refactor

    • Centralized formatting, parsing, and string utilities for consistent outputs across the app.
  • New Features

    • Added many shared helpers (formatting, date, tokens, async, string, fuzzy, json, array, verbose, Claude helpers) improving consistency and APIs.
    • Timelog: new --from / --to range flags and work‑item titles shown alongside IDs.
    • Improved CLI messaging when enabling/toggling servers (clearer no‑changes/installation prompts).
  • Documentation

    • Updated Azure DevOps and timelog docs/examples and command names.

Create 11 new shared utility modules under src/utils/ that centralize
duplicated functions from across the codebase: format, string, array,
async, tokens, date, json, object, verbose, claude, and markdown.
Expand fuzzy-match with levenshtein/similarity. Replace colors.ts
function bodies with re-exports from format.ts.
Replace local implementations with imports from shared utils:
- formatDuration (4 styles) in history, timely, ask/cli
- formatBytes in files-to-prompt, ask/cli
- formatRelativeTime in last-changes, azure-devops/utils
- levenshtein/similarity in azure-devops/utils
- htmlToMarkdown, slugify in azure-devops/utils
- removeDiacritics in azure-devops/history (replaces DIACRITICS_MAP)
- wrapArray in mcp-ripgrep, mcp-tsc
- stripAnsi, escapeShellArg in mcp-ripgrep
- Generic utils (retry, debounce, etc.) in ask/helpers

Net: -411 lines across 12 files via re-exports and thin wrappers.
Copilot AI review requested due to automatic review settings February 9, 2026 04:34
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Centralizes many local utilities into new shared modules under src/utils/, replacing in-file implementations with imports/re-exports; adds new utilities (format, string, date, async, tokens, json, fuzzy-match, array, claude, verbose) and updates Azure DevOps timelog flows to track/display work item titles and add --from/--to options.

Changes

Cohort / File(s) Summary
Core formatting
src/utils/format.ts, src/utils/prompts/colors.ts, src/ask/utils/cli.ts, src/files-to-prompt/index.ts, src/last-changes/index.ts, src/timely/utils/date.ts, src/timely/utils/entry-processor.ts, src/azure-devops/commands/history.ts, src/azure-devops/commands/history-search.ts
Add centralized formatting helpers (durations, relative time, bytes, tokens, cost, lists, numbers). Replace local duration/bytes logic with calls or re-exports to src/utils/format.ts.
String & markdown
src/utils/string.ts, src/utils/markdown/html-to-md.ts, src/azure-devops/history.ts, src/azure-devops/utils.ts, src/mcp-ripgrep/index.ts
Introduce shared string utilities (slugify, stripAnsi, escapeShellArg, removeDiacritics, truncate/sanitize) and HTML→Markdown converter; consuming modules delegate diacritic/slug/ANSI handling and HTML→MD to these utilities.
Async / array / object helpers
src/utils/async.ts, src/utils/array.ts, src/utils/object.ts, src/mcp-tsc/utils/helpers.ts
Add retry/debounce/throttle/withTimeout, wrapArray, isObject, deepMerge. Replace local implementations with re-exports/imports.
Tokens & JSON
src/utils/tokens.ts, src/utils/json.ts, src/ask/utils/helpers.ts
Add token estimation/counting/truncation and safe JSON parser; ask helpers now re-export token/format utilities instead of local code.
Fuzzy matching
src/utils/fuzzy-match.ts, src/azure-devops/utils.ts
Add Levenshtein distance and similarityScore; consume/re-export in Azure DevOps utils.
Claude & verbose helpers
src/utils/claude/index.ts, src/utils/verbose.ts
New Claude project helpers (paths, JSONL parsing) and scoped verbose logger factory.
Azure DevOps timelog & workitem titles
src/azure-devops/commands/timelog/list.ts, src/azure-devops/commands/timelog/import.ts, src/azure-devops/commands/timelog/prepare-import.ts
Add --from/--to aliases and unified date resolution; support --user @me``; cache and propagate work item titles through precheck, storage (_workitemTitle), logs and created-entry messages.
MCP & tooling
src/mcp-ripgrep/index.ts, src/mcp-tsc/utils/helpers.ts, src/mcp-manager/commands/toggle-server.ts
Replace local ANSI/shell/wrap utilities with shared imports; toggle-server handles install rejection and logs NoChanges when applicable.
Docs / skills
plugins/.../SKILL.md
Docs updated for Azure DevOps/timelog commands (flag names, examples, --format json usage, command name fixes).

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI
  participant Precheck as PrecheckService
  participant AD as AzureDevOpsAPI
  participant Storage as LocalStorage

  CLI->>Precheck: run precheck(entries)
  Precheck->>AD: fetch workitem metadata (id or redirect)
  AD-->>Precheck: return title, type, redirectedId?
  Precheck->>Precheck: store precheckRedirected entries (original/redirected title/type)
  Precheck->>CLI: return precheck results
  CLI->>Storage: persist entries with _workitemTitle when available
  CLI->>CLI: display dry-run/created logs including "#id Title" when titles exist
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 I hopped through files, one by one,
Gathered helpers till the job was done,
Durations, bytes, tokens in a row,
A tidy burrow where utilities grow,
Nibbles of code — neat and fun! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'refactor: DRY consolidation — shared utility modules' clearly and concisely summarizes the primary change: consolidating duplicate utility functions into shared modules following the DRY principle.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/dry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @genesiscz, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly refactors the codebase by consolidating numerous duplicated utility functions into a new set of shared modules. The primary goal is to adhere to the DRY (Don't Repeat Yourself) principle, enhancing code maintainability, consistency, and reducing the overall footprint of redundant logic. This change streamlines development by providing a single source of truth for common operations, making future enhancements and bug fixes more efficient.

Highlights

  • New Shared Utility Modules: Eleven new utility modules have been introduced under src/utils/ to centralize commonly used functions. These modules cover areas such as formatting (format.ts), string manipulation (string.ts), array operations (array.ts), asynchronous utilities (async.ts), fuzzy matching (fuzzy-match.ts), token handling (tokens.ts), date operations (date.ts), JSON parsing (json.ts), object manipulation (object.ts), verbose logging (verbose.ts), Claude-specific utilities (claude/index.ts), and HTML to Markdown conversion (markdown/html-to-md.ts).
  • Migration of Consumer Files: Twelve existing files across various parts of the codebase have been refactored to utilize these new shared utility modules. This involved replacing local implementations of functions with imports from the centralized src/utils/ directory. Affected areas include azure-devops commands and utilities, timely date and entry processing, ask helpers and CLI, files-to-prompt, last-changes, mcp-ripgrep, mcp-tsc, and colors.ts.
  • Code Consolidation and Maintainability: The refactoring effort resulted in a net increase of 669 lines of new shared code and a reduction of 411 lines of duplicated code. This significantly improves code maintainability, reduces redundancy, and ensures consistent behavior across the application for common operations.
  • Backward Compatibility: All migrations were implemented with backward compatibility in mind, primarily through re-exports or thin wrappers, ensuring no breaking changes to external consumers of the refactored functions.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/ask/utils/cli.ts
    • Replaced local formatElapsedTime and formatBytes implementations with imports from ../../utils/format.
  • src/ask/utils/helpers.ts
    • Removed local implementations of estimateTokens, formatTokens, formatCost, formatDuration, truncateText, sanitizeOutput, parseJSON, debounce, throttle, retry, formatFileSize, isObject, deepMerge, and withTimeout.
    • Re-exported these utilities from the new shared utils modules (tokens, format, string, json, object, async).
  • src/azure-devops/commands/history-search.ts
    • Replaced local formatDuration function with _formatDuration imported from @app/utils/format.
  • src/azure-devops/commands/history.ts
    • Replaced local formatDuration function with _formatDuration imported from @app/utils/format.
  • src/azure-devops/history.ts
    • Removed the DIACRITICS_MAP constant and manual diacritic removal logic.
    • Utilized the new removeDiacritics function imported from @app/utils/string.
  • src/azure-devops/utils.ts
    • Removed local htmlToMarkdown, slugify, getRelativeTime, levenshteinDistance, and similarityScore functions.
    • Re-exported htmlToMarkdown from @app/utils/markdown/html-to-md, slugify from @app/utils/string, formatRelativeTime from @app/utils/format, and levenshteinDistance, similarityScore from @app/utils/fuzzy-match.
  • src/files-to-prompt/index.ts
    • Replaced local formatFileSize function with _formatBytes imported from @app/utils/format.
  • src/last-changes/index.ts
    • Replaced local formatRelativeTime function with _formatRelativeTime imported from @app/utils/format, adapting options for maxDays and fallbackFormat.
  • src/mcp-ripgrep/index.ts
    • Removed local stripAnsiEscapeCodes, wrapArray, and escapeShellArg functions.
    • Imported stripAnsi and escapeShellArg from @app/utils/string and wrapArray from @app/utils/array.
    • Updated calls to use the newly imported utility functions.
  • src/mcp-tsc/utils/helpers.ts
    • Replaced local wrapArray function with a re-export from ../../utils/array.
  • src/timely/utils/date.ts
    • Removed local getMonthDateRange, getDatesInMonth, and formatDuration functions.
    • Re-exported getMonthDateRange and getDatesInMonth from @app/utils/date.
    • Replaced local formatDuration with _formatDuration imported from @app/utils/format.
  • src/timely/utils/entry-processor.ts
    • Replaced local formatDuration function with _formatDuration imported from @app/utils/format.
  • src/utils/array.ts
    • Added new utility module array.ts containing the wrapArray function.
  • src/utils/async.ts
    • Added new utility module async.ts containing retry, debounce, throttle, and withTimeout functions.
  • src/utils/claude/index.ts
    • Added new utility module claude/index.ts containing getClaudeProjectsDir and parseJsonlTranscript functions.
  • src/utils/date.ts
    • Added new utility module date.ts containing getMonthDateRange and getDatesInMonth functions.
  • src/utils/format.ts
    • Added new utility module format.ts containing formatDuration (with multiple styles), formatRelativeTime, formatBytes, formatCost, formatTokens, formatList, and formatNumber functions.
  • src/utils/fuzzy-match.ts
    • Expanded fuzzy-match.ts to include levenshteinDistance and similarityScore functions.
  • src/utils/json.ts
    • Added new utility module json.ts containing the parseJSON function.
  • src/utils/markdown/html-to-md.ts
    • Added new utility module markdown/html-to-md.ts containing the htmlToMarkdown function.
  • src/utils/object.ts
    • Added new utility module object.ts containing isObject and deepMerge functions.
  • src/utils/prompts/colors.ts
    • Removed local formatList, formatBytes, and formatDuration functions.
    • Re-exported these formatting utilities from the new ../format module.
  • src/utils/string.ts
    • Added new utility module string.ts containing slugify, stripAnsi, escapeShellArg, removeDiacritics, truncateText, and sanitizeOutput functions.
  • src/utils/tokens.ts
    • Added new utility module tokens.ts containing estimateTokens, countTokens, and limitToTokens functions.
  • src/utils/verbose.ts
    • Added new utility module verbose.ts containing the createVerboseLogger function.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request is a large-scale refactoring that centralizes many duplicated utility functions into new shared modules under src/utils/. This is an excellent change that significantly improves code maintainability and consistency by reducing code duplication (DRY principle). The new utility modules for formatting, strings, async operations, and more are well-structured. The migration of existing files to use these new shared utilities appears to be handled carefully, with attention to preserving existing functionality through different formatting styles. I found one minor discrepancy in the output of a refactored function, which I've commented on. Overall, this is a high-quality refactoring that will benefit the codebase long-term.

Comment on lines 223 to 225
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
return _formatDuration(minutes, "min", "hm-smart");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The refactoring to use the shared formatDuration with the hm-smart style introduces a subtle change in the output format compared to the original implementation.

The previous implementation would format a duration of exactly N hours (e.g., 120 minutes) as Nh 0m. The new hm-smart style will format this as Nh.

For example:

  • Old: formatDuration(120) -> '2h 0m'
  • New: _formatDuration(120, 'min', 'hm-smart') -> '2h'

While this might be a desired improvement, it contradicts the goal of preserving the exact per-caller output mentioned in the pull request description. If the original format is important, you might consider adjusting the hm-smart style or using a different style.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mcp-ripgrep/index.ts (1)

18-22: ⚠️ Potential issue | 🟠 Major

Debug log left in processOutput; useColors parameter is unused.

Line 20 logs "searchRoot" to stderr on every call—this looks like a leftover debug statement. Additionally, the useColors parameter is never read inside the function body, making it dead code. Callers pass false or useColors but the value is ignored.

Proposed fix
-function processOutput(output: string, useColors: boolean): string {
+function processOutput(output: string): string {
     if (!output) return output;
-    console.error("searchRoot", searchRoot);
     return searchRoot ? output.replaceAll(searchRoot, ".") : output;
 }

If useColors was meant to gate ANSI stripping, the body should call stripAnsi when colors are unwanted. Otherwise, drop the parameter and update all call sites.

🤖 Fix all issues with AI agents
In `@src/utils/async.ts`:
- Around line 119-130: withTimeout currently creates a setTimeout that is never
cleared when the input promise settles, keeping the event loop alive; modify
withTimeout to store the timer handle returned by setTimeout and clear it
(clearTimeout) as soon as either the input promise or the timeout wins (i.e., on
settlement of the returned race), or restructure to use an AbortController tied
to the timer and abort/cleanup on settlement; update the function (withTimeout)
so the timer is cancelled in all resolution/rejection paths to prevent the timer
leak.
- Around line 82-91: The generic parameter constraint for debounce (function
debounce) and throttle (function throttle) is too narrow—using T extends
(...args: unknown[]) => void causes contravariance issues for callers; change
the constraint to use any (e.g., T extends (...args: any[]) => any or T extends
(...args: any[]) => void) so functions with concrete parameter types are
assignable, update the timeout/return types accordingly (keep ReturnType<typeof
setTimeout> for timeout) and ensure the returned function signature remains
(...args: Parameters<T>) => ReturnType<T> or void to preserve typing for
callers.

In `@src/utils/claude/index.ts`:
- Around line 30-34: Before calling createReadStream(filePath) in this module,
add an existence/access check for filePath (e.g., using fs.existsSync or
fs.promises.access) and handle the missing file case by throwing a clear error
or returning early; update the logic around fileStream and the readline
interface (rl / createInterface) to only create them after confirming the file
is present to avoid uncaught ENOENT from createReadStream.

In `@src/utils/date.ts`:
- Around line 28-34: The loop mutates local-time fields while parsing and
outputting UTC, causing off-by-one dates; fix by treating dates in UTC only:
parse since/upto into UTC-based Dates (or construct via Date.UTC using the
year/month/day parsed from the input strings), then when advancing use UTC
methods (e.g., current.setUTCDate(current.getUTCDate() + 1) or increment the
timestamp by 86400000 ms) and keep using toISOString().split("T")[0] for output;
update the variables referenced (since, upto, start, end, current, dates)
accordingly so all operations are performed in UTC.

In `@src/utils/format.ts`:
- Around line 23-76: The switch in formatDuration (function formatDuration, type
DurationStyle) is not exhaustive and can fall through returning undefined; add a
default/fallback that assigns the unhandled style to a never-typed variable to
force compile-time exhaustiveness (e.g., const _exhaustiveCheck: never = style)
and then return a safe fallback string (like "0ms" or an error string) so the
function always returns a string at runtime.

In `@src/utils/string.ts`:
- Around line 53-59: sanitizeOutput currently uses a limited regex that only
strips SGR sequences; update it to reuse the existing stripAnsi utility
(stripAnsi) instead of the narrower /\x1b\[[0-9;]*m/g so all CSI/ANSI escape
sequences are removed when removeANSI is true. Locate sanitizeOutput and replace
the removeANSI branch to call or invoke stripAnsi(sanitized) (keeping the
subsequent control-character removal) so callers get full ANSI stripping; ensure
you reference the existing stripAnsi symbol and preserve the sanitized variable
flow and return value.
- Around line 45-48: truncateText currently appends "..." even when maxLength ≤
3 resulting in output longer than maxLength; update truncateText to handle small
limits by first clamping maxLength to >=0 and if maxLength <= 3 return
text.substring(0, maxLength) (no ellipsis), otherwise keep existing behavior of
returning text.substring(0, maxLength - 3) + "..." so the final string never
exceeds maxLength; reference the truncateText(text: string, maxLength: number =
100) function and adjust its conditional logic accordingly.
🧹 Nitpick comments (7)
src/utils/json.ts (1)

8-14: Return type doesn't reflect fallback guarantee.

When a fallback is provided, the return can never be null, yet the signature always includes | null. Consider overloads to give callers tighter types:

Proposed overloads
-export function parseJSON<T>(text: string, fallback?: T): T | null {
+export function parseJSON<T>(text: string, fallback: T): T;
+export function parseJSON<T>(text: string): T | null;
+export function parseJSON<T>(text: string, fallback?: T): T | null {
src/utils/claude/index.ts (1)

17-19: getClaudeProjectsDir() is a trivial getter over a constant.

Since PROJECTS_DIR is already exported, the function adds no value unless it's intended to allow future dynamic resolution. Consider whether both exports are needed.

src/utils/async.ts (1)

41-73: Consider replacing the explicit new Promise constructor with async/await + sleep.

The manual resolve/reject wiring (explicit construction anti-pattern) makes the retry logic harder to follow. An async loop with a sleep helper is equivalent and more readable:

Suggested alternative
-    return new Promise((resolve, reject) => {
-        let attempt = 0;
-
-        const tryOperation = async () => {
-            try {
-                const result = await operation();
-                resolve(result);
-            } catch (error) {
-                attempt++;
-                if (attempt >= maxAttempts || (shouldRetry && !shouldRetry(error))) {
-                    reject(error);
-                    return;
-                }
-
-                let nextDelay: number;
-                switch (backoff) {
-                    case "linear":
-                        nextDelay = delay * attempt;
-                        break;
-                    case "fixed":
-                        nextDelay = delay;
-                        break;
-                    default: // exponential
-                        nextDelay = delay * 2 ** (attempt - 1);
-                }
-
-                onRetry?.(attempt, nextDelay);
-                setTimeout(tryOperation, nextDelay);
-            }
-        };
-
-        tryOperation();
-    });
+    const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+    for (let attempt = 0; attempt < maxAttempts; attempt++) {
+        try {
+            return await operation();
+        } catch (error) {
+            if (attempt + 1 >= maxAttempts || (shouldRetry && !shouldRetry(error))) {
+                throw error;
+            }
+            let nextDelay: number;
+            switch (backoff) {
+                case "linear":
+                    nextDelay = delay * (attempt + 1);
+                    break;
+                case "fixed":
+                    nextDelay = delay;
+                    break;
+                default:
+                    nextDelay = delay * 2 ** attempt;
+            }
+            onRetry?.(attempt + 1, nextDelay);
+            await sleep(nextDelay);
+        }
+    }
+    throw new Error("Retry exhausted"); // unreachable
src/ask/utils/cli.ts (1)

4-4: Inconsistent import path style: relative vs. alias.

Other migrated files in this PR use the @app/utils/format path alias (e.g., history-search.ts, history.ts, entry-processor.ts, last-changes/index.ts), but here both the import (line 4) and re-export (line 329) use a relative path ../../utils/format. Consider aligning with the alias for consistency.

♻️ Suggested change
-import { formatDuration as _formatDuration } from "../../utils/format";
+import { formatDuration as _formatDuration } from "@app/utils/format";
-export { formatBytes } from "../../utils/format";
+export { formatBytes } from "@app/utils/format";

Also applies to: 329-329

src/utils/object.ts (1)

16-29: for...in iterates inherited enumerable properties.

If source ever has prototype pollution or inherits enumerable keys, they'll be merged into the result. Consider guarding with Object.hasOwn:

Suggested hardening
     for (const key in source) {
+        if (!Object.hasOwn(source, key)) continue;
         if (source[key] && isObject(source[key]) && isObject(result[key])) {

Low risk given the Partial<T> typing, but a good defensive habit for a shared utility.

src/utils/tokens.ts (1)

19-26: Consider using import() instead of require() for the optional dependency.

require() works at runtime in Bun but is a CJS pattern. A dynamic await import("gpt-3-encoder") would be more idiomatic in an ESM/TypeScript codebase. This would require making countTokens async (or caching the import at module level), so it's a trade-off.

Not blocking—the try/catch fallback keeps it safe either way.

src/utils/string.ts (1)

34-40: Doc comment overstates coverage: removeDiacritics only strips the basic Combining Diacritical Marks block.

The JSDoc says "Handles all Unicode combining marks", but the regex [\u0300-\u036f] only covers the basic block (U+0300–U+036F). Extended combining marks (e.g., U+1AB0–U+1AFF, U+20D0–U+20FF) are not removed. This is fine for Latin-script use cases but the comment is misleading.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR consolidates duplicated helper logic across the CLI tools into shared src/utils/* modules and updates multiple consumers to import/re-export those shared utilities to reduce duplication and improve maintainability.

Changes:

  • Added a set of shared utility modules under src/utils/ (formatting, strings, async, tokens, JSON, objects, dates, markdown, verbose, Claude helpers).
  • Migrated multiple tool implementations to use shared imports and added re-exports/thin wrappers for compatibility.
  • Expanded fuzzy matching utilities with Levenshtein distance and similarity scoring.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/utils/verbose.ts Adds a scoped verbose logger factory.
src/utils/tokens.ts Adds shared token estimation/counting/truncation helpers.
src/utils/string.ts Adds consolidated string helpers (slugify, ANSI stripping, shell escaping, etc.).
src/utils/prompts/colors.ts Removes local formatting helpers and re-exports shared format utilities.
src/utils/object.ts Adds isObject and deepMerge utilities.
src/utils/markdown/html-to-md.ts Centralizes HTML→Markdown conversion via Turndown + GFM.
src/utils/json.ts Adds safe JSON parsing helper.
src/utils/fuzzy-match.ts Extends fuzzy matching with Levenshtein + similarity score.
src/utils/format.ts Adds shared formatting utilities (duration/bytes/cost/tokens/relative time/list/number).
src/utils/date.ts Adds shared month/date-range utilities.
src/utils/claude/index.ts Adds shared Claude projects path + JSONL transcript parsing.
src/utils/async.ts Adds shared retry/debounce/throttle/timeout helpers.
src/utils/array.ts Adds shared wrapArray.
src/timely/utils/entry-processor.ts Switches duration formatting to shared formatDuration.
src/timely/utils/date.ts Re-exports shared date helpers and maps duration formatting to shared formatter.
src/mcp-tsc/utils/helpers.ts Re-exports shared wrapArray.
src/mcp-ripgrep/index.ts Replaces local string/array helpers with shared implementations.
src/last-changes/index.ts Switches relative time formatting to shared formatRelativeTime.
src/files-to-prompt/index.ts Switches file-size formatting to shared formatBytes.
src/azure-devops/utils.ts Replaces local markdown/string/fuzzy utilities with shared modules + re-exports.
src/azure-devops/history.ts Switches diacritics normalization to shared removeDiacritics.
src/azure-devops/commands/history.ts Switches duration formatting to shared formatDuration.
src/azure-devops/commands/history-search.ts Switches duration formatting to shared formatDuration.
src/ask/utils/helpers.ts Re-exports shared helpers instead of maintaining local copies.
src/ask/utils/cli.ts Switches elapsed-time formatting to shared formatDuration and re-exports shared formatBytes.
Comments suppressed due to low confidence (1)

src/mcp-ripgrep/index.ts:270

  • wrapArray no longer drops empty-string values (it only returns [] for null/undefined). If args.filePattern is "", this will produce [''] and the loop will add -g '' to the ripgrep command, which can error or match unexpectedly. Consider filtering out empty/whitespace-only patterns before building the command.
                const pattern = String(args.pattern || "");
                const path = String(args.path);
                const caseSensitive = typeof args.caseSensitive === "boolean" ? args.caseSensitive : undefined;
                const filePattern = wrapArray(args.filePattern as string | string[] | undefined);
                const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
                const context = typeof args.context === "number" ? args.context : undefined;
                const useColors = typeof args.useColors === "boolean" ? args.useColors : false;

                if (!pattern) {
                    return {
                        isError: true,
                        content: [{ type: "text", text: "Error: Pattern is required" }],
                    };
                }

                // Resolve the search path relative to the configured root
                const fullPath = nodePath.resolve(searchRoot, path);

                // Build the rg command with flags
                let command = "rg";

                // Add case sensitivity flag if specified
                if (caseSensitive === true) {
                    command += " -s"; // Case sensitive
                } else if (caseSensitive === false) {
                    command += " -i"; // Case insensitive
                }

                // Add file pattern if specified
                if (filePattern && filePattern.length > 0) {
                    for (const pattern of filePattern) {
                        command += ` -g ${escapeShellArg(pattern)}`;
                    }
                }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +86 to +88
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

debounce declares let timeout: ReturnType<typeof setTimeout>; and calls clearTimeout(timeout) before timeout is assigned. Under strict TS this is flagged as “used before being assigned” (and at runtime the first call clears an uninitialized handle). Initialize the variable (e.g., to undefined) and guard the clearTimeout call.

Suggested change
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
let timeout: ReturnType<typeof setTimeout> | undefined;
return (...args: Parameters<T>) => {
if (timeout !== undefined) {
clearTimeout(timeout);
}

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +130
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutError?: Error,
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]);
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withTimeout starts a timer but never clears it when the wrapped promise settles. In CLIs this can keep the event loop alive unnecessarily and create stray timers. Store the timeout handle and clearTimeout it in a finally/settlement path.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +65
case "hm-smart": {
if (totalMinutes < 1) return "< 1m";
const hours = Math.floor(totalMinutes / 60);
const mins = Math.round(totalMinutes % 60);
if (hours === 0) return `${mins}m`;
if (mins === 0) return `${hours}h`;
return `${hours}h ${mins}m`;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In hm-smart, rounding minutes can produce mins === 60 (e.g., 59.6m) which then formats as 60m or Xh 60m. Consider normalizing the carry (increment hours and set mins to 0) to avoid impossible minute values in the output.

Copilot uses AI. Check for mistakes.
): { text: string; tokens: number; truncated: boolean } {
const tokens = countTokens(text);

if (!maxTokens || tokens <= maxTokens) {
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!maxTokens || tokens <= maxTokens) treats maxTokens = 0 as “no limit” because 0 is falsy. If callers can pass 0, this will skip truncation unexpectedly. Prefer a nullish check (maxTokens == null) instead of a truthiness check.

Suggested change
if (!maxTokens || tokens <= maxTokens) {
if (maxTokens == null || tokens <= maxTokens) {

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +110
export function throttle<T extends (...args: unknown[]) => void>(
func: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throttle declares let inThrottle: boolean; and reads it before assignment (if (!inThrottle)). This is a TS “used before being assigned” issue under strict and should be initialized (e.g., let inThrottle = false).

Copilot uses AI. Check for mistakes.
return `${hours}h ${mins}m`;
}
return `${mins}m`;
return _formatDuration(minutes, "min", "hm-smart");
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactor changes output for whole-hour values: the previous implementation returned "2h 0m", but hm-smart returns "2h" when minutes are 0. That’s a behavior change and also contradicts the function’s doc comment ("Xh Ym"). Either keep the old formatting behavior here or add/use a duration style that preserves the old output.

Suggested change
return _formatDuration(minutes, "min", "hm-smart");
return _formatDuration(minutes, "min", "hm");

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +24
export function countTokens(text: string): number {
try {
const { encode } = require("gpt-3-encoder");
return encode(text).length;
} catch {
return Math.ceil(text.split(/\s+/).length * 1.3);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

countTokens/limitToTokens use require("gpt-3-encoder"), but this repo is ESM (package.json#type: module, tsconfig#module: ESNext). In Node ESM there is no global require, so this will throw at runtime even when the package exists. Use an ESM-safe loader (e.g., createRequire(import.meta.url)) or switch to an import-based approach while keeping the lazy-load behavior.

Copilot uses AI. Check for mistakes.
const { encode } = require("gpt-3-encoder");
return encode(text).length;
} catch {
return Math.ceil(text.split(/\s+/).length * 1.3);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback heuristic Math.ceil(text.split(/\s+/).length * 1.3) counts an empty string as 1 “word” ("".split(...) => [""]), producing non-zero tokens for empty/whitespace-only input. Consider using text.trim() and returning 0 when the trimmed string is empty.

Suggested change
return Math.ceil(text.split(/\s+/).length * 1.3);
const trimmed = text.trim();
if (!trimmed) {
return 0;
}
return Math.ceil(trimmed.split(/\s+/).length * 1.3);

Copilot uses AI. Check for mistakes.
…--to and @me to timelog list

- import/prepare-import: Display workitem titles in precheck redirects,
  dry-run entries, and real import success output
- prepare-import: Store _workitemTitle in entries for list/totals display
- timelog list: Add --from/--to as primary date flags (--since/--upto hidden)
- timelog list: Resolve --user @me to configured defaultUser
- skills: Update command references and document new features
…providers

installServer() result was ignored — user rejecting the diff still
marked the server as enabled. Now properly skips on rejection.

Added feedback logging for WriteResult.NoChanges so providers like
Codex and Cursor (where presence = enabled) show a message instead
of producing zero output.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/azure-devops/commands/timelog/list.ts (1)

78-81: ⚠️ Potential issue | 🟡 Minor

Stale error message still references --since instead of --from.

Line 79 was not updated to match the new primary option names introduced in this PR.

Proposed fix
                    if (!fromDate) {
-                        console.error("--since or --day is required for date queries");
+                        console.error("--from or --day is required for date queries");
                        process.exit(1);
                    }
🤖 Fix all issues with AI agents
In `@plugins/genesis-tools/skills/azure-devops/SKILL.md`:
- Line 384: Update the wording in the sentence that currently reads "The `--user
`@me`` resolves to the configured default user name. Use `--from`/`--to` for date
ranges (`--since`/`--upto` also accepted as aliases)." to use the compound
"username" instead of "user name" (so it reads "...configured default
username."); search for the exact text containing `--user `@me`` in SKILL.md to
locate the sentence and replace only the phrase "user name" with "username" to
keep the rest of the wording and flags unchanged.

In `@src/azure-devops/commands/timelog/list.ts`:
- Around line 94-101: The code silently leaves "@me" as a literal when
config.timelog?.defaultUser?.userName is missing, causing empty results; update
the user resolution logic in the block that builds resolvedUsers (using
options.user and defaultUserName) to detect any "@me" that could not be resolved
and throw or return a clear error message instead of keeping the literal.
Specifically, after mapping to resolvedUsers (or during mapping), if any entry
equals "@me" and defaultUserName is falsy, raise a user-facing error (or exit)
explaining that "@me" cannot be resolved and instruct to configure
timelog.defaultUser.userName or pass an explicit user; keep the rest of the
filtering logic (userFilters and entries filter) unchanged.

In `@src/azure-devops/commands/timelog/prepare-import.ts`:
- Around line 154-161: The code uses workitemTitle derived from precheck.status
and precheck.redirectTitle, but when precheck.status === "redirect" and
redirectTitle is undefined while redirectId exists you silently store the
original title; update the logic that sets workitemTitle (and thus
storedEntry._workitemTitle) to detect this case and produce an explicit value
indicating a redirect (e.g., prefer redirectTitle, else if redirectId exists set
a composed title like "Redirected -> <redirectId>" or append a note to
originalTitle) so the stored entry clearly reflects that the item was redirected
even when redirectTitle is missing; adjust the assignment around workitemTitle
and storedEntry._workitemTitle accordingly.
🧹 Nitpick comments (3)
src/azure-devops/commands/timelog/import.ts (1)

231-233: Minor inconsistency in label formatting between dry-run and creation output.

Dry-run (line 233) formats as #${id}${titlePart}: (with a colon after the label), while creation (line 257–258) formats as #${id} ${title} without a colon, joined by " | ". This subtle inconsistency in the output style between the two code paths may confuse users comparing dry-run output against actual creation output.

Consider aligning the two paths — e.g., using wiLabel + pipe-joined parts consistently in both places.

src/azure-devops/commands/timelog/list.ts (2)

38-40: --day silently overrides --from/--to — consider a conflict guard.

If a user passes both --day 2026-01-15 and --from 2026-01-01 --to 2026-01-31, --day silently wins for both bounds, discarding the explicit range. A brief validation emitting a warning (or error) when --day is combined with --from/--to would prevent silent surprises.


90-92: Dead code — else branch is unreachable.

The first branch covers hasWorkItem && !hasDateFilter, the second covers hasDateFilter || !hasWorkItem. Together they exhaust all four boolean combinations of (hasWorkItem, hasDateFilter), so this else is never entered.

Proposed removal
                    entries = raw;
-                } else {
-                    entries = [];
                 }

…Gemini

- async: widen debounce/throttle generic constraint to (...args: any[])
- async: initialize debounce timeout and throttle flag properly
- async: clear timer in withTimeout to prevent event loop leak
- format: add exhaustive default case to formatDuration switch
- format: handle hm-smart rounding edge case (59.6min → 60m carry)
- date: use UTC methods in getDatesInMonth to fix timezone mismatch
- string: handle truncateText edge case when maxLength ≤ 3
- string: reuse stripAnsi in sanitizeOutput instead of narrower regex
- claude: add existsSync guard before createReadStream in parseJsonlTranscript
- tokens: use nullish check for maxTokens to handle 0 correctly
- tokens: handle empty string in countTokens fallback heuristic
- entry-processor: use hm-always style to preserve original "Xh Ym" output
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/utils/format.ts`:
- Around line 101-120: formatRelativeTime currently produces negative "X days
ago" for future dates because diffMs can be negative; add a guard near the top
of formatRelativeTime that checks if diffMs < 0 and handle future timestamps
(e.g., return "just now" for very near-future or compute absolute
diffMinutes/diffHours/diffDays and return a future-formatted string like "in X
minutes/hours/days") instead of falling through to the existing past logic;
update uses of diffMs/diffMinutes/diffHours/diffDays (and respect
rounding/fallbackFormat) so future cases are symmetric and do not produce
negative values.

In `@src/utils/string.ts`:
- Around line 11-18: The slugify function currently preserves original casing;
update slugify(title: string) to produce lowercase slugs by applying
.toLowerCase() to the normalized/replaced string (e.g., add .toLowerCase() in
the method chain before trimming/slicing) so outputs become all-lowercase and
consistent with the other local slugify implementation; ensure the inserted
.toLowerCase() is applied after diacritics removal and non-alphanumeric
replacement but before final .replace(/^-+|-+$/g, "") and .slice(0, 50) to keep
behavior unchanged except for casing.
🧹 Nitpick comments (4)
src/utils/async.ts (1)

41-73: retry uses the explicit-construction anti-pattern around an inner async function.

Wrapping an async function inside new Promise(...) is a well-known anti-pattern. The inner async already returns a promise, and if an unexpected throw occurs outside the try/catch (e.g., a bug in the scheduling logic), the rejection would be unhandled rather than propagated to the caller. A recursive-async approach is simpler and avoids this risk.

♻️ Suggested recursive-async rewrite
-    return new Promise((resolve, reject) => {
-        let attempt = 0;
-
-        const tryOperation = async () => {
-            try {
-                const result = await operation();
-                resolve(result);
-            } catch (error) {
-                attempt++;
-                if (attempt >= maxAttempts || (shouldRetry && !shouldRetry(error))) {
-                    reject(error);
-                    return;
-                }
-
-                let nextDelay: number;
-                switch (backoff) {
-                    case "linear":
-                        nextDelay = delay * attempt;
-                        break;
-                    case "fixed":
-                        nextDelay = delay;
-                        break;
-                    default: // exponential
-                        nextDelay = delay * 2 ** (attempt - 1);
-                }
-
-                onRetry?.(attempt, nextDelay);
-                setTimeout(tryOperation, nextDelay);
-            }
-        };
-
-        tryOperation();
-    });
+    let attempt = 0;
+    const execute = async (): Promise<T> => {
+        try {
+            return await operation();
+        } catch (error) {
+            attempt++;
+            if (attempt >= maxAttempts || (shouldRetry && !shouldRetry(error))) {
+                throw error;
+            }
+            let nextDelay: number;
+            switch (backoff) {
+                case "linear":
+                    nextDelay = delay * attempt;
+                    break;
+                case "fixed":
+                    nextDelay = delay;
+                    break;
+                default:
+                    nextDelay = delay * 2 ** (attempt - 1);
+            }
+            onRetry?.(attempt, nextDelay);
+            await new Promise((r) => setTimeout(r, nextDelay));
+            return execute();
+        }
+    };
+    return execute();
src/utils/tokens.ts (1)

19-28: Consider caching the dynamic require("gpt-3-encoder") result.

limitToTokens triggers up to two separate require("gpt-3-encoder") calls per invocation — once inside countTokens (line 21) and once directly (line 46). While Node/Bun caches require results, you could avoid the repeated try/catch overhead and make the code clearer by caching the module at module scope:

♻️ Suggested approach
+let _encoder: { encode: (t: string) => number[]; decode: (t: number[]) => string } | null = null;
+let _encoderLoaded = false;
+
+function getEncoder() {
+    if (!_encoderLoaded) {
+        try { _encoder = require("gpt-3-encoder"); } catch { _encoder = null; }
+        _encoderLoaded = true;
+    }
+    return _encoder;
+}
+
 export function countTokens(text: string): number {
-    try {
-        const { encode } = require("gpt-3-encoder");
-        return encode(text).length;
-    } catch {
+    const enc = getEncoder();
+    if (enc) return enc.encode(text).length;
+    {
         const trimmed = text.trim();
         if (!trimmed) return 0;
         return Math.ceil(trimmed.split(/\s+/).length * 1.3);
     }
 }

This also makes the fallback path explicit rather than relying on catch-as-control-flow.

Also applies to: 45-63

src/utils/string.ts (1)

38-40: Doc says "all Unicode combining marks" but regex only covers the basic block.

The regex [\u0300-\u036f] covers the "Combining Diacritical Marks" block, which handles the vast majority of Latin-script diacritics. However, the JSDoc claim "Handles all Unicode combining marks" is slightly overstated — it misses supplemental blocks (e.g., \u1AB0-\u1AFF, \u20D0-\u20FF). Consider softening the doc to "Handles common Latin diacritical marks" or using \p{M} with the unicode flag if broader coverage is needed.

src/utils/format.ts (1)

175-180: formatNumber and formatTokens are near-identical — consider consolidating.

formatTokens (line 152-155) and formatNumber (line 175-179) differ only in that formatNumber includes a billions tier. If the distinction is intentional for semantic clarity, that's fine, but it's worth noting the duplication.

Comment on lines +101 to +120
export function formatRelativeTime(date: Date, options?: FormatRelativeTimeOptions): string {
const { maxDays = Infinity, fallbackFormat, rounding = "floor" } = options ?? {};
const round = rounding === "round" ? Math.round : Math.floor;

const diffMs = Date.now() - date.getTime();
const diffMinutes = round(diffMs / 60000);
const diffHours = round(diffMs / 3600000);
const diffDays = round(diffMs / 86400000);

if (maxDays !== Infinity && diffDays >= maxDays && fallbackFormat) {
return fallbackFormat(date);
}

if (diffMinutes < 1) return "just now";
if (diffMinutes === 1) return "1 minute ago";
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
if (diffHours === 1) return "1 hour ago";
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

formatRelativeTime produces nonsensical output for future dates.

If date is in the future, diffMs is negative, causing all comparisons to fall through to line 119, which returns something like "-1 days ago". If this is only used for past dates, consider adding a guard:

♻️ Suggested guard
 export function formatRelativeTime(date: Date, options?: FormatRelativeTimeOptions): string {
     const { maxDays = Infinity, fallbackFormat, rounding = "floor" } = options ?? {};
     const round = rounding === "round" ? Math.round : Math.floor;
 
     const diffMs = Date.now() - date.getTime();
+    if (diffMs < 0) return "just now";
     const diffMinutes = round(diffMs / 60000);
🤖 Prompt for AI Agents
In `@src/utils/format.ts` around lines 101 - 120, formatRelativeTime currently
produces negative "X days ago" for future dates because diffMs can be negative;
add a guard near the top of formatRelativeTime that checks if diffMs < 0 and
handle future timestamps (e.g., return "just now" for very near-future or
compute absolute diffMinutes/diffHours/diffDays and return a future-formatted
string like "in X minutes/hours/days") instead of falling through to the
existing past logic; update uses of diffMs/diffMinutes/diffHours/diffDays (and
respect rounding/fallbackFormat) so future cases are symmetric and do not
produce negative values.

Comment on lines +11 to +18
export function slugify(title: string): string {
return title
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'slugify' --type=ts --type=tsx -B2 -A2

Repository: genesiscz/GenesisTools

Length of output: 93


🏁 Script executed:

rg -n 'slugify' --type=ts -B2 -A2

Repository: genesiscz/GenesisTools

Length of output: 2509


slugify doesn't lowercase the output.

Slugs are conventionally all-lowercase. The current implementation preserves the original casing, which can produce slugs like My-Article-Title instead of the expected my-article-title. This is especially important since the function is used for filesystem operations in src/azure-devops/utils.ts (line 259), and the codebase already has a local slugify implementation in src/git-rebranch/index.ts that includes .toLowerCase().

♻️ Suggested fix
 export function slugify(title: string): string {
     return title
         .normalize("NFD")
         .replace(/[\u0300-\u036f]/g, "")
+        .toLowerCase()
         .replace(/[^a-zA-Z0-9]+/g, "-")
         .replace(/^-+|-+$/g, "")
         .slice(0, 50);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function slugify(title: string): string {
return title
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
}
export function slugify(title: string): string {
return title
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
}
🤖 Prompt for AI Agents
In `@src/utils/string.ts` around lines 11 - 18, The slugify function currently
preserves original casing; update slugify(title: string) to produce lowercase
slugs by applying .toLowerCase() to the normalized/replaced string (e.g., add
.toLowerCase() in the method chain before trimming/slicing) so outputs become
all-lowercase and consistent with the other local slugify implementation; ensure
the inserted .toLowerCase() is applied after diacritics removal and
non-alphanumeric replacement but before final .replace(/^-+|-+$/g, "") and
.slice(0, 50) to keep behavior unchanged except for casing.

- SKILL.md: fix "user name" → "username" wording
- list: error early when @me cannot be resolved (no defaultUser configured)
- prepare-import: show redirect indicator in title when redirectTitle is missing
@genesiscz genesiscz merged commit 0639cbb into master Feb 9, 2026
1 check failed
@genesiscz genesiscz deleted the feat/dry branch February 9, 2026 23:36
genesiscz added a commit that referenced this pull request Feb 13, 2026
- Fix critical stale cleanup scope bug (Thread #4/#11): project-scoped
  listing no longer deletes other projects' cache entries
- Preserve matchSnippet when merging search results (Thread #19)
- Fix rg-only results getting 0 slots when meta returns 20 (Thread #13)
- Use rmSync with force flag instead of repetitive try/catch (Thread #1)
- Add try/catch fallback for METADATA_VERSION (Thread #3/#6)
- Add -- and -F flags to ripgrep commands (Thread #5/#17)
- Reuse extractTextFromMessage in rgExtractSnippet (Thread #2)
- Add 10MB file size cap + early exit in extraction (Thread #7/#18)
- Add error handling for ripgrep functions (Thread #10/#16)
genesiscz added a commit that referenced this pull request Feb 15, 2026
- Fix critical stale cleanup scope bug (Thread #4/#11): project-scoped
  listing no longer deletes other projects' cache entries
- Preserve matchSnippet when merging search results (Thread #19)
- Fix rg-only results getting 0 slots when meta returns 20 (Thread #13)
- Use rmSync with force flag instead of repetitive try/catch (Thread #1)
- Add try/catch fallback for METADATA_VERSION (Thread #3/#6)
- Add -- and -F flags to ripgrep commands (Thread #5/#17)
- Reuse extractTextFromMessage in rgExtractSnippet (Thread #2)
- Add 10MB file size cap + early exit in extraction (Thread #7/#18)
- Add error handling for ripgrep functions (Thread #10/#16)
genesiscz added a commit that referenced this pull request Feb 15, 2026
#25)

* fix(claude-history): full metadata extraction, rg search, auto-reindex

- Read entire JSONL files for metadata (was limited to 50 lines/64KB)
- Store full firstPrompt (was truncated to 120 chars)
- Index ALL user messages into allUserText field (capped 5000 chars)
- Summary/custom-title: latest wins (captures tail of file)
- Add ripgrep full-content search fallback (rgSearchFiles/rgExtractSnippet)
- Auto-reindex via MD5 hash of lib.ts+cache.ts (METADATA_VERSION)
- Clean up stale cache entries for deleted session files
- Rename DB from stats-cache.db to index.db
- Show match snippets in claude-resume session picker
- Search firstPrompt in matchByIdOrName

* feat(claude-resume): show conversation excerpts in session picker

Adds a second line below each session name showing additional
context: summary (if name is from title), first prompt (if name
is from summary), or match snippet (if from search).

* fix(claude-resume): run metadata + rg search in parallel, merge results

Previously rg only ran as fallback when metadata returned 0 results.
Now both phases run concurrently via Promise.all. Results are deduped
and merged: metadata matches first (ranked by relevance), then rg-only
matches with snippets. Catches content in assistant messages and user
text past the 5000-char metadata cap.

* fix(claude-history): resolve dashed project names, add nerdy stats

- extractProjectName: progressively resolve encoded dir against
  filesystem to recover dashed names (col-fe was showing as "fe")
- Cache resolved project names to avoid repeated filesystem lookups
- Show detailed stats in session picker: session count, project count,
  scope, indexed/stale/reindex status

* feat(claude-resume): nerdy search diagnostics

Show detailed breakdown of where results came from:
- Index matches: count + what was searched (name/branch/project/prompt)
- Content search: meta hits, rg total hits, overlap, rg-only unique hits

* fix(claude-history): address PR #25 review comments

- Fix critical stale cleanup scope bug (Thread #4/#11): project-scoped
  listing no longer deletes other projects' cache entries
- Preserve matchSnippet when merging search results (Thread #19)
- Fix rg-only results getting 0 slots when meta returns 20 (Thread #13)
- Use rmSync with force flag instead of repetitive try/catch (Thread #1)
- Add try/catch fallback for METADATA_VERSION (Thread #3/#6)
- Add -- and -F flags to ripgrep commands (Thread #5/#17)
- Reuse extractTextFromMessage in rgExtractSnippet (Thread #2)
- Add 10MB file size cap + early exit in extraction (Thread #7/#18)
- Add error handling for ripgrep functions (Thread #10/#16)
* fix(claude-history): add cwd to early-exit check, use null for empty allUserText

- Include cwd in early-exit condition to avoid skipping cwd extraction
- Return null instead of "" for allUserText when no user text found
- Anonymize path examples in comments
genesiscz added a commit that referenced this pull request Feb 17, 2026
- Replace duplicate stripAnsi with import from utils/string (#18)
- Fix wrapToWidth ANSI-aware truncation (#1/#3/#22)
- Add await to recursive executeTool call (#9/#20)
- Guard cursor when filtered list is empty (#5/#19)
- Use basename() instead of split("/").pop() (#10/#16)
- Validate --width NaN input (#14)
- Re-display watch message after screen clear (#15)
genesiscz added a commit that referenced this pull request Feb 18, 2026
- Replace duplicate stripAnsi with import from utils/string (#18)
- Fix wrapToWidth ANSI-aware truncation (#1/#3/#22)
- Add await to recursive executeTool call (#9/#20)
- Guard cursor when filtered list is empty (#5/#19)
- Use basename() instead of split("/").pop() (#10/#16)
- Validate --width NaN input (#14)
- Re-display watch message after screen clear (#15)
genesiscz added a commit that referenced this pull request Feb 18, 2026
* fix: clean error formatting and fuzzy match for tools entry point

* feat(tools): add discovery, introspection, and search-select modules

- discovery.ts: scans src/ for tools, extracts descriptions from README.md
- introspect.ts: runs --help on tools and parses Commander output into structured data
- search-select.ts: single-select search prompt with onHighlight callback, modeled on search-multiselect

* feat(tools): interactive browser with search, README preview, and subcommand explorer

* feat(markdown): add render options and CLI flags (--watch, --width, --theme, --no-color)

* fix: improve fuzzy matching to handle trailing dashes and segment matching

"tools cli-" now correctly matches "markdown-cli" by stripping trailing
dashes and checking dash-separated segments.

* feat(markdown-cli): add demo subcommand with interactive template gallery

* Revert "feat(markdown-cli): add demo subcommand with interactive template gallery"

This reverts commit f5c84db.

* feat(markdown): implement theme palette system (dark/light/minimal)

Wire up the --theme flag to actual color palettes that change
mermaid blocks, table borders, and alert colors per theme.

* fix: address PR #28 code review feedback

- Replace duplicate stripAnsi with import from utils/string (#18)
- Fix wrapToWidth ANSI-aware truncation (#1/#3/#22)
- Add await to recursive executeTool call (#9/#20)
- Guard cursor when filtered list is empty (#5/#19)
- Use basename() instead of split("/").pop() (#10/#16)
- Validate --width NaN input (#14)
- Re-display watch message after screen clear (#15)

* fix(markdown-cli/tools): address PR #28 review feedback

- Remove stray n8n.json (unrelated to PR scope)
- fix(markdown-cli): reject invalid --theme values via Commander .choices() (Thread #23)
- fix(tools): guard discoverTools against missing srcDir with existsSync check (Thread #24)
- fix(markdown): use display width for emoji/wide chars in wrapToWidth (Thread #25)
genesiscz added a commit that referenced this pull request Feb 19, 2026
- Use Promise.allSettled in showConfig to handle stale tokens (#14)
- Clamp pct in renderBar to prevent RangeError (#17)
- Clear firedThresholds on period reset in watch mode (#18)
- Wrap entire getKeychainCredentials in try/catch for non-macOS (#19)
- Escape sound parameter in AppleScript notifications (#20)
- Fix import.meta.dir off-by-one in update tool (#21)
- Use isCancelled() in withCancel() for light-mode cancel (#22)
- Remove 100% cap on projected usage
genesiscz added a commit that referenced this pull request Feb 19, 2026
- Use Promise.allSettled in showConfig to handle stale tokens (#14)
- Clamp pct in renderBar to prevent RangeError (#17)
- Clear firedThresholds on period reset in watch mode (#18)
- Wrap entire getKeychainCredentials in try/catch for non-macOS (#19)
- Escape sound parameter in AppleScript notifications (#20)
- Fix import.meta.dir off-by-one in update tool (#21)
- Use isCancelled() in withCancel() for light-mode cancel (#22)
- Remove 100% cap on projected usage
@coderabbitai coderabbitai bot mentioned this pull request Feb 20, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants