diff --git a/.github/instructions/copilot.instructions.md b/.github/instructions/copilot.instructions.md new file mode 100644 index 0000000000..8686521cfc --- /dev/null +++ b/.github/instructions/copilot.instructions.md @@ -0,0 +1,64 @@ +# OpenClaw Codebase Patterns + +**Always reuse existing code - no redundancy!** + +## Tech Stack + +- **Runtime**: Node 22+ (Bun also supported for dev/scripts) +- **Language**: TypeScript (ESM, strict mode) +- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync) +- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`) +- **Tests**: Vitest with V8 coverage +- **CLI Framework**: Commander + clack/prompts +- **Build**: tsdown (outputs to `dist/`) + +## Anti-Redundancy Rules + +- Avoid files that just re-export from another file. Import directly from the original source. +- If a function already exists, import it - do NOT create a duplicate in another file. +- Before creating any formatter, utility, or helper, search for existing implementations first. + +## Source of Truth Locations + +### Formatting Utilities (`src/infra/`) + +- **Time formatting**: `src\infra\format-time` + +**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.** + +### Terminal Output (`src/terminal/`) + +- Tables: `src/terminal/table.ts` (`renderTable`) +- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.) +- Progress: `src/cli/progress.ts` (spinners, progress bars) + +### CLI Patterns + +- CLI option wiring: `src/cli/` +- Commands: `src/commands/` +- Dependency injection via `createDefaultDeps` + +## Import Conventions + +- Use `.js` extension for cross-package imports (ESM) +- Direct imports only - no re-export wrapper files +- Types: `import type { X }` for type-only imports + +## Code Quality + +- TypeScript (ESM), strict typing, avoid `any` +- Keep files under ~700 LOC - extract helpers when larger +- Colocated tests: `*.test.ts` next to source files +- Run `pnpm check` before commits (lint + format) +- Run `pnpm tsgo` for type checking + +## Stack & Commands + +- **Package manager**: pnpm (`pnpm install`) +- **Dev**: `pnpm openclaw ...` or `pnpm dev` +- **Type-check**: `pnpm tsgo` +- **Lint/format**: `pnpm check` +- **Tests**: `pnpm test` +- **Build**: `pnpm build` + +If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality. diff --git a/.gitignore b/.gitignore index f9f3bc99b8..b4cc172365 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,7 @@ USER.md .serena/ # Agent credentials and memory (NEVER COMMIT) -memory/ +/memory/ .agent/*.json !.agent/workflows/ local/ diff --git a/docs/security/CONTRIBUTING-THREAT-MODEL.md b/docs/security/CONTRIBUTING-THREAT-MODEL.md index 65aebd8125..884a8ff9bc 100644 --- a/docs/security/CONTRIBUTING-THREAT-MODEL.md +++ b/docs/security/CONTRIBUTING-THREAT-MODEL.md @@ -1,12 +1,12 @@ # Contributing to the OpenClaw Threat Model -Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert. +Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert. ## Ways to Contribute ### Add a Threat -Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/trust](https://github.com/openclaw/trust/issues) and describe it in your own words. You don't need to know any frameworks or fill in every field - just describe the scenario. +Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/trust](https://github.com/openclaw/trust/issues) and describe it in your own words. You don't need to know any frameworks or fill in every field - just describe the scenario. **Helpful to include (but not required):** @@ -15,13 +15,13 @@ Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/ - How severe you think it is (low / medium / high / critical) - Any links to related research, CVEs, or real-world examples -We'll handle the ATLAS mapping, threat IDs, and risk assessment during review. If you want to include those details, great - but it's not expected. +We'll handle the ATLAS mapping, threat IDs, and risk assessment during review. If you want to include those details, great - but it's not expected. > **This is for adding to the threat model, not reporting live vulnerabilities.** If you've found an exploitable vulnerability, see our [Trust page](https://trust.openclaw.ai) for responsible disclosure instructions. ### Suggest a Mitigation -Have an idea for how to address an existing threat? Open an issue or PR referencing the threat. Useful mitigations are specific and actionable - for example, "per-sender rate limiting of 10 messages/minute at the gateway" is better than "implement rate limiting." +Have an idea for how to address an existing threat? Open an issue or PR referencing the threat. Useful mitigations are specific and actionable - for example, "per-sender rate limiting of 10 messages/minute at the gateway" is better than "implement rate limiting." ### Propose an Attack Chain @@ -29,48 +29,48 @@ Attack chains show how multiple threats combine into a realistic attack scenario ### Fix or Improve Existing Content -Typos, clarifications, outdated info, better examples - PRs welcome, no issue needed. +Typos, clarifications, outdated info, better examples - PRs welcome, no issue needed. ## What We Use ### MITRE ATLAS -This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/) (Adversarial Threat Landscape for AI Systems), a framework designed specifically for AI/ML threats like prompt injection, tool misuse, and agent exploitation. You don't need to know ATLAS to contribute - we map submissions to the framework during review. +This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/) (Adversarial Threat Landscape for AI Systems), a framework designed specifically for AI/ML threats like prompt injection, tool misuse, and agent exploitation. You don't need to know ATLAS to contribute - we map submissions to the framework during review. ### Threat IDs Each threat gets an ID like `T-EXEC-003`. The categories are: -| Code | Category | -|------|----------| -| RECON | Reconnaissance - information gathering | -| ACCESS | Initial access - gaining entry | -| EXEC | Execution - running malicious actions | -| PERSIST | Persistence - maintaining access | -| EVADE | Defense evasion - avoiding detection | -| DISC | Discovery - learning about the environment | -| EXFIL | Exfiltration - stealing data | -| IMPACT | Impact - damage or disruption | +| Code | Category | +| ------- | ------------------------------------------ | +| RECON | Reconnaissance - information gathering | +| ACCESS | Initial access - gaining entry | +| EXEC | Execution - running malicious actions | +| PERSIST | Persistence - maintaining access | +| EVADE | Defense evasion - avoiding detection | +| DISC | Discovery - learning about the environment | +| EXFIL | Exfiltration - stealing data | +| IMPACT | Impact - damage or disruption | IDs are assigned by maintainers during review. You don't need to pick one. ### Risk Levels -| Level | Meaning | -|-------|---------| -| **Critical** | Full system compromise, or high likelihood + critical impact | -| **High** | Significant damage likely, or medium likelihood + critical impact | -| **Medium** | Moderate risk, or low likelihood + high impact | -| **Low** | Unlikely and limited impact | +| Level | Meaning | +| ------------ | ----------------------------------------------------------------- | +| **Critical** | Full system compromise, or high likelihood + critical impact | +| **High** | Significant damage likely, or medium likelihood + critical impact | +| **Medium** | Moderate risk, or low likelihood + high impact | +| **Low** | Unlikely and limited impact | If you're unsure about the risk level, just describe the impact and we'll assess it. ## Review Process -1. **Triage** - We review new submissions within 48 hours -2. **Assessment** - We verify feasibility, assign ATLAS mapping and threat ID, validate risk level -3. **Documentation** - We ensure everything is formatted and complete -4. **Merge** - Added to the threat model and visualization +1. **Triage** - We review new submissions within 48 hours +2. **Assessment** - We verify feasibility, assign ATLAS mapping and threat ID, validate risk level +3. **Documentation** - We ensure everything is formatted and complete +4. **Merge** - Added to the threat model and visualization ## Resources diff --git a/docs/security/THREAT-MODEL-ATLAS.md b/docs/security/THREAT-MODEL-ATLAS.md index 60582cdcf2..c5d0387a51 100644 --- a/docs/security/THREAT-MODEL-ATLAS.md +++ b/docs/security/THREAT-MODEL-ATLAS.md @@ -12,6 +12,7 @@ This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the industry-standard framework for documenting adversarial threats to AI/ML systems. ATLAS is maintained by [MITRE](https://www.mitre.org/) in collaboration with the AI security community. **Key ATLAS Resources:** + - [ATLAS Techniques](https://atlas.mitre.org/techniques/) - [ATLAS Tactics](https://atlas.mitre.org/tactics/) - [ATLAS Case Studies](https://atlas.mitre.org/studies/) @@ -21,6 +22,7 @@ This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the indus ### Contributing to This Threat Model This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](./CONTRIBUTING-THREAT-MODEL.md) for guidelines on contributing: + - Reporting new threats - Updating existing threats - Proposing attack chains @@ -36,14 +38,14 @@ This threat model documents adversarial threats to the OpenClaw AI agent platfor ### 1.2 Scope -| Component | Included | Notes | -|-----------|----------|-------| -| OpenClaw Agent Runtime | Yes | Core agent execution, tool calls, sessions | -| Gateway | Yes | Authentication, routing, channel integration | -| Channel Integrations | Yes | WhatsApp, Telegram, Discord, Signal, Slack, etc. | -| ClawHub Marketplace | Yes | Skill publishing, moderation, distribution | -| MCP Servers | Yes | External tool providers | -| User Devices | Partial | Mobile apps, desktop clients | +| Component | Included | Notes | +| ---------------------- | -------- | ------------------------------------------------ | +| OpenClaw Agent Runtime | Yes | Core agent execution, tool calls, sessions | +| Gateway | Yes | Authentication, routing, channel integration | +| Channel Integrations | Yes | WhatsApp, Telegram, Discord, Signal, Slack, etc. | +| ClawHub Marketplace | Yes | Skill publishing, moderation, distribution | +| MCP Servers | Yes | External tool providers | +| User Devices | Partial | Mobile apps, desktop clients | ### 1.3 Out of Scope @@ -122,14 +124,14 @@ Nothing is explicitly out of scope for this threat model. ### 2.2 Data Flows -| Flow | Source | Destination | Data | Protection | -|------|--------|-------------|------|------------| -| F1 | Channel | Gateway | User messages | TLS, AllowFrom | -| F2 | Gateway | Agent | Routed messages | Session isolation | -| F3 | Agent | Tools | Tool invocations | Policy enforcement | -| F4 | Agent | External | web_fetch requests | SSRF blocking | -| F5 | ClawHub | Agent | Skill code | Moderation, scanning | -| F6 | Agent | Channel | Responses | Output filtering | +| Flow | Source | Destination | Data | Protection | +| ---- | ------- | ----------- | ------------------ | -------------------- | +| F1 | Channel | Gateway | User messages | TLS, AllowFrom | +| F2 | Gateway | Agent | Routed messages | Session isolation | +| F3 | Agent | Tools | Tool invocations | Policy enforcement | +| F4 | Agent | External | web_fetch requests | SSRF blocking | +| F5 | ClawHub | Agent | Skill code | Moderation, scanning | +| F6 | Agent | Channel | Responses | Output filtering | --- @@ -139,27 +141,27 @@ Nothing is explicitly out of scope for this threat model. #### T-RECON-001: Agent Endpoint Discovery -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0006 - Active Scanning | -| **Description** | Attacker scans for exposed OpenClaw gateway endpoints | -| **Attack Vector** | Network scanning, shodan queries, DNS enumeration | -| **Affected Components** | Gateway, exposed API endpoints | -| **Current Mitigations** | Tailscale auth option, bind to loopback by default | -| **Residual Risk** | Medium - Public gateways discoverable | -| **Recommendations** | Document secure deployment, add rate limiting on discovery endpoints | +| Attribute | Value | +| ----------------------- | -------------------------------------------------------------------- | +| **ATLAS ID** | AML.T0006 - Active Scanning | +| **Description** | Attacker scans for exposed OpenClaw gateway endpoints | +| **Attack Vector** | Network scanning, shodan queries, DNS enumeration | +| **Affected Components** | Gateway, exposed API endpoints | +| **Current Mitigations** | Tailscale auth option, bind to loopback by default | +| **Residual Risk** | Medium - Public gateways discoverable | +| **Recommendations** | Document secure deployment, add rate limiting on discovery endpoints | #### T-RECON-002: Channel Integration Probing -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0006 - Active Scanning | -| **Description** | Attacker probes messaging channels to identify AI-managed accounts | -| **Attack Vector** | Sending test messages, observing response patterns | -| **Affected Components** | All channel integrations | -| **Current Mitigations** | None specific | -| **Residual Risk** | Low - Limited value from discovery alone | -| **Recommendations** | Consider response timing randomization | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------------------ | +| **ATLAS ID** | AML.T0006 - Active Scanning | +| **Description** | Attacker probes messaging channels to identify AI-managed accounts | +| **Attack Vector** | Sending test messages, observing response patterns | +| **Affected Components** | All channel integrations | +| **Current Mitigations** | None specific | +| **Residual Risk** | Low - Limited value from discovery alone | +| **Recommendations** | Consider response timing randomization | --- @@ -167,39 +169,39 @@ Nothing is explicitly out of scope for this threat model. #### T-ACCESS-001: Pairing Code Interception -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | -| **Description** | Attacker intercepts pairing code during 30s grace period | -| **Attack Vector** | Shoulder surfing, network sniffing, social engineering | -| **Affected Components** | Device pairing system | -| **Current Mitigations** | 30s expiry, codes sent via existing channel | -| **Residual Risk** | Medium - Grace period exploitable | -| **Recommendations** | Reduce grace period, add confirmation step | +| Attribute | Value | +| ----------------------- | -------------------------------------------------------- | +| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | +| **Description** | Attacker intercepts pairing code during 30s grace period | +| **Attack Vector** | Shoulder surfing, network sniffing, social engineering | +| **Affected Components** | Device pairing system | +| **Current Mitigations** | 30s expiry, codes sent via existing channel | +| **Residual Risk** | Medium - Grace period exploitable | +| **Recommendations** | Reduce grace period, add confirmation step | #### T-ACCESS-002: AllowFrom Spoofing -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | -| **Description** | Attacker spoofs allowed sender identity in channel | -| **Attack Vector** | Depends on channel - phone number spoofing, username impersonation | -| **Affected Components** | AllowFrom validation per channel | -| **Current Mitigations** | Channel-specific identity verification | -| **Residual Risk** | Medium - Some channels vulnerable to spoofing | -| **Recommendations** | Document channel-specific risks, add cryptographic verification where possible | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------------------------------ | +| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | +| **Description** | Attacker spoofs allowed sender identity in channel | +| **Attack Vector** | Depends on channel - phone number spoofing, username impersonation | +| **Affected Components** | AllowFrom validation per channel | +| **Current Mitigations** | Channel-specific identity verification | +| **Residual Risk** | Medium - Some channels vulnerable to spoofing | +| **Recommendations** | Document channel-specific risks, add cryptographic verification where possible | #### T-ACCESS-003: Token Theft -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | -| **Description** | Attacker steals authentication tokens from config files | -| **Attack Vector** | Malware, unauthorized device access, config backup exposure | -| **Affected Components** | ~/.openclaw/credentials/, config storage | -| **Current Mitigations** | File permissions | -| **Residual Risk** | High - Tokens stored in plaintext | -| **Recommendations** | Implement token encryption at rest, add token rotation | +| Attribute | Value | +| ----------------------- | ----------------------------------------------------------- | +| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | +| **Description** | Attacker steals authentication tokens from config files | +| **Attack Vector** | Malware, unauthorized device access, config backup exposure | +| **Affected Components** | ~/.openclaw/credentials/, config storage | +| **Current Mitigations** | File permissions | +| **Residual Risk** | High - Tokens stored in plaintext | +| **Recommendations** | Implement token encryption at rest, add token rotation | --- @@ -207,51 +209,51 @@ Nothing is explicitly out of scope for this threat model. #### T-EXEC-001: Direct Prompt Injection -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct | -| **Description** | Attacker sends crafted prompts to manipulate agent behavior | -| **Attack Vector** | Channel messages containing adversarial instructions | -| **Affected Components** | Agent LLM, all input surfaces | -| **Current Mitigations** | Pattern detection, external content wrapping | -| **Residual Risk** | Critical - Detection only, no blocking; sophisticated attacks bypass | -| **Recommendations** | Implement multi-layer defense, output validation, user confirmation for sensitive actions | +| Attribute | Value | +| ----------------------- | ----------------------------------------------------------------------------------------- | +| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct | +| **Description** | Attacker sends crafted prompts to manipulate agent behavior | +| **Attack Vector** | Channel messages containing adversarial instructions | +| **Affected Components** | Agent LLM, all input surfaces | +| **Current Mitigations** | Pattern detection, external content wrapping | +| **Residual Risk** | Critical - Detection only, no blocking; sophisticated attacks bypass | +| **Recommendations** | Implement multi-layer defense, output validation, user confirmation for sensitive actions | #### T-EXEC-002: Indirect Prompt Injection -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0051.001 - LLM Prompt Injection: Indirect | -| **Description** | Attacker embeds malicious instructions in fetched content | -| **Attack Vector** | Malicious URLs, poisoned emails, compromised webhooks | -| **Affected Components** | web_fetch, email ingestion, external data sources | -| **Current Mitigations** | Content wrapping with XML tags and security notice | -| **Residual Risk** | High - LLM may ignore wrapper instructions | -| **Recommendations** | Implement content sanitization, separate execution contexts | +| Attribute | Value | +| ----------------------- | ----------------------------------------------------------- | +| **ATLAS ID** | AML.T0051.001 - LLM Prompt Injection: Indirect | +| **Description** | Attacker embeds malicious instructions in fetched content | +| **Attack Vector** | Malicious URLs, poisoned emails, compromised webhooks | +| **Affected Components** | web_fetch, email ingestion, external data sources | +| **Current Mitigations** | Content wrapping with XML tags and security notice | +| **Residual Risk** | High - LLM may ignore wrapper instructions | +| **Recommendations** | Implement content sanitization, separate execution contexts | #### T-EXEC-003: Tool Argument Injection -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct | -| **Description** | Attacker manipulates tool arguments through prompt injection | -| **Attack Vector** | Crafted prompts that influence tool parameter values | -| **Affected Components** | All tool invocations | -| **Current Mitigations** | Exec approvals for dangerous commands | -| **Residual Risk** | High - Relies on user judgment | -| **Recommendations** | Implement argument validation, parameterized tool calls | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------------ | +| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct | +| **Description** | Attacker manipulates tool arguments through prompt injection | +| **Attack Vector** | Crafted prompts that influence tool parameter values | +| **Affected Components** | All tool invocations | +| **Current Mitigations** | Exec approvals for dangerous commands | +| **Residual Risk** | High - Relies on user judgment | +| **Recommendations** | Implement argument validation, parameterized tool calls | #### T-EXEC-004: Exec Approval Bypass -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | -| **Description** | Attacker crafts commands that bypass approval allowlist | -| **Attack Vector** | Command obfuscation, alias exploitation, path manipulation | -| **Affected Components** | exec-approvals.ts, command allowlist | -| **Current Mitigations** | Allowlist + ask mode | -| **Residual Risk** | High - No command sanitization | -| **Recommendations** | Implement command normalization, expand blocklist | +| Attribute | Value | +| ----------------------- | ---------------------------------------------------------- | +| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | +| **Description** | Attacker crafts commands that bypass approval allowlist | +| **Attack Vector** | Command obfuscation, alias exploitation, path manipulation | +| **Affected Components** | exec-approvals.ts, command allowlist | +| **Current Mitigations** | Allowlist + ask mode | +| **Residual Risk** | High - No command sanitization | +| **Recommendations** | Implement command normalization, expand blocklist | --- @@ -259,39 +261,39 @@ Nothing is explicitly out of scope for this threat model. #### T-PERSIST-001: Malicious Skill Installation -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software | -| **Description** | Attacker publishes malicious skill to ClawHub | -| **Attack Vector** | Create account, publish skill with hidden malicious code | -| **Affected Components** | ClawHub, skill loading, agent execution | -| **Current Mitigations** | GitHub account age verification, pattern-based moderation flags | -| **Residual Risk** | Critical - No sandboxing, limited review | -| **Recommendations** | VirusTotal integration (in progress), skill sandboxing, community review | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------------------------ | +| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software | +| **Description** | Attacker publishes malicious skill to ClawHub | +| **Attack Vector** | Create account, publish skill with hidden malicious code | +| **Affected Components** | ClawHub, skill loading, agent execution | +| **Current Mitigations** | GitHub account age verification, pattern-based moderation flags | +| **Residual Risk** | Critical - No sandboxing, limited review | +| **Recommendations** | VirusTotal integration (in progress), skill sandboxing, community review | #### T-PERSIST-002: Skill Update Poisoning -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software | -| **Description** | Attacker compromises popular skill and pushes malicious update | -| **Attack Vector** | Account compromise, social engineering of skill owner | -| **Affected Components** | ClawHub versioning, auto-update flows | -| **Current Mitigations** | Version fingerprinting | -| **Residual Risk** | High - Auto-updates may pull malicious versions | -| **Recommendations** | Implement update signing, rollback capability, version pinning | +| Attribute | Value | +| ----------------------- | -------------------------------------------------------------- | +| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software | +| **Description** | Attacker compromises popular skill and pushes malicious update | +| **Attack Vector** | Account compromise, social engineering of skill owner | +| **Affected Components** | ClawHub versioning, auto-update flows | +| **Current Mitigations** | Version fingerprinting | +| **Residual Risk** | High - Auto-updates may pull malicious versions | +| **Recommendations** | Implement update signing, rollback capability, version pinning | #### T-PERSIST-003: Agent Configuration Tampering -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0010.002 - Supply Chain Compromise: Data | -| **Description** | Attacker modifies agent configuration to persist access | -| **Attack Vector** | Config file modification, settings injection | -| **Affected Components** | Agent config, tool policies | -| **Current Mitigations** | File permissions | -| **Residual Risk** | Medium - Requires local access | -| **Recommendations** | Config integrity verification, audit logging for config changes | +| Attribute | Value | +| ----------------------- | --------------------------------------------------------------- | +| **ATLAS ID** | AML.T0010.002 - Supply Chain Compromise: Data | +| **Description** | Attacker modifies agent configuration to persist access | +| **Attack Vector** | Config file modification, settings injection | +| **Affected Components** | Agent config, tool policies | +| **Current Mitigations** | File permissions | +| **Residual Risk** | Medium - Requires local access | +| **Recommendations** | Config integrity verification, audit logging for config changes | --- @@ -299,27 +301,27 @@ Nothing is explicitly out of scope for this threat model. #### T-EVADE-001: Moderation Pattern Bypass -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | -| **Description** | Attacker crafts skill content to evade moderation patterns | -| **Attack Vector** | Unicode homoglyphs, encoding tricks, dynamic loading | -| **Affected Components** | ClawHub moderation.ts | -| **Current Mitigations** | Pattern-based FLAG_RULES | -| **Residual Risk** | High - Simple regex easily bypassed | -| **Recommendations** | Add behavioral analysis (VirusTotal Code Insight), AST-based detection | +| Attribute | Value | +| ----------------------- | ---------------------------------------------------------------------- | +| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | +| **Description** | Attacker crafts skill content to evade moderation patterns | +| **Attack Vector** | Unicode homoglyphs, encoding tricks, dynamic loading | +| **Affected Components** | ClawHub moderation.ts | +| **Current Mitigations** | Pattern-based FLAG_RULES | +| **Residual Risk** | High - Simple regex easily bypassed | +| **Recommendations** | Add behavioral analysis (VirusTotal Code Insight), AST-based detection | #### T-EVADE-002: Content Wrapper Escape -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | -| **Description** | Attacker crafts content that escapes XML wrapper context | -| **Attack Vector** | Tag manipulation, context confusion, instruction override | -| **Affected Components** | External content wrapping | -| **Current Mitigations** | XML tags + security notice | -| **Residual Risk** | Medium - Novel escapes discovered regularly | -| **Recommendations** | Multiple wrapper layers, output-side validation | +| Attribute | Value | +| ----------------------- | --------------------------------------------------------- | +| **ATLAS ID** | AML.T0043 - Craft Adversarial Data | +| **Description** | Attacker crafts content that escapes XML wrapper context | +| **Attack Vector** | Tag manipulation, context confusion, instruction override | +| **Affected Components** | External content wrapping | +| **Current Mitigations** | XML tags + security notice | +| **Residual Risk** | Medium - Novel escapes discovered regularly | +| **Recommendations** | Multiple wrapper layers, output-side validation | --- @@ -327,27 +329,27 @@ Nothing is explicitly out of scope for this threat model. #### T-DISC-001: Tool Enumeration -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | -| **Description** | Attacker enumerates available tools through prompting | -| **Attack Vector** | "What tools do you have?" style queries | -| **Affected Components** | Agent tool registry | -| **Current Mitigations** | None specific | -| **Residual Risk** | Low - Tools generally documented | -| **Recommendations** | Consider tool visibility controls | +| Attribute | Value | +| ----------------------- | ----------------------------------------------------- | +| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | +| **Description** | Attacker enumerates available tools through prompting | +| **Attack Vector** | "What tools do you have?" style queries | +| **Affected Components** | Agent tool registry | +| **Current Mitigations** | None specific | +| **Residual Risk** | Low - Tools generally documented | +| **Recommendations** | Consider tool visibility controls | #### T-DISC-002: Session Data Extraction -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | -| **Description** | Attacker extracts sensitive data from session context | -| **Attack Vector** | "What did we discuss?" queries, context probing | -| **Affected Components** | Session transcripts, context window | -| **Current Mitigations** | Session isolation per sender | -| **Residual Risk** | Medium - Within-session data accessible | -| **Recommendations** | Implement sensitive data redaction in context | +| Attribute | Value | +| ----------------------- | ----------------------------------------------------- | +| **ATLAS ID** | AML.T0040 - AI Model Inference API Access | +| **Description** | Attacker extracts sensitive data from session context | +| **Attack Vector** | "What did we discuss?" queries, context probing | +| **Affected Components** | Session transcripts, context window | +| **Current Mitigations** | Session isolation per sender | +| **Residual Risk** | Medium - Within-session data accessible | +| **Recommendations** | Implement sensitive data redaction in context | --- @@ -355,39 +357,39 @@ Nothing is explicitly out of scope for this threat model. #### T-EXFIL-001: Data Theft via web_fetch -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0009 - Collection | -| **Description** | Attacker exfiltrates data by instructing agent to send to external URL | -| **Attack Vector** | Prompt injection causing agent to POST data to attacker server | -| **Affected Components** | web_fetch tool | -| **Current Mitigations** | SSRF blocking for internal networks | -| **Residual Risk** | High - External URLs permitted | -| **Recommendations** | Implement URL allowlisting, data classification awareness | +| Attribute | Value | +| ----------------------- | ---------------------------------------------------------------------- | +| **ATLAS ID** | AML.T0009 - Collection | +| **Description** | Attacker exfiltrates data by instructing agent to send to external URL | +| **Attack Vector** | Prompt injection causing agent to POST data to attacker server | +| **Affected Components** | web_fetch tool | +| **Current Mitigations** | SSRF blocking for internal networks | +| **Residual Risk** | High - External URLs permitted | +| **Recommendations** | Implement URL allowlisting, data classification awareness | #### T-EXFIL-002: Unauthorized Message Sending -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0009 - Collection | -| **Description** | Attacker causes agent to send messages containing sensitive data | -| **Attack Vector** | Prompt injection causing agent to message attacker | -| **Affected Components** | Message tool, channel integrations | -| **Current Mitigations** | Outbound messaging gating | -| **Residual Risk** | Medium - Gating may be bypassed | -| **Recommendations** | Require explicit confirmation for new recipients | +| Attribute | Value | +| ----------------------- | ---------------------------------------------------------------- | +| **ATLAS ID** | AML.T0009 - Collection | +| **Description** | Attacker causes agent to send messages containing sensitive data | +| **Attack Vector** | Prompt injection causing agent to message attacker | +| **Affected Components** | Message tool, channel integrations | +| **Current Mitigations** | Outbound messaging gating | +| **Residual Risk** | Medium - Gating may be bypassed | +| **Recommendations** | Require explicit confirmation for new recipients | #### T-EXFIL-003: Credential Harvesting -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0009 - Collection | -| **Description** | Malicious skill harvests credentials from agent context | -| **Attack Vector** | Skill code reads environment variables, config files | -| **Affected Components** | Skill execution environment | -| **Current Mitigations** | None specific to skills | -| **Residual Risk** | Critical - Skills run with agent privileges | -| **Recommendations** | Skill sandboxing, credential isolation | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------- | +| **ATLAS ID** | AML.T0009 - Collection | +| **Description** | Malicious skill harvests credentials from agent context | +| **Attack Vector** | Skill code reads environment variables, config files | +| **Affected Components** | Skill execution environment | +| **Current Mitigations** | None specific to skills | +| **Residual Risk** | Critical - Skills run with agent privileges | +| **Recommendations** | Skill sandboxing, credential isolation | --- @@ -395,39 +397,39 @@ Nothing is explicitly out of scope for this threat model. #### T-IMPACT-001: Unauthorized Command Execution -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | -| **Description** | Attacker executes arbitrary commands on user system | -| **Attack Vector** | Prompt injection combined with exec approval bypass | -| **Affected Components** | Bash tool, command execution | -| **Current Mitigations** | Exec approvals, Docker sandbox option | -| **Residual Risk** | Critical - Host execution without sandbox | -| **Recommendations** | Default to sandbox, improve approval UX | +| Attribute | Value | +| ----------------------- | --------------------------------------------------- | +| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | +| **Description** | Attacker executes arbitrary commands on user system | +| **Attack Vector** | Prompt injection combined with exec approval bypass | +| **Affected Components** | Bash tool, command execution | +| **Current Mitigations** | Exec approvals, Docker sandbox option | +| **Residual Risk** | Critical - Host execution without sandbox | +| **Recommendations** | Default to sandbox, improve approval UX | #### T-IMPACT-002: Resource Exhaustion (DoS) -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | -| **Description** | Attacker exhausts API credits or compute resources | -| **Attack Vector** | Automated message flooding, expensive tool calls | -| **Affected Components** | Gateway, agent sessions, API provider | -| **Current Mitigations** | None | -| **Residual Risk** | High - No rate limiting | -| **Recommendations** | Implement per-sender rate limits, cost budgets | +| Attribute | Value | +| ----------------------- | -------------------------------------------------- | +| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | +| **Description** | Attacker exhausts API credits or compute resources | +| **Attack Vector** | Automated message flooding, expensive tool calls | +| **Affected Components** | Gateway, agent sessions, API provider | +| **Current Mitigations** | None | +| **Residual Risk** | High - No rate limiting | +| **Recommendations** | Implement per-sender rate limits, cost budgets | #### T-IMPACT-003: Reputation Damage -| Attribute | Value | -|-----------|-------| -| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | -| **Description** | Attacker causes agent to send harmful/offensive content | -| **Attack Vector** | Prompt injection causing inappropriate responses | -| **Affected Components** | Output generation, channel messaging | -| **Current Mitigations** | LLM provider content policies | -| **Residual Risk** | Medium - Provider filters imperfect | -| **Recommendations** | Output filtering layer, user controls | +| Attribute | Value | +| ----------------------- | ------------------------------------------------------- | +| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity | +| **Description** | Attacker causes agent to send harmful/offensive content | +| **Attack Vector** | Prompt injection causing inappropriate responses | +| **Affected Components** | Output generation, channel messaging | +| **Current Mitigations** | LLM provider content policies | +| **Residual Risk** | Medium - Provider filters imperfect | +| **Recommendations** | Output filtering layer, user controls | --- @@ -435,15 +437,15 @@ Nothing is explicitly out of scope for this threat model. ### 4.1 Current Security Controls -| Control | Implementation | Effectiveness | -|---------|----------------|---------------| -| GitHub Account Age | `requireGitHubAccountAge()` | Medium - Raises bar for new attackers | -| Path Sanitization | `sanitizePath()` | High - Prevents path traversal | -| File Type Validation | `isTextFile()` | Medium - Only text files, but can still be malicious | -| Size Limits | 50MB total bundle | High - Prevents resource exhaustion | -| Required SKILL.md | Mandatory readme | Low security value - Informational only | -| Pattern Moderation | FLAG_RULES in moderation.ts | Low - Easily bypassed | -| Moderation Status | `moderationStatus` field | Medium - Manual review possible | +| Control | Implementation | Effectiveness | +| -------------------- | --------------------------- | ---------------------------------------------------- | +| GitHub Account Age | `requireGitHubAccountAge()` | Medium - Raises bar for new attackers | +| Path Sanitization | `sanitizePath()` | High - Prevents path traversal | +| File Type Validation | `isTextFile()` | Medium - Only text files, but can still be malicious | +| Size Limits | 50MB total bundle | High - Prevents resource exhaustion | +| Required SKILL.md | Mandatory readme | Low security value - Informational only | +| Pattern Moderation | FLAG_RULES in moderation.ts | Low - Easily bypassed | +| Moderation Status | `moderationStatus` field | Medium - Manual review possible | ### 4.2 Moderation Flag Patterns @@ -463,6 +465,7 @@ Current patterns in `moderation.ts`: ``` **Limitations:** + - Only checks slug, displayName, summary, frontmatter, metadata, file paths - Does not analyze actual skill code content - Simple regex easily bypassed with obfuscation @@ -470,12 +473,12 @@ Current patterns in `moderation.ts`: ### 4.3 Planned Improvements -| Improvement | Status | Impact | -|-------------|--------|--------| -| VirusTotal Integration | In Progress | High - Code Insight behavioral analysis | -| Community Reporting | Partial (`skillReports` table exists) | Medium | -| Audit Logging | Partial (`auditLogs` table exists) | Medium | -| Badge System | Implemented | Medium - `highlighted`, `official`, `deprecated`, `redactionApproved` | +| Improvement | Status | Impact | +| ---------------------- | ------------------------------------- | --------------------------------------------------------------------- | +| VirusTotal Integration | In Progress | High - Code Insight behavioral analysis | +| Community Reporting | Partial (`skillReports` table exists) | Medium | +| Audit Logging | Partial (`auditLogs` table exists) | Medium | +| Badge System | Implemented | Medium - `highlighted`, `official`, `deprecated`, `redactionApproved` | --- @@ -483,37 +486,40 @@ Current patterns in `moderation.ts`: ### 5.1 Likelihood vs Impact -| Threat ID | Likelihood | Impact | Risk Level | Priority | -|-----------|------------|--------|------------|----------| -| T-EXEC-001 | High | Critical | **Critical** | P0 | -| T-PERSIST-001 | High | Critical | **Critical** | P0 | -| T-EXFIL-003 | Medium | Critical | **Critical** | P0 | -| T-IMPACT-001 | Medium | Critical | **High** | P1 | -| T-EXEC-002 | High | High | **High** | P1 | -| T-EXEC-004 | Medium | High | **High** | P1 | -| T-ACCESS-003 | Medium | High | **High** | P1 | -| T-EXFIL-001 | Medium | High | **High** | P1 | -| T-IMPACT-002 | High | Medium | **High** | P1 | -| T-EVADE-001 | High | Medium | **Medium** | P2 | -| T-ACCESS-001 | Low | High | **Medium** | P2 | -| T-ACCESS-002 | Low | High | **Medium** | P2 | -| T-PERSIST-002 | Low | High | **Medium** | P2 | +| Threat ID | Likelihood | Impact | Risk Level | Priority | +| ------------- | ---------- | -------- | ------------ | -------- | +| T-EXEC-001 | High | Critical | **Critical** | P0 | +| T-PERSIST-001 | High | Critical | **Critical** | P0 | +| T-EXFIL-003 | Medium | Critical | **Critical** | P0 | +| T-IMPACT-001 | Medium | Critical | **High** | P1 | +| T-EXEC-002 | High | High | **High** | P1 | +| T-EXEC-004 | Medium | High | **High** | P1 | +| T-ACCESS-003 | Medium | High | **High** | P1 | +| T-EXFIL-001 | Medium | High | **High** | P1 | +| T-IMPACT-002 | High | Medium | **High** | P1 | +| T-EVADE-001 | High | Medium | **Medium** | P2 | +| T-ACCESS-001 | Low | High | **Medium** | P2 | +| T-ACCESS-002 | Low | High | **Medium** | P2 | +| T-PERSIST-002 | Low | High | **Medium** | P2 | ### 5.2 Critical Path Attack Chains **Attack Chain 1: Skill-Based Data Theft** + ``` T-PERSIST-001 → T-EVADE-001 → T-EXFIL-003 (Publish malicious skill) → (Evade moderation) → (Harvest credentials) ``` **Attack Chain 2: Prompt Injection to RCE** + ``` T-EXEC-001 → T-EXEC-004 → T-IMPACT-001 (Inject prompt) → (Bypass exec approval) → (Execute commands) ``` **Attack Chain 3: Indirect Injection via Fetched Content** + ``` T-EXEC-002 → T-EXFIL-001 → External exfiltration (Poison URL content) → (Agent fetches & follows instructions) → (Data sent to attacker) @@ -525,28 +531,28 @@ T-EXEC-002 → T-EXFIL-001 → External exfiltration ### 6.1 Immediate (P0) -| ID | Recommendation | Addresses | -|----|----------------|-----------| -| R-001 | Complete VirusTotal integration | T-PERSIST-001, T-EVADE-001 | -| R-002 | Implement skill sandboxing | T-PERSIST-001, T-EXFIL-003 | -| R-003 | Add output validation for sensitive actions | T-EXEC-001, T-EXEC-002 | +| ID | Recommendation | Addresses | +| ----- | ------------------------------------------- | -------------------------- | +| R-001 | Complete VirusTotal integration | T-PERSIST-001, T-EVADE-001 | +| R-002 | Implement skill sandboxing | T-PERSIST-001, T-EXFIL-003 | +| R-003 | Add output validation for sensitive actions | T-EXEC-001, T-EXEC-002 | ### 6.2 Short-term (P1) -| ID | Recommendation | Addresses | -|----|----------------|-----------| -| R-004 | Implement rate limiting | T-IMPACT-002 | -| R-005 | Add token encryption at rest | T-ACCESS-003 | -| R-006 | Improve exec approval UX and validation | T-EXEC-004 | -| R-007 | Implement URL allowlisting for web_fetch | T-EXFIL-001 | +| ID | Recommendation | Addresses | +| ----- | ---------------------------------------- | ------------ | +| R-004 | Implement rate limiting | T-IMPACT-002 | +| R-005 | Add token encryption at rest | T-ACCESS-003 | +| R-006 | Improve exec approval UX and validation | T-EXEC-004 | +| R-007 | Implement URL allowlisting for web_fetch | T-EXFIL-001 | ### 6.3 Medium-term (P2) -| ID | Recommendation | Addresses | -|----|----------------|-----------| -| R-008 | Add cryptographic channel verification where possible | T-ACCESS-002 | -| R-009 | Implement config integrity verification | T-PERSIST-003 | -| R-010 | Add update signing and version pinning | T-PERSIST-002 | +| ID | Recommendation | Addresses | +| ----- | ----------------------------------------------------- | ------------- | +| R-008 | Add cryptographic channel verification where possible | T-ACCESS-002 | +| R-009 | Implement config integrity verification | T-PERSIST-003 | +| R-010 | Add update signing and version pinning | T-PERSIST-002 | --- @@ -554,44 +560,44 @@ T-EXEC-002 → T-EXFIL-001 → External exfiltration ### 7.1 ATLAS Technique Mapping -| ATLAS ID | Technique Name | OpenClaw Threats | -|----------|----------------|------------------| -| AML.T0006 | Active Scanning | T-RECON-001, T-RECON-002 | -| AML.T0009 | Collection | T-EXFIL-001, T-EXFIL-002, T-EXFIL-003 | -| AML.T0010.001 | Supply Chain: AI Software | T-PERSIST-001, T-PERSIST-002 | -| AML.T0010.002 | Supply Chain: Data | T-PERSIST-003 | -| AML.T0031 | Erode AI Model Integrity | T-IMPACT-001, T-IMPACT-002, T-IMPACT-003 | -| AML.T0040 | AI Model Inference API Access | T-ACCESS-001, T-ACCESS-002, T-ACCESS-003, T-DISC-001, T-DISC-002 | -| AML.T0043 | Craft Adversarial Data | T-EXEC-004, T-EVADE-001, T-EVADE-002 | -| AML.T0051.000 | LLM Prompt Injection: Direct | T-EXEC-001, T-EXEC-003 | -| AML.T0051.001 | LLM Prompt Injection: Indirect | T-EXEC-002 | +| ATLAS ID | Technique Name | OpenClaw Threats | +| ------------- | ------------------------------ | ---------------------------------------------------------------- | +| AML.T0006 | Active Scanning | T-RECON-001, T-RECON-002 | +| AML.T0009 | Collection | T-EXFIL-001, T-EXFIL-002, T-EXFIL-003 | +| AML.T0010.001 | Supply Chain: AI Software | T-PERSIST-001, T-PERSIST-002 | +| AML.T0010.002 | Supply Chain: Data | T-PERSIST-003 | +| AML.T0031 | Erode AI Model Integrity | T-IMPACT-001, T-IMPACT-002, T-IMPACT-003 | +| AML.T0040 | AI Model Inference API Access | T-ACCESS-001, T-ACCESS-002, T-ACCESS-003, T-DISC-001, T-DISC-002 | +| AML.T0043 | Craft Adversarial Data | T-EXEC-004, T-EVADE-001, T-EVADE-002 | +| AML.T0051.000 | LLM Prompt Injection: Direct | T-EXEC-001, T-EXEC-003 | +| AML.T0051.001 | LLM Prompt Injection: Indirect | T-EXEC-002 | ### 7.2 Key Security Files -| Path | Purpose | Risk Level | -|------|---------|------------| -| `src/infra/exec-approvals.ts` | Command approval logic | **Critical** | -| `src/gateway/auth.ts` | Gateway authentication | **Critical** | -| `src/web/inbound/access-control.ts` | Channel access control | **Critical** | -| `src/infra/net/ssrf.ts` | SSRF protection | **Critical** | -| `src/security/external-content.ts` | Prompt injection mitigation | **Critical** | -| `src/agents/sandbox/tool-policy.ts` | Tool policy enforcement | **Critical** | -| `convex/lib/moderation.ts` | ClawHub moderation | **High** | -| `convex/lib/skillPublish.ts` | Skill publishing flow | **High** | -| `src/routing/resolve-route.ts` | Session isolation | **Medium** | +| Path | Purpose | Risk Level | +| ----------------------------------- | --------------------------- | ------------ | +| `src/infra/exec-approvals.ts` | Command approval logic | **Critical** | +| `src/gateway/auth.ts` | Gateway authentication | **Critical** | +| `src/web/inbound/access-control.ts` | Channel access control | **Critical** | +| `src/infra/net/ssrf.ts` | SSRF protection | **Critical** | +| `src/security/external-content.ts` | Prompt injection mitigation | **Critical** | +| `src/agents/sandbox/tool-policy.ts` | Tool policy enforcement | **Critical** | +| `convex/lib/moderation.ts` | ClawHub moderation | **High** | +| `convex/lib/skillPublish.ts` | Skill publishing flow | **High** | +| `src/routing/resolve-route.ts` | Session isolation | **Medium** | ### 7.3 Glossary -| Term | Definition | -|------|------------| -| **ATLAS** | MITRE's Adversarial Threat Landscape for AI Systems | -| **ClawHub** | OpenClaw's skill marketplace | -| **Gateway** | OpenClaw's message routing and authentication layer | -| **MCP** | Model Context Protocol - tool provider interface | +| Term | Definition | +| -------------------- | --------------------------------------------------------- | +| **ATLAS** | MITRE's Adversarial Threat Landscape for AI Systems | +| **ClawHub** | OpenClaw's skill marketplace | +| **Gateway** | OpenClaw's message routing and authentication layer | +| **MCP** | Model Context Protocol - tool provider interface | | **Prompt Injection** | Attack where malicious instructions are embedded in input | -| **Skill** | Downloadable extension for OpenClaw agents | -| **SSRF** | Server-Side Request Forgery | +| **Skill** | Downloadable extension for OpenClaw agents | +| **SSRF** | Server-Side Request Forgery | --- -*This threat model is a living document. Report security issues to security@openclaw.ai* +_This threat model is a living document. Report security issues to security@openclaw.ai_ diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index e34398e51d..85ce7cd602 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -1,7 +1,10 @@ #!/bin/sh FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') [ -z "$FILES" ] && exit 0 -echo "$FILES" | xargs pnpm format:fix --no-error-on-unmatched-pattern + +# Lint and format staged files +echo "$FILES" | xargs pnpm exec oxlint --fix 2>/dev/null || true +echo "$FILES" | xargs pnpm exec oxfmt --write 2>/dev/null || true echo "$FILES" | xargs git add exit 0 diff --git a/scripts/analyze_code_files.py b/scripts/analyze_code_files.py new file mode 100644 index 0000000000..66e48a2971 --- /dev/null +++ b/scripts/analyze_code_files.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Lists the longest and shortest code files in the project. +Threshold can be set to warn about files longer or shorter than a certain number of lines. +""" + +import os +import re +import argparse +from pathlib import Path +from typing import List, Tuple, Dict, Set +from collections import defaultdict + +# File extensions to consider as code files +CODE_EXTENSIONS = { + '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', # TypeScript/JavaScript + '.swift', # macOS/iOS + '.kt', '.java', # Android + '.py', '.sh', # Scripts +} + +# Directories to skip +SKIP_DIRS = { + 'node_modules', '.git', 'dist', 'build', 'coverage', + '__pycache__', '.turbo', 'out', '.worktrees', 'vendor', + 'Pods', 'DerivedData', '.gradle', '.idea' +} + +# Filename patterns to skip in short-file warnings (barrel exports, stubs) +SKIP_SHORT_PATTERNS = { + 'index.js', 'index.ts', 'postinstall.js', +} +SKIP_SHORT_SUFFIXES = ('-cli.ts',) + +# Function names to skip in duplicate detection (common utilities, test helpers) +SKIP_DUPLICATE_FUNCTIONS = { + # Common utility names + 'main', 'init', 'setup', 'teardown', 'cleanup', 'dispose', 'destroy', + 'open', 'close', 'connect', 'disconnect', 'execute', 'run', 'start', 'stop', + 'render', 'update', 'refresh', 'reset', 'clear', 'flush', +} + +SKIP_DUPLICATE_PREFIXES = ( + # Transformers + 'normalize', 'parse', 'validate', 'serialize', 'deserialize', + 'convert', 'transform', 'extract', 'encode', 'decode', + # Predicates + 'is', 'has', 'can', 'should', 'will', + # Constructors/factories + 'create', 'make', 'build', 'generate', 'new', + # Accessors + 'get', 'set', 'read', 'write', 'load', 'save', 'fetch', + # Handlers + 'handle', 'on', 'emit', + # Modifiers + 'add', 'remove', 'delete', 'update', 'insert', 'append', + # Other common + 'to', 'from', 'with', 'apply', 'process', 'resolve', 'ensure', 'check', + 'filter', 'map', 'reduce', 'merge', 'split', 'join', 'find', 'search', + 'register', 'unregister', 'subscribe', 'unsubscribe', +) +SKIP_DUPLICATE_FILE_PATTERNS = ('.test.ts', '.test.tsx', '.spec.ts') + +# Known packages in the monorepo +PACKAGES = { + 'src', 'apps', 'extensions', 'packages', 'scripts', 'ui', 'test', 'docs' +} + + +def get_package(file_path: Path, root_dir: Path) -> str: + """Get the package name for a file, or 'root' if at top level.""" + try: + relative = file_path.relative_to(root_dir) + parts = relative.parts + if len(parts) > 0 and parts[0] in PACKAGES: + return parts[0] + return 'root' + except ValueError: + return 'root' + + +def count_lines(file_path: Path) -> int: + """Count the number of lines in a file.""" + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + return sum(1 for _ in f) + except Exception: + return 0 + + +def find_code_files(root_dir: Path) -> List[Tuple[Path, int]]: + """Find all code files and their line counts.""" + files_with_counts = [] + + for dirpath, dirnames, filenames in os.walk(root_dir): + # Remove skip directories from dirnames to prevent walking into them + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + + for filename in filenames: + file_path = Path(dirpath) / filename + if file_path.suffix.lower() in CODE_EXTENSIONS: + line_count = count_lines(file_path) + files_with_counts.append((file_path, line_count)) + + return files_with_counts + + +# Regex patterns for TypeScript functions (exported and internal) +TS_FUNCTION_PATTERNS = [ + # export function name(...) or function name(...) + re.compile(r'^(?:export\s+)?(?:async\s+)?function\s+(\w+)', re.MULTILINE), + # export const name = or const name = + re.compile(r'^(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>', re.MULTILINE), +] + + +def extract_functions(file_path: Path) -> Set[str]: + """Extract function names from a TypeScript file.""" + if file_path.suffix.lower() not in {'.ts', '.tsx'}: + return set() + + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + except Exception: + return set() + + functions = set() + for pattern in TS_FUNCTION_PATTERNS: + for match in pattern.finditer(content): + functions.add(match.group(1)) + + return functions + + +def find_duplicate_functions(files: List[Tuple[Path, int]], root_dir: Path) -> Dict[str, List[Path]]: + """Find function names that appear in multiple files.""" + function_locations: Dict[str, List[Path]] = defaultdict(list) + + for file_path, _ in files: + # Skip test files for duplicate detection + if any(file_path.name.endswith(pat) for pat in SKIP_DUPLICATE_FILE_PATTERNS): + continue + + functions = extract_functions(file_path) + for func in functions: + # Skip known common function names + if func in SKIP_DUPLICATE_FUNCTIONS: + continue + if any(func.startswith(prefix) for prefix in SKIP_DUPLICATE_PREFIXES): + continue + function_locations[func].append(file_path) + + # Filter to only duplicates + return {name: paths for name, paths in function_locations.items() if len(paths) > 1} + + +def main(): + parser = argparse.ArgumentParser( + description='List the longest and shortest code files in a project' + ) + parser.add_argument( + '-t', '--threshold', + type=int, + default=1000, + help='Warn about files longer than this many lines (default: 1000)' + ) + parser.add_argument( + '--min-threshold', + type=int, + default=10, + help='Warn about files shorter than this many lines (default: 10)' + ) + parser.add_argument( + '-n', '--top', + type=int, + default=20, + help='Show top N longest files (default: 20)' + ) + parser.add_argument( + '-b', '--bottom', + type=int, + default=10, + help='Show bottom N shortest files (default: 10)' + ) + parser.add_argument( + '-d', '--directory', + type=str, + default='.', + help='Directory to scan (default: current directory)' + ) + + args = parser.parse_args() + + root_dir = Path(args.directory).resolve() + print(f"\n📂 Scanning: {root_dir}\n") + + # Find and sort files by line count + files = find_code_files(root_dir) + files_desc = sorted(files, key=lambda x: x[1], reverse=True) + files_asc = sorted(files, key=lambda x: x[1]) + + # Show top N longest files + top_files = files_desc[:args.top] + + print(f"📊 Top {min(args.top, len(top_files))} longest code files:\n") + print(f"{'Lines':>8} {'File'}") + print("-" * 60) + + long_warnings = [] + + for file_path, line_count in top_files: + relative_path = file_path.relative_to(root_dir) + + # Check if over threshold + if line_count >= args.threshold: + marker = " ⚠️" + long_warnings.append((relative_path, line_count)) + else: + marker = "" + + print(f"{line_count:>8} {relative_path}{marker}") + + # Show bottom N shortest files + bottom_files = files_asc[:args.bottom] + + print(f"\n📉 Bottom {min(args.bottom, len(bottom_files))} shortest code files:\n") + print(f"{'Lines':>8} {'File'}") + print("-" * 60) + + short_warnings = [] + + for file_path, line_count in bottom_files: + relative_path = file_path.relative_to(root_dir) + filename = file_path.name + + # Skip known barrel exports and stubs + is_expected_short = ( + filename in SKIP_SHORT_PATTERNS or + any(filename.endswith(suffix) for suffix in SKIP_SHORT_SUFFIXES) + ) + + # Check if under threshold + if line_count <= args.min_threshold and not is_expected_short: + marker = " ⚠️" + short_warnings.append((relative_path, line_count)) + else: + marker = "" + + print(f"{line_count:>8} {relative_path}{marker}") + + # Summary + total_files = len(files) + total_lines = sum(count for _, count in files) + + print("-" * 60) + print(f"\n📈 Summary:") + print(f" Total code files: {total_files:,}") + print(f" Total lines: {total_lines:,}") + print(f" Average lines/file: {total_lines // total_files if total_files else 0:,}") + + # Per-package breakdown + package_stats: dict[str, dict] = {} + for file_path, line_count in files: + pkg = get_package(file_path, root_dir) + if pkg not in package_stats: + package_stats[pkg] = {'files': 0, 'lines': 0} + package_stats[pkg]['files'] += 1 + package_stats[pkg]['lines'] += line_count + + print(f"\n📦 Per-package breakdown:\n") + print(f"{'Package':<15} {'Files':>8} {'Lines':>10} {'Avg':>8}") + print("-" * 45) + + for pkg in sorted(package_stats.keys(), key=lambda p: package_stats[p]['lines'], reverse=True): + stats = package_stats[pkg] + avg = stats['lines'] // stats['files'] if stats['files'] else 0 + print(f"{pkg:<15} {stats['files']:>8,} {stats['lines']:>10,} {avg:>8,}") + + # Long file warnings + if long_warnings: + print(f"\n⚠️ Warning: {len(long_warnings)} file(s) exceed {args.threshold} lines (consider refactoring):") + for path, count in long_warnings: + print(f" - {path} ({count:,} lines)") + else: + print(f"\n✅ No files exceed {args.threshold} lines") + + # Short file warnings + if short_warnings: + print(f"\n⚠️ Warning: {len(short_warnings)} file(s) are {args.min_threshold} lines or less (check if needed):") + for path, count in short_warnings: + print(f" - {path} ({count} lines)") + else: + print(f"\n✅ No files are {args.min_threshold} lines or less") + + # Duplicate function names + duplicates = find_duplicate_functions(files, root_dir) + if duplicates: + print(f"\n⚠️ Warning: {len(duplicates)} function name(s) appear in multiple files (consider renaming):") + for func_name in sorted(duplicates.keys()): + paths = duplicates[func_name] + print(f" - {func_name}:") + for path in paths: + print(f" {path.relative_to(root_dir)}") + else: + print(f"\n✅ No duplicate function names") + + print() + + +if __name__ == '__main__': + main() diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 9532441f4e..8c6f08594e 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -1,5 +1,6 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { deleteSession, drainSession, @@ -12,7 +13,6 @@ import { } from "./bash-process-registry.js"; import { deriveSessionName, - formatDuration, killSession, pad, sliceLogLines, @@ -118,7 +118,7 @@ export function createProcessTool( .toSorted((a, b) => b.startedAt - a.startedAt) .map((s) => { const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120); - return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`; + return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`; }); return { content: [ diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index e0f68c613b..f0cb672d8f 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -244,19 +244,6 @@ function stripQuotes(value: string): string { return trimmed; } -export function formatDuration(ms: number) { - if (ms < 1000) { - return `${ms}ms`; - } - const seconds = Math.floor(ms / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const rem = seconds % 60; - return `${minutes}m${rem.toString().padStart(2, "0")}s`; -} - export function pad(str: string, width: number) { if (str.length >= width) { return str; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index ae771dade0..f5a0444d35 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -9,6 +9,7 @@ import { resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; +import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -25,23 +26,6 @@ import { import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { readLatestAssistantReply } from "./tools/agent-step.js"; -function formatDurationShort(valueMs?: number) { - if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { - return undefined; - } - const totalSeconds = Math.round(valueMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}h${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m${seconds}s`; - } - return `${seconds}s`; -} - function formatTokenCount(value?: number) { if (!value || !Number.isFinite(value)) { return "0"; @@ -267,7 +251,7 @@ async function buildSubagentStatsLine(params: { : undefined; const parts: string[] = []; - const runtime = formatDurationShort(runtimeMs); + const runtime = formatDurationCompact(runtimeMs); parts.push(`runtime ${runtime ?? "n/a"}`); if (typeof total === "number") { const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a"; diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 96b81b6b0f..af10b15ef1 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveUserTimezone } from "../agents/date-time.js"; import { normalizeChatType } from "../channels/chat-type.js"; import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js"; +import { + resolveTimezone, + formatUtcTimestamp, + formatZonedTimestamp, +} from "../infra/format-time/format-datetime.ts"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; export type AgentEnvelopeParams = { channel: string; @@ -66,15 +72,6 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn }; } -function resolveExplicitTimezone(value: string): string | undefined { - try { - new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); - return value; - } catch { - return undefined; - } -} - function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone { const trimmed = options.timezone?.trim(); if (!trimmed) { @@ -90,46 +87,10 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn if (lowered === "user") { return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) }; } - const explicit = resolveExplicitTimezone(trimmed); + const explicit = resolveTimezone(trimmed); return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" }; } -function formatUtcTimestamp(date: Date): string { - const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); - const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(date.getUTCDate()).padStart(2, "0"); - const hh = String(date.getUTCHours()).padStart(2, "0"); - const min = String(date.getUTCMinutes()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; -} - -export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - timeZoneName: "short", - }).formatToParts(date); - const pick = (type: string) => parts.find((part) => part.type === type)?.value; - const yyyy = pick("year"); - const mm = pick("month"); - const dd = pick("day"); - const hh = pick("hour"); - const min = pick("minute"); - const tz = [...parts] - .toReversed() - .find((part) => part.type === "timeZoneName") - ?.value?.trim(); - if (!yyyy || !mm || !dd || !hh || !min) { - return undefined; - } - return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; -} - function formatTimestamp( ts: number | Date | undefined, options?: EnvelopeFormatOptions, @@ -152,47 +113,27 @@ function formatTimestamp( if (zone.mode === "local") { return formatZonedTimestamp(date); } - return formatZonedTimestamp(date, zone.timeZone); -} - -function formatElapsedTime(currentMs: number, previousMs: number): string | undefined { - const elapsedMs = currentMs - previousMs; - if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { - return undefined; - } - - const seconds = Math.floor(elapsedMs / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - return `${minutes}m`; - } - - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return `${hours}h`; - } - - const days = Math.floor(hours / 24); - return `${days}d`; + return formatZonedTimestamp(date, { timeZone: zone.timeZone }); } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { const channel = params.channel?.trim() || "Channel"; const parts: string[] = [channel]; const resolved = normalizeEnvelopeOptions(params.envelope); - const elapsed = - resolved.includeElapsed && params.timestamp && params.previousTimestamp - ? formatElapsedTime( - params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp, - params.previousTimestamp instanceof Date - ? params.previousTimestamp.getTime() - : params.previousTimestamp, - ) - : undefined; + let elapsed: string | undefined; + if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) { + const currentMs = + params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp; + const previousMs = + params.previousTimestamp instanceof Date + ? params.previousTimestamp.getTime() + : params.previousTimestamp; + const elapsedMs = currentMs - previousMs; + elapsed = + Number.isFinite(elapsedMs) && elapsedMs >= 0 + ? formatTimeAgo(elapsedMs, { suffix: false }) + : undefined; + } if (params.from?.trim()) { const from = params.from.trim(); parts.push(elapsed ? `${from} +${elapsed}` : from); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 7d0d47e62b..3830805598 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -14,17 +14,13 @@ import { import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.ts"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { stopSubagentsForRequester } from "./abort.js"; import { clearSessionQueues } from "./queue.js"; -import { - formatAgeShort, - formatDurationShort, - formatRunLabel, - formatRunStatus, - sortSubagentRuns, -} from "./subagents-utils.js"; +import { formatRunLabel, formatRunStatus, sortSubagentRuns } from "./subagents-utils.js"; type SubagentTargetResolution = { entry?: SubagentRunRecord; @@ -45,7 +41,7 @@ function formatTimestampWithAge(valueMs?: number) { if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { return "n/a"; } - return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`; + return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`; } function resolveRequesterSessionKey(params: Parameters[0]): string | undefined { @@ -214,8 +210,8 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo const label = formatRunLabel(entry); const runtime = entry.endedAt && entry.startedAt - ? formatDurationShort(entry.endedAt - entry.startedAt) - : formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt)); + ? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a") + : formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" }); const runId = entry.runId.slice(0, 8); lines.push( `${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`, @@ -296,7 +292,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey); const runtime = run.startedAt && Number.isFinite(run.startedAt) - ? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt) + ? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a") : "n/a"; const outcome = run.outcome ? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}` diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 36cd0a02ce..556ac9bbdd 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -5,6 +5,11 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { buildChannelSummary } from "../../infra/channel-summary.js"; +import { + resolveTimezone, + formatUtcTimestamp, + formatZonedTimestamp, +} from "../../infra/format-time/format-datetime.ts"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { drainSystemEventEntries } from "../../infra/system-events.js"; @@ -39,15 +44,6 @@ export async function prependSystemEvents(params: { return trimmed; }; - const resolveExplicitTimezone = (value: string): string | undefined => { - try { - new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); - return value; - } catch { - return undefined; - } - }; - const resolveSystemEventTimezone = (cfg: OpenClawConfig) => { const raw = cfg.agents?.defaults?.envelopeTimezone?.trim(); if (!raw) { @@ -66,49 +62,10 @@ export async function prependSystemEvents(params: { timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), }; } - const explicit = resolveExplicitTimezone(raw); + const explicit = resolveTimezone(raw); return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const }; }; - const formatUtcTimestamp = (date: Date): string => { - const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); - const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(date.getUTCDate()).padStart(2, "0"); - const hh = String(date.getUTCHours()).padStart(2, "0"); - const min = String(date.getUTCMinutes()).padStart(2, "0"); - const sec = String(date.getUTCSeconds()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; - }; - - const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - timeZoneName: "short", - }).formatToParts(date); - const pick = (type: string) => parts.find((part) => part.type === type)?.value; - const yyyy = pick("year"); - const mm = pick("month"); - const dd = pick("day"); - const hh = pick("hour"); - const min = pick("minute"); - const sec = pick("second"); - const tz = [...parts] - .toReversed() - .find((part) => part.type === "timeZoneName") - ?.value?.trim(); - if (!yyyy || !mm || !dd || !hh || !min || !sec) { - return undefined; - } - return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`; - }; - const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => { const date = new Date(ts); if (Number.isNaN(date.getTime())) { @@ -116,12 +73,15 @@ export async function prependSystemEvents(params: { } const zone = resolveSystemEventTimezone(cfg); if (zone.mode === "utc") { - return formatUtcTimestamp(date); + return formatUtcTimestamp(date, { displaySeconds: true }); } if (zone.mode === "local") { - return formatZonedTimestamp(date) ?? "unknown-time"; + return formatZonedTimestamp(date, { displaySeconds: true }) ?? "unknown-time"; } - return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time"; + return ( + formatZonedTimestamp(date, { timeZone: zone.timeZone, displaySeconds: true }) ?? + "unknown-time" + ); }; const systemLines: string[] = []; diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts index bec83a8a23..b66a70680d 100644 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ b/src/auto-reply/reply/subagents-utils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; import { - formatDurationShort, formatRunLabel, formatRunStatus, resolveSubagentLabel, @@ -54,8 +54,8 @@ describe("subagents utils", () => { ); }); - it("formats duration short for seconds and minutes", () => { - expect(formatDurationShort(45_000)).toBe("45s"); - expect(formatDurationShort(65_000)).toBe("1m5s"); + it("formats duration compact for seconds and minutes", () => { + expect(formatDurationCompact(45_000)).toBe("45s"); + expect(formatDurationCompact(65_000)).toBe("1m5s"); }); }); diff --git a/src/auto-reply/reply/subagents-utils.ts b/src/auto-reply/reply/subagents-utils.ts index 092ac6465d..1c5ecba118 100644 --- a/src/auto-reply/reply/subagents-utils.ts +++ b/src/auto-reply/reply/subagents-utils.ts @@ -1,42 +1,6 @@ import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import { truncateUtf16Safe } from "../../utils.js"; -export function formatDurationShort(valueMs?: number) { - if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { - return "n/a"; - } - const totalSeconds = Math.round(valueMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}h${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m${seconds}s`; - } - return `${seconds}s`; -} - -export function formatAgeShort(valueMs?: number) { - if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { - return "n/a"; - } - const minutes = Math.round(valueMs / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -} - export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") { const raw = entry.label?.trim() || entry.task?.trim() || ""; return raw || fallback; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 0b3f842d01..4ff2771e1d 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -16,6 +16,7 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { resolveCommitHash } from "../infra/git-commit.js"; import { listPluginCommands } from "../plugins/commands.js"; import { @@ -134,25 +135,6 @@ export const formatContextUsageShort = ( contextTokens: number | null | undefined, ) => `Context ${formatTokens(total, contextTokens ?? null)}`; -const formatAge = (ms?: number | null) => { - if (!ms || ms < 0) { - return "unknown"; - } - const minutes = Math.round(ms / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - const formatQueueDetails = (queue?: QueueStatus) => { if (!queue) { return ""; @@ -386,7 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string { const updatedAt = entry?.updatedAt; const sessionLine = [ `Session: ${args.sessionKey ?? "unknown"}`, - typeof updatedAt === "number" ? `updated ${formatAge(now - updatedAt)}` : "no activity", + typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity", ] .filter(Boolean) .join(" • "); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index bd7f473c63..bb5f02711e 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -2,6 +2,7 @@ import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; +import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; @@ -107,19 +108,6 @@ const formatIsoMinute = (iso: string) => { return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`; }; -const formatDuration = (ms: number) => { - if (ms < 60_000) { - return `${Math.max(1, Math.round(ms / 1000))}s`; - } - if (ms < 3_600_000) { - return `${Math.round(ms / 60_000)}m`; - } - if (ms < 86_400_000) { - return `${Math.round(ms / 3_600_000)}h`; - } - return `${Math.round(ms / 86_400_000)}d`; -}; - const formatSpan = (ms: number) => { if (ms < 60_000) { return "<1m"; @@ -147,7 +135,7 @@ const formatSchedule = (schedule: CronSchedule) => { return `at ${formatIsoMinute(schedule.at)}`; } if (schedule.kind === "every") { - return `every ${formatDuration(schedule.everyMs)}`; + return `every ${formatDurationHuman(schedule.everyMs)}`; } return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`; }; diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 8e0b8bd3d5..9635ce2711 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { callGateway } from "../gateway/call.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; @@ -49,23 +50,6 @@ type DevicePairingList = { paired?: PairedDevice[]; }; -function formatAge(msAgo: number) { - const s = Math.max(0, Math.floor(msAgo / 1000)); - if (s < 60) { - return `${s}s`; - } - const m = Math.floor(s / 60); - if (m < 60) { - return `${m}m`; - } - const h = Math.floor(m / 60); - if (h < 24) { - return `${h}h`; - } - const d = Math.floor(h / 24); - return `${d}d`; -} - const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => cmd .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") @@ -147,7 +131,7 @@ export function registerDevicesCli(program: Command) { Device: req.displayName || req.deviceId, Role: req.role ?? "", IP: req.remoteIp ?? "", - Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "", + Age: typeof req.ts === "number" ? formatTimeAgo(Date.now() - req.ts) : "", Flags: req.isRepair ? "repair" : "", })), }).trimEnd(), diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 924778e57e..2b151d37f0 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -8,6 +8,7 @@ import { type ExecApprovalsAgent, type ExecApprovalsFile, } from "../infra/exec-approvals.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { renderTable } from "../terminal/table.js"; @@ -31,23 +32,6 @@ type ExecApprovalsCliOpts = NodesRpcOpts & { agent?: string; }; -function formatAge(msAgo: number) { - const s = Math.max(0, Math.floor(msAgo / 1000)); - if (s < 60) { - return `${s}s`; - } - const m = Math.floor(s / 60); - if (m < 60) { - return `${m}m`; - } - const h = Math.floor(m / 60); - if (h < 24) { - return `${h}h`; - } - const d = Math.floor(h / 24); - return `${d}d`; -} - async function readStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { @@ -142,7 +126,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s Target: targetLabel, Agent: agentId, Pattern: pattern, - LastUsed: lastUsedAt ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` : muted("unknown"), + LastUsed: lastUsedAt ? formatTimeAgo(Math.max(0, now - lastUsedAt)) : muted("unknown"), }); } } diff --git a/src/cli/nodes-cli/format.ts b/src/cli/nodes-cli/format.ts index f4dc94fc88..646c5ac4cc 100644 --- a/src/cli/nodes-cli/format.ts +++ b/src/cli/nodes-cli/format.ts @@ -1,22 +1,5 @@ import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./types.js"; -export function formatAge(msAgo: number) { - const s = Math.max(0, Math.floor(msAgo / 1000)); - if (s < 60) { - return `${s}s`; - } - const m = Math.floor(s / 60); - if (m < 60) { - return `${m}m`; - } - const h = Math.floor(m / 60); - if (h < 24) { - return `${h}h`; - } - const d = Math.floor(h / 24); - return `${d}d`; -} - export function parsePairingList(value: unknown): PairingList { const obj = typeof value === "object" && value !== null ? (value as Record) : {}; const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : []; diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 65b361cffc..9241aeff78 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,9 +1,10 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; import { renderTable } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { formatAge, parsePairingList } from "./format.js"; +import { parsePairingList } from "./format.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; export function registerNodesPairingCommands(nodes: Command) { @@ -32,9 +33,7 @@ export function registerNodesPairingCommands(nodes: Command) { Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, IP: r.remoteIp ?? "", Requested: - typeof r.ts === "number" - ? `${formatAge(Math.max(0, now - r.ts))} ago` - : muted("unknown"), + typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : muted("unknown"), Repair: r.isRepair ? warn("yes") : "", })); defaultRuntime.log(heading("Pending")); diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e8cdd52720..e29b79d069 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,11 +1,12 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; import { renderTable } from "../../terminal/table.js"; import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js"; +import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; function formatVersionLabel(raw: string) { @@ -178,7 +179,7 @@ export function registerNodesStatusCommands(nodes: Command) { const connected = n.connected ? ok("connected") : muted("disconnected"); const since = typeof n.connectedAtMs === "number" - ? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)` + ? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})` : ""; return { @@ -361,7 +362,7 @@ export function registerNodesStatusCommands(nodes: Command) { IP: r.remoteIp ?? "", Requested: typeof r.ts === "number" - ? `${formatAge(Math.max(0, now - r.ts))} ago` + ? formatTimeAgo(Math.max(0, now - r.ts)) : muted("unknown"), Repair: r.isRepair ? warn("yes") : "", })); @@ -397,7 +398,7 @@ export function registerNodesStatusCommands(nodes: Command) { IP: n.remoteIp ?? "", LastConnect: typeof lastConnectedAtMs === "number" - ? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago` + ? formatTimeAgo(Math.max(0, now - lastConnectedAtMs)) : muted("unknown"), }; }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 62bd17f8be..e52258fbdf 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -16,6 +16,7 @@ import { } from "../commands/status.update.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import { trimLogTail } from "../infra/restart-sentinel.js"; import { parseSemver } from "../infra/runtime-guard.js"; @@ -575,7 +576,7 @@ function createUpdateProgress(enabled: boolean): ProgressController { } const label = getStepLabel(step); - const duration = theme.muted(`(${formatDuration(step.durationMs)})`); + const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`); const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717"); currentSpinner.stop(`${icon} ${label} ${duration}`); @@ -603,14 +604,6 @@ function createUpdateProgress(enabled: boolean): ProgressController { }; } -function formatDuration(ms: number): string { - if (ms < 1000) { - return `${ms}ms`; - } - const seconds = (ms / 1000).toFixed(1); - return `${seconds}s`; -} - function formatStepStatus(exitCode: number | null): string { if (exitCode === 0) { return theme.success("\u2713"); @@ -668,7 +661,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) { defaultRuntime.log(theme.heading("Steps:")); for (const step of result.steps) { const status = formatStepStatus(step.exitCode); - const duration = theme.muted(`(${formatDuration(step.durationMs)})`); + const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`); defaultRuntime.log(` ${status} ${step.name} ${duration}`); if (step.exitCode !== 0 && step.stderrTail) { @@ -683,7 +676,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) { } defaultRuntime.log(""); - defaultRuntime.log(`Total time: ${theme.muted(formatDuration(result.durationMs))}`); + defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`); } export async function updateCommand(opts: UpdateCommandOptions): Promise { diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 145187c141..be1e3bb9fa 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -5,8 +5,8 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { withProgress } from "../../cli/progress.js"; import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { formatAge } from "../../infra/channel-summary.js"; import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; @@ -48,10 +48,10 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) { bits.push(`mode:${account.mode}`); diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index ea0c4fbb47..1804586d9c 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -5,12 +5,8 @@ import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { - formatAge, - formatImageMatch, - formatSimpleStatus, - formatStatus, -} from "./sandbox-formatters.js"; +import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; +import { formatImageMatch, formatSimpleStatus, formatStatus } from "./sandbox-formatters.js"; type DisplayConfig = { emptyMessage: string; @@ -40,8 +36,12 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R rt.log(` ${container.containerName}`); rt.log(` Status: ${formatStatus(container.running)}`); rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`); - rt.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`); - rt.log(` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`); + rt.log( + ` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`, + ); + rt.log( + ` Idle: ${formatDurationCompact(Date.now() - container.lastUsedAtMs, { spaced: true }) ?? "0s"}`, + ); rt.log(` Session: ${container.sessionKey}`); rt.log(""); }, @@ -64,8 +64,12 @@ export function displayBrowsers(browsers: SandboxBrowserInfo[], runtime: Runtime if (browser.noVncPort) { rt.log(` noVNC: ${browser.noVncPort}`); } - rt.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`); - rt.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`); + rt.log( + ` Age: ${formatDurationCompact(Date.now() - browser.createdAtMs, { spaced: true }) ?? "0s"}`, + ); + rt.log( + ` Idle: ${formatDurationCompact(Date.now() - browser.lastUsedAtMs, { spaced: true }) ?? "0s"}`, + ); rt.log(` Session: ${browser.sessionKey}`); rt.log(""); }, diff --git a/src/commands/sandbox-formatters.test.ts b/src/commands/sandbox-formatters.test.ts index d8bf8383a8..b9c4b70298 100644 --- a/src/commands/sandbox-formatters.test.ts +++ b/src/commands/sandbox-formatters.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; +import { formatDurationCompact } from "../infra/format-time/format-duration.js"; import { countMismatches, countRunning, - formatAge, formatImageMatch, formatSimpleStatus, formatStatus, } from "./sandbox-formatters.js"; +/** Helper matching old formatAge behavior: spaced compound duration */ +const formatAge = (ms: number) => formatDurationCompact(ms, { spaced: true }) ?? "0s"; + describe("sandbox-formatters", () => { describe("formatStatus", () => { it("should format running status", () => { @@ -47,21 +50,21 @@ describe("sandbox-formatters", () => { it("should format minutes", () => { expect(formatAge(60000)).toBe("1m"); - expect(formatAge(90000)).toBe("1m"); + expect(formatAge(90000)).toBe("1m 30s"); // 90 seconds = 1m 30s expect(formatAge(300000)).toBe("5m"); }); it("should format hours and minutes", () => { - expect(formatAge(3600000)).toBe("1h 0m"); + expect(formatAge(3600000)).toBe("1h"); expect(formatAge(3660000)).toBe("1h 1m"); - expect(formatAge(7200000)).toBe("2h 0m"); + expect(formatAge(7200000)).toBe("2h"); expect(formatAge(5400000)).toBe("1h 30m"); }); it("should format days and hours", () => { - expect(formatAge(86400000)).toBe("1d 0h"); + expect(formatAge(86400000)).toBe("1d"); expect(formatAge(90000000)).toBe("1d 1h"); - expect(formatAge(172800000)).toBe("2d 0h"); + expect(formatAge(172800000)).toBe("2d"); expect(formatAge(183600000)).toBe("2d 3h"); }); @@ -70,9 +73,9 @@ describe("sandbox-formatters", () => { }); it("should handle edge cases", () => { - expect(formatAge(59999)).toBe("59s"); // Just under 1 minute - expect(formatAge(3599999)).toBe("59m"); // Just under 1 hour - expect(formatAge(86399999)).toBe("23h 59m"); // Just under 1 day + expect(formatAge(59999)).toBe("1m"); // Rounds to 1 minute exactly + expect(formatAge(3599999)).toBe("1h"); // Rounds to 1 hour exactly + expect(formatAge(86399999)).toBe("1d"); // Rounds to 1 day exactly }); }); diff --git a/src/commands/sandbox-formatters.ts b/src/commands/sandbox-formatters.ts index 915017d191..f96fc631ff 100644 --- a/src/commands/sandbox-formatters.ts +++ b/src/commands/sandbox-formatters.ts @@ -14,24 +14,6 @@ export function formatImageMatch(matches: boolean): string { return matches ? "✓" : "⚠️ mismatch"; } -export function formatAge(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - return `${days}d ${hours % 24}h`; - } - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m`; - } - return `${seconds}s`; -} - /** * Type guard and counter utilities */ diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 54235086cd..849fecb759 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -5,6 +5,7 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; import { info } from "../globals.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { isRich, theme } from "../terminal/theme.js"; type SessionRow = { @@ -90,7 +91,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { }; const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => { - const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown"; + const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown"; const padded = ageLabel.padEnd(AGE_PAD); return rich ? theme.muted(padded) : padded; }; @@ -116,25 +117,6 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => { return label.length === 0 ? "" : rich ? theme.muted(label) : label; }; -const formatAge = (ms: number | null | undefined) => { - if (!ms || ms < 0) { - return "unknown"; - } - const minutes = Math.round(ms / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] { if (key === "global") { return "global"; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 8f04e985b8..25051a546b 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -28,7 +28,7 @@ import { VERSION } from "../version.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; import { getAgentLocalStatuses } from "./status-all/agents.js"; import { buildChannelsTable } from "./status-all/channels.js"; -import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js"; +import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js"; import { pickGatewaySelfPresence } from "./status-all/gateway.js"; import { buildStatusAllReportLines } from "./status-all/report-lines.js"; @@ -354,7 +354,7 @@ export async function statusAllCommand( const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url; const gatewayStatus = gatewayReachable - ? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}` + ? `reachable ${formatDurationPrecise(gatewayProbe?.connectLatencyMs ?? 0)}` : gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable"; diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 0919211612..bb17e3c2e7 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { sha256HexPrefix } from "../../logging/redact-identifier.js"; -import { formatAge } from "./format.js"; +import { formatTimeAgo } from "./format.js"; export type ChannelRow = { id: ChannelId; @@ -436,7 +436,7 @@ export async function buildChannelsTable( extra.push(link.selfE164); } if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) { - extra.push(`auth ${formatAge(link.authAgeMs)}`); + extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`); } if (accounts.length > 1 || plugin.meta.forceAccountBinding) { extra.push(`accounts ${accounts.length || 1}`); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 7a4447f7de..35da8ab97e 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -5,7 +5,7 @@ import { type RestartSentinelPayload, summarizeRestartSentinel, } from "../../infra/restart-sentinel.js"; -import { formatAge, redactSecrets } from "./format.js"; +import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; type ConfigIssueLike = { path: string; message: string }; @@ -106,7 +106,7 @@ export async function appendStatusAllDiagnosis(params: { if (params.sentinel?.payload) { emitCheck("Restart sentinel present", "warn"); lines.push( - ` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`, + ` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatTimeAgo(Date.now() - params.sentinel.payload.ts)}`)}`, ); } else { emitCheck("Restart sentinel: none", "ok"); diff --git a/src/commands/status-all/format.ts b/src/commands/status-all/format.ts index 979fa4e6db..be443d563c 100644 --- a/src/commands/status-all/format.ts +++ b/src/commands/status-all/format.ts @@ -1,31 +1,5 @@ -export const formatAge = (ms: number | null | undefined) => { - if (!ms || ms < 0) { - return "unknown"; - } - const minutes = Math.round(ms / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - -export const formatDuration = (ms: number | null | undefined) => { - if (ms == null || !Number.isFinite(ms)) { - return "unknown"; - } - if (ms < 1000) { - return `${Math.round(ms)}ms`; - } - return `${(ms / 1000).toFixed(1)}s`; -}; +export { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +export { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; export function formatGatewayAuthUsed( auth: { diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 84d2656dd2..71dc035ad8 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -2,7 +2,7 @@ import type { ProgressReporter } from "../../cli/progress.js"; import { renderTable } from "../../terminal/table.js"; import { isRich, theme } from "../../terminal/theme.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js"; -import { formatAge } from "./format.js"; +import { formatTimeAgo } from "./format.js"; type OverviewRow = { Item: string; Value: string }; @@ -128,7 +128,7 @@ export async function buildStatusAllReportLines(params: { ? ok("OK") : "unknown", Sessions: String(a.sessionsCount), - Active: a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown", + Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown", Store: a.sessionsPath, })); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a8e288b82f..cbe5d6d78a 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -5,6 +5,7 @@ import { withProgress } from "../cli/progress.js"; import { resolveGatewayPort } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js"; import { formatUpdateChannelLabel, @@ -26,7 +27,6 @@ import { statusAllCommand } from "./status-all.js"; import { formatGatewayAuthUsed } from "./status-all/format.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { - formatAge, formatDuration, formatKTokens, formatTokensCompact, @@ -239,7 +239,7 @@ export async function statusCommand( ? `${agentStatus.bootstrapPendingCount} bootstrapping` : "no bootstraps"; const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId); - const defActive = def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown"; + const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown"; const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; })(); @@ -294,7 +294,7 @@ export async function statusCommand( if (!lastHeartbeat) { return muted("none"); } - const age = formatAge(Date.now() - lastHeartbeat.ts); + const age = formatTimeAgo(Date.now() - lastHeartbeat.ts); const channel = lastHeartbeat.channel ?? "unknown"; const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null; return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · "); @@ -527,7 +527,7 @@ export async function statusCommand( ? summary.sessions.recent.map((sess) => ({ Key: shortenText(sess.key, 32), Kind: sess.kind, - Age: sess.updatedAt ? formatAge(sess.age) : "no activity", + Age: sess.updatedAt ? formatTimeAgo(sess.age) : "no activity", Model: sess.model ?? "unknown", Tokens: formatTokensCompact(sess), })) diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts index 7572ca18cb..9c4a7a59b2 100644 --- a/src/commands/status.format.ts +++ b/src/commands/status.format.ts @@ -1,35 +1,14 @@ import type { SessionStatus } from "./status.types.js"; +import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; export const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; -export const formatAge = (ms: number | null | undefined) => { - if (!ms || ms < 0) { - return "unknown"; - } - const minutes = Math.round(ms / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - export const formatDuration = (ms: number | null | undefined) => { if (ms == null || !Number.isFinite(ms)) { return "unknown"; } - if (ms < 1000) { - return `${Math.round(ms)}ms`; - } - return `${(ms / 1000).toFixed(1)}s`; + return formatDurationPrecise(ms, { decimals: 1 }); }; export const shortenText = (value: string, maxLen: number) => { diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index e6f85be9e3..41b9fae12b 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -7,7 +7,7 @@ import { PresenceUpdateListener, } from "@buape/carbon"; import { danger } from "../../globals.js"; -import { formatDurationSeconds } from "../../infra/format-duration.js"; +import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; diff --git a/src/entry.ts b/src/entry.ts index bbf2173a36..81ce2c3e60 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -4,7 +4,7 @@ import path from "node:path"; import process from "node:process"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; -import { installProcessWarningFilter } from "./infra/warnings.js"; +import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; process.title = "openclaw"; diff --git a/src/gateway/server-methods/agent-timestamp.test.ts b/src/gateway/server-methods/agent-timestamp.test.ts index 42afa0a820..1482194c2e 100644 --- a/src/gateway/server-methods/agent-timestamp.test.ts +++ b/src/gateway/server-methods/agent-timestamp.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { formatZonedTimestamp } from "../../auto-reply/envelope.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; describe("injectTimestamp", () => { @@ -23,7 +23,7 @@ describe("injectTimestamp", () => { it("uses channel envelope format with DOW prefix", () => { const now = new Date(); - const expected = formatZonedTimestamp(now, "America/New_York"); + const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" }); const result = injectTimestamp("hello", { timezone: "America/New_York" }); diff --git a/src/gateway/server-methods/agent-timestamp.ts b/src/gateway/server-methods/agent-timestamp.ts index 715262de28..b83245650b 100644 --- a/src/gateway/server-methods/agent-timestamp.ts +++ b/src/gateway/server-methods/agent-timestamp.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../../config/types.js"; import { resolveUserTimezone } from "../../agents/date-time.js"; -import { formatZonedTimestamp } from "../../auto-reply/envelope.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; /** * Cron jobs inject "Current time: ..." into their messages. @@ -56,7 +56,7 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption const now = opts?.now ?? new Date(); const timezone = opts?.timezone ?? "UTC"; - const formatted = formatZonedTimestamp(now, timezone); + const formatted = formatZonedTimestamp(now, { timeZone: timezone }); if (!formatted) { return message; } diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index d95a3adfe1..d56282d77e 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -3,6 +3,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { theme } from "../terminal/theme.js"; +import { formatTimeAgo } from "./format-time/format-relative.ts"; export type ChannelSummaryOptions = { colorize?: boolean; @@ -224,7 +225,7 @@ export async function buildChannelSummary( line += ` ${self.e164}`; } if (authAgeMs != null && authAgeMs >= 0) { - line += ` auth ${formatAge(authAgeMs)}`; + line += ` auth ${formatTimeAgo(authAgeMs)}`; } lines.push(tint(line, statusColor)); @@ -252,22 +253,3 @@ export async function buildChannelSummary( return lines; } - -export function formatAge(ms: number): string { - if (ms < 0) { - return "unknown"; - } - const minutes = Math.round(ms / 60_000); - if (minutes < 1) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.round(minutes / 60); - if (hours < 48) { - return `${hours}h ago`; - } - const days = Math.round(hours / 24); - return `${days}d ago`; -} diff --git a/src/infra/format-duration.ts b/src/infra/format-duration.ts deleted file mode 100644 index b6cb694d75..0000000000 --- a/src/infra/format-duration.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type FormatDurationSecondsOptions = { - decimals?: number; - unit?: "s" | "seconds"; -}; - -export function formatDurationSeconds( - ms: number, - options: FormatDurationSecondsOptions = {}, -): string { - if (!Number.isFinite(ms)) { - return "unknown"; - } - const decimals = options.decimals ?? 1; - const unit = options.unit ?? "s"; - const seconds = Math.max(0, ms) / 1000; - const fixed = seconds.toFixed(Math.max(0, decimals)); - const trimmed = fixed.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1"); - return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`; -} - -export type FormatDurationMsOptions = { - decimals?: number; - unit?: "s" | "seconds"; -}; - -export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string { - if (!Number.isFinite(ms)) { - return "unknown"; - } - if (ms < 1000) { - return `${ms}ms`; - } - return formatDurationSeconds(ms, { - decimals: options.decimals ?? 2, - unit: options.unit ?? "s", - }); -} diff --git a/src/infra/format-time/format-datetime.ts b/src/infra/format-time/format-datetime.ts new file mode 100644 index 0000000000..d7ed13f5c2 --- /dev/null +++ b/src/infra/format-time/format-datetime.ts @@ -0,0 +1,94 @@ +/** + * Centralized date/time formatting utilities. + * + * All formatters are timezone-aware, using Intl.DateTimeFormat. + * Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone + * that previously lived in envelope.ts and session-updates.ts. + */ + +/** + * Validate an IANA timezone string. Returns the string if valid, undefined otherwise. + */ +export function resolveTimezone(value: string): string | undefined { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return value; + } catch { + return undefined; + } +} + +export type FormatTimestampOptions = { + /** Include seconds in the output. Default: false */ + displaySeconds?: boolean; +}; + +export type FormatZonedTimestampOptions = FormatTimestampOptions & { + /** IANA timezone string (e.g., 'America/New_York'). Default: system timezone */ + timeZone?: string; +}; + +/** + * Format a Date as a UTC timestamp string. + * + * Without seconds: `2024-01-15T14:30Z` + * With seconds: `2024-01-15T14:30:05Z` + */ +export function formatUtcTimestamp(date: Date, options?: FormatTimestampOptions): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); + if (!options?.displaySeconds) { + return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; + } + const sec = String(date.getUTCSeconds()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; +} + +/** + * Format a Date with timezone display using Intl.DateTimeFormat. + * + * Without seconds: `2024-01-15 14:30 EST` + * With seconds: `2024-01-15 14:30:05 EST` + * + * Returns undefined if Intl formatting fails. + */ +export function formatZonedTimestamp( + date: Date, + options?: FormatZonedTimestampOptions, +): string | undefined { + const intlOptions: Intl.DateTimeFormatOptions = { + timeZone: options?.timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }; + if (options?.displaySeconds) { + intlOptions.second = "2-digit"; + } + const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date); + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const sec = options?.displaySeconds ? pick("second") : undefined; + const tz = [...parts] + .toReversed() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + if (!yyyy || !mm || !dd || !hh || !min) { + return undefined; + } + if (options?.displaySeconds && sec) { + return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`; + } + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} diff --git a/src/infra/format-time/format-duration.ts b/src/infra/format-time/format-duration.ts new file mode 100644 index 0000000000..4f486c3809 --- /dev/null +++ b/src/infra/format-time/format-duration.ts @@ -0,0 +1,103 @@ +export type FormatDurationSecondsOptions = { + decimals?: number; + unit?: "s" | "seconds"; +}; + +export type FormatDurationCompactOptions = { + /** Add space between units: "2m 5s" instead of "2m5s". Default: false */ + spaced?: boolean; +}; + +export function formatDurationSeconds( + ms: number, + options: FormatDurationSecondsOptions = {}, +): string { + if (!Number.isFinite(ms)) { + return "unknown"; + } + const decimals = options.decimals ?? 1; + const unit = options.unit ?? "s"; + const seconds = Math.max(0, ms) / 1000; + const fixed = seconds.toFixed(Math.max(0, decimals)); + const trimmed = fixed.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1"); + return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`; +} + +/** Precise decimal-seconds output: "500ms" or "1.23s". Input is milliseconds. */ +export function formatDurationPrecise( + ms: number, + options: FormatDurationSecondsOptions = {}, +): string { + if (!Number.isFinite(ms)) { + return "unknown"; + } + if (ms < 1000) { + return `${ms}ms`; + } + return formatDurationSeconds(ms, { + decimals: options.decimals ?? 2, + unit: options.unit ?? "s", + }); +} + +/** + * Compact compound duration: "500ms", "45s", "2m5s", "1h30m". + * With `spaced`: "45s", "2m 5s", "1h 30m". + * Omits trailing zero components: "1m" not "1m 0s", "2h" not "2h 0m". + * Returns undefined for null/undefined/non-finite/non-positive input. + */ +export function formatDurationCompact( + ms?: number | null, + options?: FormatDurationCompactOptions, +): string | undefined { + if (ms == null || !Number.isFinite(ms) || ms <= 0) { + return undefined; + } + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + const sep = options?.spaced ? " " : ""; + const totalSeconds = Math.round(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days}d${sep}${remainingHours}h` : `${days}d`; + } + if (hours > 0) { + return minutes > 0 ? `${hours}h${sep}${minutes}m` : `${hours}h`; + } + if (minutes > 0) { + return seconds > 0 ? `${minutes}m${sep}${seconds}s` : `${minutes}m`; + } + return `${seconds}s`; +} + +/** + * Rounded single-unit duration for display: "500ms", "5s", "3m", "2h", "5d". + * Returns fallback string for null/undefined/non-finite input. + */ +export function formatDurationHuman(ms?: number | null, fallback = "n/a"): string { + if (ms == null || !Number.isFinite(ms) || ms < 0) { + return fallback; + } + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + const sec = Math.round(ms / 1000); + if (sec < 60) { + return `${sec}s`; + } + const min = Math.round(sec / 60); + if (min < 60) { + return `${min}m`; + } + const hr = Math.round(min / 60); + if (hr < 24) { + return `${hr}h`; + } + const day = Math.round(hr / 24); + return `${day}d`; +} diff --git a/src/infra/format-time/format-relative.ts b/src/infra/format-time/format-relative.ts new file mode 100644 index 0000000000..4b4da396fb --- /dev/null +++ b/src/infra/format-time/format-relative.ts @@ -0,0 +1,112 @@ +/** + * Centralized relative-time formatting utilities. + * + * Consolidates 7+ scattered implementations (formatAge, formatAgeShort, formatAgo, + * formatRelativeTime, formatElapsedTime) into two functions: + * + * - `formatTimeAgo(durationMs)` — format a duration as "5m ago" / "5m" (for known elapsed time) + * - `formatRelativeTimestamp(epochMs)` — format an epoch timestamp relative to now (handles future) + */ + +export type FormatTimeAgoOptions = { + /** Append "ago" suffix. Default: true. When false, returns bare unit: "5m", "2h" */ + suffix?: boolean; + /** Return value for invalid/null/negative input. Default: "unknown" */ + fallback?: string; +}; + +/** + * Format a duration (in ms) as a human-readable relative time. + * + * Input: how many milliseconds ago something happened. + * + * With suffix (default): "just now", "5m ago", "3h ago", "2d ago" + * Without suffix: "0s", "5m", "3h", "2d" + */ +export function formatTimeAgo( + durationMs: number | null | undefined, + options?: FormatTimeAgoOptions, +): string { + const suffix = options?.suffix !== false; + const fallback = options?.fallback ?? "unknown"; + + if (durationMs == null || !Number.isFinite(durationMs) || durationMs < 0) { + return fallback; + } + + const totalSeconds = Math.round(durationMs / 1000); + const minutes = Math.round(totalSeconds / 60); + + if (minutes < 1) { + return suffix ? "just now" : `${totalSeconds}s`; + } + if (minutes < 60) { + return suffix ? `${minutes}m ago` : `${minutes}m`; + } + const hours = Math.round(minutes / 60); + if (hours < 48) { + return suffix ? `${hours}h ago` : `${hours}h`; + } + const days = Math.round(hours / 24); + return suffix ? `${days}d ago` : `${days}d`; +} + +export type FormatRelativeTimestampOptions = { + /** If true, fall back to short date (e.g. "Oct 5") for timestamps >7 days. Default: false */ + dateFallback?: boolean; + /** IANA timezone for date fallback display */ + timezone?: string; + /** Return value for invalid/null input. Default: "n/a" */ + fallback?: string; +}; + +/** + * Format an epoch timestamp relative to now. + * + * Handles both past ("5m ago") and future ("in 5m") timestamps. + * Optionally falls back to a short date for timestamps older than 7 days. + */ +export function formatRelativeTimestamp( + timestampMs: number | null | undefined, + options?: FormatRelativeTimestampOptions, +): string { + const fallback = options?.fallback ?? "n/a"; + if (timestampMs == null || !Number.isFinite(timestampMs)) { + return fallback; + } + + const diff = Date.now() - timestampMs; + const absDiff = Math.abs(diff); + const isPast = diff >= 0; + + const sec = Math.round(absDiff / 1000); + if (sec < 60) { + return isPast ? "just now" : "in <1m"; + } + + const min = Math.round(sec / 60); + if (min < 60) { + return isPast ? `${min}m ago` : `in ${min}m`; + } + + const hr = Math.round(min / 60); + if (hr < 48) { + return isPast ? `${hr}h ago` : `in ${hr}h`; + } + + const day = Math.round(hr / 24); + if (!options?.dateFallback || day <= 7) { + return isPast ? `${day}d ago` : `in ${day}d`; + } + + // Fall back to short date display for old timestamps + try { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + ...(options.timezone ? { timeZone: options.timezone } : {}), + }).format(new Date(timestampMs)); + } catch { + return `${day}d ago`; + } +} diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts new file mode 100644 index 0000000000..7367fdac5a --- /dev/null +++ b/src/infra/format-time/format-time.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js"; +import { + formatDurationCompact, + formatDurationHuman, + formatDurationPrecise, + formatDurationSeconds, +} from "./format-duration.js"; +import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js"; + +describe("format-duration", () => { + describe("formatDurationCompact", () => { + it("returns undefined for null/undefined/non-positive", () => { + expect(formatDurationCompact(null)).toBeUndefined(); + expect(formatDurationCompact(undefined)).toBeUndefined(); + expect(formatDurationCompact(0)).toBeUndefined(); + expect(formatDurationCompact(-100)).toBeUndefined(); + }); + + it("formats milliseconds for sub-second durations", () => { + expect(formatDurationCompact(500)).toBe("500ms"); + expect(formatDurationCompact(999)).toBe("999ms"); + }); + + it("formats seconds", () => { + expect(formatDurationCompact(1000)).toBe("1s"); + expect(formatDurationCompact(45000)).toBe("45s"); + expect(formatDurationCompact(59000)).toBe("59s"); + }); + + it("formats minutes and seconds", () => { + expect(formatDurationCompact(60000)).toBe("1m"); + expect(formatDurationCompact(65000)).toBe("1m5s"); + expect(formatDurationCompact(90000)).toBe("1m30s"); + }); + + it("omits trailing zero components", () => { + expect(formatDurationCompact(60000)).toBe("1m"); // not "1m0s" + expect(formatDurationCompact(3600000)).toBe("1h"); // not "1h0m" + expect(formatDurationCompact(86400000)).toBe("1d"); // not "1d0h" + }); + + it("formats hours and minutes", () => { + expect(formatDurationCompact(3660000)).toBe("1h1m"); + expect(formatDurationCompact(5400000)).toBe("1h30m"); + }); + + it("formats days and hours", () => { + expect(formatDurationCompact(90000000)).toBe("1d1h"); + expect(formatDurationCompact(172800000)).toBe("2d"); + }); + + it("supports spaced option", () => { + expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s"); + expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m"); + expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h"); + }); + + it("rounds at boundaries", () => { + // 59.5 seconds rounds to 60s = 1m + expect(formatDurationCompact(59500)).toBe("1m"); + // 59.4 seconds rounds to 59s + expect(formatDurationCompact(59400)).toBe("59s"); + }); + }); + + describe("formatDurationHuman", () => { + it("returns fallback for invalid input", () => { + expect(formatDurationHuman(null)).toBe("n/a"); + expect(formatDurationHuman(undefined)).toBe("n/a"); + expect(formatDurationHuman(-100)).toBe("n/a"); + expect(formatDurationHuman(null, "unknown")).toBe("unknown"); + }); + + it("formats single unit", () => { + expect(formatDurationHuman(500)).toBe("500ms"); + expect(formatDurationHuman(5000)).toBe("5s"); + expect(formatDurationHuman(180000)).toBe("3m"); + expect(formatDurationHuman(7200000)).toBe("2h"); + expect(formatDurationHuman(172800000)).toBe("2d"); + }); + + it("uses 24h threshold for days", () => { + expect(formatDurationHuman(23 * 3600000)).toBe("23h"); + expect(formatDurationHuman(24 * 3600000)).toBe("1d"); + expect(formatDurationHuman(25 * 3600000)).toBe("1d"); // rounds + }); + }); + + describe("formatDurationPrecise", () => { + it("shows milliseconds for sub-second", () => { + expect(formatDurationPrecise(500)).toBe("500ms"); + expect(formatDurationPrecise(999)).toBe("999ms"); + }); + + it("shows decimal seconds for >=1s", () => { + expect(formatDurationPrecise(1000)).toBe("1s"); + expect(formatDurationPrecise(1500)).toBe("1.5s"); + expect(formatDurationPrecise(1234)).toBe("1.23s"); + }); + + it("returns unknown for non-finite", () => { + expect(formatDurationPrecise(NaN)).toBe("unknown"); + expect(formatDurationPrecise(Infinity)).toBe("unknown"); + }); + }); + + describe("formatDurationSeconds", () => { + it("formats with configurable decimals", () => { + expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s"); + expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s"); + expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s"); + }); + + it("supports seconds unit", () => { + expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds"); + }); + }); +}); + +describe("format-datetime", () => { + describe("resolveTimezone", () => { + it("returns valid IANA timezone strings", () => { + expect(resolveTimezone("America/New_York")).toBe("America/New_York"); + expect(resolveTimezone("Europe/London")).toBe("Europe/London"); + expect(resolveTimezone("UTC")).toBe("UTC"); + }); + + it("returns undefined for invalid timezones", () => { + expect(resolveTimezone("Invalid/Timezone")).toBeUndefined(); + expect(resolveTimezone("garbage")).toBeUndefined(); + expect(resolveTimezone("")).toBeUndefined(); + }); + }); + + describe("formatUtcTimestamp", () => { + it("formats without seconds by default", () => { + const date = new Date("2024-01-15T14:30:45.000Z"); + expect(formatUtcTimestamp(date)).toBe("2024-01-15T14:30Z"); + }); + + it("includes seconds when requested", () => { + const date = new Date("2024-01-15T14:30:45.000Z"); + expect(formatUtcTimestamp(date, { displaySeconds: true })).toBe("2024-01-15T14:30:45Z"); + }); + }); + + describe("formatZonedTimestamp", () => { + it("formats with timezone abbreviation", () => { + const date = new Date("2024-01-15T14:30:00.000Z"); + const result = formatZonedTimestamp(date, { timeZone: "UTC" }); + expect(result).toMatch(/2024-01-15 14:30/); + }); + + it("includes seconds when requested", () => { + const date = new Date("2024-01-15T14:30:45.000Z"); + const result = formatZonedTimestamp(date, { timeZone: "UTC", displaySeconds: true }); + expect(result).toMatch(/2024-01-15 14:30:45/); + }); + }); +}); + +describe("format-relative", () => { + describe("formatTimeAgo", () => { + it("returns fallback for invalid input", () => { + expect(formatTimeAgo(null)).toBe("unknown"); + expect(formatTimeAgo(undefined)).toBe("unknown"); + expect(formatTimeAgo(-100)).toBe("unknown"); + expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a"); + }); + + it("formats with 'ago' suffix by default", () => { + expect(formatTimeAgo(0)).toBe("just now"); + expect(formatTimeAgo(29000)).toBe("just now"); // rounds to <1m + expect(formatTimeAgo(30000)).toBe("1m ago"); // 30s rounds to 1m + expect(formatTimeAgo(300000)).toBe("5m ago"); + expect(formatTimeAgo(7200000)).toBe("2h ago"); + expect(formatTimeAgo(172800000)).toBe("2d ago"); + }); + + it("omits suffix when suffix: false", () => { + expect(formatTimeAgo(0, { suffix: false })).toBe("0s"); + expect(formatTimeAgo(300000, { suffix: false })).toBe("5m"); + expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h"); + }); + + it("uses 48h threshold before switching to days", () => { + expect(formatTimeAgo(47 * 3600000)).toBe("47h ago"); + expect(formatTimeAgo(48 * 3600000)).toBe("2d ago"); + }); + }); + + describe("formatRelativeTimestamp", () => { + it("returns fallback for invalid input", () => { + expect(formatRelativeTimestamp(null)).toBe("n/a"); + expect(formatRelativeTimestamp(undefined)).toBe("n/a"); + expect(formatRelativeTimestamp(null, { fallback: "unknown" })).toBe("unknown"); + }); + + it("formats past timestamps", () => { + const now = Date.now(); + expect(formatRelativeTimestamp(now - 10000)).toBe("just now"); + expect(formatRelativeTimestamp(now - 300000)).toBe("5m ago"); + expect(formatRelativeTimestamp(now - 7200000)).toBe("2h ago"); + }); + + it("formats future timestamps", () => { + const now = Date.now(); + expect(formatRelativeTimestamp(now + 30000)).toBe("in <1m"); + expect(formatRelativeTimestamp(now + 300000)).toBe("in 5m"); + expect(formatRelativeTimestamp(now + 7200000)).toBe("in 2h"); + }); + + it("falls back to date for old timestamps when enabled", () => { + const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago + const result = formatRelativeTimestamp(oldDate, { dateFallback: true }); + // Should be a short date like "Jan 9" not "30d ago" + expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/); + }); + }); +}); diff --git a/src/infra/warnings.ts b/src/infra/warnings.ts deleted file mode 100644 index e8b048ddb9..0000000000 --- a/src/infra/warnings.ts +++ /dev/null @@ -1 +0,0 @@ -export { installProcessWarningFilter } from "./warning-filter.js"; diff --git a/src/memory/batch-voyage.ts b/src/memory/batch-voyage.ts index 7b11299485..b559e92da9 100644 --- a/src/memory/batch-voyage.ts +++ b/src/memory/batch-voyage.ts @@ -59,7 +59,9 @@ function getVoyageHeaders( } function splitVoyageBatchRequests(requests: VoyageBatchRequest[]): VoyageBatchRequest[][] { - if (requests.length <= VOYAGE_BATCH_MAX_REQUESTS) return [requests]; + if (requests.length <= VOYAGE_BATCH_MAX_REQUESTS) { + return [requests]; + } const groups: VoyageBatchRequest[][] = []; for (let i = 0; i < requests.length; i += VOYAGE_BATCH_MAX_REQUESTS) { groups.push(requests.slice(i, i + VOYAGE_BATCH_MAX_REQUESTS)); @@ -170,7 +172,9 @@ async function readVoyageBatchError(params: { throw new Error(`voyage batch error file content failed: ${res.status} ${text}`); } const text = await res.text(); - if (!text.trim()) return undefined; + if (!text.trim()) { + return undefined; + } const lines = text .split("\n") .map((line) => line.trim()) @@ -246,7 +250,9 @@ export async function runVoyageEmbeddingBatches(params: { concurrency: number; debug?: (message: string, data?: Record) => void; }): Promise> { - if (params.requests.length === 0) return new Map(); + if (params.requests.length === 0) { + return new Map(); + } const groups = splitVoyageBatchRequests(params.requests); const byCustomId = new Map(); @@ -307,15 +313,19 @@ export async function runVoyageEmbeddingBatches(params: { if (contentRes.body) { const reader = createInterface({ - input: Readable.fromWeb(contentRes.body as any), + input: Readable.fromWeb(contentRes.body as unknown as import("stream/web").ReadableStream), terminal: false, }); for await (const rawLine of reader) { - if (!rawLine.trim()) continue; + if (!rawLine.trim()) { + continue; + } const line = JSON.parse(rawLine) as VoyageBatchOutputLine; const customId = line.custom_id; - if (!customId) continue; + if (!customId) { + continue; + } remaining.delete(customId); if (line.error?.message) { errors.push(`${customId}: ${line.error.message}`); diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index 0d626ccc76..e3586b48a8 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(), requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { - if (auth?.apiKey) return auth.apiKey; + if (auth?.apiKey) { + return auth.apiKey; + } throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`); }, })); diff --git a/src/memory/embeddings-voyage.ts b/src/memory/embeddings-voyage.ts index 8850fca50b..8585b3dc34 100644 --- a/src/memory/embeddings-voyage.ts +++ b/src/memory/embeddings-voyage.ts @@ -12,8 +12,12 @@ const DEFAULT_VOYAGE_BASE_URL = "https://api.voyageai.com/v1"; export function normalizeVoyageModel(model: string): string { const trimmed = model.trim(); - if (!trimmed) return DEFAULT_VOYAGE_EMBEDDING_MODEL; - if (trimmed.startsWith("voyage/")) return trimmed.slice("voyage/".length); + if (!trimmed) { + return DEFAULT_VOYAGE_EMBEDDING_MODEL; + } + if (trimmed.startsWith("voyage/")) { + return trimmed.slice("voyage/".length); + } return trimmed; } @@ -24,12 +28,16 @@ export async function createVoyageEmbeddingProvider( const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`; const embed = async (input: string[], input_type?: "query" | "document"): Promise => { - if (input.length === 0) return []; + if (input.length === 0) { + return []; + } const body: { model: string; input: string[]; input_type?: "query" | "document" } = { model: client.model, input, }; - if (input_type) body.input_type = input_type; + if (input_type) { + body.input_type = input_type; + } const res = await fetch(url, { method: "POST", diff --git a/src/memory/internal.ts b/src/memory/internal.ts index 5cb1bc8a26..bf5a2d0933 100644 --- a/src/memory/internal.ts +++ b/src/memory/internal.ts @@ -280,7 +280,9 @@ export async function runWithConcurrency( tasks: Array<() => Promise>, limit: number, ): Promise { - if (tasks.length === 0) return []; + if (tasks.length === 0) { + return []; + } const resolvedLimit = Math.max(1, Math.min(limit, tasks.length)); const results: T[] = Array.from({ length: tasks.length }); let next = 0; @@ -288,10 +290,14 @@ export async function runWithConcurrency( const workers = Array.from({ length: resolvedLimit }, async () => { while (true) { - if (firstError) return; + if (firstError) { + return; + } const index = next; next += 1; - if (index >= tasks.length) return; + if (index >= tasks.length) { + return; + } try { results[index] = await tasks[index](); } catch (err) { @@ -302,6 +308,8 @@ export async function runWithConcurrency( }); await Promise.allSettled(workers); - if (firstError) throw firstError; + if (firstError) { + throw firstError; + } return results; } diff --git a/src/memory/manager.ts b/src/memory/manager.ts index b772d3fda4..94a6048a2f 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -1890,7 +1890,9 @@ export class MemoryIndexManager implements MemorySearchManager { if (!voyage) { return this.embedChunksInBatches(chunks); } - if (chunks.length === 0) return []; + if (chunks.length === 0) { + return []; + } const cached = this.loadEmbeddingCache(chunks.map((chunk) => chunk.hash)); const embeddings: number[][] = Array.from({ length: chunks.length }, () => []); const missing: Array<{ index: number; chunk: MemoryChunk }> = []; @@ -1905,7 +1907,9 @@ export class MemoryIndexManager implements MemorySearchManager { } } - if (missing.length === 0) return embeddings; + if (missing.length === 0) { + return embeddings; + } const requests: VoyageBatchRequest[] = []; const mapping = new Map(); @@ -1937,13 +1941,17 @@ export class MemoryIndexManager implements MemorySearchManager { }), fallback: async () => await this.embedChunksInBatches(chunks), }); - if (Array.isArray(batchResult)) return batchResult; + if (Array.isArray(batchResult)) { + return batchResult; + } const byCustomId = batchResult; const toCache: Array<{ hash: string; embedding: number[] }> = []; for (const [customId, embedding] of byCustomId.entries()) { const mapped = mapping.get(customId); - if (!mapped) continue; + if (!mapped) { + continue; + } embeddings[mapped.index] = embedding; toCache.push({ hash: mapped.hash, embedding }); } diff --git a/src/memory/sqlite.ts b/src/memory/sqlite.ts index bbda48dd20..00308fb607 100644 --- a/src/memory/sqlite.ts +++ b/src/memory/sqlite.ts @@ -1,5 +1,5 @@ import { createRequire } from "node:module"; -import { installProcessWarningFilter } from "../infra/warnings.js"; +import { installProcessWarningFilter } from "../infra/warning-filter.js"; const require = createRequire(import.meta.url); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index d4f0891da7..0905c43558 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -5,7 +5,7 @@ import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { formatDurationMs } from "../infra/format-duration.js"; +import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; @@ -195,7 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const reason = isConflict ? "getUpdates conflict" : "network error"; const errMsg = formatErrorMessage(err); (opts.runtime?.error ?? console.error)( - `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, + `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`, ); try { await sleepWithAbort(delayMs, opts.abortSignal); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index bb1bae48b6..8be381f622 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -14,8 +14,8 @@ import { normalizeUsageDisplay, resolveResponseUsageMode, } from "../auto-reply/thinking.js"; +import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts"; import { normalizeAgentId } from "../routing/session-key.js"; -import { formatRelativeTime } from "../utils/time-format.js"; import { helpText, parseCommand } from "./commands.js"; import { createFilterableSelectList, @@ -158,7 +158,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { // Avoid redundant "title (key)" when title matches key const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; // Build description: time + message preview - const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; + const timePart = session.updatedAt + ? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" }) + : ""; const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); const description = timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart); diff --git a/src/tui/tui-status-summary.ts b/src/tui/tui-status-summary.ts index bda1b1b760..fa5345cf14 100644 --- a/src/tui/tui-status-summary.ts +++ b/src/tui/tui-status-summary.ts @@ -1,5 +1,5 @@ import type { GatewayStatusSummary } from "./tui-types.js"; -import { formatAge } from "../infra/channel-summary.js"; +import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { formatTokenCount } from "../utils/usage-format.js"; import { formatContextUsageLine } from "./tui-formatters.js"; @@ -14,7 +14,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) { const linked = summary.linkChannel.linked === true; const authAge = linked && typeof summary.linkChannel.authAgeMs === "number" - ? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})` + ? ` (last refreshed ${formatTimeAgo(summary.linkChannel.authAgeMs)})` : ""; lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`); } @@ -63,7 +63,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) { if (recent.length > 0) { lines.push("Recent sessions:"); for (const entry of recent) { - const ageLabel = typeof entry.age === "number" ? formatAge(entry.age) : "no activity"; + const ageLabel = typeof entry.age === "number" ? formatTimeAgo(entry.age) : "no activity"; const model = entry.model ?? "unknown"; const usage = formatContextUsageLine({ total: entry.totalTokens ?? null, diff --git a/src/utils/time-format.ts b/src/utils/time-format.ts index 188cec4c79..6ec8777623 100644 --- a/src/utils/time-format.ts +++ b/src/utils/time-format.ts @@ -1,25 +1,6 @@ -export function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); +import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts"; - if (seconds < 60) { - return "just now"; - } - if (minutes < 60) { - return `${minutes}m ago`; - } - if (hours < 24) { - return `${hours}h ago`; - } - if (days === 1) { - return "Yesterday"; - } - if (days < 7) { - return `${days}d ago`; - } - return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +/** Delegates to centralized formatRelativeTimestamp with date fallback for >7d. */ +export function formatRelativeTime(timestamp: number): string { + return formatRelativeTimestamp(timestamp, { dateFallback: true, fallback: "unknown" }); } diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index 5ca1dd8649..4cbb8d5922 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -7,7 +7,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { waitForever } from "../../cli/wait.js"; import { loadConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; -import { formatDurationMs } from "../../infra/format-duration.js"; +import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js"; import { getChildLogger } from "../../logging.js"; @@ -432,7 +432,7 @@ export async function monitorWebChannel( "web reconnect: scheduling retry", ); runtime.error( - `WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationMs(delay)}… (${errorStr})`, + `WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(delay)}… (${errorStr})`, ); await closeListener(); try { diff --git a/test/helpers/envelope-timestamp.ts b/test/helpers/envelope-timestamp.ts index 1bd7ad17ce..aa63d612d9 100644 --- a/test/helpers/envelope-timestamp.ts +++ b/test/helpers/envelope-timestamp.ts @@ -1,53 +1,19 @@ +import { + formatUtcTimestamp, + formatZonedTimestamp, +} from "../../src/infra/format-time/format-datetime.js"; + type EnvelopeTimestampZone = string; -function formatUtcTimestamp(date: Date): string { - const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); - const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(date.getUTCDate()).padStart(2, "0"); - const hh = String(date.getUTCHours()).padStart(2, "0"); - const min = String(date.getUTCMinutes()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; -} - -function formatZonedTimestamp(date: Date, timeZone?: string): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - timeZoneName: "short", - }).formatToParts(date); - - const pick = (type: string) => parts.find((part) => part.type === type)?.value; - const yyyy = pick("year"); - const mm = pick("month"); - const dd = pick("day"); - const hh = pick("hour"); - const min = pick("minute"); - const tz = [...parts] - .toReversed() - .find((part) => part.type === "timeZoneName") - ?.value?.trim(); - - if (!yyyy || !mm || !dd || !hh || !min) { - throw new Error("Missing date parts for envelope timestamp formatting."); - } - - return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; -} - export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string { const normalized = zone.trim().toLowerCase(); if (normalized === "utc" || normalized === "gmt") { return formatUtcTimestamp(date); } if (normalized === "local" || normalized === "host") { - return formatZonedTimestamp(date); + return formatZonedTimestamp(date) ?? formatUtcTimestamp(date); } - return formatZonedTimestamp(date, zone); + return formatZonedTimestamp(date, { timeZone: zone }) ?? formatUtcTimestamp(date); } export function formatLocalEnvelopeTimestamp(date: Date): string { diff --git a/test/setup.ts b/test/setup.ts index 215935b930..725554b7f3 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -10,7 +10,7 @@ import type { } from "../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; -import { installProcessWarningFilter } from "../src/infra/warnings.js"; +import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; import { setActivePluginRegistry } from "../src/plugins/runtime.js"; import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; import { withIsolatedTestHome } from "./test-env"; diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 4260f07dac..956ebaf84a 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -1,34 +1,34 @@ import { describe, expect, it } from "vitest"; -import { formatAgo, stripThinkingTags } from "./format.ts"; +import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts"; describe("formatAgo", () => { it("returns 'in <1m' for timestamps less than 60s in the future", () => { - expect(formatAgo(Date.now() + 30_000)).toBe("in <1m"); + expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m"); }); it("returns 'Xm from now' for future timestamps", () => { - expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now"); + expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("5m from now"); }); it("returns 'Xh from now' for future timestamps", () => { - expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now"); + expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("3h from now"); }); it("returns 'Xd from now' for future timestamps beyond 48h", () => { - expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now"); + expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now"); }); it("returns 'Xs ago' for recent past timestamps", () => { - expect(formatAgo(Date.now() - 10_000)).toBe("10s ago"); + expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("10s ago"); }); it("returns 'Xm ago' for past timestamps", () => { - expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago"); + expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago"); }); it("returns 'n/a' for null/undefined", () => { - expect(formatAgo(null)).toBe("n/a"); - expect(formatAgo(undefined)).toBe("n/a"); + expect(formatRelativeTimestamp(null)).toBe("n/a"); + expect(formatRelativeTimestamp(undefined)).toBe("n/a"); }); }); diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index 91debb2e41..da3d544f19 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -1,5 +1,9 @@ +import { formatDurationHuman } from "../../../src/infra/format-time/format-duration.ts"; +import { formatRelativeTimestamp } from "../../../src/infra/format-time/format-relative.ts"; import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; +export { formatRelativeTimestamp, formatDurationHuman }; + export function formatMs(ms?: number | null): string { if (!ms && ms !== 0) { return "n/a"; @@ -7,52 +11,6 @@ export function formatMs(ms?: number | null): string { return new Date(ms).toLocaleString(); } -export function formatAgo(ms?: number | null): string { - if (!ms && ms !== 0) { - return "n/a"; - } - const diff = Date.now() - ms; - const absDiff = Math.abs(diff); - const suffix = diff < 0 ? "from now" : "ago"; - const sec = Math.round(absDiff / 1000); - if (sec < 60) { - return diff < 0 ? "in <1m" : `${sec}s ago`; - } - const min = Math.round(sec / 60); - if (min < 60) { - return `${min}m ${suffix}`; - } - const hr = Math.round(min / 60); - if (hr < 48) { - return `${hr}h ${suffix}`; - } - const day = Math.round(hr / 24); - return `${day}d ${suffix}`; -} - -export function formatDurationMs(ms?: number | null): string { - if (!ms && ms !== 0) { - return "n/a"; - } - if (ms < 1000) { - return `${ms}ms`; - } - const sec = Math.round(ms / 1000); - if (sec < 60) { - return `${sec}s`; - } - const min = Math.round(sec / 60); - if (min < 60) { - return `${min}m`; - } - const hr = Math.round(min / 60); - if (hr < 48) { - return `${hr}h`; - } - const day = Math.round(hr / 24); - return `${day}d`; -} - export function formatList(values?: Array): string { if (!values || values.length === 0) { return "none"; diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 7c99380a86..13fa32722c 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -1,5 +1,5 @@ import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts"; -import { formatAgo, formatDurationMs, formatMs } from "./format.ts"; +import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts"; export function formatPresenceSummary(entry: PresenceEntry): string { const host = entry.host ?? "unknown"; @@ -11,14 +11,14 @@ export function formatPresenceSummary(entry: PresenceEntry): string { export function formatPresenceAge(entry: PresenceEntry): string { const ts = entry.ts ?? null; - return ts ? formatAgo(ts) : "n/a"; + return ts ? formatRelativeTimestamp(ts) : "n/a"; } export function formatNextRun(ms?: number | null) { if (!ms) { return "n/a"; } - return `${formatMs(ms)} (${formatAgo(ms)})`; + return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; } export function formatSessionTokens(row: GatewaySessionRow) { @@ -57,7 +57,7 @@ export function formatCronSchedule(job: CronJob) { return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`; } if (s.kind === "every") { - return `Every ${formatDurationMs(s.everyMs)}`; + return `Every ${formatDurationHuman(s.everyMs)}`; } return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`; } diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 1cc352d35e..765daa60ed 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -16,7 +16,7 @@ import { normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy.js"; -import { formatAgo } from "../format.ts"; +import { formatRelativeTimestamp } from "../format.ts"; import { formatCronPayload, formatCronSchedule, @@ -1112,7 +1112,9 @@ function renderAgentChannels(params: { params.agentIdentity, ); const entries = resolveChannelEntries(params.snapshot); - const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never"; + const lastSuccessLabel = params.lastSuccess + ? formatRelativeTimestamp(params.lastSuccess) + : "never"; return html`
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")} @@ -1407,7 +1409,7 @@ function renderAgentFiles(params: { function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) { const status = file.missing ? "Missing" - : `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`; + : `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`; return html`